diff --git a/packages/cli/package.json b/packages/cli/package.json index 21e5cf6..cfb2d70 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,9 +10,7 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/cli/src/__tests__/create-sense.test.ts b/packages/cli/src/__tests__/create-sense.test.ts new file mode 100644 index 0000000..662b272 --- /dev/null +++ b/packages/cli/src/__tests__/create-sense.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for nerve create sense template helpers. + */ + +import { describe, expect, it } from "vitest"; + +import { + buildSenseIndexJs, + buildSenseMigrationSql, + buildSenseSchemaTs, + validateSenseName, +} from "../commands/create.js"; + +describe("validateSenseName", () => { + it("accepts valid ids", () => { + expect(validateSenseName("a")).toBe(null); + expect(validateSenseName("my-sense")).toBe(null); + expect(validateSenseName("cpu-usage")).toBe(null); + }); + + it("rejects invalid ids", () => { + expect(validateSenseName("")).not.toBe(null); + expect(validateSenseName("My-Sense")).not.toBe(null); + expect(validateSenseName("-bad")).not.toBe(null); + }); +}); + +describe("buildSenseSchemaTs", () => { + it("maps kebab-case id to snake table and camel export", () => { + const src = buildSenseSchemaTs("my-sense"); + expect(src).toContain('sqliteTable("my_sense"'); + expect(src).toContain("export const mySense = "); + }); + + it("handles single-segment id", () => { + const src = buildSenseSchemaTs("metrics"); + expect(src).toContain('sqliteTable("metrics"'); + expect(src).toContain("export const metrics = "); + }); +}); + +describe("buildSenseMigrationSql", () => { + it("uses snake_case table name", () => { + expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io"); + }); +}); + +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"); + }); +}); diff --git a/packages/cli/src/__tests__/init-workflow.test.ts b/packages/cli/src/__tests__/create-workflow.test.ts similarity index 91% rename from packages/cli/src/__tests__/init-workflow.test.ts rename to packages/cli/src/__tests__/create-workflow.test.ts index b917756..c450f5f 100644 --- a/packages/cli/src/__tests__/init-workflow.test.ts +++ b/packages/cli/src/__tests__/create-workflow.test.ts @@ -1,15 +1,15 @@ /** - * Tests for nerve init workflow scaffold logic. + * Tests for nerve create workflow scaffold logic. * * We test the file-generation path by isolating the template rendering, * not by invoking the full citty command (which calls process.exit). */ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildWorkflowTemplate } from "../commands/init.js"; +import { buildWorkflowTemplate } from "../commands/create.js"; let tmpDir: string; @@ -68,7 +68,6 @@ describe("buildWorkflowTemplate", () => { describe("workflow scaffold file writing (simulated)", () => { it("writes the template to disk correctly", () => { - const { mkdirSync, writeFileSync } = require("node:fs"); const workflowDir = join(tmpDir, "workflows", "my-task"); mkdirSync(workflowDir, { recursive: true }); const content = buildWorkflowTemplate("my-task"); diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts new file mode 100644 index 0000000..16a081b --- /dev/null +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -0,0 +1,169 @@ +/** + * E2E-style tests for `nerve create workflow` and `nerve create sense`. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { defineCommand, runCommand } from "citty"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createCommand } from "../commands/create.js"; +import { initCommand } from "../commands/init.js"; + +const testRootCommand = defineCommand({ + meta: { name: "nerve", description: "e2e-create" }, + subCommands: { + init: initCommand, + create: createCommand, + }, +}); + +type CliRunResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +class ProcessExitError extends Error { + readonly code: number; + constructor(code: number) { + super(`process.exit(${String(code)})`); + this.name = "ProcessExitError"; + this.code = code; + } +} + +function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void { + const orig = stream.write.bind(stream) as ( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => boolean; + + stream.write = (( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => { + if (typeof chunk === "string") { + sink.push(chunk); + } else { + sink.push(Buffer.from(chunk).toString("utf8")); + } + if (typeof encodingOrCb === "function") { + encodingOrCb(null); + return true; + } + if (cb !== undefined) { + cb(null); + } + return true; + }) as typeof stream.write; + + return () => { + stream.write = orig as typeof stream.write; + }; +} + +async function runTestCli(fakeHome: string, args: string[]): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const restoreOut = patchWriteStream(process.stdout, stdoutChunks); + const restoreErr = patchWriteStream(process.stderr, stderrChunks); + + const prevHome = process.env.HOME; + process.env.HOME = fakeHome; + + let exitCode = 0; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = typeof code === "number" ? code : 0; + throw new ProcessExitError(exitCode); + }) as typeof process.exit; + + try { + await runCommand(testRootCommand, { rawArgs: args }); + } catch (e) { + if (e instanceof ProcessExitError) { + exitCode = e.code; + } else { + exitCode = 1; + stderrChunks.push(e instanceof Error ? e.message : String(e)); + } + } finally { + process.exit = origExit; + if (prevHome === undefined) { + // biome-ignore lint/performance/noDelete: semantically correct for env cleanup + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + restoreOut(); + restoreErr(); + } + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + exitCode, + }; +} + +describe("e2e create", () => { + let fakeHome: string | null = null; + + afterEach(() => { + if (fakeHome !== null) { + rmSync(fakeHome, { recursive: true, force: true }); + fakeHome = null; + } + }); + + it("create workflow scaffolds index.ts", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + + await runTestCli(fakeHome, ["init", "--force", "--skip-install"]); + + const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]); + expect(wf.exitCode).toBe(0); + expect(wf.stdout).toContain("āœ…"); + + const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts"); + expect(existsSync(indexPath)).toBe(true); + expect(readFileSync(indexPath, "utf8")).toContain("e2e-flow started"); + }); + + 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"); + + 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 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); + }); + + it( + "create workflow exits 1 when directory exists without --force", + { timeout: 10_000 }, + async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + mkdirSync(join(nerveRoot, "workflows", "dup-wf"), { recursive: true }); + writeFileSync(join(nerveRoot, "workflows", "dup-wf", "index.ts"), "// x", "utf8"); + + const first = await runTestCli(fakeHome, ["create", "workflow", "dup-wf"]); + expect(first.exitCode).toBe(1); + expect(first.stderr).toContain("already exists"); + }, + ); +}); diff --git a/packages/cli/src/__tests__/e2e/init.md b/packages/cli/src/__tests__/e2e/init.md index 652b505..5f5336b 100644 --- a/packages/cli/src/__tests__/e2e/init.md +++ b/packages/cli/src/__tests__/e2e/init.md @@ -8,7 +8,7 @@ - šŸ”² init in empty dir without `--force` — succeeds - šŸ”² `--from ` clones and sets up workspace -## init workflow +## create workflow / create sense -- šŸ”² creates workflow scaffold under workflows/ -- šŸ”² generated workflow has valid structure +- šŸ”² `nerve create workflow ` scaffolds under workflows/ +- šŸ”² `nerve create sense ` scaffolds under senses/ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 96c691b..a599dd7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import "@uncaged/nerve-daemon/experimental-warning-suppression.js"; import { defineCommand, runMain } from "citty"; import { consumeGlobalDaemonCliFlags } from "./cli-global.js"; +import { createCommand } from "./commands/create.js"; import { daemonCommand } from "./commands/daemon.js"; import { devCommand } from "./commands/dev.js"; import { initCommand } from "./commands/init.js"; @@ -41,6 +42,7 @@ const main = defineCommand({ }, subCommands: { init: initCommand, + create: createCommand, daemon: daemonCommand, dev: devCommand, validate: validateCommand, diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts new file mode 100644 index 0000000..ed5c985 --- /dev/null +++ b/packages/cli/src/commands/create.ts @@ -0,0 +1,218 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { defineCommand } from "citty"; + +import { getNerveRoot } from "../workspace.js"; + +export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +export function validateWorkflowName(name: string): string | null { + if (name.length === 0) return "Workflow name must not be empty."; + if (name.length > 64) return "Workflow name must be 64 characters or fewer."; + if (!WORKFLOW_NAME_RE.test(name)) + return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen."; + return null; +} + +export function validateSenseName(name: string): string | null { + if (name.length === 0) return "Sense name must not be empty."; + if (name.length > 64) return "Sense name must be 64 characters or fewer."; + if (!WORKFLOW_NAME_RE.test(name)) + return "Sense name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen."; + return null; +} + +export function buildWorkflowTemplate(name: string): string { + return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon"; + +const workflow: WorkflowDefinition = { + roles: { + main: { + async execute(prompt, ctx) { + ctx.log("${name} started"); + // TODO: implement your role logic here + return { type: "done" }; + }, + }, + }, + + moderate(thread, event) { + if (event.type === "thread_start") { + return { role: "main", prompt: {} }; + } + return null; // workflow complete + }, +}; + +export default workflow; +`; +} + +function senseIdToSqlTableName(id: string): string { + return id.replaceAll("-", "_"); +} + +function senseIdToSchemaExportName(id: string): string { + const parts = id.split("-"); + return parts + .map((part, index) => + index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); +} + +export function buildSenseSchemaTs(senseId: string): string { + const table = senseIdToSqlTableName(senseId); + const exportName = senseIdToSchemaExportName(senseId); + return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const ${exportName} = sqliteTable("${table}", { + id: integer("id").primaryKey({ autoIncrement: true }), + ts: integer("ts").notNull(), + label: text("label").notNull(), +}); +`; +} + +export function buildSenseIndexJs(senseId: string): string { + return `/** + * ${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) { + return { + label: "${senseId}", + ts: Date.now(), + }; +} +`; +} + +export function buildSenseMigrationSql(senseId: string): string { + const table = senseIdToSqlTableName(senseId); + return `CREATE TABLE IF NOT EXISTS ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + label TEXT NOT NULL +); +`; +} + +function writeFile(filePath: string, content: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, "utf8"); +} + +const createWorkflowCommand = defineCommand({ + meta: { + name: "workflow", + description: "Scaffold a new workflow at ~/.uncaged-nerve/workflows//", + }, + args: { + name: { + type: "positional", + description: "Workflow name (must match the key in nerve.yaml workflows section)", + }, + force: { + type: "boolean", + description: "Overwrite if the workflow directory already exists", + default: false, + }, + }, + async run({ args }) { + const nerveRoot = getNerveRoot(); + const workflowDir = join(nerveRoot, "workflows", args.name); + + const nameError = validateWorkflowName(args.name); + if (nameError !== null) { + process.stderr.write(`āŒ Invalid workflow name: ${nameError}\n`); + process.exit(1); + } + + if (existsSync(workflowDir) && !args.force) { + process.stderr.write( + `āš ļø Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`, + ); + process.exit(1); + } + + mkdirSync(workflowDir, { recursive: true }); + writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name)); + + process.stdout.write(`āœ… Workflow scaffolded: ${workflowDir}/index.ts\n`); + process.stdout.write("\nšŸ’” Next steps:\n"); + process.stdout.write(" 1. Add to nerve.yaml:\n"); + process.stdout.write(" workflows:\n"); + process.stdout.write(` ${args.name}:\n`); + process.stdout.write(" concurrency: 1\n"); + process.stdout.write(" overflow: drop\n"); + process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`); + process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n"); + }, +}); + +const createSenseCommand = defineCommand({ + meta: { + name: "sense", + description: "Scaffold a new sense at ~/.uncaged-nerve/senses//", + }, + args: { + name: { + type: "positional", + description: "Sense id (must match the key in nerve.yaml senses section)", + }, + force: { + type: "boolean", + description: "Overwrite if the sense directory already exists", + default: false, + }, + }, + async run({ args }) { + const nerveRoot = getNerveRoot(); + const senseDir = join(nerveRoot, "senses", args.name); + + const nameError = validateSenseName(args.name); + if (nameError !== null) { + process.stderr.write(`āŒ Invalid sense name: ${nameError}\n`); + process.exit(1); + } + + if (existsSync(senseDir) && !args.force) { + process.stderr.write( + `āš ļø Sense "${args.name}" already exists at ${senseDir}. Use --force to overwrite.\n`, + ); + process.exit(1); + } + + 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, "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, "migrations", "0001_init.sql")}\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`); + process.stdout.write(" group: default\n"); + 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"); + }, +}); + +export const createCommand = defineCommand({ + meta: { + name: "create", + description: "Scaffold a new workflow or sense in the Nerve workspace", + }, + subCommands: { + workflow: createWorkflowCommand, + sense: createSenseCommand, + }, +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9a8b415..5c9a742 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -145,90 +145,6 @@ async function detectPackageManager(): Promise<{ cmd: string; installArgs: strin return { cmd: "npm", installArgs: ["install"] }; } -export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; - -export function validateWorkflowName(name: string): string | null { - if (name.length === 0) return "Workflow name must not be empty."; - if (name.length > 64) return "Workflow name must be 64 characters or fewer."; - if (!WORKFLOW_NAME_RE.test(name)) - return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen."; - return null; -} - -export function buildWorkflowTemplate(name: string): string { - return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon"; - -const workflow: WorkflowDefinition = { - roles: { - main: { - async execute(prompt, ctx) { - ctx.log("${name} started"); - // TODO: implement your role logic here - return { type: "done" }; - }, - }, - }, - - moderate(thread, event) { - if (event.type === "thread_start") { - return { role: "main", prompt: {} }; - } - return null; // workflow complete - }, -}; - -export default workflow; -`; -} - -const initWorkflowCommand = defineCommand({ - meta: { - name: "workflow", - description: "Scaffold a new workflow template in ~/.uncaged-nerve/workflows//", - }, - args: { - name: { - type: "positional", - description: "Workflow name (must match the key in nerve.yaml workflows section)", - }, - force: { - type: "boolean", - description: "Overwrite if the workflow directory already exists", - default: false, - }, - }, - async run({ args }) { - const nerveRoot = getNerveRoot(); - const workflowDir = join(nerveRoot, "workflows", args.name); - - const nameError = validateWorkflowName(args.name); - if (nameError !== null) { - process.stderr.write(`āŒ Invalid workflow name: ${nameError}\n`); - process.exit(1); - } - - if (existsSync(workflowDir) && !args.force) { - process.stderr.write( - `āš ļø Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`, - ); - process.exit(1); - } - - mkdirSync(workflowDir, { recursive: true }); - writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name)); - - process.stdout.write(`āœ… Workflow scaffolded: ${workflowDir}/index.ts\n`); - process.stdout.write("\nšŸ’” Next steps:\n"); - process.stdout.write(" 1. Add to nerve.yaml:\n"); - process.stdout.write(" workflows:\n"); - process.stdout.write(` ${args.name}:\n`); - process.stdout.write(" concurrency: 1\n"); - process.stdout.write(" overflow: drop\n"); - process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`); - process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n"); - }, -}); - const initWorkspaceCommand = defineCommand({ meta: { name: "workspace", @@ -400,7 +316,7 @@ export const initCommand = defineCommand({ meta: { name: "init", description: - "Initialize workspace (nerve init), clone from git (nerve init --from ), or scaffold templates (nerve init workflow )", + "Initialize workspace (nerve init), clone from git (nerve init --from ), or reinit workspace (nerve init workspace)", }, args: { force: { @@ -420,7 +336,6 @@ export const initCommand = defineCommand({ }, }, subCommands: { - workflow: initWorkflowCommand, workspace: initWorkspaceCommand, }, async run({ args }) { diff --git a/packages/core/package.json b/packages/core/package.json index 8b5b226..e784889 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,9 +9,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 8e28950..daea5f4 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -15,9 +15,7 @@ }, "./package.json": "./package.json" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/store/package.json b/packages/store/package.json index 997831f..90f9f20 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -4,9 +4,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/workflow-utils/package.json b/packages/workflow-utils/package.json index 1682010..cf7f48e 100644 --- a/packages/workflow-utils/package.json +++ b/packages/workflow-utils/package.json @@ -4,9 +4,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" },