diff --git a/packages/cli-workflow/__tests__/init-template.test.ts b/packages/cli-workflow/__tests__/init-template.test.ts new file mode 100644 index 0000000..4db9614 --- /dev/null +++ b/packages/cli-workflow/__tests__/init-template.test.ts @@ -0,0 +1,142 @@ +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 { runCli } from "../src/cli-dispatch.js"; +import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js"; +import { pathExists } from "../src/fs-utils.js"; + +describe("init template", () => { + let parent: string; + + beforeEach(async () => { + parent = join( + tmpdir(), + `wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(parent, { recursive: true }); + }); + + afterEach(async () => { + await rm(parent, { recursive: true, force: true }); + }); + + test("creates templates/ with expected files", async () => { + const ws = await cmdInitWorkspace(parent, "my-workflows"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + + const created = await cmdInitTemplate(root, "review-pr"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const tdir = join(root, "templates", "review-pr"); + expect(created.value.templatePath).toBe(tdir); + expect(await pathExists(join(tdir, "package.json"))).toBe(true); + expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true); + expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true); + expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true); + expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true); + + const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as { + name: string; + type: string; + dependencies: Record; + }; + expect(pkg.type).toBe("module"); + expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined(); + expect(pkg.dependencies.zod).toBeDefined(); + expect(pkg.name).toContain("review-pr"); + + const idx = await readFile(join(tdir, "src", "index.ts"), "utf8"); + expect(idx).toContain("WorkflowDefinition"); + + const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8"); + expect(roles).not.toContain("interface "); + expect(roles).not.toContain("?:"); + expect(roles).not.toContain("export default"); + + const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8"); + expect(moder).not.toContain("export default"); + }); + + test("finds workspace walking up from nested cwd", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + const nested = join(root, "a", "b"); + await mkdir(nested, { recursive: true }); + + const created = await cmdInitTemplate(nested, "nested-tpl"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true); + }); + + test("errors when not inside a workflow workspace", async () => { + const orphan = join(parent, "nowhere"); + await mkdir(orphan, { recursive: true }); + const r = await cmdInitTemplate(orphan, "x"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("templates/*"); + } + }); + + test("errors when template directory already exists", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + + const first = await cmdInitTemplate(root, "dup"); + expect(first.ok).toBe(true); + + const second = await cmdInitTemplate(root, "dup"); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain("already exists"); + } + }); + + test("errors on invalid template name", async () => { + const ws = await cmdInitWorkspace(parent, "ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const bad = await cmdInitTemplate(ws.value.rootPath, "a/b"); + expect(bad.ok).toBe(false); + }); + + test.serial("runCli init template uses cwd and succeeds in workspace", async () => { + const ws = await cmdInitWorkspace(parent, "cli-ws"); + expect(ws.ok).toBe(true); + if (!ws.ok) { + return; + } + const root = ws.value.rootPath; + const prev = process.cwd(); + try { + process.chdir(root); + const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]); + expect(code).toBe(0); + expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true); + } finally { + process.chdir(prev); + } + }); +}); diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts index 1589796..a5a1bc9 100644 --- a/packages/cli-workflow/__tests__/init-workspace.test.ts +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -4,7 +4,7 @@ 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 { cmdInitWorkspace } from "../src/cmd-init.js"; import { pathExists } from "../src/fs-utils.js"; describe("init workspace", () => { @@ -85,14 +85,6 @@ describe("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); @@ -109,9 +101,4 @@ describe("init workspace", () => { 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 4151631..d40fe07 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -65,11 +65,12 @@ async function dispatchInit(_storageRoot: string, argv: string[]): Promise { if (name.length === 0) { return err("workspace name must not be empty"); @@ -155,6 +159,182 @@ export async function cmdInitWorkspace( return ok({ rootPath }); } -export function cmdInitTemplate(_parentDir: string, _templateName: string): Result { - return err("not implemented yet"); +function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean { + return Array.isArray(workspaces) && workspaces.includes("templates/*"); +} + +async function readPackageJsonWorkspaces(dir: string): Promise { + const pkgPath = join(dir, "package.json"); + if (!(await pathExists(pkgPath))) { + return null; + } + let raw: string; + try { + raw = await readFile(pkgPath, "utf8"); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) { + return null; + } + return (parsed as { workspaces: unknown }).workspaces; +} + +/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */ +async function findWorkflowWorkspaceRoot(startDir: string): Promise> { + let dir = resolve(startDir); + for (;;) { + const workspaces = await readPackageJsonWorkspaces(dir); + if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) { + return ok(dir); + } + const parent = dirname(dir); + if (parent === dir) { + return err( + 'not inside a workflow workspace (no package.json with workspaces containing "templates/*")', + ); + } + dir = parent; + } +} + +function templatePackageJson(templateName: string): string { + return `${JSON.stringify( + { + name: `template-${templateName}`, + version: "0.0.0", + private: true, + type: "module", + dependencies: { + "@uncaged/workflow": "^0.1.0", + zod: "^4.0.0", + }, + }, + null, + 2, + )}\n`; +} + +function templateTsconfigJson(): string { + return `${JSON.stringify( + { + extends: "../../tsconfig.json", + compilerOptions: { + rootDir: "src", + outDir: "dist", + }, + include: ["src/**/*.ts"], + }, + null, + 2, + )}\n`; +} + +function templateRolesTs(): string { + return `import type { RoleDefinition } from "@uncaged/workflow"; +import * as z from "zod/v4"; + +export const HELLO_TEMPLATE_DESCRIPTION = + "Minimal starter template: one greeter role, then END."; + +export type HelloTemplateMeta = { + greeter: { + message: string; + }; +}; + +const greeterMetaSchema = z.object({ + message: z.string(), +}); + +export const greeterRole: RoleDefinition = { + description: "Says hello — replace with your first role.", + systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.", + extractPrompt: "Extract the assistant's greeting as message.", + schema: greeterMetaSchema, + extractRefs: null, +}; +`; +} + +function templateModeratorTs(): string { + return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow"; + +import type { HelloTemplateMeta } from "./roles.js"; + +export const helloTemplateModerator: Moderator = ( + ctx: ModeratorContext, +) => { + if (ctx.steps.length === 0) { + return "greeter"; + } + return END; +}; +`; +} + +function templateIndexTs(): string { + return `import type { WorkflowDefinition } from "@uncaged/workflow"; + +import { helloTemplateModerator } from "./moderator.js"; +import { + HELLO_TEMPLATE_DESCRIPTION, + type HelloTemplateMeta, + greeterRole, +} from "./roles.js"; + +export { + HELLO_TEMPLATE_DESCRIPTION, + type HelloTemplateMeta, + greeterRole, +} from "./roles.js"; +export { helloTemplateModerator } from "./moderator.js"; + +export const helloTemplateWorkflowDefinition: WorkflowDefinition = { + description: HELLO_TEMPLATE_DESCRIPTION, + roles: { + greeter: greeterRole, + }, + moderator: helloTemplateModerator, +}; +`; +} + +export async function cmdInitTemplate( + startDir: string, + templateName: string, +): Promise> { + const validated = validateWorkspaceSegment(templateName); + if (!validated.ok) { + return validated; + } + + const rootResult = await findWorkflowWorkspaceRoot(startDir); + if (!rootResult.ok) { + return rootResult; + } + + const workspaceRoot = rootResult.value; + const templateDir = join(workspaceRoot, "templates", templateName); + if (await pathExists(templateDir)) { + return err(`template already exists: ${templateDir}`); + } + + await mkdir(join(templateDir, "src"), { recursive: true }); + + await Promise.all([ + writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"), + writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"), + writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"), + writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"), + writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"), + ]); + + return ok({ templatePath: templateDir }); }