@cat-factory/cli 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Decision core for `cat-factory supervise` — the self-healing local-dev supervisor.
3
+ *
4
+ * WHY THIS EXISTS. Every local deployment runs its server under `node --watch`, and
5
+ * `node --watch` PARKS on crash: it restarts the entry only on a FILE CHANGE, never on a process
6
+ * exit. A sleeping laptop is the common trigger — on resume the Postgres/Docker connection is
7
+ * gone, the server dies in `migrate`, and the watcher settles at "Waiting for file changes before
8
+ * restarting". The result is the worst kind of failure: the wrapper PID is still alive and the
9
+ * ready banner has already scrolled past, so the stack LOOKS running while nothing is bound to
10
+ * the port, and the SPA reports only a generic "can't reach backend". It never self-heals, and it
11
+ * stays that way until someone notices and restarts by hand.
12
+ *
13
+ * This module is the JUDGEMENT half of the fix, kept pure — no sockets, no processes, no ambient
14
+ * clock — so every transition is unit-testable from a table of observations (`supervise.test.ts`).
15
+ * `supervise-runtime.ts` owns the effects and feeds observations in. That split is the same one
16
+ * `scripts/silent-catch.mjs` documents for itself: a guard whose judgement nothing tests is a
17
+ * guard that is trusted without evidence.
18
+ */
19
+ /** Tuning for the supervisor loop. Resolve partial input with {@link resolveSuperviseConfig}. */
20
+ export interface SuperviseConfig {
21
+ /** How often the health probe runs. */
22
+ pollMs: number;
23
+ /**
24
+ * Grace window after a (re)start during which a failed probe does NOT count against the child.
25
+ * A cold boot builds the workspace dependency and runs migrations first, so the port legitimately
26
+ * stays unbound for a while.
27
+ */
28
+ bootGraceMs: number;
29
+ /** Grace window after a detected resume, so a still-waking Docker/Postgres isn't blamed. */
30
+ resumeGraceMs: number;
31
+ /**
32
+ * A tick arriving this much later than `pollMs` means time jumped — the host slept (or stalled
33
+ * hard). Timers do not fire while suspended, so lateness is the signal.
34
+ *
35
+ * The measurement is deliberately taken tick-START to tick-START (see {@link step}): sampling it
36
+ * after the probe would fold the probe's own duration into the drift, and a probe that times out
37
+ * on a filtered port takes seconds — enough to read as a suspend on a short `--poll` and so to
38
+ * bypass `failureThreshold` entirely.
39
+ */
40
+ clockJumpMs: number;
41
+ /** Consecutive failed probes required before a repair (outside any grace window). */
42
+ failureThreshold: number;
43
+ /**
44
+ * How many restarts in a row may fail to produce a SERVING stack before the supervisor reports
45
+ * and gives up. A command that is simply broken (a syntax error, a missing binary, a port already
46
+ * owned by something else) can never be fixed by restarting it, and looping forever on it is the
47
+ * exact pathology this supervisor exists to end — the motivating incident was a container that
48
+ * restarted 518 times, exiting 0 each time, while `docker ps` showed healthy motion.
49
+ */
50
+ maxFailedStarts: number;
51
+ }
52
+ /** Defaults chosen for a laptop-dev loop: notice within ~30s, never fight a cold boot. */
53
+ export declare const SUPERVISE_DEFAULTS: {
54
+ readonly pollMs: 10000;
55
+ readonly bootGraceMs: 60000;
56
+ readonly resumeGraceMs: 25000;
57
+ readonly failureThreshold: 3;
58
+ readonly maxFailedStarts: 5;
59
+ };
60
+ /**
61
+ * Fill in defaults and derive `clockJumpMs` from the poll interval. A tick 3 intervals late is
62
+ * well outside normal scheduler jitter but still catches a short suspend.
63
+ */
64
+ export declare function resolveSuperviseConfig(partial?: Partial<SuperviseConfig>): SuperviseConfig;
65
+ /** Loop state carried between ticks. Treated as immutable: {@link step} returns the next one. */
66
+ export interface SuperviseState {
67
+ /** Consecutive failed probes so far, reset by any success or repair. */
68
+ failures: number;
69
+ /** No failed probe counts against the child until this timestamp (boot/resume grace). */
70
+ quietUntil: number;
71
+ /** When the previous tick ran — the basis for clock-jump (sleep) detection. */
72
+ lastTickAt: number;
73
+ }
74
+ /** What the runtime should do about this tick. Every branch of {@link step} names one. */
75
+ export type SuperviseAction =
76
+ /** Serving, and it was serving before too — nothing to say. */
77
+ {
78
+ kind: 'serving';
79
+ }
80
+ /** Serving again after one or more failed probes, without needing a repair. */
81
+ | {
82
+ kind: 'recovered';
83
+ afterFailures: number;
84
+ }
85
+ /** Not serving, but inside a boot/resume grace window — wait it out. */
86
+ | {
87
+ kind: 'grace';
88
+ msLeft: number;
89
+ }
90
+ /** Not serving; failure counted but still below the threshold. */
91
+ | {
92
+ kind: 'counting';
93
+ failures: number;
94
+ threshold: number;
95
+ }
96
+ /** Run the recovery ladder: re-check dependencies, then restart the child. */
97
+ | {
98
+ kind: 'repair';
99
+ reason: string;
100
+ }
101
+ /** The host resumed from sleep and the stack is still serving. */
102
+ | {
103
+ kind: 'resumed';
104
+ driftMs: number;
105
+ };
106
+ /** State for a freshly started child: clean counters and a full boot grace window. */
107
+ export declare function initialState(now: number, config: SuperviseConfig): SuperviseState;
108
+ /**
109
+ * State to adopt right after (re)spawning a child mid-run — a fresh boot grace, counters clear.
110
+ *
111
+ * `lastTickAt` is re-based on `now` (the moment the new child started), NOT carried over from the
112
+ * previous tick, because a repair is not instantaneous: it runs the whole dependency ladder first,
113
+ * and those budgets are 90s (compose readiness) and 120s (apiserver readiness) against a default
114
+ * `clockJumpMs` of 30s. Carrying the old timestamp forward makes the very next tick measure the
115
+ * repair's own duration as drift, read a slow-but-successful recovery as a host suspend, and — since
116
+ * resume detection deliberately outranks the boot-grace window — immediately kill the child it just
117
+ * started. Re-basing means the clock-jump signal only ever measures time we were genuinely idle.
118
+ */
119
+ export declare function stateAfterStart(now: number, config: SuperviseConfig): SuperviseState;
120
+ /**
121
+ * One tick of the supervisor: current state + what we just observed -> next state + the action to
122
+ * take. Pure; the caller supplies `now` and the probe result.
123
+ *
124
+ * `now` must be sampled at the START of the tick, before the probe runs — see `clockJumpMs`.
125
+ *
126
+ * Order matters:
127
+ * 1. The clock-jump check runs FIRST and outranks the grace windows, because a resume is precisely
128
+ * when the stack is most likely already dead — deferring it to the normal threshold path would
129
+ * idle for another `failureThreshold * pollMs` before repairing something we can already tell
130
+ * is broken.
131
+ * 2. A confirmed-serving stack short-circuits everything below it.
132
+ * 3. A child that has EXITED then repairs immediately, ahead of the grace window and the failure
133
+ * counter, because neither can tell us anything a dead process handle hasn't already: counting
134
+ * three more probes against a process that does not exist just adds `failureThreshold * pollMs`
135
+ * of downtime. This is checked only once the stack is known not to be serving, so a wrapper that
136
+ * exits while its grandchild keeps serving (a shell that `exec`s away, say) is left alone
137
+ * rather than having a healthy server restarted out from under it.
138
+ */
139
+ export declare function step(state: SuperviseState, observation: {
140
+ now: number;
141
+ serving: boolean;
142
+ childExited?: boolean;
143
+ }, config: SuperviseConfig): {
144
+ state: SuperviseState;
145
+ action: SuperviseAction;
146
+ };
147
+ //# sourceMappingURL=supervise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervise.d.ts","sourceRoot":"","sources":["../src/supervise.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,iGAAiG;AACjG,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB,4FAA4F;IAC5F,aAAa,EAAE,MAAM,CAAA;IACrB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB,qFAAqF;IACrF,gBAAgB,EAAE,MAAM,CAAA;IACxB;;;;;;OAMG;IACH,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,0FAA0F;AAC1F,eAAO,MAAM,kBAAkB;aAC7B,MAAM,EAAE,KAAM;aACd,WAAW,EAAE,KAAM;aACnB,aAAa,EAAE,KAAM;aACrB,gBAAgB,EAAE,CAAC;aACnB,eAAe,EAAE,CAAC;CACV,CAAA;AAEV;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,OAAO,CAAC,eAAe,CAAM,GAAG,eAAe,CAU9F;AAED,iGAAiG;AACjG,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAA;IAChB,yFAAyF;IACzF,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,0FAA0F;AAC1F,MAAM,MAAM,eAAe;AACzB,+DAA+D;AAC7D;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE;AACrB,+EAA+E;GAC7E;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE;AAC9C,wEAAwE;GACtE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACnC,kEAAkE;GAChE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AAC3D,8EAA8E;GAC5E;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACpC,kEAAkE;GAChE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAExC,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,cAAc,CAEjF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,cAAc,CAEpF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,IAAI,CAClB,KAAK,EAAE,cAAc,EACrB,WAAW,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,EACrE,MAAM,EAAE,eAAe,GACtB;IAAE,KAAK,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAwDpD"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Decision core for `cat-factory supervise` — the self-healing local-dev supervisor.
3
+ *
4
+ * WHY THIS EXISTS. Every local deployment runs its server under `node --watch`, and
5
+ * `node --watch` PARKS on crash: it restarts the entry only on a FILE CHANGE, never on a process
6
+ * exit. A sleeping laptop is the common trigger — on resume the Postgres/Docker connection is
7
+ * gone, the server dies in `migrate`, and the watcher settles at "Waiting for file changes before
8
+ * restarting". The result is the worst kind of failure: the wrapper PID is still alive and the
9
+ * ready banner has already scrolled past, so the stack LOOKS running while nothing is bound to
10
+ * the port, and the SPA reports only a generic "can't reach backend". It never self-heals, and it
11
+ * stays that way until someone notices and restarts by hand.
12
+ *
13
+ * This module is the JUDGEMENT half of the fix, kept pure — no sockets, no processes, no ambient
14
+ * clock — so every transition is unit-testable from a table of observations (`supervise.test.ts`).
15
+ * `supervise-runtime.ts` owns the effects and feeds observations in. That split is the same one
16
+ * `scripts/silent-catch.mjs` documents for itself: a guard whose judgement nothing tests is a
17
+ * guard that is trusted without evidence.
18
+ */
19
+ /** Defaults chosen for a laptop-dev loop: notice within ~30s, never fight a cold boot. */
20
+ export const SUPERVISE_DEFAULTS = {
21
+ pollMs: 10_000,
22
+ bootGraceMs: 60_000,
23
+ resumeGraceMs: 25_000,
24
+ failureThreshold: 3,
25
+ maxFailedStarts: 5,
26
+ };
27
+ /**
28
+ * Fill in defaults and derive `clockJumpMs` from the poll interval. A tick 3 intervals late is
29
+ * well outside normal scheduler jitter but still catches a short suspend.
30
+ */
31
+ export function resolveSuperviseConfig(partial = {}) {
32
+ const pollMs = partial.pollMs ?? SUPERVISE_DEFAULTS.pollMs;
33
+ return {
34
+ pollMs,
35
+ bootGraceMs: partial.bootGraceMs ?? SUPERVISE_DEFAULTS.bootGraceMs,
36
+ resumeGraceMs: partial.resumeGraceMs ?? SUPERVISE_DEFAULTS.resumeGraceMs,
37
+ clockJumpMs: partial.clockJumpMs ?? pollMs * 3,
38
+ failureThreshold: partial.failureThreshold ?? SUPERVISE_DEFAULTS.failureThreshold,
39
+ maxFailedStarts: partial.maxFailedStarts ?? SUPERVISE_DEFAULTS.maxFailedStarts,
40
+ };
41
+ }
42
+ /** State for a freshly started child: clean counters and a full boot grace window. */
43
+ export function initialState(now, config) {
44
+ return { failures: 0, quietUntil: now + config.bootGraceMs, lastTickAt: now };
45
+ }
46
+ /**
47
+ * State to adopt right after (re)spawning a child mid-run — a fresh boot grace, counters clear.
48
+ *
49
+ * `lastTickAt` is re-based on `now` (the moment the new child started), NOT carried over from the
50
+ * previous tick, because a repair is not instantaneous: it runs the whole dependency ladder first,
51
+ * and those budgets are 90s (compose readiness) and 120s (apiserver readiness) against a default
52
+ * `clockJumpMs` of 30s. Carrying the old timestamp forward makes the very next tick measure the
53
+ * repair's own duration as drift, read a slow-but-successful recovery as a host suspend, and — since
54
+ * resume detection deliberately outranks the boot-grace window — immediately kill the child it just
55
+ * started. Re-basing means the clock-jump signal only ever measures time we were genuinely idle.
56
+ */
57
+ export function stateAfterStart(now, config) {
58
+ return { failures: 0, quietUntil: now + config.bootGraceMs, lastTickAt: now };
59
+ }
60
+ /**
61
+ * One tick of the supervisor: current state + what we just observed -> next state + the action to
62
+ * take. Pure; the caller supplies `now` and the probe result.
63
+ *
64
+ * `now` must be sampled at the START of the tick, before the probe runs — see `clockJumpMs`.
65
+ *
66
+ * Order matters:
67
+ * 1. The clock-jump check runs FIRST and outranks the grace windows, because a resume is precisely
68
+ * when the stack is most likely already dead — deferring it to the normal threshold path would
69
+ * idle for another `failureThreshold * pollMs` before repairing something we can already tell
70
+ * is broken.
71
+ * 2. A confirmed-serving stack short-circuits everything below it.
72
+ * 3. A child that has EXITED then repairs immediately, ahead of the grace window and the failure
73
+ * counter, because neither can tell us anything a dead process handle hasn't already: counting
74
+ * three more probes against a process that does not exist just adds `failureThreshold * pollMs`
75
+ * of downtime. This is checked only once the stack is known not to be serving, so a wrapper that
76
+ * exits while its grandchild keeps serving (a shell that `exec`s away, say) is left alone
77
+ * rather than having a healthy server restarted out from under it.
78
+ */
79
+ export function step(state, observation, config) {
80
+ const { now, serving } = observation;
81
+ const driftMs = now - state.lastTickAt - config.pollMs;
82
+ if (driftMs > config.clockJumpMs) {
83
+ // Timers stalled, so wall-clock time passed without us running: the host suspended. Extend the
84
+ // quiet window either way — a resume needs a moment for Docker's VM and the DB to come back.
85
+ const quietUntil = Math.max(state.quietUntil, now + config.resumeGraceMs);
86
+ if (serving) {
87
+ return {
88
+ state: { failures: 0, quietUntil, lastTickAt: now },
89
+ action: { kind: 'resumed', driftMs },
90
+ };
91
+ }
92
+ return {
93
+ state: { failures: 0, quietUntil, lastTickAt: now },
94
+ action: {
95
+ kind: 'repair',
96
+ reason: `not serving after a ${Math.round(driftMs / 1000)}s stall (host slept?)`,
97
+ },
98
+ };
99
+ }
100
+ if (serving) {
101
+ const action = state.failures > 0
102
+ ? { kind: 'recovered', afterFailures: state.failures }
103
+ : { kind: 'serving' };
104
+ return { state: { failures: 0, quietUntil: state.quietUntil, lastTickAt: now }, action };
105
+ }
106
+ if (observation.childExited === true) {
107
+ return {
108
+ state: { failures: 0, quietUntil: state.quietUntil, lastTickAt: now },
109
+ action: { kind: 'repair', reason: 'the supervised command exited' },
110
+ };
111
+ }
112
+ if (now < state.quietUntil) {
113
+ return {
114
+ state: { ...state, lastTickAt: now },
115
+ action: { kind: 'grace', msLeft: state.quietUntil - now },
116
+ };
117
+ }
118
+ const failures = state.failures + 1;
119
+ if (failures >= config.failureThreshold) {
120
+ return {
121
+ state: { failures: 0, quietUntil: state.quietUntil, lastTickAt: now },
122
+ action: { kind: 'repair', reason: `${failures} consecutive failed health probes` },
123
+ };
124
+ }
125
+ return {
126
+ state: { failures, quietUntil: state.quietUntil, lastTickAt: now },
127
+ action: { kind: 'counting', failures, threshold: config.failureThreshold },
128
+ };
129
+ }
130
+ //# sourceMappingURL=supervise.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervise.js","sourceRoot":"","sources":["../src/supervise.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `cat-factory supervise` — wire the real effects into the supervision loop.
3
+ *
4
+ * Thin on purpose: the judgement lives in `supervise.ts`, the effects in `supervise-runtime.ts`.
5
+ * This resolves flags into a config, builds the process/socket/shell-backed seams, prints what it
6
+ * is about to watch, and hands over to `runSupervisor`.
7
+ */
8
+ import { type CliOptions } from './args.js';
9
+ /**
10
+ * Re-quote a token that contains whitespace. The child is launched as one shell string (see
11
+ * `createChildLauncher`), so a path with a space in it would otherwise split into two arguments —
12
+ * `C:\Program Files\nodejs\node.exe` being the case that matters on Windows.
13
+ *
14
+ * Deliberately NOT `JSON.stringify`: that escapes backslashes (`C:\\Program Files\\…`), which no
15
+ * shell unescapes, so the quoted path becomes a path that does not exist. Only the surrounding
16
+ * quotes and any embedded quote need handling.
17
+ *
18
+ * The escape for an embedded quote is platform-specific, because `shell: true` means a genuinely
19
+ * different shell on each side: `cmd.exe` does not honour a backslash escape at all (it would pass
20
+ * the backslash through and treat the quote as closing the argument), and doubles the quote instead.
21
+ * `platform` is injectable so both dialects are testable from either host.
22
+ */
23
+ export declare function quoteToken(token: string, platform?: string): string;
24
+ export declare function supervise(options: CliOptions): Promise<void>;
25
+ //# sourceMappingURL=superviseCommand.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"superviseCommand.d.ts","sourceRoot":"","sources":["../src/superviseCommand.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAY,KAAK,UAAU,EAAoC,MAAM,WAAW,CAAA;AAcvF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAyB,GAAG,MAAM,CAMrF;AAkBD,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsGlE"}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `cat-factory supervise` — wire the real effects into the supervision loop.
3
+ *
4
+ * Thin on purpose: the judgement lives in `supervise.ts`, the effects in `supervise-runtime.ts`.
5
+ * This resolves flags into a config, builds the process/socket/shell-backed seams, prints what it
6
+ * is about to watch, and hands over to `runSupervisor`.
7
+ */
8
+ import { resolve } from 'node:path';
9
+ import { ArgError, OPTION_DEFAULTS } from './args.js';
10
+ import { createNodeShell } from './host-shell.js';
11
+ import { createK3sClusterDependency } from './supervise-k3s.js';
12
+ import { createChildLauncher, createComposeDependency, createHealthProbe, createPortReaper, OperatorActionRequiredError, runSupervisor, } from './supervise-runtime.js';
13
+ import { resolveSuperviseConfig } from './supervise.js';
14
+ /**
15
+ * Re-quote a token that contains whitespace. The child is launched as one shell string (see
16
+ * `createChildLauncher`), so a path with a space in it would otherwise split into two arguments —
17
+ * `C:\Program Files\nodejs\node.exe` being the case that matters on Windows.
18
+ *
19
+ * Deliberately NOT `JSON.stringify`: that escapes backslashes (`C:\\Program Files\\…`), which no
20
+ * shell unescapes, so the quoted path becomes a path that does not exist. Only the surrounding
21
+ * quotes and any embedded quote need handling.
22
+ *
23
+ * The escape for an embedded quote is platform-specific, because `shell: true` means a genuinely
24
+ * different shell on each side: `cmd.exe` does not honour a backslash escape at all (it would pass
25
+ * the backslash through and treat the quote as closing the argument), and doubles the quote instead.
26
+ * `platform` is injectable so both dialects are testable from either host.
27
+ */
28
+ export function quoteToken(token, platform = process.platform) {
29
+ if (token === '')
30
+ return '""';
31
+ if (!/[\s"]/.test(token))
32
+ return token;
33
+ const escaped = platform === 'win32' ? token.replace(/"/g, '""') : token.replace(/"/g, String.raw `\"`);
34
+ return `"${escaped}"`;
35
+ }
36
+ /**
37
+ * Narrow the shared `--runtime` picklist to what a supervisor can actually start, REFUSING the
38
+ * third member rather than quietly treating it as k3d. `k3s` proper is a host service (systemd), not
39
+ * a set of containers this command owns, so it has no `cluster start` to call. Degrading silently
40
+ * would leave `k3d cluster list` never listing the cluster, so the dependency would report "not
41
+ * ready — will retry next cycle" on every cycle forever, with nothing naming the real reason.
42
+ */
43
+ function supervisedRuntime(runtime) {
44
+ if (runtime === undefined)
45
+ return 'k3d';
46
+ if (runtime === 'k3d' || runtime === 'kind')
47
+ return runtime;
48
+ throw new ArgError(`--runtime ${runtime} cannot be supervised: a k3s host service has no cluster for this command ` +
49
+ 'to start. Use --runtime k3d or --runtime kind, or drop --k3s-cluster.');
50
+ }
51
+ export async function supervise(options) {
52
+ const argv = options.superviseCommand ?? [];
53
+ if (argv.length === 0) {
54
+ throw new ArgError('supervise needs a command to run, after `--`\n' +
55
+ ' e.g. cat-factory supervise --port 8787 -- pnpm dev');
56
+ }
57
+ // Not `argv.map(quoteToken)`: `map` would pass the index as the platform argument.
58
+ const command = argv.map((token) => quoteToken(token)).join(' ');
59
+ const cwd = options.dir ? resolve(options.dir) : process.cwd();
60
+ const port = options.port ?? OPTION_DEFAULTS.port;
61
+ const healthPath = options.healthPath ?? OPTION_DEFAULTS.healthPath;
62
+ const config = resolveSuperviseConfig({
63
+ pollMs: options.pollSeconds !== undefined ? options.pollSeconds * 1_000 : undefined,
64
+ bootGraceMs: options.bootGraceSeconds !== undefined ? options.bootGraceSeconds * 1_000 : undefined,
65
+ failureThreshold: options.failures,
66
+ });
67
+ const shell = createNodeShell();
68
+ // Only wire the dependencies that were named — the supervisor is useful with none at all. Order
69
+ // matters: the database comes first because the server dies in `migrate` without it, while a dead
70
+ // cluster only breaks environment provisioning.
71
+ const dependencies = [];
72
+ if (options.composeService) {
73
+ dependencies.push(createComposeDependency(shell, {
74
+ dir: options.composeDir ? resolve(options.composeDir) : cwd,
75
+ service: options.composeService,
76
+ }));
77
+ }
78
+ if (options.k3sCluster) {
79
+ dependencies.push(createK3sClusterDependency(shell, {
80
+ cluster: options.k3sCluster,
81
+ runtime: supervisedRuntime(options.k3sRuntime),
82
+ }));
83
+ }
84
+ const launcher = createChildLauncher({ command, cwd });
85
+ const probe = createHealthProbe({ port, healthPath });
86
+ const log = (message) => {
87
+ process.stdout.write(`[supervise] ${message}\n`);
88
+ };
89
+ const reaper = createPortReaper(shell, port, { log });
90
+ log(`watching :${port}${healthPath} every ${config.pollMs / 1_000}s — ` +
91
+ `repairs after ${config.failureThreshold} failed probes or a detected resume`);
92
+ for (const dependency of dependencies)
93
+ log(`dependency: ${dependency.label}`);
94
+ log(`command: ${command}`);
95
+ // Bring the dependencies up BEFORE the first start, so a cold `pnpm dev:safe` on a machine whose
96
+ // engine was restarted doesn't spend its first boot crashing against a stopped database.
97
+ for (const dependency of dependencies) {
98
+ try {
99
+ const ready = await dependency.ensure();
100
+ log(ready
101
+ ? `✔ ${dependency.label} is ready`
102
+ : `✖ ${dependency.label} is not ready — starting anyway`);
103
+ }
104
+ catch (err) {
105
+ if (!(err instanceof OperatorActionRequiredError))
106
+ throw err;
107
+ log(`✖ ${dependency.label} NEEDS YOU: ${err.message}`);
108
+ }
109
+ }
110
+ // Shutdown is delegated to the loop, which owns the child handle: aborting makes it break out of
111
+ // its sleep, kill the child TREE, and reap the port. Reaping from here instead would kill the
112
+ // inner listener while leaving the package-manager wrapper and its parked `node --watch` alive.
113
+ const stopper = new AbortController();
114
+ const stop = () => {
115
+ if (stopper.signal.aborted)
116
+ return;
117
+ log('shutting down');
118
+ stopper.abort();
119
+ };
120
+ process.on('SIGINT', stop);
121
+ process.on('SIGTERM', stop);
122
+ const outcome = await runSupervisor({
123
+ config,
124
+ probe,
125
+ launcher,
126
+ dependencies,
127
+ reaper,
128
+ log,
129
+ stopSignal: stopper.signal,
130
+ });
131
+ // A supervisor that stopped because the command is broken must not report success — a wrapper
132
+ // exiting 0 on a dead stack is the failure shape this whole command exists to make impossible.
133
+ if (outcome.gaveUp !== undefined)
134
+ process.exitCode = 1;
135
+ }
136
+ //# sourceMappingURL=superviseCommand.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"superviseCommand.js","sourceRoot":"","sources":["../src/superviseCommand.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/cli",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "description": "Bootstrap CLI for the Agent Architecture Board: scaffold a local-mode deployment (Node/local backend + frontend SPA) on your own machine — generates the crypto secrets, populates and gitignores the .env files, and mints a GitHub/GitLab personal access token by opening the browser at the right pre-scoped URL.",
5
5
  "keywords": [
6
6
  "bootstrap",
@@ -41,8 +41,8 @@
41
41
  "typescript": "7.0.2",
42
42
  "valibot": "^1.4.2",
43
43
  "vitest": "^4.1.10",
44
- "@cat-factory/contracts": "0.168.0",
45
- "@cat-factory/kernel": "0.163.0"
44
+ "@cat-factory/contracts": "0.197.0",
45
+ "@cat-factory/kernel": "0.194.0"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc -b tsconfig.build.json",
@@ -50,6 +50,7 @@
50
50
  "typecheck": "tsc -p tsconfig.json --noEmit",
51
51
  "test": "vitest",
52
52
  "test:run": "vitest run",
53
- "test:integration": "vitest run --config vitest.integration.config.ts"
53
+ "test:integration": "vitest run --config vitest.integration.config.ts",
54
+ "test:supervise": "vitest run --config vitest.supervise.config.ts"
54
55
  }
55
56
  }