Merge pull request 'feat(cli): scaffold sense as TypeScript + esbuild bundle' (#226) from feat/225-sense-typescript-scaffold into main
This commit was merged in pull request #226.
This commit is contained in:
@@ -5,8 +5,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSenseIndexJs,
|
||||
buildSenseIndexTs,
|
||||
buildSenseMigrationSql,
|
||||
buildSensePackageJson,
|
||||
buildSenseSchemaTs,
|
||||
validateResourceName,
|
||||
} from "../commands/create.js";
|
||||
@@ -45,10 +46,28 @@ describe("buildSenseMigrationSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexJs", () => {
|
||||
it("embeds sense id in stub", () => {
|
||||
const js = buildSenseIndexJs("my-sense");
|
||||
expect(js).toContain("my-sense");
|
||||
expect(js).toContain("export async function compute");
|
||||
describe("buildSensePackageJson", () => {
|
||||
it("includes esbuild script and sense name", () => {
|
||||
const pkg = JSON.parse(buildSensePackageJson("my-sense"));
|
||||
expect(pkg.name).toBe("nerve-sense-my-sense");
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
expect(pkg.scripts.build).toContain("src/index.ts");
|
||||
expect(pkg.devDependencies.esbuild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexTs", () => {
|
||||
it("embeds sense id in stub with TypeScript types", () => {
|
||||
const ts = buildSenseIndexTs("my-sense");
|
||||
expect(ts).toContain("my-sense");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("LibSQLDatabase");
|
||||
expect(ts).toContain("Promise<SenseResult>");
|
||||
expect(ts).toContain('from "./schema.js"');
|
||||
});
|
||||
|
||||
it("imports the correct schema export", () => {
|
||||
const ts = buildSenseIndexTs("cpu-usage");
|
||||
expect(ts).toContain("cpuUsage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,21 +146,32 @@ describe("e2e create", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("create sense scaffolds index.js, schema.ts, and migration", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
it(
|
||||
"create sense scaffolds src/index.ts, src/schema.ts, package.json and migration",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
|
||||
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
|
||||
expect(sense.exitCode).toBe(0);
|
||||
expect(sense.stdout).toContain("✅");
|
||||
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
|
||||
expect(sense.exitCode).toBe(0);
|
||||
expect(sense.stdout).toContain("✅");
|
||||
|
||||
const base = join(nerveRoot, "senses", "e2e-sense");
|
||||
expect(existsSync(join(base, "index.js"))).toBe(true);
|
||||
expect(existsSync(join(base, "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
|
||||
});
|
||||
const base = join(nerveRoot, "senses", "e2e-sense");
|
||||
expect(existsSync(join(base, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(base, "package.json"), "utf8"));
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
|
||||
// pnpm install + build should produce index.js
|
||||
expect(existsSync(join(base, "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"create workflow exits 1 when directory exists without --force",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -133,12 +134,47 @@ export const ${exportName} = sqliteTable("${table}", {
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexJs(senseId: string): string {
|
||||
return `/**
|
||||
export function buildSensePackageJson(name: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `nerve-sense-${name}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
"drizzle-orm": "*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexTs(senseId: string): string {
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
|
||||
import { ${exportName} } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { label: string; ts: number };
|
||||
workflow: null;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* ${senseId} — replace this stub with your sampling logic.
|
||||
* Returns non-null to emit a signal, null to stay silent.
|
||||
*/
|
||||
export async function compute(db, peers, options) {
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<SenseResult> {
|
||||
void ${exportName};
|
||||
return {
|
||||
signal: {
|
||||
label: "${senseId}",
|
||||
@@ -165,6 +201,17 @@ function writeFile(filePath: string, content: string): void {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
function spawnAsync(cmd: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited with code ${String(code)}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const createWorkflowCommand = defineCommand({
|
||||
meta: {
|
||||
name: "workflow",
|
||||
@@ -262,15 +309,30 @@ const createSenseCommand = defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(join(senseDir, "src"), { recursive: true });
|
||||
mkdirSync(join(senseDir, "migrations"), { recursive: true });
|
||||
writeFile(join(senseDir, "index.js"), buildSenseIndexJs(args.name));
|
||||
writeFile(join(senseDir, "schema.ts"), buildSenseSchemaTs(args.name));
|
||||
writeFile(join(senseDir, "package.json"), buildSensePackageJson(args.name));
|
||||
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
|
||||
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
|
||||
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
|
||||
|
||||
process.stdout.write("✅ Sense scaffolded:\n");
|
||||
process.stdout.write(` ${join(senseDir, "index.js")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "schema.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "package.json")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
|
||||
|
||||
process.stdout.write("\nInstalling sense dependencies and building…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["install", "--no-cache"], senseDir);
|
||||
await spawnAsync("pnpm", ["run", "build"], senseDir);
|
||||
process.stdout.write("✅ Build complete — index.js ready.\n");
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`⚠️ Build failed. Run manually:\n cd ${senseDir} && pnpm install --no-cache && pnpm run build\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write("\n💡 Next steps:\n");
|
||||
process.stdout.write(" 1. Add to nerve.yaml under senses:\n");
|
||||
process.stdout.write(` ${args.name}:\n`);
|
||||
@@ -278,8 +340,11 @@ const createSenseCommand = defineCommand({
|
||||
process.stdout.write(" throttle: null\n");
|
||||
process.stdout.write(" timeout: 10s\n");
|
||||
process.stdout.write(" grace_period: null\n");
|
||||
process.stdout.write(` 2. Edit the scaffolded files to implement ${args.name}.\n`);
|
||||
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
|
||||
process.stdout.write(
|
||||
` 2. Edit ${join(senseDir, "src", "index.ts")} to implement ${args.name}.\n`,
|
||||
);
|
||||
process.stdout.write(` 3. Re-run \`pnpm run build\` in ${senseDir} after edits.\n`);
|
||||
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -40,8 +40,11 @@ External World → Sense → Signal → Workflow → Log
|
||||
package.json # 依赖(@uncaged/nerve-core, drizzle-orm 等)
|
||||
senses/ # sense 模块
|
||||
cpu-usage/
|
||||
index.js # compute 函数
|
||||
schema.ts # Drizzle ORM schema
|
||||
package.json # esbuild 构建脚本
|
||||
src/
|
||||
index.ts # compute 函数(TypeScript 源码)
|
||||
schema.ts # Drizzle ORM schema
|
||||
index.js # 构建产物(由 pnpm run build 生成)
|
||||
migrations/ # SQL 迁移
|
||||
workflows/ # workflow 模块
|
||||
alert/
|
||||
@@ -92,24 +95,47 @@ api: # HTTP API 配置
|
||||
|
||||
```
|
||||
senses/<name>/
|
||||
index.js # compute() 函数 — 必须
|
||||
schema.ts # Drizzle ORM 表定义 — 推荐
|
||||
package.json # esbuild 构建脚本 + devDependencies
|
||||
src/
|
||||
index.ts # compute() 函数(TypeScript 源码)— 必须
|
||||
schema.ts # Drizzle ORM 表定义 — 推荐
|
||||
index.js # 构建产物(daemon 实际加载此文件)— 由 pnpm run build 生成
|
||||
migrations/ # SQL 迁移文件
|
||||
0001_init.sql # 建表 SQL — Drizzle Kit 生成
|
||||
```
|
||||
|
||||
命名规范:sense ID 用 kebab-case(如 `cpu-usage`),对应 SQL 表名自动转 snake_case(`cpu_usage`)。
|
||||
|
||||
**构建流程**:`nerve create sense <name>` 脚手架后会自动运行 `pnpm install && pnpm run build`,将 `src/index.ts` 打包为 `index.js`。修改源码后需手动重新构建:
|
||||
|
||||
```bash
|
||||
cd ~/.uncaged-nerve/senses/<name>
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### compute 函数
|
||||
|
||||
compute 返回 `null`(静默)或 `{ signal, workflow }` 结构:
|
||||
|
||||
```typescript
|
||||
// senses/cpu-usage/index.js
|
||||
export async function compute(db, peers, options) {
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { cpuUsage } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { cpu: number; ts: number };
|
||||
workflow: null;
|
||||
} | null;
|
||||
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<SenseResult> {
|
||||
void cpuUsage;
|
||||
// db: Drizzle ORM SQLite 实例(读写,当前 sense 专用)
|
||||
// peers: Record<string, DrizzleDB>(只读,同组其他 sense 的 DB)
|
||||
// options: { signal: AbortSignal, blobStore: BlobStore }
|
||||
// _peers: Record<string, DrizzleDB>(只读,同组其他 sense 的 DB)
|
||||
// _options: { signal: AbortSignal }
|
||||
|
||||
const usage = getCpuUsage();
|
||||
|
||||
@@ -118,7 +144,7 @@ export async function compute(db, peers, options) {
|
||||
|
||||
// 返回 { signal, workflow } → 发 Signal,可选触发 Workflow
|
||||
return {
|
||||
signal: { cpu: usage, timestamp: Date.now() },
|
||||
signal: { cpu: usage, ts: Date.now() },
|
||||
workflow: null, // 不触发 workflow
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user