diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 11c6e2336..409cb981a 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -378,7 +378,7 @@ Each is small enough for one PR. 5. **R4** Logs: document the `--json` ndjson as stable; `--services a,b` honoured by every backend; `--max-line-bytes ` setting `entry.truncated`. 6. **R5a** Activity writer: append-only `activity.jsonl` in the Hack home, one line per `up`/`down`/`restart`/autostart (`ts, project, branch, action, origin, outcome, failed_services, duration_ms`), `O_APPEND` writes under one line size limit, rotation at a size cap, `origin` from `HACK_ORIGIN` (the app sets `app`). 7. **R5b** Activity reader: `hack activity --json [--project] [--limit]`. -8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. +8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. The Compose slice exists; see [Lifecycle progress stream](../../docs/reference/lifecycle-progress.md). Native backend support is open. 9. **R7a — exists.** Doctor audit schema 1 adds per-check `area`, `scope: machine | project` and `fix: {kind: safe | admin | manual, args[], doc_url}` inside the existing `{ok,data}` envelope. Unknown checks default to Other/manual with no executable args. A category is guidance, not permission to run it. 10. **R7b** One fix without prompts: `hack doctor --fix --only --yes --json`, only for `kind: safe`. 11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..38f856b1e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -187,6 +187,7 @@ hack up [services...] [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -216,6 +217,7 @@ hack down [options] | `--remove-data` | Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first) | | `--yes` | Confirm --prune-caches or --remove-data without prompting | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--recover` | Explicitly reconcile an interrupted native stop, or stop the services of a native run whose foreground owner exited, while retaining data | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | @@ -249,6 +251,7 @@ hack restart [services...] [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | +| `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | | `--recover-frontend` | Recover an interrupted native frontend after proving its owner and effects are gone | | `--expect-finalization-attempt <32-hex>` | Require this exact interrupted native frontend attempt | | `--expect-frontend-pid ` | Previously observed frontend PID (required only for legacy v1 state) | diff --git a/docs/reference/lifecycle-progress.md b/docs/reference/lifecycle-progress.md new file mode 100644 index 000000000..f52b6491d --- /dev/null +++ b/docs/reference/lifecycle-progress.md @@ -0,0 +1,116 @@ +# Lifecycle progress stream + +`hack up --json --progress`, `hack down --json --progress` and +`hack restart --json --progress` print a newline-delimited JSON (ndjson) stream on +stdout instead of one pretty-printed envelope. Callers that show progress, such as +the desktop app, read it line by line. Stderr is unchanged and is not part of the +contract. + +This is the Compose backend only. The native backend does not support it yet (see +[Refusals](#refusals)). + +## The stream + +Every line is one JSON object. + +1. **Start line**, written once Hack validated the options and resolved the + project: + + ```json + {"schema":"hack.lifecycle-progress.v1","type":"start","ts":"2026-11-14T17:00:00.000Z","action":"up","project":"shop"} + ``` + + `action` is `up`, `down` or `restart`. `project` is the Compose project name + (`` or `--`). `schema` appears only on this line. + +2. **Progress lines**, zero or more: + + ```json + {"type":"progress","ts":"2026-11-14T17:00:01.000Z","service":null,"phase":"images"} + {"type":"progress","ts":"2026-11-14T17:00:09.000Z","service":"api","phase":"running"} + ``` + + `service` is `null` for a project-level phase and a Compose service name + otherwise. `ts` is an ISO 8601 UTC timestamp. + +3. **Closing line**: the same envelope the command prints with `--json` alone, + written as one compact line. Nothing follows it. + + ```json + {"ok":true,"data":{"action":"up","project":"shop","branch":null,"composeProject":"shop","services":{"started":["api"],"completed":["migrate"],"stopped":[],"failed":[]},"durationMs":9120}} + ``` + + A failure after the start line also ends with exactly one envelope line, + `{"ok":false,"error":{"code":...,"message":...}}`. Exit codes are the same as + with `--json` alone. + +```mermaid +stateDiagram-v2 + [*] --> Refused: usage error or project not found + Refused --> [*]: one pretty envelope, no stream + [*] --> Started: start line (schema) + Started --> Progress: progress line + Progress --> Progress: progress line + Started --> Closed: envelope line + Progress --> Closed: envelope line + Closed --> [*] +``` + +## Phases + +Hack reports only signals the Compose path already observes. It does not parse +Compose's terminal output or poll for extra state. A phase that did not happen is +skipped, so a consumer must not wait for any particular phase. + +| Phase | `service` | Source | +|---|---|---| +| `images` | `null` | Compose image preparation (`up --no-start`) is starting. | +| `starting` | `null` | Image preparation succeeded; containers are being started (`up -d`). | +| `stopping` | `null` | `down` is about to stop the containers; `restart` is about to run its stop phase (down hooks and host processes). | +| `stopped` | `null` | That stop finished successfully. | +| `waiting`, `installing`, `verifying`, `ready`, `failed` | service | Dependency cache startup markers, also printed to stderr as `Dependency cache : `. Only services that use the dependency cache report them. | +| `running`, `completed`, `stopped`, `failed` | service | The final state of each service, taken from the closing envelope and written just before it. | + +What each command reports: + +- `up`: `images`, then `starting` if image preparation succeeded, any dependency + cache phases, then the final states. `up --json` always runs detached, so the + `images` and `starting` signals always fire; a failure during image preparation + stops after `images`. +- `down`: `stopping`, `stopped` when `docker compose down` succeeded, then one + `stopped` line per service that was running before. +- `restart`: `stopping` and `stopped` for the stop phase, then the same phases as + `up`. A restart of named services (`hack restart api`) skips the stop phase. + When a full restart's start fails and Hack repairs it with a second Compose + start, that repair reports no `images` or `starting` lines. + +Final per-service lines come from the envelope's `data.services` lists on success +(`started` maps to `running`). On an `E_STARTUP_INCOMPLETE` error they come from +`error.detail.running`, `completed` and `failed`. Other errors have no final +per-service lines. + +## Interruption + +SIGINT or SIGTERM after the start line closes the stream with one compact +`{"ok":false,"error":{"code":"E_CANCELLED",...}}` line. The existing lifecycle +cleanup still runs, and the process still ends by that signal. Nothing is +written to stdout after the closing line. A stream with no closing line was +killed (for example with SIGKILL). + +## Refusals + +These end with the ordinary pretty `--json` envelope (or usage text without +`--json`) and no stream, because they happen before the start line: + +- `--progress` without `--json`: usage error `--progress requires --json.` +- The native backend (`HACK_RUNTIME_BACKEND=native`): usage error + `Native lifecycle commands do not support --progress yet.` before any work. +- A native-authored project refuses `--progress` as an unsupported option of its + native Compose path. +- A remote target (project lifecycle routed through dispatch): usage error + `--progress is not supported for remote targets.` before dispatch. +- Other usage errors, a missing project, Docker missing from `PATH`, and an + invalid shared helper configuration. + +A consumer that needs to tell the two apart can check whether the first line +parses as a complete JSON object with `schema`. diff --git a/docs/versions/v5/core.md b/docs/versions/v5/core.md index 60dc26592..b72ad4f5f 100644 --- a/docs/versions/v5/core.md +++ b/docs/versions/v5/core.md @@ -65,7 +65,9 @@ and [Worktrees and watchers](../guides/development-runtime-safety.md). ## Use Hack from an agent or script Agents use the same CLI. `--json` returns structured results with stable error codes -and `--no-interactive` prevents a command from waiting for input. No plugin, server +and `--no-interactive` prevents a command from waiting for input. On the Compose +backend, `hack up|down|restart --json --progress` streams ndjson progress lines +before the final envelope. No plugin, server or account is required. [Agent handoff](agent-handoff.md) and [Agent integrations](../integrations.md) cover the optional extras. diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 9243c17ff..8128cc0ef 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -6,6 +6,7 @@ import { resolveComposeStartupTimeoutMs, } from "../lib/compose-startup-budget.ts"; import { withDependencyStartupProgress } from "../lib/dependency-startup-progress.ts"; +import { reportLifecycleProgress } from "../lib/lifecycle-progress.ts"; import { exec, run } from "../lib/shell.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import { createStructuredLogGrouper } from "../ui/log-group.ts"; @@ -214,6 +215,7 @@ export const composeRuntimeBackend: RuntimeBackend = { env: opts.env, onPhase: (service, phase) => { process.stderr.write(`Dependency cache ${service}: ${phase}\n`); + reportLifecycleProgress({ service, phase }); }, onUnavailable: () => { process.stderr.write( diff --git a/src/cli/run.ts b/src/cli/run.ts index 1fc41daea..7c237d851 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -7,6 +7,7 @@ import { HackCliError, } from "../lib/cli-result.ts"; import { setNoInteractiveFlag } from "../lib/interactivity.ts"; +import { closeLifecycleProgress } from "../lib/lifecycle-progress.ts"; import { nativeDetachedUpFailure } from "../lib/native-detached-up.ts"; import { logger } from "../ui/logger.ts"; import { @@ -164,6 +165,19 @@ function resolveSensitiveOutputError( } } +/** + * A started `--progress` stream must still end with one compact envelope + * line; the global pretty envelope would break the ndjson stream. + */ +function closeProgressStreamOnError(error: unknown): boolean { + return closeLifecycleProgress({ + result: + error instanceof CliUsageError + ? errorResult({ code: "E_USAGE", message: error.message }) + : errorResultFromUnknown({ error }), + }); +} + /** * Maps a top-level CLI failure to its exit path. Under `--json`, stdout * stays a single parseable envelope for every error class (usage errors @@ -197,6 +211,9 @@ export async function handleRunCliError(opts: { process.stderr.write(`${JSON.stringify(result)}\n`); return 1; } + if (jsonRequested && closeProgressStreamOnError(error)) { + return 1; + } if (error instanceof CliUsageError) { if (jsonRequested) { // Envelope only: the message is inside it, and logger backends may diff --git a/src/commands/project.ts b/src/commands/project.ts index c2569eb2e..9f5ed3f0b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -262,6 +262,13 @@ import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; import { runLifecycleCommandChild } from "../lib/lifecycle-command-child.ts"; +import { + beginLifecycleProgress, + closeLifecycleProgress, + isLifecycleProgressRequested, + prepareLifecycleProgress, + reportLifecycleProgress, +} from "../lib/lifecycle-progress.ts"; import { createLifecycleLogSink, drainLifecycleOutput, @@ -563,6 +570,13 @@ const initOptions = [ optNoDiscovery, optWith, ] as const; +const optProgress = defineOption({ + name: "progress", + type: "boolean", + long: "--progress", + description: + "With --json: stream ndjson progress lines before the result envelope (Compose backend)", +} as const); const upOptions = [ optPath, optProject, @@ -572,6 +586,7 @@ const upOptions = [ optProfile, optTarget, optJson, + optProgress, ] as const; const downOptions = [ optPath, @@ -590,6 +605,7 @@ const downOptions = [ } as const), optYes, optJson, + optProgress, defineOption({ name: "recoverNativeCompose", type: "boolean", @@ -606,6 +622,7 @@ const restartOptions = [ optProfile, optTarget, optJson, + optProgress, optRecoverFrontend, optExpectFinalizationAttempt, optExpectFrontendPid, @@ -1121,6 +1138,10 @@ function trackComposeStartupPhase(): { return { onStartupPhase: (phase) => { current = phase; + reportLifecycleProgress({ + service: null, + phase: phase === "images" ? "images" : "starting", + }); if (phase === "start") { startedAt = performance.now(); } @@ -1155,12 +1176,16 @@ function composeStartupIncompleteMessage( /** * Emit the `--json` envelope for a lifecycle command and map it to the - * standard 0/1 exit semantics. + * standard 0/1 exit semantics. A started `--progress` stream closes with it + * as one compact line; otherwise it is the ordinary pretty envelope. */ function emitLifecycleResult(opts: { readonly result: CliResult; readonly exitCode: number; }): number { + if (closeLifecycleProgress({ result: opts.result })) { + return opts.exitCode; + } emitCliResult({ result: opts.result }); return opts.exitCode; } @@ -1193,6 +1218,10 @@ function emitRemoteLifecycleJson(opts: { readonly branch: string | null; readonly startedAtMs?: number; }): number { + const composeProject = opts.branch + ? `${opts.project}--${opts.branch}` + : opts.project; + beginLifecycleProgress({ project: composeProject }); if (opts.exitCode !== 0) { return emitLifecycleResult({ result: errorResult({ @@ -1209,9 +1238,7 @@ function emitRemoteLifecycleJson(opts: { action: opts.action, project: opts.project, branch: opts.branch, - composeProject: opts.branch - ? `${opts.project}--${opts.branch}` - : opts.project, + composeProject, durationMs: Date.now() - (opts.startedAtMs ?? Date.now()), }), }), @@ -5899,6 +5926,10 @@ async function runRemoteLifecycleCommand(opts: { if (target.resolvedTarget === "local") { return null; } + if (isLifecycleProgressRequested()) { + // Dispatch output would precede `start`; remote targets are unsupported. + throw new CliUsageError("--progress is not supported for remote targets."); + } const projectSelector = await resolveDispatchProjectSelector({ project: opts.project, @@ -6356,6 +6387,7 @@ async function restartNativeImageProject({ removeData: false, yes: false, recoverNativeCompose: false, + progress: false, }, positionals: {}, raw: args.raw, @@ -6395,6 +6427,12 @@ async function handleUp({ readonly ctx: CliContext; readonly args: UpArgs; }): Promise { + prepareLifecycleProgress({ + action: "up", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); if (resolveNativeRuntimeSelection()) { @@ -6472,7 +6510,7 @@ async function handleUp({ json: args.options.json, detach: args.options.detach, services: args.positionals.services, - unsupportedOptions: Boolean(args.options.target), + unsupportedOptions: Boolean(args.options.target || args.options.progress), }); if (authored !== null) { return authored; @@ -6604,6 +6642,7 @@ async function runUpCommand({ projectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const devHost = await resolveRuntimeDevHost({ project, branch }); const aliasHost = resolveBranchAliasHost({ devHost, cfg }); const internalSettings = resolveInternalSettings(cfg); @@ -7145,6 +7184,12 @@ async function handleDown({ /** The native runtime requests of this down; a restart starts them in their own process group. */ readonly invoke?: typeof invokeNativeRuntime; }): Promise { + prepareLifecycleProgress({ + action: "down", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); const authored = await tryNativeComposeCommand({ @@ -7165,6 +7210,7 @@ async function handleDown({ args.options.pruneCaches, args.options.removeData, args.options.yes, + args.options.progress, ].some(Boolean), }); if (authored !== null) { @@ -7443,6 +7489,7 @@ async function runDownCommand({ projectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const effectiveEnvName = await resolveStoredRuntimeEnvName({ requestedEnvName: envName, projectDir: project.projectDir, @@ -7506,6 +7553,7 @@ async function runDownCommand({ cfg, composeProject: lifecycleComposeProject, }); + reportLifecycleProgress({ service: null, phase: "stopping" }); code = await composeRuntimeBackend.down({ composeFiles: [project.composeFile], composeProject: composeProjectName, @@ -7538,6 +7586,7 @@ async function runDownCommand({ } return code; } + reportLifecycleProgress({ service: null, phase: "stopped" }); if (helperReleaseFailures.length > 0) { const message = sharedHelperReleaseFailureMessage(helperReleaseFailures); if (json) { @@ -8394,6 +8443,12 @@ async function handleRestart({ const expectAttempt = args.options.expectFinalizationAttempt; const legacyPid = args.options.expectFrontendPid; validateFrontendRecoveryOptions({ recover, expectAttempt, legacyPid }); + prepareLifecycleProgress({ + action: "restart", + progress: args.options.progress, + json: args.options.json, + backend: process.env.HACK_RUNTIME_BACKEND, + }); // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); const authored = await tryNativeComposeCommand({ @@ -8411,6 +8466,7 @@ async function handleRestart({ recover, expectAttempt, legacyPid, + args.options.progress, ].some(Boolean), }); if (authored !== null) { @@ -8530,6 +8586,7 @@ async function runRestartCommand({ baseProjectName, branch, }); + beginLifecycleProgress({ project: composeProjectName ?? baseProjectName }); const projectName = sanitizeProjectSlug(baseProjectName); const allServiceNames = await readComposeServiceNames(project.composeFile); const requestedServices = resolveRequestedComposeServices({ @@ -8606,6 +8663,7 @@ async function runRestartCommand({ envByService: preflightEnv.preflightEnvByService, }); + reportLifecycleProgress({ service: null, phase: "stopping" }); const downPhase = await runRestartDownPhase({ project, cfg, @@ -8652,6 +8710,7 @@ async function runRestartCommand({ return downCode; } + reportLifecycleProgress({ service: null, phase: "stopped" }); await removeProjectRuntimeStateEntry({ projectDir: project.projectDir, composeProject: lifecycleComposeProject, diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 099e20fb7..4c7dc728f 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -98,12 +98,15 @@ export function errorResult(opts: { } /** - * Serialize an envelope for stdout (pretty-printed, trailing newline). + * Serialize an envelope for stdout with a trailing newline: pretty-printed + * by default, or as one line with `compact` (the closing line of an ndjson + * stream such as `--progress`). */ export function renderCliResult(opts: { readonly result: CliResult; + readonly compact?: boolean; }): string { - return `${JSON.stringify(opts.result, null, 2)}\n`; + return `${JSON.stringify(opts.result, null, opts.compact ? undefined : 2)}\n`; } /** diff --git a/src/lib/lifecycle-progress.ts b/src/lib/lifecycle-progress.ts new file mode 100644 index 000000000..768809569 --- /dev/null +++ b/src/lib/lifecycle-progress.ts @@ -0,0 +1,299 @@ +/** + * `hack up|down|restart --json --progress`: an ndjson stream on stdout. + * + * Contract (schema {@link LIFECYCLE_PROGRESS_SCHEMA}): + * - line 1: `{schema, type: "start", ts, action, project?}`, written once the + * command resolved its project; + * - zero or more `{type: "progress", ts, service, phase}` lines, where + * `service` is `null` for project-level phases; + * - one closing line: the command's ordinary `--json` envelope, compact. + * Nothing follows it. + * + * A command arms the stream before any work and starts it once validation + * and project resolution succeeded. A result emitted before the stream + * started (a refusal) is the ordinary pretty envelope, not a stream. + * + * Phases are only signals the Compose path already observes; a phase that + * did not happen is skipped, never synthesized. + */ + +import { writeSync } from "node:fs"; +import { CliUsageError } from "../cli/command.ts"; +import { type CliResult, errorResult, renderCliResult } from "./cli-result.ts"; +import type { DependencyStartupPhase } from "./dependency-startup-progress.ts"; +import { isRecord } from "./guards.ts"; + +export const LIFECYCLE_PROGRESS_SCHEMA = "hack.lifecycle-progress.v1"; + +export type LifecycleProgressAction = "up" | "down" | "restart"; + +/** Project-level phases (`service: null`). */ +export type LifecycleProjectPhase = + | "images" + | "starting" + | "stopping" + | "stopped"; + +/** Final per-service state, taken from the closing envelope. */ +export type LifecycleServiceFinalPhase = + | "running" + | "completed" + | "stopped" + | "failed"; + +export type LifecycleProgressPhase = + | LifecycleProjectPhase + | DependencyStartupPhase + | LifecycleServiceFinalPhase; + +export type LifecycleProgressEvent = { + readonly service: string | null; + readonly phase: LifecycleProgressPhase; +}; + +/** + * One command's stream. `write` receives complete lines (with a trailing + * newline); `now` supplies the timestamp, injectable for tests. + */ +export class LifecycleProgressStream { + private state: "armed" | "streaming" | "closed" = "armed"; + private readonly action: LifecycleProgressAction; + private readonly write: (line: string) => void; + private readonly now: () => Date; + + constructor(opts: { + readonly action: LifecycleProgressAction; + readonly write: (line: string) => void; + readonly now?: () => Date; + }) { + this.action = opts.action; + this.write = opts.write; + this.now = opts.now ?? (() => new Date()); + } + + /** Write the first line. Later calls are ignored. */ + begin(opts: { readonly project?: string }): void { + if (this.state !== "armed") { + return; + } + this.state = "streaming"; + this.line({ + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + ts: this.ts(), + action: this.action, + ...(opts.project === undefined ? {} : { project: opts.project }), + }); + } + + /** Write one progress line; ignored before {@link begin} or after close. */ + progress(event: LifecycleProgressEvent): void { + if (this.state !== "streaming") { + return; + } + this.line({ + type: "progress", + ts: this.ts(), + service: event.service, + phase: event.phase, + }); + } + + /** + * Write the final per-service lines and the compact envelope, then close. + * Returns false (writing nothing) when the stream never began, so the + * caller prints the ordinary pretty envelope. + */ + close(opts: { readonly result: CliResult }): boolean { + if (this.state === "closed") { + // Already closed (e.g. by a signal): swallow later envelopes so the + // compact closing line stays last. + return true; + } + if (this.state !== "streaming") { + return false; + } + for (const event of finalServiceEvents(opts.result)) { + this.progress(event); + } + this.state = "closed"; + this.write(renderCliResult({ result: opts.result, compact: true })); + return true; + } + + private ts(): string { + return this.now().toISOString(); + } + + private line(value: Record): void { + this.write(`${JSON.stringify(value)}\n`); + } +} + +const FINAL_SERVICE_KEYS: ReadonlyArray< + readonly [string, LifecycleServiceFinalPhase] +> = [ + ["started", "running"], + ["running", "running"], + ["completed", "completed"], + ["stopped", "stopped"], + ["failed", "failed"], +]; + +/** + * Per-service final states the envelope already carries: `data.services` + * on success, or the `running/completed/failed` lists an incomplete-startup + * error puts in `detail`. Other envelopes yield none. + */ +export function finalServiceEvents( + result: CliResult +): readonly LifecycleProgressEvent[] { + let source: unknown = result.ok ? undefined : result.error.detail; + if (result.ok && isRecord(result.data)) { + source = result.data.services; + } + if (!isRecord(source)) { + return []; + } + const events: LifecycleProgressEvent[] = []; + for (const [key, phase] of FINAL_SERVICE_KEYS) { + const names = source[key]; + if (!Array.isArray(names)) { + continue; + } + for (const name of names) { + if (typeof name === "string") { + events.push({ service: name, phase }); + } + } + } + return events; +} + +let active: LifecycleProgressStream | null = null; + +/** + * Validate `--progress` before any project or runtime work and arm the + * stream when requested. It needs `--json`, and only the Compose backend + * reports phases today: the native backend (`HACK_RUNTIME_BACKEND=native`) + * refuses it. Native-authored projects refuse it through their + * unsupported-options check. + */ +export function prepareLifecycleProgress(opts: { + readonly action: LifecycleProgressAction; + readonly progress: boolean | undefined; + readonly json: boolean | undefined; + readonly backend: string | undefined; +}): void { + if (opts.progress !== true) { + return; + } + if (opts.json !== true) { + throw new CliUsageError("--progress requires --json."); + } + if (opts.backend === "native") { + throw new CliUsageError( + "Native lifecycle commands do not support --progress yet." + ); + } + armLifecycleProgress({ action: opts.action }); +} + +/** + * Arm the process-wide stream for this command. One lifecycle command runs + * per process; deep Compose code reports through + * {@link reportLifecycleProgress} without threading a parameter. + */ +export function armLifecycleProgress(opts: { + readonly action: LifecycleProgressAction; + readonly write?: (line: string) => void; + readonly now?: () => Date; +}): LifecycleProgressStream { + active = new LifecycleProgressStream({ + action: opts.action, + // Synchronous: the signal path closes the stream and then re-raises the + // signal, which would drop a write still queued on a stdout pipe. + write: opts.write ?? ((line) => writeSync(1, line)), + now: opts.now, + }); + // Installed when armed, well before `start`: signal handlers may register + // asynchronously, and a signal before `start` closes nothing (close is a + // no-op while armed) and still ends the process by that signal. + removeSignalClose(); + installSignalClose(); + return active; +} + +/** Start the armed stream, if any. */ +export function beginLifecycleProgress(opts: { + readonly project?: string; +}): void { + if (!active) { + return; + } + active.begin(opts); +} + +let signalHandlers: Array void]> = []; + +/** + * SIGINT/SIGTERM after `start` closes the stream with an `E_CANCELLED` + * envelope; before `start` it writes nothing. Other handlers (lifecycle cleanup) keep running and own the exit; + * when none remain, the signal is raised again so the process still ends by + * that signal, as it would without `--progress`. + */ +function installSignalClose(): void { + for (const signal of ["SIGINT", "SIGTERM"] as const) { + const handler = (): void => { + removeSignalClose(); + active?.close({ + result: errorResult({ + code: "E_CANCELLED", + message: `Interrupted by ${signal}.`, + }), + }); + if (process.listenerCount(signal) === 0) { + process.kill(process.pid, signal); + } + }; + signalHandlers.push([signal, handler]); + process.on(signal, handler); + } +} + +function removeSignalClose(): void { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + signalHandlers = []; +} + +/** Report one phase; a no-op unless a stream is armed and started. */ +export function reportLifecycleProgress(event: LifecycleProgressEvent): void { + active?.progress(event); +} + +/** + * Close the active stream with the command's envelope. Returns false when no + * stream started; the caller then prints the ordinary envelope. + */ +export function closeLifecycleProgress(opts: { + readonly result: CliResult; +}): boolean { + const closed = active?.close(opts) ?? false; + if (closed) { + removeSignalClose(); + } + return closed; +} + +/** Whether `--progress` was requested for this process (armed or started). */ +export function isLifecycleProgressRequested(): boolean { + return active !== null; +} + +/** Test seam: drop the process-wide stream. */ +export function resetLifecycleProgress(): void { + removeSignalClose(); + active = null; +} diff --git a/tests/lifecycle-progress-cli.test.ts b/tests/lifecycle-progress-cli.test.ts new file mode 100644 index 000000000..9d670de78 --- /dev/null +++ b/tests/lifecycle-progress-cli.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { LIFECYCLE_PROGRESS_SCHEMA } from "../src/lib/lifecycle-progress.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const CLI = resolve(import.meta.dir, "..", "index.ts"); + +/** + * Stub `docker`: Compose `up` succeeds unless STUB_UP_FAIL=1, `ps --format` + * reports `api` running and `migrate` completed, everything else succeeds. + */ +const STUB = `#!/bin/sh +if [ "$1" != "compose" ]; then exit 0; fi +case " $* " in + *" up "*) + if [ "\${STUB_UP_FAIL:-}" = "1" ]; then exit 3; fi + if [ "\${STUB_UP_SLEEP:-}" = "1" ]; then sleep 8; fi + exit 0 ;; + *" ps "*) + case " $* " in + *" --format "*) + printf '%s\\n' '{"ID":"c1","Service":"api","Name":"shop-api-1","State":"running","Status":"Up","ExitCode":0}' + printf '%s\\n' '{"ID":"c2","Service":"migrate","Name":"shop-migrate-1","State":"exited","Status":"Exited (0)","ExitCode":0}' ;; + esac + exit 0 ;; + *) exit 0 ;; +esac +`; + +let root = ""; +let project = ""; +let bin = ""; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-lifecycle-progress-")); + project = join(root, "project"); + bin = join(root, "bin"); + const hack = join(project, ".hack"); + await mkdir(hack, { recursive: true }); + await mkdir(bin); + await writeFile(join(bin, "docker"), STUB); + await chmod(join(bin, "docker"), 0o755); + await writeFile( + join(hack, "hack.config.json"), + JSON.stringify({ name: "shop", internal: { dns: false, tls: false } }) + ); + await writeFile( + join(hack, "docker-compose.yml"), + "services:\n api:\n image: alpine:3.22\n migrate:\n image: alpine:3.22\n restart: 'no'\n" + ); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function cli( + args: readonly string[], + extra: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: project, + env: privateCliEnv({ + root, + env: { + PATH: `${bin}:/usr/bin:/bin`, + HACK_RUNTIME_BACKEND: "compose", + HACK_NO_INTERACTIVE: "1", + NO_COLOR: "1", + ...extra, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +/** Every stdout line must be one JSON object; returns them parsed. */ +function ndjson(stdout: string): Record[] { + expect(stdout.endsWith("\n")).toBe(true); + return stdout + .slice(0, -1) + .split("\n") + .map((line) => JSON.parse(line) as Record); +} + +function expectStreamShape( + lines: readonly Record[], + action: string +): void { + expect(lines[0]).toMatchObject({ + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + action, + project: "shop", + }); + expect(typeof lines[0]?.ts).toBe("string"); + for (const line of lines.slice(1, -1)) { + expect(line.type).toBe("progress"); + expect(line).not.toHaveProperty("schema"); + expect(typeof line.ts).toBe("string"); + } + expect(lines.at(-1)).toHaveProperty("ok"); +} + +test("up --json --progress streams start, progress lines and one compact envelope", async () => { + const result = await cli(["up", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "up"); + const progress = lines.slice(1, -1).map((line) => [line.service, line.phase]); + expect(progress).toEqual([ + [null, "images"], + [null, "starting"], + ["api", "running"], + ["migrate", "completed"], + ]); + const envelope = lines.at(-1); + expect(envelope).toMatchObject({ + ok: true, + data: { action: "up", services: { started: ["api"] } }, + }); + // The envelope is one compact line and nothing follows it. + const rawLines = result.stdout.trimEnd().split("\n"); + expect(rawLines.at(-1)?.startsWith('{"ok":true,')).toBe(true); +}, 60_000); + +test("a failing Compose up ends the stream with one compact error envelope", async () => { + const result = await cli(["up", "--json", "--progress"], { + STUB_UP_FAIL: "1", + }); + expect(result.exitCode).not.toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "up"); + expect(lines.slice(1, -1).map((line) => line.phase)).toEqual(["images"]); + expect(lines.at(-1)).toMatchObject({ + ok: false, + error: { code: "E_COMPOSE_FAILED" }, + }); + expect( + result.stdout.trimEnd().split("\n").at(-1)?.startsWith('{"ok":false,') + ).toBe(true); +}, 60_000); + +test("down --json --progress reports stopping and stopped", async () => { + const result = await cli(["down", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "down"); + expect(lines.slice(1, -1).map((line) => [line.service, line.phase])).toEqual([ + [null, "stopping"], + [null, "stopped"], + ["api", "stopped"], + ]); + expect(lines.at(-1)).toMatchObject({ ok: true, data: { action: "down" } }); +}, 60_000); + +test("restart --json --progress reports its stop and start phases", async () => { + const result = await cli(["restart", "--json", "--progress"]); + expect(result.exitCode).toBe(0); + const lines = ndjson(result.stdout); + expectStreamShape(lines, "restart"); + expect(lines.slice(1, -1).map((line) => [line.service, line.phase])).toEqual([ + [null, "stopping"], + [null, "stopped"], + [null, "images"], + [null, "starting"], + ["api", "running"], + ["migrate", "completed"], + ]); + expect(lines.at(-1)).toMatchObject({ + ok: true, + data: { action: "restart" }, + }); +}, 60_000); + +test("--progress without --json is a usage error", async () => { + for (const action of ["up", "down", "restart"]) { + const result = await cli([action, "--progress"]); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain( + "--progress requires --json." + ); + } +}, 60_000); + +test("the native backend refuses --progress with a single usage envelope", async () => { + for (const action of ["up", "down", "restart"]) { + const result = await cli([action, "--json", "--progress"], { + HACK_RUNTIME_BACKEND: "native", + }); + expect(result.exitCode).toBe(1); + // A refusal before the stream starts is the ordinary envelope. + const parsed = JSON.parse(result.stdout) as { + ok: boolean; + error?: { code: string; message: string }; + }; + expect(parsed).toEqual({ + ok: false, + error: { + code: "E_USAGE", + message: "Native lifecycle commands do not support --progress yet.", + }, + }); + } +}, 60_000); + +test("SIGTERM after start closes the stream with one E_CANCELLED line", async () => { + const child = Bun.spawn( + [process.execPath, CLI, "up", "--json", "--progress"], + { + cwd: project, + env: privateCliEnv({ + root, + env: { + PATH: `${bin}:/usr/bin:/bin`, + HACK_RUNTIME_BACKEND: "compose", + HACK_NO_INTERACTIVE: "1", + NO_COLOR: "1", + STUB_UP_SLEEP: "1", + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + while (!stdout.includes('"type":"start"')) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + stdout += decoder.decode(chunk.value, { stream: true }); + } + child.kill("SIGTERM"); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + stdout += decoder.decode(chunk.value, { stream: true }); + } + await child.exited; + + const lines = ndjson(stdout); + expect(lines[0]).toMatchObject({ type: "start", action: "up" }); + expect(lines.at(-1)).toMatchObject({ + ok: false, + error: { code: "E_CANCELLED" }, + }); + expect(lines.filter((line) => "ok" in line)).toHaveLength(1); + expect(child.exitCode === 0).toBe(false); +}, 60_000); diff --git a/tests/lifecycle-progress.test.ts b/tests/lifecycle-progress.test.ts new file mode 100644 index 000000000..667da93a0 --- /dev/null +++ b/tests/lifecycle-progress.test.ts @@ -0,0 +1,185 @@ +import { afterEach, expect, test } from "bun:test"; +import { errorResult, okResult } from "../src/lib/cli-result.ts"; +import { + armLifecycleProgress, + beginLifecycleProgress, + closeLifecycleProgress, + finalServiceEvents, + LIFECYCLE_PROGRESS_SCHEMA, + LifecycleProgressStream, + prepareLifecycleProgress, + reportLifecycleProgress, + resetLifecycleProgress, +} from "../src/lib/lifecycle-progress.ts"; + +const FIXED = new Date("2026-01-02T03:04:05.000Z"); +const TS = FIXED.toISOString(); + +function capture(): { + readonly lines: string[]; + readonly write: (line: string) => void; +} { + const lines: string[] = []; + return { lines, write: (line) => lines.push(line) }; +} + +afterEach(() => { + resetLifecycleProgress(); +}); + +test("a stream writes start, progress and a compact envelope, one line each", () => { + const out = capture(); + const stream = new LifecycleProgressStream({ + action: "up", + write: out.write, + now: () => FIXED, + }); + stream.begin({ project: "shop" }); + stream.progress({ service: null, phase: "images" }); + stream.progress({ service: "api", phase: "installing" }); + const services = { + started: ["api"], + completed: ["migrate"], + stopped: [], + failed: [], + }; + expect(stream.close({ result: okResult({ data: { services } }) })).toBe(true); + // Each write is exactly one newline-terminated line. + expect(out.lines.every((line) => line.split("\n").length === 2)).toBe(true); + expect(out.lines.every((line) => line.endsWith("\n"))).toBe(true); + const parsed = out.lines.map((line) => JSON.parse(line)); + expect(parsed).toEqual([ + { + schema: LIFECYCLE_PROGRESS_SCHEMA, + type: "start", + ts: TS, + action: "up", + project: "shop", + }, + { type: "progress", ts: TS, service: null, phase: "images" }, + { type: "progress", ts: TS, service: "api", phase: "installing" }, + { type: "progress", ts: TS, service: "api", phase: "running" }, + { type: "progress", ts: TS, service: "migrate", phase: "completed" }, + { ok: true, data: { services } }, + ]); + expect(parsed.slice(1).some((line) => "schema" in line)).toBe(false); + expect(out.lines.at(-1)?.startsWith('{"ok":true,')).toBe(true); +}); + +test("nothing is written before begin or after close", () => { + const out = capture(); + const stream = new LifecycleProgressStream({ + action: "down", + write: out.write, + }); + const failure = errorResult({ code: "E_USAGE", message: "x" }); + stream.progress({ service: null, phase: "stopping" }); + expect(stream.close({ result: failure })).toBe(false); + expect(out.lines).toEqual([]); + stream.begin({}); + stream.begin({ project: "again" }); + expect(stream.close({ result: failure })).toBe(true); + stream.progress({ service: null, phase: "stopped" }); + // Closed: later envelopes are swallowed, not printed. + expect(stream.close({ result: failure })).toBe(true); + const parsed = out.lines.map((line) => JSON.parse(line)); + expect(parsed).toEqual([ + expect.objectContaining({ type: "start", action: "down" }), + { ok: false, error: { code: "E_USAGE", message: "x" } }, + ]); + expect(parsed[0]).not.toHaveProperty("project"); +}); + +test("final service events come from the envelope only", () => { + expect( + finalServiceEvents( + errorResult({ + code: "E_STARTUP_INCOMPLETE", + message: "m", + detail: { running: ["api"], completed: [], failed: ["worker"] }, + }) + ) + ).toEqual([ + { service: "api", phase: "running" }, + { service: "worker", phase: "failed" }, + ]); + expect( + finalServiceEvents( + errorResult({ + code: "E_COMPOSE_FAILED", + message: "m", + detail: { exitCode: 3 }, + }) + ) + ).toEqual([]); + expect( + finalServiceEvents( + okResult({ data: { services: { stopped: ["api", 4] } } }) + ) + ).toEqual([{ service: "api", phase: "stopped" }]); +}); + +test("the process-wide stream is a no-op until armed and begun", () => { + const empty = okResult({ data: {} }); + reportLifecycleProgress({ service: null, phase: "images" }); + expect(closeLifecycleProgress({ result: empty })).toBe(false); + const out = capture(); + armLifecycleProgress({ action: "restart", write: out.write }); + reportLifecycleProgress({ service: null, phase: "stopping" }); + expect(out.lines).toEqual([]); + beginLifecycleProgress({ project: "shop" }); + reportLifecycleProgress({ service: null, phase: "stopping" }); + expect(closeLifecycleProgress({ result: empty })).toBe(true); + expect(out.lines).toHaveLength(3); +}); + +test("prepare refuses --progress without --json and on the native backend", () => { + expect(() => + prepareLifecycleProgress({ + action: "up", + progress: true, + json: false, + backend: undefined, + }) + ).toThrow("--progress requires --json."); + expect(() => + prepareLifecycleProgress({ + action: "down", + progress: true, + json: true, + backend: "native", + }) + ).toThrow("Native lifecycle commands do not support --progress yet."); + // Without --progress nothing is validated or armed. + prepareLifecycleProgress({ + action: "up", + progress: false, + json: false, + backend: "native", + }); + expect(closeLifecycleProgress({ result: okResult({ data: {} }) })).toBe( + false + ); +}); + +test("an error thrown after the stream started still closes it with one compact line", async () => { + const { handleRunCliError } = await import("../src/cli/run.ts"); + const out = capture(); + armLifecycleProgress({ action: "up", write: out.write, now: () => FIXED }); + beginLifecycleProgress({ project: "shop" }); + + const code = await handleRunCliError({ + error: new Error("unexpected failure"), + jsonRequested: true, + }); + + expect(code).toBe(1); + expect(out.lines).toHaveLength(2); + const last = out.lines[1] ?? ""; + expect(last.endsWith("\n")).toBe(true); + expect(last.trimEnd().includes("\n")).toBe(false); + expect(JSON.parse(last)).toMatchObject({ + ok: false, + error: { code: "E_UNEXPECTED" }, + }); +});