refactor(cli): add nerve create command, remove init workflow
- Add top-level `nerve create` with `workflow` and `sense` subcommands - Move workflow scaffold from `init workflow` to `nerve create workflow` - Add `nerve create sense <name>` to scaffold sense boilerplate - Keep `nerve init` for workspace initialization only - Add tests for create workflow, create sense, and e2e Closes #188
This commit is contained in:
@@ -10,9 +10,7 @@
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+3
-4
@@ -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");
|
||||
@@ -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<CliRunResult> {
|
||||
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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
- 🔲 init in empty dir without `--force` — succeeds
|
||||
- 🔲 `--from <git-url>` clones and sets up workspace
|
||||
|
||||
## init workflow
|
||||
## create workflow / create sense
|
||||
|
||||
- 🔲 creates workflow scaffold under workflows/
|
||||
- 🔲 generated workflow has valid structure
|
||||
- 🔲 `nerve create workflow <name>` scaffolds under workflows/
|
||||
- 🔲 `nerve create sense <name>` scaffolds under senses/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<name>/",
|
||||
},
|
||||
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/<name>/",
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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/<name>/",
|
||||
},
|
||||
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 <url>), or scaffold templates (nerve init workflow <name>)",
|
||||
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or reinit workspace (nerve init workspace)",
|
||||
},
|
||||
args: {
|
||||
force: {
|
||||
@@ -420,7 +336,6 @@ export const initCommand = defineCommand({
|
||||
},
|
||||
},
|
||||
subCommands: {
|
||||
workflow: initWorkflowCommand,
|
||||
workspace: initWorkspaceCommand,
|
||||
},
|
||||
async run({ args }) {
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user