diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts new file mode 100644 index 0000000..1589796 --- /dev/null +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { formatCliUsage, runCli } from "../src/cli-dispatch.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; +import { pathExists } from "../src/fs-utils.js"; + +describe("init workspace", () => { + let parent: string; + + beforeEach(async () => { + parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(parent, { recursive: true }); + }); + + afterEach(async () => { + await rm(parent, { recursive: true, force: true }); + }); + + test("creates expected files and directories", async () => { + const created = await cmdInitWorkspace(parent, "my-workflows"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const root = created.value.rootPath; + expect(await pathExists(join(root, "package.json"))).toBe(true); + expect(await pathExists(join(root, "biome.json"))).toBe(true); + expect(await pathExists(join(root, "tsconfig.json"))).toBe(true); + expect(await pathExists(join(root, "AGENTS.md"))).toBe(true); + expect(await pathExists(join(root, "README.md"))).toBe(true); + expect(await pathExists(join(root, "templates"))).toBe(true); + expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true); + expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true); + + const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { + workspaces: string[]; + }; + expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]); + + const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as { + type: string; + dependencies: Record; + }; + expect(wfPkg.type).toBe("module"); + expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined(); + expect(wfPkg.dependencies.zod).toBeDefined(); + + const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as { + compilerOptions: { strict: boolean; module: string; target: string }; + }; + expect(tsconfig.compilerOptions.strict).toBe(true); + expect(tsconfig.compilerOptions.module).toBe("ESNext"); + expect(tsconfig.compilerOptions.target).toBe("ESNext"); + }); + + test("errors when directory already exists", async () => { + const first = await cmdInitWorkspace(parent, "dup"); + expect(first.ok).toBe(true); + + const second = await cmdInitWorkspace(parent, "dup"); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain("already exists"); + } + }); + + test("errors on invalid workspace name", async () => { + const slash = await cmdInitWorkspace(parent, "a/b"); + expect(slash.ok).toBe(false); + + const dots = await cmdInitWorkspace(parent, ".."); + expect(dots.ok).toBe(false); + + const empty = await cmdInitWorkspace(parent, ""); + expect(empty.ok).toBe(false); + }); + + test("usage lists init subcommands", () => { + const u = formatCliUsage(); + expect(u).toContain("uncaged-workflow init workspace "); + expect(u).toContain("uncaged-workflow init template "); + }); + + test("init template command is stubbed", () => { + const r = cmdInitTemplate(parent, "x"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe("not implemented yet"); + } + }); + + test("runCli rejects unknown init subcommand", async () => { + const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]); + expect(code).toBe(1); + }); + + test.serial("runCli init workspace uses cwd", async () => { + const prev = process.cwd(); + try { + process.chdir(parent); + const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]); + expect(code).toBe(0); + expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true); + } finally { + process.chdir(prev); + } + }); + + test("runCli init template exits with error", async () => { + const code = await runCli(join(parent, "_storage"), ["init", "template", "t"]); + expect(code).toBe(1); + }); +}); diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index 044d20f..4151631 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -4,6 +4,7 @@ import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js"; import { cmdFork, parseForkArgv } from "./cmd-fork.js"; import { cmdGc } from "./cmd-gc.js"; import { cmdHistory } from "./cmd-history.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js"; import { cmdKill } from "./cmd-kill.js"; import { cmdList, formatListLines } from "./cmd-list.js"; import { cmdPause } from "./cmd-pause.js"; @@ -17,7 +18,7 @@ import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js"; import { cmdThreads } from "./cmd-threads.js"; import { parseRunArgv } from "./run-argv.js"; -function usage(): string { +export function formatCliUsage(): string { return [ "Usage:", " uncaged-workflow add [--types ]", @@ -40,13 +41,46 @@ function usage(): string { " uncaged-workflow cas put ", " uncaged-workflow cas list ", " uncaged-workflow cas rm ", + " uncaged-workflow init workspace ", + " uncaged-workflow init template ", ].join("\n"); } +async function dispatchInit(_storageRoot: string, argv: string[]): Promise { + const sub = argv[0]; + const name = argv[1]; + if (sub === undefined || name === undefined || argv.length > 2) { + printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template `); + return 1; + } + + if (sub === "workspace") { + const result = await cmdInitWorkspace(process.cwd(), name); + if (!result.ok) { + printCliError(result.error); + return 1; + } + printCliLine(`initialized workflow workspace at ${result.value.rootPath}`); + return 0; + } + + if (sub === "template") { + const result = cmdInitTemplate(process.cwd(), name); + if (!result.ok) { + printCliError(result.error); + return 1; + } + return 0; + } + + printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`); + return 1; +} + async function dispatchAdd(storageRoot: string, argv: string[]): Promise { const parsed = parseAddArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } const result = await cmdAdd(storageRoot, parsed.value); @@ -63,7 +97,7 @@ async function dispatchAdd(storageRoot: string, argv: string[]): Promise async function dispatchList(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: list takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`); return 1; } const result = await cmdList(storageRoot); @@ -80,7 +114,7 @@ async function dispatchList(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: show requires `); + printCliError(`${formatCliUsage()}\n\nerror: show requires `); return 1; } const result = await cmdShow(storageRoot, name); @@ -95,7 +129,7 @@ async function dispatchShow(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: remove requires `); + printCliError(`${formatCliUsage()}\n\nerror: remove requires `); return 1; } const result = await cmdRemove(storageRoot, name); @@ -110,7 +144,7 @@ async function dispatchRemove(storageRoot: string, argv: string[]): Promise { const parsed = parseRunArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } @@ -131,7 +165,7 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise async function dispatchPs(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: ps takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`); return 1; } for (const line of await cmdPs(storageRoot)) { @@ -143,7 +177,7 @@ async function dispatchPs(storageRoot: string, argv: string[]): Promise async function dispatchKill(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: kill requires `); + printCliError(`${formatCliUsage()}\n\nerror: kill requires `); return 1; } const result = await cmdKill(storageRoot, threadId); @@ -158,7 +192,7 @@ async function dispatchKill(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: history requires `); + printCliError(`${formatCliUsage()}\n\nerror: history requires `); return 1; } const result = await cmdHistory(storageRoot, name); @@ -175,7 +209,7 @@ async function dispatchHistory(storageRoot: string, argv: string[]): Promise { const name = argv[0]; if (name === undefined || argv.length > 2) { - printCliError(`${usage()}\n\nerror: rollback requires [hash]`); + printCliError(`${formatCliUsage()}\n\nerror: rollback requires [hash]`); return 1; } const hashArg = argv[1]; @@ -191,7 +225,7 @@ async function dispatchRollback(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: pause requires `); + printCliError(`${formatCliUsage()}\n\nerror: pause requires `); return 1; } const result = await cmdPause(storageRoot, threadId); @@ -206,7 +240,7 @@ async function dispatchPause(storageRoot: string, argv: string[]): Promise { const threadId = argv[0]; if (threadId === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: resume requires `); + printCliError(`${formatCliUsage()}\n\nerror: resume requires `); return 1; } const result = await cmdResume(storageRoot, threadId); @@ -233,7 +267,7 @@ async function dispatchThreads(storageRoot: string, argv: string[]): Promise { const id = argv[0]; if (id === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: thread requires `); + printCliError(`${formatCliUsage()}\n\nerror: thread requires `); return 1; } const result = await cmdThreadShow(storageRoot, id); @@ -248,7 +282,7 @@ async function dispatchThread(storageRoot: string, argv: string[]): Promise { const id = argv[0]; if (id === undefined || argv.length > 1) { - printCliError(`${usage()}\n\nerror: thread rm requires `); + printCliError(`${formatCliUsage()}\n\nerror: thread rm requires `); return 1; } const result = await cmdThreadRemove(storageRoot, id); @@ -270,7 +304,7 @@ async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promis async function dispatchGc(storageRoot: string, argv: string[]): Promise { if (argv.length > 0) { - printCliError(`${usage()}\n\nerror: gc takes no arguments`); + printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`); return 1; } const result = await cmdGc(storageRoot); @@ -288,7 +322,7 @@ async function dispatchGc(storageRoot: string, argv: string[]): Promise async function dispatchFork(storageRoot: string, argv: string[]): Promise { const parsed = parseForkArgv(argv); if (!parsed.ok) { - printCliError(`${usage()}\n\nerror: ${parsed.error}`); + printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`); return 1; } const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole); @@ -304,7 +338,7 @@ async function dispatchCasGet(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas get requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas get requires `); return 1; } const result = await cmdCasGet(storageRoot, threadId, hash); @@ -320,7 +354,7 @@ async function dispatchCasPut(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas put requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas put requires `); return 1; } const result = await cmdCasPut(storageRoot, threadId, content); @@ -335,7 +369,7 @@ async function dispatchCasPut(storageRoot: string, rest: string[]): Promise { const threadId = rest[0]; if (threadId === undefined || rest.length > 1) { - printCliError(`${usage()}\n\nerror: cas list requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas list requires `); return 1; } const result = await cmdCasList(storageRoot, threadId); @@ -353,7 +387,7 @@ async function dispatchCasRm(storageRoot: string, rest: string[]): Promise 2) { - printCliError(`${usage()}\n\nerror: cas rm requires `); + printCliError(`${formatCliUsage()}\n\nerror: cas rm requires `); return 1; } const result = await cmdCasRm(storageRoot, threadId, hash); @@ -378,12 +412,12 @@ const CAS_SUBCOMMAND_TABLE: Record< async function dispatchCas(storageRoot: string, argv: string[]): Promise { const sub = argv[0]; if (sub === undefined) { - printCliError(`${usage()}\n\nerror: unknown cas subcommand: (none)`); + printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: (none)`); return 1; } const handler = CAS_SUBCOMMAND_TABLE[sub]; if (handler === undefined) { - printCliError(`${usage()}\n\nerror: unknown cas subcommand: ${sub}`); + printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`); return 1; } return handler(storageRoot, argv.slice(1)); @@ -393,6 +427,7 @@ type DispatchFn = (storageRoot: string, argv: string[]) => Promise; const COMMAND_TABLE: Record = { add: dispatchAdd, + init: dispatchInit, list: dispatchList, show: dispatchShow, remove: dispatchRemove, @@ -412,18 +447,18 @@ const COMMAND_TABLE: Record = { export async function runCli(storageRoot: string, argv: string[]): Promise { if (argv.length === 0) { - printCliError(usage()); + printCliError(formatCliUsage()); return 1; } const command = argv[0]; if (command === undefined) { - printCliError(usage()); + printCliError(formatCliUsage()); return 1; } const rest = argv.slice(1); const dispatch = COMMAND_TABLE[command]; if (dispatch === undefined) { - printCliError(`${usage()}\n\nerror: unknown command ${command}`); + printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`); return 1; } return dispatch(storageRoot, rest); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts old mode 100644 new mode 100755 diff --git a/packages/cli-workflow/src/cmd-init.ts b/packages/cli-workflow/src/cmd-init.ts new file mode 100644 index 0000000..21b1f5b --- /dev/null +++ b/packages/cli-workflow/src/cmd-init.ts @@ -0,0 +1,160 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { err, ok, type Result } from "@uncaged/workflow"; + +import { pathExists } from "./fs-utils.js"; + +export type CmdInitWorkspaceSuccess = { + rootPath: string; +}; + +function validateWorkspaceSegment(name: string): Result { + if (name.length === 0) { + return err("workspace name must not be empty"); + } + if (name === "." || name === "..") { + return err("invalid workspace name"); + } + if (name.includes("/") || name.includes("\\")) { + return err("workspace name must not contain path separators"); + } + return ok(undefined); +} + +function rootPackageJson(workspaceName: string): string { + return `${JSON.stringify( + { + name: workspaceName, + private: true, + type: "module", + workspaces: ["templates/*", "workflows"], + }, + null, + 2, + )}\n`; +} + +function workflowsPackageJson(): string { + return `${JSON.stringify( + { + name: "workflows", + version: "0.0.0", + private: true, + type: "module", + dependencies: { + "@uncaged/workflow": "^0.1.0", + zod: "^4.0.0", + }, + }, + null, + 2, + )}\n`; +} + +function biomeJson(): string { + return `${JSON.stringify( + { + $schema: "https://biomejs.dev/schemas/2.4.14/schema.json", + files: { + includes: ["**", "!**/node_modules", "!**/dist"], + }, + formatter: { + indentWidth: 2, + }, + linter: { + enabled: true, + rules: { + recommended: true, + }, + }, + }, + null, + 2, + )}\n`; +} + +function tsconfigJson(): string { + return `${JSON.stringify( + { + compilerOptions: { + strict: true, + target: "ESNext", + module: "ESNext", + moduleResolution: "Bundler", + skipLibCheck: true, + }, + }, + null, + 2, + )}\n`; +} + +function agentsMd(): string { + return `# AGENTS + +Placeholder: coding agent instructions for this workflow workspace will be added in a later phase. +`; +} + +function readmeMd(workspaceName: string): string { + return `# ${workspaceName} + +Local workflow development workspace (Bun monorepo). + +## Layout + +- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding +- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\` + +## Commands + +\`\`\`sh +bun install +bun run check # after you add scripts / Biome +uncaged-workflow add +uncaged-workflow run +\`\`\` + +Create this skeleton with: + +\`\`\`sh +uncaged-workflow init workspace ${workspaceName} +\`\`\` +`; +} + +export async function cmdInitWorkspace( + parentDir: string, + workspaceName: string, +): Promise> { + const validated = validateWorkspaceSegment(workspaceName); + if (!validated.ok) { + return validated; + } + + const rootPath = join(parentDir, workspaceName); + if (await pathExists(rootPath)) { + return err(`directory already exists: ${rootPath}`); + } + + await mkdir(rootPath, { recursive: false }); + await mkdir(join(rootPath, "templates"), { recursive: false }); + await mkdir(join(rootPath, "workflows"), { recursive: false }); + + await Promise.all([ + writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"), + writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"), + writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"), + writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"), + writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"), + writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"), + writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"), + ]); + + return ok({ rootPath }); +} + +export function cmdInitTemplate(_parentDir: string, _templateName: string): Result { + return err("not implemented yet"); +}