diff --git a/src/cli-argv.test.ts b/src/cli-argv.test.ts new file mode 100644 index 0000000..1a5594f --- /dev/null +++ b/src/cli-argv.test.ts @@ -0,0 +1,426 @@ +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 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, + 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"); + }); +}); + +// 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-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, + ]), +); diff --git a/src/cli.ts b/src/cli.ts index 9f6d166..a1ff987 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 { CliError, 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,64 @@ 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 } : {}), - }; -} +// `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, + 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), + ); + // `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); + }); }