diff --git a/packages/cli/src/__tests__/create-sense.test.ts b/packages/cli/src/__tests__/create-sense.test.ts index 65cff8b..5a3e68d 100644 --- a/packages/cli/src/__tests__/create-sense.test.ts +++ b/packages/cli/src/__tests__/create-sense.test.ts @@ -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"); + expect(ts).toContain('from "./schema.js"'); + }); + + it("imports the correct schema export", () => { + const ts = buildSenseIndexTs("cpu-usage"); + expect(ts).toContain("cpuUsage"); }); }); diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts index f7432ff..92044e3 100644 --- a/packages/cli/src/__tests__/e2e-create.test.ts +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -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", diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 16bcd40..d079d57 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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, + _options: { signal: AbortSignal }, +): Promise { + 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 { + return new Promise((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"); }, }); diff --git a/packages/skills/nerve-dev/SKILL.md b/packages/skills/nerve-dev/SKILL.md index d236b3b..7f4d246 100644 --- a/packages/skills/nerve-dev/SKILL.md +++ b/packages/skills/nerve-dev/SKILL.md @@ -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// - 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 ` 脚手架后会自动运行 `pnpm install && pnpm run build`,将 `src/index.ts` 打包为 `index.js`。修改源码后需手动重新构建: + +```bash +cd ~/.uncaged-nerve/senses/ +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, + _options: { signal: AbortSignal }, +): Promise { + void cpuUsage; // db: Drizzle ORM SQLite 实例(读写,当前 sense 专用) - // peers: Record(只读,同组其他 sense 的 DB) - // options: { signal: AbortSignal, blobStore: BlobStore } + // _peers: Record(只读,同组其他 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 }; }