@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.
- package/dist/bootstrap.d.ts +7 -0
- package/dist/bootstrap.js +28 -4
- package/dist/cli-args.d.ts +24 -0
- package/dist/cli-args.js +51 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +21 -2
- package/dist/env-writer.d.ts +46 -3
- package/dist/env-writer.js +83 -25
- package/dist/health.d.ts +22 -1
- package/dist/health.js +36 -1
- package/dist/https-origin.d.ts +46 -10
- package/dist/https-origin.js +47 -16
- package/dist/lockfile-detect.d.ts +8 -0
- package/dist/lockfile-detect.js +10 -0
- package/dist/prepare-fingerprint.d.ts +23 -0
- package/dist/prepare-fingerprint.js +63 -0
- package/dist/process-manager.d.ts +50 -2
- package/dist/process-manager.js +188 -40
- package/dist/provider.d.ts +80 -1
- package/dist/provider.js +242 -56
- package/dist/redact.js +13 -1
- package/dist/state.d.ts +26 -0
- package/package.json +5 -5
package/dist/https-origin.js
CHANGED
|
@@ -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
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
|
60
|
-
*
|
|
61
|
-
* `set_env` stays absent (never `""`)
|
|
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
|
-
|
|
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.
|
package/dist/lockfile-detect.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/process-manager.js
CHANGED
|
@@ -5,10 +5,38 @@
|
|
|
5
5
|
* health check waits, and graceful shutdown.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|