From 610943510d36c591e214a3b0753bc1c4134060da Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:15:20 +0000 Subject: [PATCH 1/6] feat(cli): define command tree with effect/unstable/cli and argv tests Replace the four hand-rolled argument parsers with a single command tree built from effect/unstable/cli. Each command (init, serve, start, runs, log, run) is defined with typed flags and positional arguments. The new argv tests feed argv directly to the parser via Command.runWith rather than bypassing it with structured options. Flag.Int rejects non-numeric values for --port, Flag.Boolean handles --watch correctly, and all required/optional flags are validated by the framework. --- src/cli-argv.test.ts | 369 +++++++++++++++++++++++++++++++++++++++++++ src/cli-commands.ts | 243 ++++++++++++++++++++++++++++ 2 files changed, 612 insertions(+) create mode 100644 src/cli-argv.test.ts create mode 100644 src/cli-commands.ts diff --git a/src/cli-argv.test.ts b/src/cli-argv.test.ts new file mode 100644 index 0000000..0a03573 --- /dev/null +++ b/src/cli-argv.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, test } from "bun:test"; +import { Effect, FileSystem, Layer, Option, Path, Stdio, Terminal } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { factoryCommand } from "./cli-commands"; + +const CliTestLayer = Layer.mergeAll( + FileSystem.layerNoop({}), + Path.layer, + Stdio.layerTest({}), + Layer.succeed( + Terminal.Terminal, + Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.die("unused"), + readLine: Effect.die("unused"), + display: () => Effect.void, + }), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unused")), + ), +); + +function runFactory(argv: ReadonlyArray) { + let captured: unknown; + const testCmd = Command.make("factory").pipe( + Command.withSubcommands([ + Command.make( + "init", + { + dir: Flag.String("dir").pipe(Flag.withDefault(".")), + force: Flag.Boolean("force").pipe(Flag.withDefault(false)), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + Command.make( + "serve", + { + port: Flag.Int("port").pipe(Flag.optional), + db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), + config: Flag.String("config").pipe(Flag.optional), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + Command.make( + "start", + { + workflowId: Argument.String("workflowId"), + input: Flag.String("input"), + url: Flag.String("url").pipe(Flag.optional), + watch: Flag.Boolean("watch").pipe(Flag.withDefault(false)), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + Command.make( + "runs", + { + db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + Command.make( + "log", + { + runId: Argument.String("runId"), + db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + Command.make( + "run", + { + workflowPath: Argument.String("workflowPath"), + input: Flag.String("input"), + dir: Flag.String("dir"), + clone: Flag.String("clone").pipe(Flag.optional), + "git-name": Flag.String("git-name").pipe(Flag.optional), + "git-email": Flag.String("git-email").pipe(Flag.optional), + out: Flag.String("out").pipe(Flag.optional), + db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), + }, + (config) => + Effect.sync(() => { + captured = config; + }), + ), + ]), + ); + + return Effect.gen(function* () { + yield* Command.runWith(testCmd, { version: "0.0.0", renderErrors: false })(argv); + return captured; + }).pipe(Effect.provide(CliTestLayer)); +} + +describe("CLI argv parsing with effect/unstable/cli", () => { + describe("init", () => { + test("defaults: --dir defaults to '.', --force defaults to false", async () => { + const result = await Effect.runPromise(runFactory(["init"])); + const r = result as { dir: string; force: boolean }; + expect(r.dir).toBe("."); + expect(r.force).toBe(false); + }); + + test("--dir and --force flags", async () => { + const result = await Effect.runPromise(runFactory(["init", "--dir", "/tmp/test", "--force"])); + const r = result as { dir: string; force: boolean }; + expect(r.dir).toBe("/tmp/test"); + expect(r.force).toBe(true); + }); + }); + + describe("serve", () => { + test("defaults: --db defaults, --port and --config are optional", async () => { + const result = await Effect.runPromise(runFactory(["serve"])); + const r = result as { + port: Option.Option; + db: string; + config: Option.Option; + }; + expect(r.db).toBe(".factory/factory.db"); + expect(Option.isNone(r.port)).toBe(true); + expect(Option.isNone(r.config)).toBe(true); + }); + + test("--port accepts an integer", async () => { + const result = await Effect.runPromise(runFactory(["serve", "--port", "8080"])); + const r = result as { port: Option.Option }; + expect(Option.getOrNull(r.port)).toBe(8080); + }); + + test("--port rejects a non-numeric value", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["serve", "--port", "abc"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + + test("--db and --config flags", async () => { + const result = await Effect.runPromise( + runFactory(["serve", "--db", "/tmp/factory.db", "--config", "/tmp/factory.config.ts"]), + ); + const r = result as { db: string; config: Option.Option }; + expect(r.db).toBe("/tmp/factory.db"); + expect(Option.getOrNull(r.config)).toBe("/tmp/factory.config.ts"); + }); + }); + + describe("start", () => { + test("required positional workflowId and --input", async () => { + const result = await Effect.runPromise( + runFactory(["start", "my-workflow", "--input", '{"x":1}']), + ); + const r = result as { + workflowId: string; + input: string; + watch: boolean; + url: Option.Option; + }; + expect(r.workflowId).toBe("my-workflow"); + expect(r.input).toBe('{"x":1}'); + expect(r.watch).toBe(false); + expect(Option.isNone(r.url)).toBe(true); + }); + + test("--watch flag is boolean", async () => { + const result = await Effect.runPromise( + runFactory(["start", "wf", "--input", "{}", "--watch"]), + ); + const r = result as { watch: boolean }; + expect(r.watch).toBe(true); + }); + + test("--url flag", async () => { + const result = await Effect.runPromise( + runFactory(["start", "wf", "--input", "{}", "--url", "http://localhost:3000"]), + ); + const r = result as { url: Option.Option }; + expect(Option.getOrNull(r.url)).toBe("http://localhost:3000"); + }); + + test("missing --input fails", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["start", "wf"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + + test("missing workflowId fails", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["start", "--input", "{}"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + }); + + describe("runs", () => { + test("defaults", async () => { + const result = await Effect.runPromise(runFactory(["runs"])); + const r = result as { db: string }; + expect(r.db).toBe(".factory/factory.db"); + }); + + test("--db flag", async () => { + const result = await Effect.runPromise(runFactory(["runs", "--db", "/tmp/factory.db"])); + const r = result as { db: string }; + expect(r.db).toBe("/tmp/factory.db"); + }); + }); + + describe("log", () => { + test("required positional runId and --db default", async () => { + const result = await Effect.runPromise(runFactory(["log", "run-123"])); + const r = result as { runId: string; db: string }; + expect(r.runId).toBe("run-123"); + expect(r.db).toBe(".factory/factory.db"); + }); + + test("missing runId fails", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["log"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + }); + + describe("run", () => { + test("required flags: --input and --dir", async () => { + const result = await Effect.runPromise( + runFactory(["run", "workflow.ts", "--input", '{"a":1}', "--dir", "/tmp/work"]), + ); + const r = result as { + workflowPath: string; + input: string; + dir: string; + db: string; + clone: Option.Option; + out: Option.Option; + }; + expect(r.workflowPath).toBe("workflow.ts"); + expect(r.input).toBe('{"a":1}'); + expect(r.dir).toBe("/tmp/work"); + expect(r.db).toBe(".factory/factory.db"); + expect(Option.isNone(r.clone)).toBe(true); + expect(Option.isNone(r.out)).toBe(true); + }); + + test("--clone group with --git-name and --git-email", async () => { + const result = await Effect.runPromise( + runFactory([ + "run", + "wf.ts", + "--input", + "{}", + "--dir", + "/tmp", + "--clone", + "git@github.com:a/b.git", + "--git-name", + "Factory", + "--git-email", + "factory@test.com", + ]), + ); + const r = result as { + clone: Option.Option; + "git-name": Option.Option; + "git-email": Option.Option; + }; + expect(Option.getOrNull(r.clone)).toBe("git@github.com:a/b.git"); + expect(Option.getOrNull(r["git-name"])).toBe("Factory"); + expect(Option.getOrNull(r["git-email"])).toBe("factory@test.com"); + }); + + test("--out and --db flags", async () => { + const result = await Effect.runPromise( + runFactory([ + "run", + "wf.ts", + "--input", + "{}", + "--dir", + "/tmp", + "--out", + "/tmp/events.ndjson", + "--db", + "/tmp/factory.db", + ]), + ); + const r = result as { out: Option.Option; db: string }; + expect(Option.getOrNull(r.out)).toBe("/tmp/events.ndjson"); + expect(r.db).toBe("/tmp/factory.db"); + }); + + test("missing --dir fails", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["run", "wf.ts", "--input", "{}"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + }); + + describe("help", () => { + test("--help completes (help is generated by the CLI definition)", async () => { + await Effect.runPromise(runFactory(["--help"])); + }); + + test("subcommand --help completes (help is generated per subcommand)", async () => { + await Effect.runPromise(runFactory(["serve", "--help"])); + }); + }); + + describe("unknown flags", () => { + test("unknown flag is rejected", async () => { + let failed = false; + try { + await Effect.runPromise(runFactory(["serve", "--nope"])); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + }); +}); + +describe("factoryCommand exports", () => { + test("the root command has the expected subcommands", () => { + const names = factoryCommand.subcommands.flatMap((g) => g.commands.map((c) => c.name)); + expect(names).toContain("init"); + expect(names).toContain("serve"); + expect(names).toContain("start"); + expect(names).toContain("runs"); + expect(names).toContain("log"); + expect(names).toContain("run"); + }); +}); diff --git a/src/cli-commands.ts b/src/cli-commands.ts new file mode 100644 index 0000000..d49d176 --- /dev/null +++ b/src/cli-commands.ts @@ -0,0 +1,243 @@ +import { Effect, Option } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import { findFactoryConfig, loadFactoryConfig } from "./config"; +import { initCli } from "./init"; +import { opencodeAdapter } from "./runtime/opencode-adapter"; +import { resolve } from "node:path"; +import { + runCli, + listRunsCli, + logRunCli, + startCli, + type CliOptions, + type StartCliOptions, +} from "./cli"; +import { startDaemon, type DaemonOptions } from "./server/daemon"; + +const DEFAULT_DB_PATH = ".factory/factory.db"; +const DEFAULT_DAEMON_URL = "http://localhost:3000"; + +export const initCommand = Command.make( + "init", + { + dir: Flag.String("dir").pipe( + Flag.withDefault("."), + Flag.withDescription("Directory to scaffold .factory/ in"), + ), + force: Flag.Boolean("force").pipe( + Flag.withDefault(false), + Flag.withDescription("Overwrite existing files"), + ), + }, + (config) => + Effect.gen(function* () { + const exitCode = yield* Effect.promise(() => + initCli({ + cwd: resolve(config.dir), + force: config.force, + }), + ); + return yield* Effect.sync(() => process.exit(exitCode)); + }), +).pipe(Command.withDescription("Scaffold .factory/ with a config and a starter workflow")); + +export const serveCommand = Command.make( + "serve", + { + port: Flag.Int("port").pipe( + Flag.optional, + Flag.withDescription("Port to listen on (default: 3000)"), + ), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), + config: Flag.String("config").pipe( + Flag.optional, + Flag.withDescription("Path to factory.config.ts (auto-detected when omitted)"), + ), + }, + (config) => + Effect.gen(function* () { + const configPath = Option.getOrUndefined(config.config); + const resolvedConfigPath = configPath ?? (yield* Effect.promise(() => findFactoryConfig())); + const configLoaded = + resolvedConfigPath !== undefined + ? yield* Effect.promise(() => Promise.resolve(loadFactoryConfig(resolvedConfigPath))) + : undefined; + + const daemonOptions: DaemonOptions = { + dbPath: config.db, + port: Option.getOrUndefined(config.port), + ...(resolvedConfigPath !== undefined ? { config: configLoaded } : {}), + }; + + const handle = yield* Effect.promise(() => startDaemon(daemonOptions)); + yield* Effect.sync(() => + console.log(`factory serve: listening on http://localhost:${handle.server.port}`), + ); + yield* Effect.sync(() => + console.log( + handle.schedulerFiber !== undefined + ? "scheduler: running (config schedules)" + : "scheduler: none (no schedules in config)", + ), + ); + }), +).pipe(Command.withDescription("Run the daemon: HTTP API, live event stream, and the web UI")); + +export const startCommand = Command.make( + "start", + { + workflowId: Argument.String("workflowId").pipe( + Argument.withDescription("The workflow id to start"), + ), + input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), + url: Flag.String("url").pipe( + Flag.optional, + Flag.withDescription("Base URL of the daemon (or set $FACTORY_URL)"), + ), + watch: Flag.Boolean("watch").pipe( + Flag.withDefault(false), + Flag.withDescription("Stream the run's events and exit with its exit code"), + ), + }, + (config) => + Effect.gen(function* () { + let input: unknown; + try { + input = JSON.parse(config.input); + } catch (err) { + console.error( + `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const options: StartCliOptions = { + workflowId: config.workflowId, + input, + watch: config.watch, + baseUrl: Option.getOrElse(config.url, () => process.env.FACTORY_URL ?? DEFAULT_DAEMON_URL), + }; + + const exitCode = yield* Effect.promise(() => startCli(options)); + return yield* Effect.sync(() => process.exit(exitCode)); + }), +).pipe( + Command.withDescription( + "Start a run on a running daemon. --watch streams it and exits 0 completed / 1 failed / 130 cancelled.", + ), +); + +export const runsCommand = Command.make( + "runs", + { + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), + }, + (config) => + Effect.sync(() => { + listRunsCli(config.db); + }), +).pipe(Command.withDescription("List every run this project has recorded")); + +export const logCommand = Command.make( + "log", + { + runId: Argument.String("runId").pipe(Argument.withDescription("The run id to replay")), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), + }, + (config) => + Effect.sync(() => { + logRunCli(config.db, config.runId); + }), +).pipe(Command.withDescription("Replay one run's full event history")); + +export const runCommand = Command.make( + "run", + { + workflowPath: Argument.String("workflowPath").pipe( + Argument.withDescription("Path to the workflow .ts file"), + ), + input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), + dir: Flag.String("dir").pipe(Flag.withDescription("Working directory for the run")), + clone: Flag.String("clone").pipe( + Flag.optional, + Flag.withDescription("SSH URL to clone before running"), + ), + "git-name": Flag.String("git-name").pipe( + Flag.optional, + Flag.withDescription("Git committer name (required with --clone)"), + ), + "git-email": Flag.String("git-email").pipe( + Flag.optional, + Flag.withDescription("Git committer email (required with --clone)"), + ), + out: Flag.String("out").pipe( + Flag.optional, + Flag.withDescription("Path for the NDJSON event log"), + ), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), + }, + (config) => + Effect.gen(function* () { + let input: unknown; + try { + input = JSON.parse(config.input); + } catch (err) { + console.error( + `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const sshUrl = Option.getOrUndefined(config.clone); + let clone: CliOptions["clone"]; + if (sshUrl !== undefined) { + const name = Option.getOrUndefined(config["git-name"]); + const email = Option.getOrUndefined(config["git-email"]); + if (name === undefined || email === undefined) { + console.error("error: --clone requires --git-name and --git-email"); + return yield* Effect.sync(() => process.exit(1)); + } + clone = { sshUrl, identity: { name, email } }; + } + + const options: CliOptions = { + workflowPath: config.workflowPath, + input, + dir: config.dir, + clone, + outPath: Option.getOrElse( + config.out, + () => `.factory/runs/run-${Date.now()}/events.ndjson`, + ), + dbPath: config.db, + adapter: opencodeAdapter, + }; + + const exitCode = yield* Effect.promise(() => runCli(options)); + return yield* Effect.sync(() => process.exit(exitCode)); + }), +).pipe(Command.withDescription("Run one workflow file directly — no daemon, no UI")); + +export const factoryCommand = Command.make("factory").pipe( + Command.withDescription("factory — imperative TypeScript workflows over coding agents."), + Command.withSubcommands([ + initCommand, + serveCommand, + startCommand, + runsCommand, + logCommand, + runCommand, + ]), +); From a5917acb937804dc749fb3af4a734f71436f9e5d Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:15:26 +0000 Subject: [PATCH 2/6] refactor(cli): use effect/unstable/cli command tree, remove hand-rolled parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the import.meta.main dispatch block with Command.run(factoryCommand). Remove the USAGE constant, parseFlags, parseArgs, parseStartArgs, parseServeArgs, and usageError — help text is now generated from the command definitions, flag values are validated before reaching command bodies, and no process.exit call remains inside a parsing function. The command body functions (runCli, startCli, listRunsCli, logRunCli, watchSse) are untouched — they are wrapped at the Effect boundary in cli-commands.ts. Exit codes are preserved: 0 completed, 1 failed, 130 cancelled. --- src/cli.ts | 229 ++++++++--------------------------------------------- 1 file changed, 33 insertions(+), 196 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9f6d166..9ec97fa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,22 +15,21 @@ */ import { mkdir } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; +import { Effect, FileSystem, Layer, Path, Stdio, Terminal } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { Command } from "effect/unstable/cli"; import type { RunEvent } from "./events"; -import { findFactoryConfig, loadFactoryConfig } from "./config"; -import { initCli } from "./init"; +import { loadFactoryConfig } from "./config"; import type { RunRepo } from "./runtime/run"; -import { resetClone, type GitIdentity } from "./lib/clone"; +import type { GitIdentity } from "./lib/clone"; +import { resetClone } from "./lib/clone"; import { loadWorkflow } from "./lib/load-workflow"; import { streamSse } from "./lib/sse-client"; import { appendEvent, getRunEvents, listRuns, openStore } from "./persistence/store"; import type { AgentAdapter } from "./runtime/agent-adapter"; -import { opencodeAdapter } from "./runtime/opencode-adapter"; import { startRun } from "./runtime/run"; -import { startDaemon, type DaemonOptions } from "./server/daemon"; - -const DEFAULT_DB_PATH = ".factory/factory.db"; -const DEFAULT_DAEMON_URL = "http://localhost:3000"; +import { factoryCommand } from "./cli-commands"; export interface CliOptions { readonly workflowPath: string; @@ -232,193 +231,31 @@ export async function startCli(options: StartCliOptions): Promise { return watchSse(options.baseUrl, body.runId); } -const USAGE = [ - "factory — imperative TypeScript workflows over coding agents.", - "", - "usage:", - " factory init [--dir ] [--force]", - " Scaffold .factory/ with a config and a starter workflow. Never", - " overwrites an existing file unless --force is given.", - "", - " factory serve [--port ] [--db ] [--config ]", - " Run the daemon: HTTP API, live event stream, and the web UI.", - " Finds .factory/factory.config.ts on its own when --config is omitted.", - "", - " factory start --input [--url ] [--watch]", - " Start a run on a running daemon. --watch streams it and exits 0", - " completed / 1 failed / 130 cancelled. Honours $FACTORY_URL.", - "", - " factory runs [--db ]", - " List every run this project has recorded.", - "", - " factory log [--db ]", - " Replay one run's full event history.", - "", - " factory run --input --dir ", - " [--clone --git-name --git-email ]", - " [--out ] [--db ]", - " Run one workflow file directly — no daemon, no UI.", - "", -].join("\n"); - -function usageError(message: string): never { - console.error(`error: ${message}`); - console.error(""); - console.error(USAGE); - process.exit(1); -} - -function parseFlags(argv: ReadonlyArray, startAt: number): Map { - const flags = new Map(); - for (let i = startAt; i < argv.length; i += 2) { - const key = argv[i]; - const value = argv[i + 1]; - if (key === undefined || !key.startsWith("--") || value === undefined) { - usageError(`malformed flag at position ${i}: ${key ?? ""}`); - } - flags.set(key.slice(2), value); - } - return flags; -} - -function parseArgs(argv: ReadonlyArray): CliOptions { - if (argv[0] !== "run" || argv[1] === undefined) { - usageError("expected: factory run ..."); - } - - const workflowPath = argv[1] as string; - const flags = parseFlags(argv, 2); - - const inputRaw = flags.get("input"); - const dir = flags.get("dir"); - const out = flags.get("out") ?? `.factory/runs/run-${Date.now()}/events.ndjson`; - const dbPath = flags.get("db") ?? DEFAULT_DB_PATH; - if (inputRaw === undefined) usageError("--input is required"); - if (dir === undefined) usageError("--dir is required"); - - let input: unknown; - try { - input = JSON.parse(inputRaw); - } catch (err) { - usageError(`--input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); - } - - const sshUrl = flags.get("clone"); - let clone: CliOptions["clone"]; - if (sshUrl !== undefined) { - const name = flags.get("git-name"); - const email = flags.get("git-email"); - if (name === undefined || email === undefined) { - usageError("--clone requires --git-name and --git-email"); - } - clone = { sshUrl, identity: { name, email } }; - } - - return { workflowPath, input, dir, clone, outPath: out, dbPath, adapter: opencodeAdapter }; -} - -function parseStartArgs(argv: ReadonlyArray): StartCliOptions { - const workflowId = argv[1]; - if (workflowId === undefined) usageError("expected: factory start ..."); - - let inputRaw: string | undefined; - let url: string | undefined; - let watch = false; - for (let i = 2; i < argv.length; i++) { - const flag = argv[i]; - if (flag === "--input") { - inputRaw = argv[++i]; - if (inputRaw === undefined) usageError("--input needs a JSON value"); - } else if (flag === "--url") { - url = argv[++i]; - if (url === undefined) usageError("--url needs a base URL"); - } else if (flag === "--watch") { - watch = true; - } else { - usageError(`unknown flag: ${flag ?? ""}`); - } - } - - if (inputRaw === undefined) usageError("--input is required"); - - let input: unknown; - try { - input = JSON.parse(inputRaw); - } catch (err) { - usageError(`--input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); - } - - return { - workflowId, - input, - watch, - baseUrl: url ?? process.env.FACTORY_URL ?? DEFAULT_DAEMON_URL, - }; -} - -async function parseServeArgs(argv: ReadonlyArray): Promise { - const flags = parseFlags(argv, 1); - const dbPath = flags.get("db") ?? DEFAULT_DB_PATH; - const portRaw = flags.get("port"); - const port = portRaw !== undefined ? Number(portRaw) : undefined; - // No `--config` means "find the project I am standing in" — the `factory - // init` layout first, then the pre-init root config. Finding neither is not - // an error: the daemon still serves the UI and the phase 3 path-based API, - // just with an empty workflow registry. - const configPath = flags.get("config") ?? (await findFactoryConfig()); - const configLoaded = - configPath !== undefined ? loadFactoryConfig(configPath) : Promise.resolve(undefined); - - return { - dbPath, - port, - ...(configPath !== undefined ? { config: await configLoaded } : {}), - }; -} +const CliEnvLayer = Layer.mergeAll( + FileSystem.layerNoop({}), + Path.layer, + Stdio.layerTest({ args: Effect.succeed(process.argv.slice(2)) }), + Layer.succeed( + Terminal.Terminal, + Terminal.make({ + columns: Effect.succeed(process.stdout.columns || 80), + rows: Effect.succeed(process.stdout.rows || 24), + readInput: Effect.die("unused"), + readLine: Effect.die("unused"), + display: () => Effect.void, + }), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unused")), + ), +); if (import.meta.main) { - const argv = process.argv.slice(2); - if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") { - console.log(USAGE); - process.exit(0); - } else if (argv[0] === "init") { - const flags = new Map(); - for (let i = 1; i < argv.length; i++) { - const flag = argv[i]; - if (flag === "--force") flags.set("force", true); - else if (flag === "--dir") { - const dir = argv[++i]; - if (dir === undefined) usageError("--dir needs a path"); - flags.set("dir", dir); - } else usageError(`unknown flag: ${flag ?? ""}`); - } - const dir = flags.get("dir"); - const exitCode = await initCli({ - cwd: typeof dir === "string" ? resolve(dir) : process.cwd(), - force: flags.get("force") === true, - }); - process.exit(exitCode); - } else if (argv[0] === "runs") { - listRunsCli(parseFlags(argv, 1).get("db") ?? DEFAULT_DB_PATH); - } else if (argv[0] === "log") { - const runId = argv[1]; - if (runId === undefined) usageError("expected: factory log ..."); - logRunCli(parseFlags(argv, 2).get("db") ?? DEFAULT_DB_PATH, runId); - } else if (argv[0] === "start") { - const exitCode = await startCli(parseStartArgs(argv)); - process.exit(exitCode); - } else if (argv[0] === "serve") { - const daemonOptions = await parseServeArgs(argv); - const { server, schedulerFiber } = await startDaemon(daemonOptions); - console.log(`factory serve: listening on http://localhost:${server.port}`); - console.log( - schedulerFiber !== undefined - ? "scheduler: running (config schedules)" - : "scheduler: none (no schedules in config)", - ); - } else { - const options = parseArgs(argv); - const exitCode = await runCli(options); - process.exit(exitCode); - } + const program = Command.run(factoryCommand, { version: "0.0.0" }).pipe( + Effect.provide(CliEnvLayer), + ); + Effect.runPromise(program).catch(() => { + process.exit(1); + }); } From a5023341bfdf279ccda0910962aab19d64189504 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Wed, 23 Sep 2026 06:42:13 +0000 Subject: [PATCH 3/6] fix(cli): exit 0 for no-args and --help, keep exit 1 for real parse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command.run(factoryCommand) fails with CliError.ShowHelp both when no subcommand is given and for genuine parse/validation errors, so the blanket `.catch(() => process.exit(1))` was mapping bare `factory` (and anything else that only renders help) to exit 1 instead of the pre-effect/unstable/cli behaviour of exit 0. ShowHelp.errors distinguishes the two cases — empty means "help was all that happened" — so only map that case to exit 0; a populated errors array (bad flag value, unknown flag, missing argument, ...) still exits 1. Avoid delegating to Runtime.defaultTeardown/makeRunMain for this: it calls process.exit(0) on any successful Effect completion, which would kill `factory serve` right after it starts its long-lived HTTP server. Adds subprocess-spawned regression tests in cli-argv.test.ts pinning the no-args/--help/-h exit codes and confirming --port abc and an unknown flag still exit non-zero — the exit-code mapping lives in cli.ts's import.meta.main block, so it's only observable by running the binary. --- src/cli-argv.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 24 ++++++++++++++++--- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/cli-argv.test.ts b/src/cli-argv.test.ts index 0a03573..1a5594f 100644 --- a/src/cli-argv.test.ts +++ b/src/cli-argv.test.ts @@ -4,6 +4,12 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcessSpawner } from "effect/unstable/process"; import { factoryCommand } from "./cli-commands"; +const CLI = `${import.meta.dir}/cli.ts`; + +async function readAll(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + const CliTestLayer = Layer.mergeAll( FileSystem.layerNoop({}), Path.layer, @@ -367,3 +373,54 @@ describe("factoryCommand exports", () => { expect(names).toContain("run"); }); }); + +// The exit-code mapping for `Command.run`'s ShowHelp failure lives in the +// `import.meta.main` block at the bottom of cli.ts, not in `runWith` (which +// the `runFactory` helper above exercises in-process). It's only observable +// by actually running the binary as a subprocess, matching the style of +// cli.start.test.ts / cli.crash.test.ts. +describe("factory binary: process exit codes", () => { + test("bare `factory` with no args prints root help to stdout and exits 0", async () => { + const proc = Bun.spawn(["bun", CLI], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await readAll(proc.stdout); + const stderr = await readAll(proc.stderr); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE"); + expect(stderr).toBe(""); + }); + + test("`factory --help` prints help and exits 0", async () => { + const proc = Bun.spawn(["bun", CLI, "--help"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await readAll(proc.stdout); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE"); + }); + + test("`factory -h` prints help and exits 0", async () => { + const proc = Bun.spawn(["bun", CLI, "-h"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + }); + + test("a genuine parse error (--port abc) still exits non-zero", async () => { + const proc = Bun.spawn(["bun", CLI, "serve", "--port", "abc"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + expect(exitCode).not.toBe(0); + }); + + test("an unknown flag still exits non-zero", async () => { + const proc = Bun.spawn(["bun", CLI, "serve", "--nope"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + + expect(exitCode).not.toBe(0); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 9ec97fa..04e3158 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { Effect, FileSystem, Layer, Path, Stdio, Terminal } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Command } from "effect/unstable/cli"; +import { CliError, Command } from "effect/unstable/cli"; import type { RunEvent } from "./events"; import { loadFactoryConfig } from "./config"; import type { RunRepo } from "./runtime/run"; @@ -255,7 +255,25 @@ if (import.meta.main) { const program = Command.run(factoryCommand, { version: "0.0.0" }).pipe( Effect.provide(CliEnvLayer), ); - Effect.runPromise(program).catch(() => { - process.exit(1); + // `Command.run` fails with `CliError.ShowHelp` both for genuine parse errors + // and for "no subcommand given" / explicit `--help` (help is rendered by the + // command definition either way). `ShowHelp.errors` distinguishes them: a + // non-empty array is a real parse/validation failure (exit 1), an empty + // array means help was all that happened (exit 0) — matching this error's + // own documented exit-code mapping. + // + // We check that by hand instead of delegating to `Runtime.defaultTeardown` + // (the library's usual `makeRunMain`-style teardown): that helper calls + // `process.exit(0)` on *any* successful `Effect` completion, but `factory + // serve`'s handler effect resolves right after starting the long-lived + // HTTP server — forcing an exit there would kill the daemon immediately + // after startup. Only failures get an explicit exit call here; a + // successful run falls through to whatever keeps (or doesn't keep) the + // process alive on its own, same as before this file started handling + // `ShowHelp` specially. + Effect.runPromise(program).catch((error: unknown) => { + const isHelpOnly = + CliError.isCliError(error) && error._tag === "ShowHelp" && error.errors.length === 0; + if (!isHelpOnly) process.exit(1); }); } From 90186bab152627acaa5b4b0d74d66c2b20767c51 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Wed, 23 Sep 2026 06:42:24 +0000 Subject: [PATCH 4/6] docs(cli): explain why CliEnvLayer stays test-shaped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit effect@4.0.0-rc.115 only exports test/noop constructors for Stdio/Terminal/ FileSystem/ChildProcessSpawner (Stdio.layerTest, FileSystem.layerNoop, Terminal.make, ChildProcessSpawner.make) — no @effect/platform-node or @effect/platform-bun equivalent is installed, so there is no real platform layer to swap CliEnvLayer for. Record why the test-fixture shapes are used for the real binary and which paths would break if they were ever exercised, so the next reader doesn't have to re-derive it. --- src/cli.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 04e3158..a1ff987 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -231,6 +231,21 @@ export async function startCli(options: StartCliOptions): Promise { return watchSse(options.baseUrl, body.runId); } +// `effect@4.0.0-rc.115` ships no real platform layer for Stdio/Terminal/ +// FileSystem/ChildProcessSpawner — no `@effect/platform-node` or +// `@effect/platform-bun` equivalent is installed, and this rc only exports +// test/noop constructors (`Stdio.layerTest`, `FileSystem.layerNoop`, +// `Terminal.make`, `ChildProcessSpawner.make`). This "environment" is +// therefore assembled from those constructors even for the real binary, with +// `args`/`columns`/`rows` wired to the real process so `effect/unstable/cli` +// sees real argv and terminal size. `readInput`/`readLine` (used by +// `Prompt`/`--wizard`) and `display` are stubbed and would die or no-op if +// exercised, and `ChildProcessSpawner` dies on use — none of the commands +// below hit those paths today: help/error text renders via `Console` +// (real stdout/stderr) rather than the injected `Stdio` sink or +// `Terminal.display`, and opencode is spawned elsewhere via `@tanstack/ai`, +// not through `ChildProcessSpawner`. Revisit once a real platform adapter is +// available, or before `--wizard` ships. const CliEnvLayer = Layer.mergeAll( FileSystem.layerNoop({}), Path.layer, From a671bf812845aa6979b457628774168c22c32c75 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 08:56:00 +0000 Subject: [PATCH 5/6] fix(cli): print runtime errors, restore the help alias, read --version from package.json Handler failures (rejected promises surfaced as defects, a bad --config, an unopenable --db) exited 1 with empty stderr; print the underlying error unless Command.run already rendered it as a CliError. `factory help [cmd]` maps to `[cmd] --help` again, and --version reads package.json with a 0.0.0-dev fallback instead of a hard-coded 0.0.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cli.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a1ff987..e2c91c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,7 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import { Effect, FileSystem, Layer, Path, Stdio, Terminal } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Stdio, Terminal } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliError, Command } from "effect/unstable/cli"; import type { RunEvent } from "./events"; @@ -246,10 +246,10 @@ export async function startCli(options: StartCliOptions): Promise { // `Terminal.display`, and opencode is spawned elsewhere via `@tanstack/ai`, // not through `ChildProcessSpawner`. Revisit once a real platform adapter is // available, or before `--wizard` ships. -const CliEnvLayer = Layer.mergeAll( +export const CliEnvLayer = Layer.mergeAll( FileSystem.layerNoop({}), Path.layer, - Stdio.layerTest({ args: Effect.succeed(process.argv.slice(2)) }), + Stdio.layerTest({ args: Effect.succeed(normalizeArgv(process.argv.slice(2))) }), Layer.succeed( Terminal.Terminal, Terminal.make({ @@ -266,8 +266,47 @@ const CliEnvLayer = Layer.mergeAll( ), ); +/** + * `factory help [subcommand...]` is kept as an alias for `--help` (it was one + * before the move to `effect/unstable/cli`, which has no `help` subcommand): + * `help serve` becomes `serve --help`. + */ +export function normalizeArgv(argv: ReadonlyArray): ReadonlyArray { + return argv[0] === "help" ? [...argv.slice(1), "--help"] : argv; +} + +/** `package.json` carries no version until semantic-release stamps one at publish. */ +async function readVersion(): Promise { + try { + const pkg = (await Bun.file(new URL("../package.json", import.meta.url)).json()) as { + version?: unknown; + }; + return typeof pkg.version === "string" ? pkg.version : "0.0.0-dev"; + } catch { + return "0.0.0-dev"; + } +} + +/** + * Map a failed CLI exit to stderr output. `Command.run` already renders its + * own `CliError`s (help, parse errors, `UserError`), so those print nothing + * more here; anything else — a handler's rejected promise surfaced as a + * defect, a bad `--config`, an unopenable `--db` — would otherwise vanish + * behind a bare exit 1, so print the underlying error (with its stack). + * Returns the exit code, or `undefined` when only help was shown. + */ +export function reportCliFailure(cause: Cause.Cause): number | undefined { + const error = Cause.findErrorOption(cause); + if (Option.isSome(error) && CliError.isCliError(error.value)) { + const helpOnly = error.value._tag === "ShowHelp" && error.value.errors.length === 0; + return helpOnly ? undefined : 1; + } + console.error(Cause.squash(cause)); + return 1; +} + if (import.meta.main) { - const program = Command.run(factoryCommand, { version: "0.0.0" }).pipe( + const program = Command.run(factoryCommand, { version: await readVersion() }).pipe( Effect.provide(CliEnvLayer), ); // `Command.run` fails with `CliError.ShowHelp` both for genuine parse errors @@ -275,7 +314,8 @@ if (import.meta.main) { // command definition either way). `ShowHelp.errors` distinguishes them: a // non-empty array is a real parse/validation failure (exit 1), an empty // array means help was all that happened (exit 0) — matching this error's - // own documented exit-code mapping. + // own documented exit-code mapping. `reportCliFailure` makes that call and + // prints every non-`CliError` failure, which `Command.run` does not render. // // We check that by hand instead of delegating to `Runtime.defaultTeardown` // (the library's usual `makeRunMain`-style teardown): that helper calls @@ -286,9 +326,9 @@ if (import.meta.main) { // successful run falls through to whatever keeps (or doesn't keep) the // process alive on its own, same as before this file started handling // `ShowHelp` specially. - Effect.runPromise(program).catch((error: unknown) => { - const isHelpOnly = - CliError.isCliError(error) && error._tag === "ShowHelp" && error.errors.length === 0; - if (!isHelpOnly) process.exit(1); + void Effect.runPromiseExit(program).then((exit) => { + if (Exit.isSuccess(exit)) return; + const exitCode = reportCliFailure(exit.cause); + if (exitCode !== undefined) process.exit(exitCode); }); } From 989bb7f23385a0b8d4ee73887f411109e4b818a1 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 08:56:00 +0000 Subject: [PATCH 6/6] test(cli): parse argv against the real command specs Export each subcommand's flag/argument config from cli-commands.ts so the argv tests reuse it (and CliEnvLayer) instead of a hand-copied tree, and cover the help alias and printed runtime errors in the binary exit tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cli-argv.test.ts | 209 ++++++++++++------------ src/cli-commands.ts | 376 +++++++++++++++++++++---------------------- 2 files changed, 287 insertions(+), 298 deletions(-) diff --git a/src/cli-argv.test.ts b/src/cli-argv.test.ts index 1a5594f..572c942 100644 --- a/src/cli-argv.test.ts +++ b/src/cli-argv.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, test } from "bun:test"; -import { Effect, FileSystem, Layer, Option, Path, Stdio, Terminal } from "effect"; -import { Argument, Command, Flag } from "effect/unstable/cli"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { factoryCommand } from "./cli-commands"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Option } from "effect"; +import { Command } from "effect/unstable/cli"; +import { CliEnvLayer, normalizeArgv } from "./cli"; +import { + factoryCommand, + initConfig, + logConfig, + runConfig, + runsConfig, + serveConfig, + startConfig, +} from "./cli-commands"; const CLI = `${import.meta.dir}/cli.ts`; @@ -10,111 +21,31 @@ async function readAll(stream: ReadableStream): Promise { return new Response(stream).text(); } -const CliTestLayer = Layer.mergeAll( - FileSystem.layerNoop({}), - Path.layer, - Stdio.layerTest({}), - Layer.succeed( - Terminal.Terminal, - Terminal.make({ - columns: Effect.succeed(80), - rows: Effect.succeed(24), - readInput: Effect.die("unused"), - readLine: Effect.die("unused"), - display: () => Effect.void, - }), - ), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unused")), - ), -); - +// Each subcommand is rebuilt from the real flag/argument spec exported by +// cli-commands.ts, with a handler that only captures the parsed config — so +// these tests parse exactly what the binary parses without running opencode, +// the daemon, or sqlite. function runFactory(argv: ReadonlyArray) { let captured: unknown; + const capture = (config: unknown) => + Effect.sync(() => { + captured = config; + }); const testCmd = Command.make("factory").pipe( Command.withSubcommands([ - Command.make( - "init", - { - dir: Flag.String("dir").pipe(Flag.withDefault(".")), - force: Flag.Boolean("force").pipe(Flag.withDefault(false)), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), - Command.make( - "serve", - { - port: Flag.Int("port").pipe(Flag.optional), - db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), - config: Flag.String("config").pipe(Flag.optional), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), - Command.make( - "start", - { - workflowId: Argument.String("workflowId"), - input: Flag.String("input"), - url: Flag.String("url").pipe(Flag.optional), - watch: Flag.Boolean("watch").pipe(Flag.withDefault(false)), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), - Command.make( - "runs", - { - db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), - Command.make( - "log", - { - runId: Argument.String("runId"), - db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), - Command.make( - "run", - { - workflowPath: Argument.String("workflowPath"), - input: Flag.String("input"), - dir: Flag.String("dir"), - clone: Flag.String("clone").pipe(Flag.optional), - "git-name": Flag.String("git-name").pipe(Flag.optional), - "git-email": Flag.String("git-email").pipe(Flag.optional), - out: Flag.String("out").pipe(Flag.optional), - db: Flag.String("db").pipe(Flag.withDefault(".factory/factory.db")), - }, - (config) => - Effect.sync(() => { - captured = config; - }), - ), + Command.make("init", initConfig, capture), + Command.make("serve", serveConfig, capture), + Command.make("start", startConfig, capture), + Command.make("runs", runsConfig, capture), + Command.make("log", logConfig, capture), + Command.make("run", runConfig, capture), ]), ); return Effect.gen(function* () { yield* Command.runWith(testCmd, { version: "0.0.0", renderErrors: false })(argv); return captured; - }).pipe(Effect.provide(CliTestLayer)); + }).pipe(Effect.provide(CliEnvLayer)); } describe("CLI argv parsing with effect/unstable/cli", () => { @@ -363,14 +294,21 @@ describe("CLI argv parsing with effect/unstable/cli", () => { }); describe("factoryCommand exports", () => { - test("the root command has the expected subcommands", () => { + test("the root command has exactly the subcommands exercised above", () => { const names = factoryCommand.subcommands.flatMap((g) => g.commands.map((c) => c.name)); - expect(names).toContain("init"); - expect(names).toContain("serve"); - expect(names).toContain("start"); - expect(names).toContain("runs"); - expect(names).toContain("log"); - expect(names).toContain("run"); + expect(names.toSorted()).toEqual(["init", "log", "run", "runs", "serve", "start"]); + }); +}); + +describe("normalizeArgv", () => { + test("`help` is an alias for --help, on the root and on a subcommand", () => { + expect(normalizeArgv(["help"])).toEqual(["--help"]); + expect(normalizeArgv(["help", "serve"])).toEqual(["serve", "--help"]); + }); + + test("anything else passes through untouched", () => { + expect(normalizeArgv(["serve", "--port", "1"])).toEqual(["serve", "--port", "1"]); + expect(normalizeArgv([])).toEqual([]); }); }); @@ -407,6 +345,24 @@ describe("factory binary: process exit codes", () => { expect(exitCode).toBe(0); }); + test("`factory help` prints help and exits 0", async () => { + const proc = Bun.spawn(["bun", CLI, "help"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await readAll(proc.stdout); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE"); + }); + + test("`factory help serve` prints the subcommand's help and exits 0", async () => { + const proc = Bun.spawn(["bun", CLI, "help", "serve"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await readAll(proc.stdout); + + expect(exitCode).toBe(0); + expect(stdout).toContain("factory serve"); + }); + test("a genuine parse error (--port abc) still exits non-zero", async () => { const proc = Bun.spawn(["bun", CLI, "serve", "--port", "abc"], { stdout: "pipe", @@ -423,4 +379,43 @@ describe("factory binary: process exit codes", () => { expect(exitCode).not.toBe(0); }); + + // A handler's runtime failure (a rejected promise or a throw inside + // `Effect.promise`/`Effect.sync`) is a defect, not a `CliError`, so + // `Command.run` renders nothing for it — the binary must print it itself. + describe("runtime errors are printed, not swallowed", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "factory-cli-argv-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("an unopenable --db prints the sqlite error and exits 1", async () => { + const proc = Bun.spawn(["bun", CLI, "runs", "--db", join(dir, "missing", "x.db")], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stderr = await readAll(proc.stderr); + + expect(exitCode).toBe(1); + expect(stderr).toContain("unable to open database file"); + }); + + test("a --config that fails to load prints the error and exits 1", async () => { + const configPath = join(dir, "factory.config.ts"); + writeFileSync(configPath, 'throw new Error("boom from config");\n'); + const proc = Bun.spawn( + ["bun", CLI, "serve", "--config", configPath, "--db", join(dir, "f.db"), "--port", "0"], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stderr = await readAll(proc.stderr); + + expect(exitCode).toBe(1); + expect(stderr).toContain("boom from config"); + }); + }); }); diff --git a/src/cli-commands.ts b/src/cli-commands.ts index d49d176..651217e 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -17,217 +17,211 @@ import { startDaemon, type DaemonOptions } from "./server/daemon"; const DEFAULT_DB_PATH = ".factory/factory.db"; const DEFAULT_DAEMON_URL = "http://localhost:3000"; -export const initCommand = Command.make( - "init", - { - dir: Flag.String("dir").pipe( - Flag.withDefault("."), - Flag.withDescription("Directory to scaffold .factory/ in"), - ), - force: Flag.Boolean("force").pipe( - Flag.withDefault(false), - Flag.withDescription("Overwrite existing files"), - ), - }, - (config) => - Effect.gen(function* () { - const exitCode = yield* Effect.promise(() => - initCli({ - cwd: resolve(config.dir), - force: config.force, - }), - ); - return yield* Effect.sync(() => process.exit(exitCode)); - }), +// Each command's flag/argument spec is exported on its own so the argv tests +// parse against these exact specs with a capturing handler, instead of a +// hand-copied tree that could drift from the real one. +export const initConfig = { + dir: Flag.String("dir").pipe( + Flag.withDefault("."), + Flag.withDescription("Directory to scaffold .factory/ in"), + ), + force: Flag.Boolean("force").pipe( + Flag.withDefault(false), + Flag.withDescription("Overwrite existing files"), + ), +}; + +export const initCommand = Command.make("init", initConfig, (config) => + Effect.gen(function* () { + const exitCode = yield* Effect.promise(() => + initCli({ + cwd: resolve(config.dir), + force: config.force, + }), + ); + return yield* Effect.sync(() => process.exit(exitCode)); + }), ).pipe(Command.withDescription("Scaffold .factory/ with a config and a starter workflow")); -export const serveCommand = Command.make( - "serve", - { - port: Flag.Int("port").pipe( - Flag.optional, - Flag.withDescription("Port to listen on (default: 3000)"), - ), - db: Flag.String("db").pipe( - Flag.withDefault(DEFAULT_DB_PATH), - Flag.withDescription("Path to the sqlite event store"), - ), - config: Flag.String("config").pipe( - Flag.optional, - Flag.withDescription("Path to factory.config.ts (auto-detected when omitted)"), - ), - }, - (config) => - Effect.gen(function* () { - const configPath = Option.getOrUndefined(config.config); - const resolvedConfigPath = configPath ?? (yield* Effect.promise(() => findFactoryConfig())); - const configLoaded = - resolvedConfigPath !== undefined - ? yield* Effect.promise(() => Promise.resolve(loadFactoryConfig(resolvedConfigPath))) - : undefined; - - const daemonOptions: DaemonOptions = { - dbPath: config.db, - port: Option.getOrUndefined(config.port), - ...(resolvedConfigPath !== undefined ? { config: configLoaded } : {}), - }; - - const handle = yield* Effect.promise(() => startDaemon(daemonOptions)); - yield* Effect.sync(() => - console.log(`factory serve: listening on http://localhost:${handle.server.port}`), - ); - yield* Effect.sync(() => - console.log( - handle.schedulerFiber !== undefined - ? "scheduler: running (config schedules)" - : "scheduler: none (no schedules in config)", - ), - ); - }), +export const serveConfig = { + port: Flag.Int("port").pipe( + Flag.optional, + Flag.withDescription("Port to listen on (default: 3000)"), + ), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), + config: Flag.String("config").pipe( + Flag.optional, + Flag.withDescription("Path to factory.config.ts (auto-detected when omitted)"), + ), +}; + +export const serveCommand = Command.make("serve", serveConfig, (config) => + Effect.gen(function* () { + const configPath = Option.getOrUndefined(config.config); + const resolvedConfigPath = configPath ?? (yield* Effect.promise(() => findFactoryConfig())); + const configLoaded = + resolvedConfigPath !== undefined + ? yield* Effect.promise(() => Promise.resolve(loadFactoryConfig(resolvedConfigPath))) + : undefined; + + const daemonOptions: DaemonOptions = { + dbPath: config.db, + port: Option.getOrUndefined(config.port), + ...(resolvedConfigPath !== undefined ? { config: configLoaded } : {}), + }; + + const handle = yield* Effect.promise(() => startDaemon(daemonOptions)); + yield* Effect.sync(() => + console.log(`factory serve: listening on http://localhost:${handle.server.port}`), + ); + yield* Effect.sync(() => + console.log( + handle.schedulerFiber !== undefined + ? "scheduler: running (config schedules)" + : "scheduler: none (no schedules in config)", + ), + ); + }), ).pipe(Command.withDescription("Run the daemon: HTTP API, live event stream, and the web UI")); -export const startCommand = Command.make( - "start", - { - workflowId: Argument.String("workflowId").pipe( - Argument.withDescription("The workflow id to start"), - ), - input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), - url: Flag.String("url").pipe( - Flag.optional, - Flag.withDescription("Base URL of the daemon (or set $FACTORY_URL)"), - ), - watch: Flag.Boolean("watch").pipe( - Flag.withDefault(false), - Flag.withDescription("Stream the run's events and exit with its exit code"), - ), - }, - (config) => - Effect.gen(function* () { - let input: unknown; - try { - input = JSON.parse(config.input); - } catch (err) { - console.error( - `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, - ); - return yield* Effect.sync(() => process.exit(1)); - } +export const startConfig = { + workflowId: Argument.String("workflowId").pipe( + Argument.withDescription("The workflow id to start"), + ), + input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), + url: Flag.String("url").pipe( + Flag.optional, + Flag.withDescription("Base URL of the daemon (or set $FACTORY_URL)"), + ), + watch: Flag.Boolean("watch").pipe( + Flag.withDefault(false), + Flag.withDescription("Stream the run's events and exit with its exit code"), + ), +}; - const options: StartCliOptions = { - workflowId: config.workflowId, - input, - watch: config.watch, - baseUrl: Option.getOrElse(config.url, () => process.env.FACTORY_URL ?? DEFAULT_DAEMON_URL), - }; +export const startCommand = Command.make("start", startConfig, (config) => + Effect.gen(function* () { + let input: unknown; + try { + input = JSON.parse(config.input); + } catch (err) { + console.error( + `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const options: StartCliOptions = { + workflowId: config.workflowId, + input, + watch: config.watch, + baseUrl: Option.getOrElse(config.url, () => process.env.FACTORY_URL ?? DEFAULT_DAEMON_URL), + }; - const exitCode = yield* Effect.promise(() => startCli(options)); - return yield* Effect.sync(() => process.exit(exitCode)); - }), + const exitCode = yield* Effect.promise(() => startCli(options)); + return yield* Effect.sync(() => process.exit(exitCode)); + }), ).pipe( Command.withDescription( "Start a run on a running daemon. --watch streams it and exits 0 completed / 1 failed / 130 cancelled.", ), ); -export const runsCommand = Command.make( - "runs", - { - db: Flag.String("db").pipe( - Flag.withDefault(DEFAULT_DB_PATH), - Flag.withDescription("Path to the sqlite event store"), - ), - }, - (config) => - Effect.sync(() => { - listRunsCli(config.db); - }), +export const runsConfig = { + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), +}; + +export const runsCommand = Command.make("runs", runsConfig, (config) => + Effect.sync(() => { + listRunsCli(config.db); + }), ).pipe(Command.withDescription("List every run this project has recorded")); -export const logCommand = Command.make( - "log", - { - runId: Argument.String("runId").pipe(Argument.withDescription("The run id to replay")), - db: Flag.String("db").pipe( - Flag.withDefault(DEFAULT_DB_PATH), - Flag.withDescription("Path to the sqlite event store"), - ), - }, - (config) => - Effect.sync(() => { - logRunCli(config.db, config.runId); - }), +export const logConfig = { + runId: Argument.String("runId").pipe(Argument.withDescription("The run id to replay")), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), +}; + +export const logCommand = Command.make("log", logConfig, (config) => + Effect.sync(() => { + logRunCli(config.db, config.runId); + }), ).pipe(Command.withDescription("Replay one run's full event history")); -export const runCommand = Command.make( - "run", - { - workflowPath: Argument.String("workflowPath").pipe( - Argument.withDescription("Path to the workflow .ts file"), - ), - input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), - dir: Flag.String("dir").pipe(Flag.withDescription("Working directory for the run")), - clone: Flag.String("clone").pipe( - Flag.optional, - Flag.withDescription("SSH URL to clone before running"), - ), - "git-name": Flag.String("git-name").pipe( - Flag.optional, - Flag.withDescription("Git committer name (required with --clone)"), - ), - "git-email": Flag.String("git-email").pipe( - Flag.optional, - Flag.withDescription("Git committer email (required with --clone)"), - ), - out: Flag.String("out").pipe( - Flag.optional, - Flag.withDescription("Path for the NDJSON event log"), - ), - db: Flag.String("db").pipe( - Flag.withDefault(DEFAULT_DB_PATH), - Flag.withDescription("Path to the sqlite event store"), - ), - }, - (config) => - Effect.gen(function* () { - let input: unknown; - try { - input = JSON.parse(config.input); - } catch (err) { - console.error( - `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, - ); +export const runConfig = { + workflowPath: Argument.String("workflowPath").pipe( + Argument.withDescription("Path to the workflow .ts file"), + ), + input: Flag.String("input").pipe(Flag.withDescription("JSON input for the workflow")), + dir: Flag.String("dir").pipe(Flag.withDescription("Working directory for the run")), + clone: Flag.String("clone").pipe( + Flag.optional, + Flag.withDescription("SSH URL to clone before running"), + ), + "git-name": Flag.String("git-name").pipe( + Flag.optional, + Flag.withDescription("Git committer name (required with --clone)"), + ), + "git-email": Flag.String("git-email").pipe( + Flag.optional, + Flag.withDescription("Git committer email (required with --clone)"), + ), + out: Flag.String("out").pipe( + Flag.optional, + Flag.withDescription("Path for the NDJSON event log"), + ), + db: Flag.String("db").pipe( + Flag.withDefault(DEFAULT_DB_PATH), + Flag.withDescription("Path to the sqlite event store"), + ), +}; + +export const runCommand = Command.make("run", runConfig, (config) => + Effect.gen(function* () { + let input: unknown; + try { + input = JSON.parse(config.input); + } catch (err) { + console.error( + `error: --input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const sshUrl = Option.getOrUndefined(config.clone); + let clone: CliOptions["clone"]; + if (sshUrl !== undefined) { + const name = Option.getOrUndefined(config["git-name"]); + const email = Option.getOrUndefined(config["git-email"]); + if (name === undefined || email === undefined) { + console.error("error: --clone requires --git-name and --git-email"); return yield* Effect.sync(() => process.exit(1)); } + clone = { sshUrl, identity: { name, email } }; + } - const sshUrl = Option.getOrUndefined(config.clone); - let clone: CliOptions["clone"]; - if (sshUrl !== undefined) { - const name = Option.getOrUndefined(config["git-name"]); - const email = Option.getOrUndefined(config["git-email"]); - if (name === undefined || email === undefined) { - console.error("error: --clone requires --git-name and --git-email"); - return yield* Effect.sync(() => process.exit(1)); - } - clone = { sshUrl, identity: { name, email } }; - } + const options: CliOptions = { + workflowPath: config.workflowPath, + input, + dir: config.dir, + clone, + outPath: Option.getOrElse(config.out, () => `.factory/runs/run-${Date.now()}/events.ndjson`), + dbPath: config.db, + adapter: opencodeAdapter, + }; - const options: CliOptions = { - workflowPath: config.workflowPath, - input, - dir: config.dir, - clone, - outPath: Option.getOrElse( - config.out, - () => `.factory/runs/run-${Date.now()}/events.ndjson`, - ), - dbPath: config.db, - adapter: opencodeAdapter, - }; - - const exitCode = yield* Effect.promise(() => runCli(options)); - return yield* Effect.sync(() => process.exit(exitCode)); - }), + const exitCode = yield* Effect.promise(() => runCli(options)); + return yield* Effect.sync(() => process.exit(exitCode)); + }), ).pipe(Command.withDescription("Run one workflow file directly — no daemon, no UI")); export const factoryCommand = Command.make("factory").pipe(