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:
2026-04-28 08:37:52 +00:00
4 changed files with 157 additions and 36 deletions
@@ -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");
});
});
+23 -12
View File
@@ -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",
+74 -9
View File
@@ -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");
},
});
+35 -9
View File
@@ -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
};
}