@launchfile/macos-dev 0.11.0 → 0.13.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.
@@ -10,7 +10,8 @@
10
10
  * D-56 rule 3 stands — the provider does not verify the origin exists or is
11
11
  * ready.
12
12
  */
13
- import { suppliedAppAddress, } from "@launchfile/sdk";
13
+ import { suppliedAppAddress, UNPUBLISHED_APP_ENDPOINT, useKeys, } from "@launchfile/sdk";
14
+ import { uncoveredUses, withCoveredUses } from "./resources/index.js";
14
15
  /** The backing-service type that declares the app's public HTTPS origin (D-60). */
15
16
  export const HTTPS_ORIGIN = "https-origin";
16
17
  /**
@@ -22,6 +23,14 @@ export const HTTPS_ORIGIN = "https-origin";
22
23
  export function httpsOriginSatisfied(appUrl) {
23
24
  return appUrl !== undefined && suppliedAppAddress(appUrl).scheme === "https";
24
25
  }
26
+ /**
27
+ * The uses an `https-origin` entry declares that this provider cannot cover,
28
+ * spelled as the file spells them. Asked of the use registry, never assumed:
29
+ * a use registered for the type later is covered with no change here.
30
+ */
31
+ export function uncoveredOriginUses(entry) {
32
+ return entry.uses ? uncoveredUses(entry.type, useKeys(entry.uses)) : [];
33
+ }
25
34
  /**
26
35
  * Why one `https-origin` entry is not satisfied, for a refusal or a degraded
27
36
  * note — the same two reasons `@launchfile/docker` gives, so one Launchfile
@@ -34,21 +43,38 @@ export function httpsOriginShortfall(entry, appUrl) {
34
43
  : `${label}: the supplied publication URL's scheme is "${suppliedAppAddress(appUrl).scheme}", not https`;
35
44
  }
36
45
  /**
37
- * The component an `https-origin` entry sits on, when the file declares one
46
+ * The `$app.*` address of a primary whose component is refused (D-72):
47
+ * every field `""` — the answer D-63 rule 4 gives an endpoint the provider
48
+ * publishes no address for — with `tls` reading `false`, as D-63 rule 3 has
49
+ * a listener with no origin read it, so a literal on/off flag
50
+ * (`USE_SSL: $app.tls`) still receives a boolean. The same object
51
+ * `@launchfile/docker` resolves (P-5).
52
+ */
53
+ export const REFUSED_PRIMARY_ADDRESS = Object.freeze({ ...UNPUBLISHED_APP_ENDPOINT, tls: "false" });
54
+ /**
55
+ * The primary an `https-origin` entry declares, when the file declares one
38
56
  * (D-60 rule 3), else `undefined`. **Declaration** fixes the primary, not
39
57
  * fulfillment: a `supports:` entry this provider leaves unsatisfied still
40
- * names it, so `$app.*` does not change value with the provider's capability.
41
- * The SDK caps the app at one such entry and requires it to sit on the
42
- * component that owns the named endpoint, so the first match is the only one.
58
+ * names it, and so does a `requires:` entry whose component this provider
59
+ * refuses — `refused` says which, and `computeAppProperties` then resolves
60
+ * the empty address rather than a surviving sibling's (D-72). Either way
61
+ * `$app.*` does not move with the provider's capability. The SDK caps the app
62
+ * at one such entry and requires it to sit on the component that owns the
63
+ * named endpoint, so the first match is the only one.
64
+ *
65
+ * `up` reads this before its refusals remove anything from
66
+ * `launch.components`; `env` and `bootstrap` read the file whole. `appUrl` is
67
+ * the effective publication context — supplied or recorded — normalized.
43
68
  */
44
- export function declaredPrimaryComponent(launch) {
69
+ export function declaredPrimary(launch, appUrl) {
45
70
  for (const [name, component] of Object.entries(launch.components)) {
46
- for (const entry of [
47
- ...(component.requires ?? []),
48
- ...(component.supports ?? []),
49
- ]) {
71
+ for (const entry of component.requires ?? []) {
50
72
  if (entry.type === HTTPS_ORIGIN && entry.endpoint !== undefined)
51
- return name;
73
+ return { component: name, refused: !httpsOriginSatisfied(appUrl) };
74
+ }
75
+ for (const entry of component.supports ?? []) {
76
+ if (entry.type === HTTPS_ORIGIN && entry.endpoint !== undefined)
77
+ return { component: name, refused: false };
52
78
  }
53
79
  }
54
80
  return undefined;
@@ -56,10 +82,13 @@ export function declaredPrimaryComponent(launch) {
56
82
  /**
57
83
  * Register every satisfied `https-origin` entry as a resource so its `set_env`
58
84
  * resolves. One registered property, `url` (D-60 rule 4), holding the same
59
- * string as `$app.url`. Mutates `resourceMap`; a no-op when the recorded
60
- * publication URL does not satisfy the type, so an unsatisfied entry's
61
- * `set_env` stays absent (never `""`) exactly as for any other resource this
62
- * provider did not provision.
85
+ * string as `$app.url`, plus the properties of each declared use. Mutates
86
+ * `resourceMap`; a no-op when the recorded publication URL does not satisfy
87
+ * the type, so an unsatisfied entry's `set_env` stays absent (never `""`)
88
+ * exactly as for any other resource this provider did not provision. An entry
89
+ * declaring a use this provider cannot cover is unsatisfied the same way
90
+ * (D-65): a `supports:` entry runs degraded, and a `requires:` one refused its
91
+ * component before launch.
63
92
  */
64
93
  export function wireHttpsOrigins(launch, resourceMap, appUrl) {
65
94
  if (appUrl === undefined)
@@ -74,7 +103,9 @@ export function wireHttpsOrigins(launch, resourceMap, appUrl) {
74
103
  ]) {
75
104
  if (entry.type !== HTTPS_ORIGIN)
76
105
  continue;
77
- resourceMap[entry.name ?? entry.type] = { url };
106
+ if (uncoveredOriginUses(entry).length > 0)
107
+ continue;
108
+ resourceMap[entry.name ?? entry.type] = withCoveredUses(entry.type, entry.uses ? useKeys(entry.uses) : undefined, { url }, {});
78
109
  }
79
110
  }
80
111
  }
@@ -6,6 +6,14 @@ export interface PackageManager {
6
6
  installCommand: string;
7
7
  lockfile: string;
8
8
  }
9
+ /**
10
+ * Every lockfile name this provider knows, deduplicated. Dependency-change
11
+ * detection (D-38 on-demand prepare) reads them all rather than only the one
12
+ * `detectPackageManager` selects: a polyglot component can carry more than one,
13
+ * and priority order decides which install command to run, not which files
14
+ * count as dependency inputs.
15
+ */
16
+ export declare function lockfileNames(): string[];
9
17
  /**
10
18
  * Detect the package manager from lockfile presence in a directory.
11
19
  * Returns the first match in priority order, or null.
@@ -16,6 +16,16 @@ const LOCKFILE_ORDER = [
16
16
  { name: "poetry", installCommand: "poetry install", lockfile: "poetry.lock" },
17
17
  { name: "uv", installCommand: "uv sync", lockfile: "uv.lock" },
18
18
  ];
19
+ /**
20
+ * Every lockfile name this provider knows, deduplicated. Dependency-change
21
+ * detection (D-38 on-demand prepare) reads them all rather than only the one
22
+ * `detectPackageManager` selects: a polyglot component can carry more than one,
23
+ * and priority order decides which install command to run, not which files
24
+ * count as dependency inputs.
25
+ */
26
+ export function lockfileNames() {
27
+ return [...new Set(LOCKFILE_ORDER.map((pm) => pm.lockfile))];
28
+ }
19
29
  async function fileExists(path) {
20
30
  try {
21
31
  await access(path);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Dependency fingerprinting for the source-mode `prepare` slot (D-38).
3
+ *
4
+ * D-38 requires prepare (`install ?? build`) to run "on demand (first launch or
5
+ * a detected dependency change), never on every `dev`". The detection is a
6
+ * fingerprint of the prepare inputs: the command string plus the contents of
7
+ * every dependency manifest and lockfile in the directory the command runs in.
8
+ * A run records its fingerprint in state; a later `up` that computes the same
9
+ * fingerprint has nothing to install.
10
+ *
11
+ * Scope: files in the prepare working directory only. Dependency files nested
12
+ * deeper (a monorepo's per-workspace manifests) do not move the fingerprint, so
13
+ * editing one alone does not trigger a reinstall.
14
+ */
15
+ /**
16
+ * Fingerprint the inputs of one component's prepare run.
17
+ *
18
+ * The command string is part of the digest, so editing `install:`/`build:` in
19
+ * the Launchfile re-runs prepare even when no dependency file moved. A missing
20
+ * file contributes nothing to the digest, so creating or deleting one changes it.
21
+ */
22
+ export declare function prepareFingerprint(dir: string, command: string): Promise<string>;
23
+ //# sourceMappingURL=prepare-fingerprint.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Dependency fingerprinting for the source-mode `prepare` slot (D-38).
3
+ *
4
+ * D-38 requires prepare (`install ?? build`) to run "on demand (first launch or
5
+ * a detected dependency change), never on every `dev`". The detection is a
6
+ * fingerprint of the prepare inputs: the command string plus the contents of
7
+ * every dependency manifest and lockfile in the directory the command runs in.
8
+ * A run records its fingerprint in state; a later `up` that computes the same
9
+ * fingerprint has nothing to install.
10
+ *
11
+ * Scope: files in the prepare working directory only. Dependency files nested
12
+ * deeper (a monorepo's per-workspace manifests) do not move the fingerprint, so
13
+ * editing one alone does not trigger a reinstall.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { lockfileNames } from "./lockfile-detect.js";
19
+ /**
20
+ * Dependency manifests — the hand-edited half of the pair. They are read
21
+ * alongside lockfiles so a project that commits no lockfile still gets change
22
+ * detection, and so an edited manifest counts as a change before the install
23
+ * that would regenerate the lockfile has run.
24
+ */
25
+ const MANIFEST_FILES = [
26
+ "package.json",
27
+ "Gemfile",
28
+ "go.mod",
29
+ "Cargo.toml",
30
+ "pyproject.toml",
31
+ "Pipfile",
32
+ "setup.py",
33
+ "composer.json",
34
+ ];
35
+ /** Every file whose content is a prepare input, deduplicated and ordered. */
36
+ function dependencyFiles() {
37
+ return [...new Set([...lockfileNames(), ...MANIFEST_FILES])].sort();
38
+ }
39
+ /**
40
+ * Fingerprint the inputs of one component's prepare run.
41
+ *
42
+ * The command string is part of the digest, so editing `install:`/`build:` in
43
+ * the Launchfile re-runs prepare even when no dependency file moved. A missing
44
+ * file contributes nothing to the digest, so creating or deleting one changes it.
45
+ */
46
+ export async function prepareFingerprint(dir, command) {
47
+ const hash = createHash("sha256");
48
+ hash.update(`command ${command} `);
49
+ for (const name of dependencyFiles()) {
50
+ let content;
51
+ try {
52
+ content = await readFile(join(dir, name));
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ hash.update(`file ${name} `);
58
+ hash.update(createHash("sha256").update(content).digest("hex"));
59
+ hash.update(" ");
60
+ }
61
+ return hash.digest("hex").slice(0, 16);
62
+ }
63
+ //# sourceMappingURL=prepare-fingerprint.js.map
@@ -5,6 +5,34 @@
5
5
  * health check waits, and graceful shutdown.
6
6
  */
7
7
  import type { NormalizedHealth, NormalizedDependsOnEntry } from "@launchfile/sdk";
8
+ /**
9
+ * How long a component gets to report healthy before `up` fails. A
10
+ * provider-side budget: SPEC.md § Failure semantics binds the disposition of
11
+ * the failure, not the number of seconds (P-11). Documented in CLAUDE.md as
12
+ * PROVIDERS.md §10 rule 10 requires.
13
+ */
14
+ export declare const HEALTH_TIMEOUT_MS = 60000;
15
+ /**
16
+ * A component's declared `health:` never passed, or could not be checked at all.
17
+ * The invocation fails (SPEC.md § Failure semantics); `launchUp` tags it with
18
+ * the `health` phase so the CLI records the deployment the processes belong to.
19
+ */
20
+ export declare class HealthGateError extends Error {
21
+ constructor(message: string);
22
+ }
23
+ /** One component the health gate gave up on: the probe asked and the window it got. */
24
+ export interface StuckComponent {
25
+ name: string;
26
+ check: string;
27
+ budgetMs: number;
28
+ }
29
+ /**
30
+ * What a health-gate failure says. Every stuck component is named with the
31
+ * probe that was asked of it and the budget it actually got — budgets differ
32
+ * per component once a file declares `retries` — so "did not become healthy"
33
+ * is actionable.
34
+ */
35
+ export declare function healthFailureMessage(stuck: ReadonlyArray<StuckComponent>): string;
8
36
  /** A spawned component process recorded for cross-session shutdown. */
9
37
  export interface RecordedProcessInfo {
10
38
  pid: number;
@@ -15,7 +43,10 @@ export interface RecordedProcessInfo {
15
43
  export declare class ProcessManager {
16
44
  private processes;
17
45
  private logDir;
18
- constructor(projectDir: string);
46
+ private healthTimeoutMs;
47
+ constructor(projectDir: string, opts?: {
48
+ healthTimeoutMs?: number;
49
+ });
19
50
  register(name: string, config: {
20
51
  command: string;
21
52
  env: Record<string, string>;
@@ -25,9 +56,26 @@ export declare class ProcessManager {
25
56
  port?: number;
26
57
  }): void;
27
58
  /**
28
- * Start all registered processes respecting dependency order.
59
+ * Start all registered processes respecting dependency order, then verify
60
+ * every component that declares `health:` actually became healthy.
61
+ *
62
+ * SPEC.md § Failure semantics: a component that never becomes healthy
63
+ * FAILS THE INVOCATION. The rejection names each stuck component and the
64
+ * probe it was asked. Processes that did start are left running and stay
65
+ * registered, so the caller can record their pids for `status`/`logs`/`down`.
29
66
  */
30
67
  startAll(): Promise<void>;
68
+ /** The failure-message entry for a component whose check never passed. */
69
+ private stuck;
70
+ /** Report a health-gate failure on stderr and build the error `up` rejects with. */
71
+ private healthFailure;
72
+ /**
73
+ * Poll one component's declared check until it passes or the budget runs
74
+ * out. Throws when the check cannot run at all: a `path` check with no
75
+ * allocated port has nothing to poll, and treating that as healthy would be
76
+ * a silent pass of a check the file declared.
77
+ */
78
+ private pollHealthy;
31
79
  private startOne;
32
80
  /**
33
81
  * Graceful shutdown in reverse dependency order.
@@ -5,10 +5,38 @@
5
5
  * health check waits, and graceful shutdown.
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
- import { createWriteStream, mkdirSync } from "node:fs";
8
+ import { closeSync, fchmodSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { waitForHealthy } from "./health.js";
10
+ import { describeHealthCheck, healthBudgetMs, healthCheckNeedsPort, waitForHealthy } from "./health.js";
11
11
  import { redactSecrets } from "./redact.js";
12
+ /**
13
+ * How long a component gets to report healthy before `up` fails. A
14
+ * provider-side budget: SPEC.md § Failure semantics binds the disposition of
15
+ * the failure, not the number of seconds (P-11). Documented in CLAUDE.md as
16
+ * PROVIDERS.md §10 rule 10 requires.
17
+ */
18
+ export const HEALTH_TIMEOUT_MS = 60_000;
19
+ /**
20
+ * A component's declared `health:` never passed, or could not be checked at all.
21
+ * The invocation fails (SPEC.md § Failure semantics); `launchUp` tags it with
22
+ * the `health` phase so the CLI records the deployment the processes belong to.
23
+ */
24
+ export class HealthGateError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "HealthGateError";
28
+ }
29
+ }
30
+ /**
31
+ * What a health-gate failure says. Every stuck component is named with the
32
+ * probe that was asked of it and the budget it actually got — budgets differ
33
+ * per component once a file declares `retries` — so "did not become healthy"
34
+ * is actionable.
35
+ */
36
+ export function healthFailureMessage(stuck) {
37
+ const named = stuck.map((s) => `${s.name} (${s.check}) within ${s.budgetMs / 1000}s`).join(", ");
38
+ return `component(s) did not become healthy: ${named}`;
39
+ }
12
40
  // ANSI colors for log prefixing
13
41
  const COLORS = [
14
42
  "\x1b[36m", // cyan
@@ -41,11 +69,76 @@ function killGroupOrSelf(proc, pid, signal) {
41
69
  // Already exited.
42
70
  }
43
71
  }
72
+ const TAIL_INTERVAL_MS = 200;
73
+ /**
74
+ * Prints a component's log file as it grows. The component writes the file
75
+ * itself; this only reads what it appends, so the console view holds nothing
76
+ * the writer depends on. Polled, from the size the file had at `start`.
77
+ */
78
+ class LogTail {
79
+ path;
80
+ onLine;
81
+ offset = 0;
82
+ partial = "";
83
+ timer;
84
+ constructor(path, onLine) {
85
+ this.path = path;
86
+ this.onLine = onLine;
87
+ }
88
+ start() {
89
+ this.offset = this.size();
90
+ this.timer = setInterval(() => this.drain(), TAIL_INTERVAL_MS);
91
+ // The child handle keeps the session alive; the tail never should.
92
+ this.timer.unref();
93
+ }
94
+ /** Print the rest of the file and stop polling. */
95
+ stop() {
96
+ if (this.timer)
97
+ clearInterval(this.timer);
98
+ this.timer = undefined;
99
+ this.drain();
100
+ if (this.partial) {
101
+ this.onLine(this.partial);
102
+ this.partial = "";
103
+ }
104
+ }
105
+ size() {
106
+ try {
107
+ return statSync(this.path).size;
108
+ }
109
+ catch {
110
+ return 0;
111
+ }
112
+ }
113
+ drain() {
114
+ const size = this.size();
115
+ if (size <= this.offset)
116
+ return;
117
+ const buf = Buffer.alloc(size - this.offset);
118
+ const fd = openSync(this.path, "r");
119
+ try {
120
+ const read = readSync(fd, buf, 0, buf.length, this.offset);
121
+ this.offset += read;
122
+ this.partial += buf.subarray(0, read).toString();
123
+ }
124
+ finally {
125
+ closeSync(fd);
126
+ }
127
+ const lines = this.partial.split("\n");
128
+ this.partial = lines.pop() ?? "";
129
+ for (const line of lines) {
130
+ if (line)
131
+ this.onLine(line);
132
+ }
133
+ }
134
+ }
44
135
  export class ProcessManager {
45
136
  processes = new Map();
46
137
  logDir;
47
- constructor(projectDir) {
138
+ healthTimeoutMs;
139
+ constructor(projectDir, opts = {}) {
48
140
  this.logDir = join(projectDir, ".launchfile", "logs");
141
+ this.healthTimeoutMs = opts.healthTimeoutMs ?? HEALTH_TIMEOUT_MS;
49
142
  // Security: restrict permissions — logs may contain sensitive output
50
143
  mkdirSync(this.logDir, { recursive: true, mode: 0o700 });
51
144
  }
@@ -62,7 +155,13 @@ export class ProcessManager {
62
155
  });
63
156
  }
64
157
  /**
65
- * Start all registered processes respecting dependency order.
158
+ * Start all registered processes respecting dependency order, then verify
159
+ * every component that declares `health:` actually became healthy.
160
+ *
161
+ * SPEC.md § Failure semantics: a component that never becomes healthy
162
+ * FAILS THE INVOCATION. The rejection names each stuck component and the
163
+ * probe it was asked. Processes that did start are left running and stay
164
+ * registered, so the caller can record their pids for `status`/`logs`/`down`.
66
165
  */
67
166
  async startAll() {
68
167
  const batches = this.topologicalSort();
@@ -70,70 +169,119 @@ export class ProcessManager {
70
169
  // Start all processes in this batch concurrently
71
170
  await Promise.all(batch.map((name) => this.startOne(name)));
72
171
  }
73
- console.log("\n All components started.");
172
+ // A dependency gate above already verified some components; the sweep
173
+ // covers the rest — including every component nothing depends on.
174
+ const stuck = [];
175
+ await Promise.all([...this.processes.values()].map(async (proc) => {
176
+ const health = proc.health;
177
+ if (!health || !proc.process || proc.status === "healthy")
178
+ return;
179
+ if (!(await this.pollHealthy(proc, health))) {
180
+ stuck.push(this.stuck(proc, health));
181
+ }
182
+ }));
183
+ if (stuck.length > 0) {
184
+ throw this.healthFailure(healthFailureMessage(stuck));
185
+ }
186
+ }
187
+ /** The failure-message entry for a component whose check never passed. */
188
+ stuck(proc, health) {
189
+ return {
190
+ name: proc.name,
191
+ check: describeHealthCheck(health, proc.port),
192
+ budgetMs: healthBudgetMs(health, this.healthTimeoutMs),
193
+ };
194
+ }
195
+ /** Report a health-gate failure on stderr and build the error `up` rejects with. */
196
+ healthFailure(message) {
197
+ console.error(` ! ${message}`);
198
+ console.error(" Processes are left running: `launchfile status` lists them, .launchfile/logs/<component>.log has their output, `launchfile down` stops them.");
199
+ return new HealthGateError(message);
200
+ }
201
+ /**
202
+ * Poll one component's declared check until it passes or the budget runs
203
+ * out. Throws when the check cannot run at all: a `path` check with no
204
+ * allocated port has nothing to poll, and treating that as healthy would be
205
+ * a silent pass of a check the file declared.
206
+ */
207
+ async pollHealthy(proc, health) {
208
+ if (healthCheckNeedsPort(health) && proc.port === undefined) {
209
+ throw this.healthFailure(`component ${proc.name} declares a health check (${describeHealthCheck(health, undefined)}) but no port was allocated to poll`);
210
+ }
211
+ // A command check never reads the port; 0 only fills the parameter.
212
+ const budget = healthBudgetMs(health, this.healthTimeoutMs);
213
+ const ok = await waitForHealthy(proc.name, health, proc.port ?? 0, budget);
214
+ if (ok)
215
+ proc.status = "healthy";
216
+ return ok;
74
217
  }
75
218
  async startOne(name) {
76
219
  const proc = this.processes.get(name);
77
220
  if (!proc)
78
221
  throw new Error(`Unknown component: ${name}`);
79
- // Wait for dependencies
222
+ // Wait for dependencies. A `condition: healthy` gate fails closed: the
223
+ // dependent never starts when the dependency cannot be verified, and the
224
+ // whole invocation fails — the same answer compose gives `service_healthy`.
80
225
  for (const dep of proc.dependsOn) {
81
226
  const depProc = this.processes.get(dep.component);
82
227
  if (!depProc)
83
228
  continue;
84
229
  if (dep.condition === "healthy") {
85
230
  console.log(` [${name}] Waiting for ${dep.component} to be healthy...`);
86
- if (depProc.health && depProc.port) {
87
- await waitForHealthy(dep.component, depProc.health, depProc.port);
231
+ if (!depProc.health) {
232
+ throw this.healthFailure(`component ${name} depends on ${dep.component} with condition: healthy, but ${dep.component} declares no health check`);
233
+ }
234
+ if (depProc.status !== "healthy" && !(await this.pollHealthy(depProc, depProc.health))) {
235
+ const message = healthFailureMessage([this.stuck(depProc, depProc.health)]);
236
+ throw this.healthFailure(`${message}; ${name} was not started`);
88
237
  }
89
238
  }
90
239
  // For "started" condition, the process is already spawned by the time we get here
91
240
  }
92
241
  proc.status = "starting";
93
242
  console.log(` [${name}] Starting: ${redactSecrets(proc.command)}`);
94
- const logFile = createWriteStream(join(this.logDir, `${name}.log`), { flags: "a" });
95
243
  const colorIdx = [...this.processes.keys()].indexOf(name) % COLORS.length;
96
244
  const color = COLORS[colorIdx];
97
245
  const maxNameLen = Math.max(...[...this.processes.keys()].map((n) => n.length));
98
246
  const paddedName = name.padEnd(maxNameLen);
99
- // `detached: true` makes the child the leader of a new process group
100
- // (pgid === pid). That lets `launch down` signal the whole group later via
101
- // a negative pid, killing the app AND any children it spawned — matching
102
- // the foreground SIGINT behavior across sessions. We still keep the handle
103
- // so the foreground session can kill it directly on Ctrl+C.
104
- proc.process = spawn("sh", ["-c", proc.command], {
105
- env: { ...process.env, ...proc.env },
106
- cwd: proc.cwd,
107
- stdio: ["ignore", "pipe", "pipe"],
108
- detached: true,
247
+ // The child writes its stdout and stderr straight to its log file. A pipe
248
+ // held by this process would die with it, and a component left running
249
+ // after `up` fails (SPEC.md § Failure semantics) must survive its next
250
+ // write. The console view is a tail of that file.
251
+ const logPath = join(this.logDir, `${name}.log`);
252
+ const tail = new LogTail(logPath, (line) => {
253
+ process.stdout.write(`${color}[${paddedName}]${RESET} ${line}\n`);
109
254
  });
255
+ tail.start();
256
+ // The log holds the component's raw output, which can include a secret an
257
+ // app prints on first boot. The open mode covers a new file only, so a log
258
+ // left by an earlier run is tightened too.
259
+ const logFd = openSync(logPath, "a", 0o600);
260
+ try {
261
+ fchmodSync(logFd, 0o600);
262
+ // `detached: true` makes the child the leader of a new process group
263
+ // (pgid === pid). That lets `launch down` signal the whole group later via
264
+ // a negative pid, killing the app AND any children it spawned — matching
265
+ // the foreground SIGINT behavior across sessions. We still keep the handle
266
+ // so the foreground session can kill it directly on Ctrl+C.
267
+ proc.process = spawn("sh", ["-c", proc.command], {
268
+ env: { ...process.env, ...proc.env },
269
+ cwd: proc.cwd,
270
+ stdio: ["ignore", logFd, logFd],
271
+ detached: true,
272
+ });
273
+ }
274
+ finally {
275
+ // The child holds its own copy of the descriptor.
276
+ closeSync(logFd);
277
+ }
110
278
  if (proc.process.pid !== undefined) {
111
279
  proc.startedAt = new Date().toISOString();
112
280
  }
113
- // Pipe stdout with prefix
114
- proc.process.stdout?.on("data", (data) => {
115
- const lines = data.toString().split("\n");
116
- for (const line of lines) {
117
- if (line) {
118
- process.stdout.write(`${color}[${paddedName}]${RESET} ${line}\n`);
119
- logFile.write(`${new Date().toISOString()} ${line}\n`);
120
- }
121
- }
122
- });
123
- // Pipe stderr with prefix
124
- proc.process.stderr?.on("data", (data) => {
125
- const lines = data.toString().split("\n");
126
- for (const line of lines) {
127
- if (line) {
128
- process.stderr.write(`${color}[${paddedName}]${RESET} \x1b[2m${line}${RESET}\n`);
129
- logFile.write(`${new Date().toISOString()} ERR ${line}\n`);
130
- }
131
- }
132
- });
133
281
  proc.process.on("exit", (code) => {
134
282
  proc.status = code === 0 ? "stopped" : "failed";
283
+ tail.stop();
135
284
  console.log(`${color}[${paddedName}]${RESET} Process exited with code ${code}`);
136
- logFile.end();
137
285
  });
138
286
  proc.status = "running";
139
287
  }