From 610943510d36c591e214a3b0753bc1c4134060da Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Sat, 19 Sep 2026 11:15:20 +0000 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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,