@launchfile/macos-dev 0.1.4 → 0.3.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 +58 -0
- package/dist/bootstrap.js +224 -0
- package/dist/env-writer.d.ts +19 -13
- package/dist/env-writer.js +44 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +57 -2
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.js +176 -70
- package/dist/shell.js +1 -1
- package/dist/state.d.ts +28 -0
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +9 -4
- package/CLAUDE.md +0 -37
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.d.ts.map +0 -1
- package/dist/__tests__/dry-run.test.js +0 -129
- package/dist/__tests__/dry-run.test.js.map +0 -1
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.d.ts.map +0 -1
- package/dist/__tests__/env-writer.test.js +0 -127
- package/dist/__tests__/env-writer.test.js.map +0 -1
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/lockfile-detect.test.js.map +0 -1
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/port-allocator.test.js.map +0 -1
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/secret-generator.test.js.map +0 -1
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.d.ts.map +0 -1
- package/dist/__tests__/state.test.js +0 -30
- package/dist/__tests__/state.test.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/env-writer.d.ts.map +0 -1
- package/dist/env-writer.js.map +0 -1
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lockfile-detect.d.ts.map +0 -1
- package/dist/lockfile-detect.js.map +0 -1
- package/dist/port-allocator.d.ts.map +0 -1
- package/dist/port-allocator.js.map +0 -1
- package/dist/prereqs.d.ts.map +0 -1
- package/dist/prereqs.js.map +0 -1
- package/dist/process-manager.d.ts.map +0 -1
- package/dist/process-manager.js.map +0 -1
- package/dist/provider.d.ts.map +0 -1
- package/dist/provider.js.map +0 -1
- package/dist/resources/index.d.ts.map +0 -1
- package/dist/resources/index.js.map +0 -1
- package/dist/resources/mysql.d.ts.map +0 -1
- package/dist/resources/mysql.js.map +0 -1
- package/dist/resources/postgres.d.ts.map +0 -1
- package/dist/resources/postgres.js.map +0 -1
- package/dist/resources/redis.d.ts.map +0 -1
- package/dist/resources/redis.js.map +0 -1
- package/dist/resources/sqlite.d.ts.map +0 -1
- package/dist/resources/sqlite.js.map +0 -1
- package/dist/resources/types.d.ts.map +0 -1
- package/dist/resources/types.js.map +0 -1
- package/dist/runtimes/bun.d.ts.map +0 -1
- package/dist/runtimes/bun.js.map +0 -1
- package/dist/runtimes/index.d.ts.map +0 -1
- package/dist/runtimes/index.js.map +0 -1
- package/dist/runtimes/node.d.ts.map +0 -1
- package/dist/runtimes/node.js.map +0 -1
- package/dist/runtimes/python.d.ts.map +0 -1
- package/dist/runtimes/python.js.map +0 -1
- package/dist/runtimes/ruby.d.ts.map +0 -1
- package/dist/runtimes/ruby.js.map +0 -1
- package/dist/runtimes/types.d.ts.map +0 -1
- package/dist/runtimes/types.js.map +0 -1
- package/dist/secret-generator.d.ts.map +0 -1
- package/dist/secret-generator.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/storage.js.map +0 -1
- package/src/__tests__/dry-run.test.ts +0 -157
- package/src/__tests__/env-writer.test.ts +0 -156
- package/src/__tests__/lockfile-detect.test.ts +0 -74
- package/src/__tests__/port-allocator.test.ts +0 -59
- package/src/__tests__/secret-generator.test.ts +0 -31
- package/src/__tests__/state.test.ts +0 -33
- package/src/cli.ts +0 -78
- package/src/env-writer.ts +0 -231
- package/src/health.ts +0 -80
- package/src/index.ts +0 -9
- package/src/lockfile-detect.ts +0 -48
- package/src/port-allocator.ts +0 -97
- package/src/prereqs.ts +0 -32
- package/src/process-manager.ts +0 -242
- package/src/provider.ts +0 -415
- package/src/resources/index.ts +0 -29
- package/src/resources/mysql.ts +0 -98
- package/src/resources/postgres.ts +0 -133
- package/src/resources/redis.ts +0 -58
- package/src/resources/sqlite.ts +0 -52
- package/src/resources/types.ts +0 -47
- package/src/runtimes/bun.ts +0 -25
- package/src/runtimes/index.ts +0 -24
- package/src/runtimes/node.ts +0 -71
- package/src/runtimes/python.ts +0 -42
- package/src/runtimes/ruby.ts +0 -55
- package/src/runtimes/types.ts +0 -16
- package/src/secret-generator.ts +0 -32
- package/src/shell.ts +0 -70
- package/src/state.ts +0 -92
- package/src/storage.ts +0 -67
- package/tsconfig.json +0 -18
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap command execution for the macOS Dev provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `commands.bootstrap` lifecycle stage introduced by D-34:
|
|
5
|
+
* user-invoked, runs after `start` against the running component, captures
|
|
6
|
+
* stdout via regex patterns, re-runnable, and reports failures rather than
|
|
7
|
+
* deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
|
|
8
|
+
*
|
|
9
|
+
* The command is split into argv via whitespace and run through spawn()
|
|
10
|
+
* with shell:false to avoid shell-injection exposure. This means shell
|
|
11
|
+
* metacharacters (pipes, redirects, &&, quoted args with spaces) are not
|
|
12
|
+
* supported — apps that need shell features should wrap them in an
|
|
13
|
+
* image-level script and invoke that script.
|
|
14
|
+
*/
|
|
15
|
+
import { type CaptureEntry } from "@launchfile/sdk";
|
|
16
|
+
/**
|
|
17
|
+
* Result of running one bootstrap command. Captures may be empty even on
|
|
18
|
+
* success (the command may not produce matching output), and may be
|
|
19
|
+
* partially populated even on failure (some patterns matched before
|
|
20
|
+
* the command errored out).
|
|
21
|
+
*/
|
|
22
|
+
export interface BootstrapResult {
|
|
23
|
+
component: string;
|
|
24
|
+
command: string;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
exitCode: number;
|
|
27
|
+
captures: Record<string, string>;
|
|
28
|
+
/** Per-capture metadata (description, sensitive) for display. */
|
|
29
|
+
captureMeta: Record<string, CaptureEntry>;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Apply a set of capture patterns to a command's stdout. If a pattern has
|
|
35
|
+
* a capture group, the first group is used; otherwise the full match is
|
|
36
|
+
* used. Invalid regexes are skipped (schema validation catches them earlier).
|
|
37
|
+
*
|
|
38
|
+
* Exported for unit testing.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractCaptures(stdout: string, captures: Record<string, CaptureEntry>): Record<string, string>;
|
|
41
|
+
/** Parse a simple duration string like "5m", "30s", "1h" into milliseconds. Exported for unit testing. */
|
|
42
|
+
export declare function parseDuration(s: string): number;
|
|
43
|
+
/**
|
|
44
|
+
* Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
|
|
45
|
+
* the resolver context from persisted state (so $app.url resolves to the
|
|
46
|
+
* same value the running component sees), resolves $-expressions in each
|
|
47
|
+
* bootstrap command, runs them in declaration order, and returns structured
|
|
48
|
+
* results.
|
|
49
|
+
*
|
|
50
|
+
* Does not fail the process on command error — the caller (CLI) decides
|
|
51
|
+
* how to display failures. This matches the "reported, not deploy-failing"
|
|
52
|
+
* semantics in SPEC.md § Bootstrap stage.
|
|
53
|
+
*/
|
|
54
|
+
export declare function launchBootstrap(opts?: {
|
|
55
|
+
component?: string;
|
|
56
|
+
projectDir?: string;
|
|
57
|
+
}): Promise<BootstrapResult[]>;
|
|
58
|
+
//# sourceMappingURL=bootstrap.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap command execution for the macOS Dev provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `commands.bootstrap` lifecycle stage introduced by D-34:
|
|
5
|
+
* user-invoked, runs after `start` against the running component, captures
|
|
6
|
+
* stdout via regex patterns, re-runnable, and reports failures rather than
|
|
7
|
+
* deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
|
|
8
|
+
*
|
|
9
|
+
* The command is split into argv via whitespace and run through spawn()
|
|
10
|
+
* with shell:false to avoid shell-injection exposure. This means shell
|
|
11
|
+
* metacharacters (pipes, redirects, &&, quoted args with spaces) are not
|
|
12
|
+
* supported — apps that need shell features should wrap them in an
|
|
13
|
+
* image-level script and invoke that script.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { readLaunch, resolveExpression, } from "@launchfile/sdk";
|
|
19
|
+
import { loadState } from "./state.js";
|
|
20
|
+
import { buildResolverContext, computeAppProperties, resolveComponentEnv, resolveGenerators, } from "./env-writer.js";
|
|
21
|
+
import { getProvisioner } from "./resources/index.js";
|
|
22
|
+
/**
|
|
23
|
+
* Strip ANSI escape sequences from captured stdout before regex matching.
|
|
24
|
+
* CLI tools that detect a TTY will emit color codes that would otherwise
|
|
25
|
+
* break simple patterns like `https?://\S+`.
|
|
26
|
+
*/
|
|
27
|
+
function stripAnsi(s) {
|
|
28
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI match
|
|
29
|
+
return s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, "");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Apply a set of capture patterns to a command's stdout. If a pattern has
|
|
33
|
+
* a capture group, the first group is used; otherwise the full match is
|
|
34
|
+
* used. Invalid regexes are skipped (schema validation catches them earlier).
|
|
35
|
+
*
|
|
36
|
+
* Exported for unit testing.
|
|
37
|
+
*/
|
|
38
|
+
export function extractCaptures(stdout, captures) {
|
|
39
|
+
const result = {};
|
|
40
|
+
const clean = stripAnsi(stdout);
|
|
41
|
+
for (const [name, def] of Object.entries(captures)) {
|
|
42
|
+
try {
|
|
43
|
+
const match = clean.match(new RegExp(def.pattern));
|
|
44
|
+
if (match) {
|
|
45
|
+
result[name] = (match[1] ?? match[0] ?? "").trim();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Invalid regex — skip. CaptureEntrySchema validation catches this.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/** Parse a simple duration string like "5m", "30s", "1h" into milliseconds. Exported for unit testing. */
|
|
55
|
+
export function parseDuration(s) {
|
|
56
|
+
const match = /^(\d+)\s*(ms|s|m|h)$/.exec(s.trim());
|
|
57
|
+
if (!match)
|
|
58
|
+
return 120_000;
|
|
59
|
+
const n = Number.parseInt(match[1], 10);
|
|
60
|
+
switch (match[2]) {
|
|
61
|
+
case "ms": return n;
|
|
62
|
+
case "s": return n * 1000;
|
|
63
|
+
case "m": return n * 60 * 1000;
|
|
64
|
+
case "h": return n * 60 * 60 * 1000;
|
|
65
|
+
default: return 120_000;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run one command using argv-split (no-shell) execution. Returns the
|
|
70
|
+
* structured result; does not throw on command failure.
|
|
71
|
+
*/
|
|
72
|
+
async function runOnce(command, opts) {
|
|
73
|
+
const parts = command.trim().split(/\s+/).filter(Boolean);
|
|
74
|
+
if (parts.length === 0) {
|
|
75
|
+
return { exitCode: 1, stdout: "", stderr: "empty command" };
|
|
76
|
+
}
|
|
77
|
+
const [file, ...args] = parts;
|
|
78
|
+
return new Promise((resolveP) => {
|
|
79
|
+
const child = spawn(file, args, {
|
|
80
|
+
cwd: opts.cwd,
|
|
81
|
+
env: { ...process.env, ...opts.env },
|
|
82
|
+
shell: false,
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
});
|
|
85
|
+
let stdout = "";
|
|
86
|
+
let stderr = "";
|
|
87
|
+
let settled = false;
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
if (!settled) {
|
|
90
|
+
settled = true;
|
|
91
|
+
child.kill("SIGTERM");
|
|
92
|
+
resolveP({
|
|
93
|
+
exitCode: 124,
|
|
94
|
+
stdout,
|
|
95
|
+
stderr: stderr + `\n(killed after ${opts.timeoutMs}ms timeout)`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}, opts.timeoutMs);
|
|
99
|
+
child.stdout?.on("data", (chunk) => {
|
|
100
|
+
stdout += String(chunk);
|
|
101
|
+
});
|
|
102
|
+
child.stderr?.on("data", (chunk) => {
|
|
103
|
+
stderr += String(chunk);
|
|
104
|
+
});
|
|
105
|
+
child.on("error", (err) => {
|
|
106
|
+
if (!settled) {
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolveP({ exitCode: 1, stdout, stderr: `${stderr}\n${err.message}` });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
child.on("close", (code) => {
|
|
113
|
+
if (!settled) {
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolveP({ exitCode: code ?? 1, stdout, stderr });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
|
|
123
|
+
* the resolver context from persisted state (so $app.url resolves to the
|
|
124
|
+
* same value the running component sees), resolves $-expressions in each
|
|
125
|
+
* bootstrap command, runs them in declaration order, and returns structured
|
|
126
|
+
* results.
|
|
127
|
+
*
|
|
128
|
+
* Does not fail the process on command error — the caller (CLI) decides
|
|
129
|
+
* how to display failures. This matches the "reported, not deploy-failing"
|
|
130
|
+
* semantics in SPEC.md § Bootstrap stage.
|
|
131
|
+
*/
|
|
132
|
+
export async function launchBootstrap(opts = {}) {
|
|
133
|
+
const projectDir = opts.projectDir ?? process.cwd();
|
|
134
|
+
const launchfileContent = await readFile(join(projectDir, "Launchfile"), "utf8");
|
|
135
|
+
const launch = readLaunch(launchfileContent);
|
|
136
|
+
const state = await loadState(projectDir);
|
|
137
|
+
if (!state) {
|
|
138
|
+
throw new Error("No active launch state. Run `launch up` first.");
|
|
139
|
+
}
|
|
140
|
+
// Rebuild resource map from state so the resolver context has real
|
|
141
|
+
// values at invocation time (same pattern as launchEnv). This calls
|
|
142
|
+
// provisioner.provision() on already-provisioned resources to retrieve
|
|
143
|
+
// their current property values — relies on every provisioner being
|
|
144
|
+
// idempotent: re-running provision() must not corrupt state or
|
|
145
|
+
// re-create the resource. All current provisioners satisfy this; new
|
|
146
|
+
// provisioners must as well.
|
|
147
|
+
const resourceMap = {};
|
|
148
|
+
for (const [name, res] of Object.entries(state.resources)) {
|
|
149
|
+
const provisioner = getProvisioner(res.type);
|
|
150
|
+
if (provisioner) {
|
|
151
|
+
const result = await provisioner.provision({ type: res.type, name: res.name }, { appName: state.appName, projectDir }, res);
|
|
152
|
+
resourceMap[name] = result.properties;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const appProperties = computeAppProperties(launch, state.ports);
|
|
156
|
+
const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
|
|
157
|
+
const results = [];
|
|
158
|
+
for (const [name, component] of Object.entries(launch.components)) {
|
|
159
|
+
if (opts.component && name !== opts.component)
|
|
160
|
+
continue;
|
|
161
|
+
// `bootstrap` is mode-invariant (D-38): the same command runs from
|
|
162
|
+
// source or artifact. A path or binary that differs by mode belongs in
|
|
163
|
+
// storage:/env/PATH, not a separate command.
|
|
164
|
+
const bootstrap = component.commands?.bootstrap;
|
|
165
|
+
if (!bootstrap)
|
|
166
|
+
continue;
|
|
167
|
+
// Resolve $-expressions in the command string (e.g. $app.url) at
|
|
168
|
+
// invocation time. Bootstrap runs after start, so the resolved URL
|
|
169
|
+
// already reflects the actual allocated port.
|
|
170
|
+
const resolvedCommand = resolveExpression(bootstrap.command, context);
|
|
171
|
+
// Resolve env vars so the subprocess gets the same environment as
|
|
172
|
+
// the running component.
|
|
173
|
+
const env = resolveComponentEnv(component, context, resourceMap);
|
|
174
|
+
await resolveGenerators(component, env);
|
|
175
|
+
const port = state.ports[name];
|
|
176
|
+
if (port && !env.PORT)
|
|
177
|
+
env.PORT = String(port);
|
|
178
|
+
console.log(`\n \u2193 Bootstrap [${name}]`);
|
|
179
|
+
console.log(` $ ${resolvedCommand}`);
|
|
180
|
+
const { exitCode, stdout, stderr } = await runOnce(resolvedCommand, {
|
|
181
|
+
cwd: projectDir,
|
|
182
|
+
env,
|
|
183
|
+
timeoutMs: bootstrap.timeout ? parseDuration(bootstrap.timeout) : 120_000,
|
|
184
|
+
});
|
|
185
|
+
const captures = bootstrap.capture
|
|
186
|
+
? extractCaptures(stdout, bootstrap.capture)
|
|
187
|
+
: {};
|
|
188
|
+
const result = {
|
|
189
|
+
component: name,
|
|
190
|
+
command: resolvedCommand,
|
|
191
|
+
ok: exitCode === 0,
|
|
192
|
+
exitCode,
|
|
193
|
+
captures,
|
|
194
|
+
captureMeta: bootstrap.capture ?? {},
|
|
195
|
+
stdout,
|
|
196
|
+
stderr,
|
|
197
|
+
};
|
|
198
|
+
results.push(result);
|
|
199
|
+
// Print captures inline so the user sees them immediately.
|
|
200
|
+
if (Object.keys(captures).length > 0) {
|
|
201
|
+
console.log("\n Captured:");
|
|
202
|
+
for (const [key, value] of Object.entries(captures)) {
|
|
203
|
+
const meta = bootstrap.capture?.[key];
|
|
204
|
+
const displayValue = meta?.sensitive ? "***" : value;
|
|
205
|
+
const desc = meta?.description ? ` — ${meta.description}` : "";
|
|
206
|
+
console.log(` ${key}: ${displayValue}${desc}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (exitCode !== 0) {
|
|
210
|
+
console.error(` \u2717 Bootstrap [${name}] failed with exit code ${exitCode}`);
|
|
211
|
+
if (stderr)
|
|
212
|
+
console.error(stderr);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
console.log(` \u2713 Bootstrap [${name}] complete`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (results.length === 0) {
|
|
219
|
+
const target = opts.component ? ` for component "${opts.component}"` : "";
|
|
220
|
+
console.log(`No bootstrap command declared${target}.`);
|
|
221
|
+
}
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=bootstrap.js.map
|
package/dist/env-writer.d.ts
CHANGED
|
@@ -4,26 +4,32 @@
|
|
|
4
4
|
* Connects provisioned resource properties to the SDK's expression resolver,
|
|
5
5
|
* then writes the results to .env files.
|
|
6
6
|
*/
|
|
7
|
-
import { type NormalizedComponent, type NormalizedLaunch, type Secret } from "@launchfile/sdk";
|
|
7
|
+
import { type NormalizedComponent, type NormalizedLaunch, type ResolverContext, type Secret } from "@launchfile/sdk";
|
|
8
8
|
import type { ResourceProperties } from "./resources/types.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
9
|
+
export type { ResolverContext };
|
|
10
|
+
/**
|
|
11
|
+
* Compute the $app.* property set (D-33, D-35) for a Launchfile under the
|
|
12
|
+
* macos-dev provider. The app's "primary" port comes from the first component
|
|
13
|
+
* (in declaration order) that has at least one `exposed: true` provides entry.
|
|
14
|
+
* The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
|
|
15
|
+
* SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
|
|
16
|
+
* Apps with no exposed component get `port: 0` and `url: ""` (and empty
|
|
17
|
+
* authority/scheme/tls).
|
|
18
|
+
*
|
|
19
|
+
* For multi-exposed-component apps that need a specific component's URL,
|
|
20
|
+
* use `$components.<name>.url` instead — `$app.*` always points at the
|
|
21
|
+
* first exposed component to give a single, predictable answer.
|
|
12
22
|
*/
|
|
13
|
-
export
|
|
14
|
-
resource?: Record<string, string | number>;
|
|
15
|
-
resources?: Record<string, Record<string, string | number>>;
|
|
16
|
-
components?: Record<string, Record<string, string | number>>;
|
|
17
|
-
secrets?: Record<string, string>;
|
|
18
|
-
}
|
|
23
|
+
export declare function computeAppProperties(launch: NormalizedLaunch, componentPorts: Record<string, number>): Record<string, string | number>;
|
|
19
24
|
/**
|
|
20
|
-
* Build a ResolverContext from provisioned resources, component ports,
|
|
25
|
+
* Build a ResolverContext from provisioned resources, component ports,
|
|
26
|
+
* secrets, and (D-33) the platform-injected app properties.
|
|
21
27
|
*/
|
|
22
|
-
export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>): ResolverContext;
|
|
28
|
+
export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>, app: Record<string, string | number>): ResolverContext;
|
|
23
29
|
/**
|
|
24
30
|
* Resolve all environment variables for a single component.
|
|
25
31
|
*/
|
|
26
|
-
export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties
|
|
32
|
+
export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, storage?: Record<string, Record<string, string>>): Record<string, string>;
|
|
27
33
|
/**
|
|
28
34
|
* Generate all app-wide secrets, reusing values from state when available.
|
|
29
35
|
*/
|
package/dist/env-writer.js
CHANGED
|
@@ -6,12 +6,44 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { resolveExpression, isExpression, } from "@launchfile/sdk";
|
|
9
|
+
import { deriveAppUrlProperties, resolveExpression, isExpression, } from "@launchfile/sdk";
|
|
10
10
|
import { generateValue } from "./secret-generator.js";
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Compute the $app.* property set (D-33, D-35) for a Launchfile under the
|
|
13
|
+
* macos-dev provider. The app's "primary" port comes from the first component
|
|
14
|
+
* (in declaration order) that has at least one `exposed: true` provides entry.
|
|
15
|
+
* The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
|
|
16
|
+
* SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
|
|
17
|
+
* Apps with no exposed component get `port: 0` and `url: ""` (and empty
|
|
18
|
+
* authority/scheme/tls).
|
|
19
|
+
*
|
|
20
|
+
* For multi-exposed-component apps that need a specific component's URL,
|
|
21
|
+
* use `$components.<name>.url` instead — `$app.*` always points at the
|
|
22
|
+
* first exposed component to give a single, predictable answer.
|
|
23
|
+
*/
|
|
24
|
+
export function computeAppProperties(launch, componentPorts) {
|
|
25
|
+
let primaryPort = 0;
|
|
26
|
+
for (const [name, component] of Object.entries(launch.components)) {
|
|
27
|
+
const hasExposed = component.provides?.some((p) => p.exposed !== false) ?? false;
|
|
28
|
+
if (hasExposed && componentPorts[name]) {
|
|
29
|
+
primaryPort = componentPorts[name];
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const url = primaryPort > 0 ? `http://localhost:${primaryPort}` : "";
|
|
34
|
+
return {
|
|
35
|
+
name: launch.name,
|
|
36
|
+
host: "localhost",
|
|
37
|
+
port: primaryPort,
|
|
38
|
+
url,
|
|
39
|
+
...deriveAppUrlProperties(url),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a ResolverContext from provisioned resources, component ports,
|
|
44
|
+
* secrets, and (D-33) the platform-injected app properties.
|
|
13
45
|
*/
|
|
14
|
-
export function buildResolverContext(resourceMap, componentPorts, secrets) {
|
|
46
|
+
export function buildResolverContext(resourceMap, componentPorts, secrets, app) {
|
|
15
47
|
// Build components map from ports
|
|
16
48
|
const components = {};
|
|
17
49
|
for (const [name, port] of Object.entries(componentPorts)) {
|
|
@@ -32,13 +64,17 @@ export function buildResolverContext(resourceMap, componentPorts, secrets) {
|
|
|
32
64
|
}
|
|
33
65
|
resources[name] = record;
|
|
34
66
|
}
|
|
35
|
-
return { resources, components, secrets };
|
|
67
|
+
return { resources, components, secrets, app };
|
|
36
68
|
}
|
|
37
69
|
/**
|
|
38
70
|
* Resolve all environment variables for a single component.
|
|
39
71
|
*/
|
|
40
|
-
export function resolveComponentEnv(component, context, resourceMap) {
|
|
72
|
+
export function resolveComponentEnv(component, context, resourceMap, storage) {
|
|
41
73
|
const env = {};
|
|
74
|
+
// This component's provider-resolved storage paths (D-39). Scoped per
|
|
75
|
+
// component because volume names are component-local — component A's
|
|
76
|
+
// `$storage.cache.path` must not see component B's `cache`.
|
|
77
|
+
const ctx = storage ? { ...context, storage } : context;
|
|
42
78
|
// 1. Resolve set_env from requires
|
|
43
79
|
for (const req of component.requires ?? []) {
|
|
44
80
|
const resourceName = req.name ?? req.type;
|
|
@@ -52,7 +88,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
52
88
|
resourceRecord[k] = v;
|
|
53
89
|
}
|
|
54
90
|
const scopedContext = {
|
|
55
|
-
...
|
|
91
|
+
...ctx,
|
|
56
92
|
resource: resourceRecord,
|
|
57
93
|
};
|
|
58
94
|
for (const [envKey, expr] of Object.entries(req.set_env)) {
|
|
@@ -71,7 +107,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
71
107
|
resourceRecord[k] = v;
|
|
72
108
|
}
|
|
73
109
|
const scopedContext = {
|
|
74
|
-
...
|
|
110
|
+
...ctx,
|
|
75
111
|
resource: resourceRecord,
|
|
76
112
|
};
|
|
77
113
|
for (const [envKey, expr] of Object.entries(sup.set_env)) {
|
|
@@ -86,7 +122,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
86
122
|
if (envVar.default !== undefined) {
|
|
87
123
|
const defaultStr = String(envVar.default);
|
|
88
124
|
if (isExpression(defaultStr)) {
|
|
89
|
-
env[key] = resolveExpression(defaultStr,
|
|
125
|
+
env[key] = resolveExpression(defaultStr, ctx);
|
|
90
126
|
}
|
|
91
127
|
else {
|
|
92
128
|
env[key] = defaultStr;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { launchUp, launchDown, launchStatus, launchEnv } from "./provider.js";
|
|
8
8
|
export type { LaunchUpOpts } from "./provider.js";
|
|
9
|
+
export { launchBootstrap } from "./bootstrap.js";
|
|
10
|
+
export type { BootstrapResult } from "./bootstrap.js";
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* health check waits, and graceful shutdown.
|
|
6
6
|
*/
|
|
7
7
|
import type { NormalizedHealth, NormalizedDependsOnEntry } from "@launchfile/sdk";
|
|
8
|
+
/** A spawned component process recorded for cross-session shutdown. */
|
|
9
|
+
export interface RecordedProcessInfo {
|
|
10
|
+
pid: number;
|
|
11
|
+
pgid: number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
command: string;
|
|
14
|
+
}
|
|
8
15
|
export declare class ProcessManager {
|
|
9
16
|
private processes;
|
|
10
17
|
private logDir;
|
|
@@ -32,6 +39,12 @@ export declare class ProcessManager {
|
|
|
32
39
|
* Returns batches of component names that can start concurrently.
|
|
33
40
|
*/
|
|
34
41
|
private topologicalSort;
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
44
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
45
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
46
|
+
*/
|
|
47
|
+
getRecordedProcesses(): Record<string, RecordedProcessInfo>;
|
|
35
48
|
/** Get status summary for all processes */
|
|
36
49
|
getStatus(): Array<{
|
|
37
50
|
name: string;
|
package/dist/process-manager.js
CHANGED
|
@@ -18,6 +18,28 @@ const COLORS = [
|
|
|
18
18
|
"\x1b[31m", // red
|
|
19
19
|
];
|
|
20
20
|
const RESET = "\x1b[0m";
|
|
21
|
+
/**
|
|
22
|
+
* Signal a detached child's whole process group (negative pid). Falls back to
|
|
23
|
+
* signaling just the child handle if the group signal fails (e.g. the group is
|
|
24
|
+
* already gone). Best-effort: swallows ESRCH so shutdown stays idempotent.
|
|
25
|
+
*/
|
|
26
|
+
function killGroupOrSelf(proc, pid, signal) {
|
|
27
|
+
if (pid !== undefined) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, signal);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Group gone or not a group leader — fall through to handle kill.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
proc?.kill(signal);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Already exited.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
21
43
|
export class ProcessManager {
|
|
22
44
|
processes = new Map();
|
|
23
45
|
logDir;
|
|
@@ -73,11 +95,20 @@ export class ProcessManager {
|
|
|
73
95
|
const color = COLORS[colorIdx];
|
|
74
96
|
const maxNameLen = Math.max(...[...this.processes.keys()].map((n) => n.length));
|
|
75
97
|
const paddedName = name.padEnd(maxNameLen);
|
|
98
|
+
// `detached: true` makes the child the leader of a new process group
|
|
99
|
+
// (pgid === pid). That lets `launch down` signal the whole group later via
|
|
100
|
+
// a negative pid, killing the app AND any children it spawned — matching
|
|
101
|
+
// the foreground SIGINT behavior across sessions. We still keep the handle
|
|
102
|
+
// so the foreground session can kill it directly on Ctrl+C.
|
|
76
103
|
proc.process = spawn("sh", ["-c", proc.command], {
|
|
77
104
|
env: { ...process.env, ...proc.env },
|
|
78
105
|
cwd: proc.cwd,
|
|
79
106
|
stdio: ["ignore", "pipe", "pipe"],
|
|
107
|
+
detached: true,
|
|
80
108
|
});
|
|
109
|
+
if (proc.process.pid !== undefined) {
|
|
110
|
+
proc.startedAt = new Date().toISOString();
|
|
111
|
+
}
|
|
81
112
|
// Pipe stdout with prefix
|
|
82
113
|
proc.process.stdout?.on("data", (data) => {
|
|
83
114
|
const lines = data.toString().split("\n");
|
|
@@ -125,15 +156,19 @@ export class ProcessManager {
|
|
|
125
156
|
resolve();
|
|
126
157
|
return;
|
|
127
158
|
}
|
|
159
|
+
const pid = proc.process.pid;
|
|
128
160
|
const timeout = setTimeout(() => {
|
|
129
|
-
|
|
161
|
+
// Escalate to the whole group so stray children die too.
|
|
162
|
+
killGroupOrSelf(proc.process, pid, "SIGKILL");
|
|
130
163
|
}, 10_000);
|
|
131
164
|
proc.process.once("exit", () => {
|
|
132
165
|
clearTimeout(timeout);
|
|
133
166
|
proc.status = "stopped";
|
|
134
167
|
resolve();
|
|
135
168
|
});
|
|
136
|
-
|
|
169
|
+
// Children are spawned detached (own process group), so signal the
|
|
170
|
+
// group (negative pid) to reap any grandchildren too.
|
|
171
|
+
killGroupOrSelf(proc.process, pid, "SIGTERM");
|
|
137
172
|
});
|
|
138
173
|
}
|
|
139
174
|
/**
|
|
@@ -175,6 +210,26 @@ export class ProcessManager {
|
|
|
175
210
|
}
|
|
176
211
|
return batches;
|
|
177
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
215
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
216
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
217
|
+
*/
|
|
218
|
+
getRecordedProcesses() {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [name, proc] of this.processes) {
|
|
221
|
+
const pid = proc.process?.pid;
|
|
222
|
+
if (pid === undefined || proc.startedAt === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
out[name] = {
|
|
225
|
+
pid,
|
|
226
|
+
pgid: pid,
|
|
227
|
+
startedAt: proc.startedAt,
|
|
228
|
+
command: proc.command,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
178
233
|
/** Get status summary for all processes */
|
|
179
234
|
getStatus() {
|
|
180
235
|
return [...this.processes.entries()].map(([name, proc]) => ({
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session app-process termination for `launch down`.
|
|
3
|
+
*
|
|
4
|
+
* The foreground `launch up` session kills its children directly via the
|
|
5
|
+
* ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
|
|
6
|
+
* (or after the up-session has exited), so it has no live ChildProcess handles —
|
|
7
|
+
* only the pids recorded in `.launchfile/state.json`. This module signals those
|
|
8
|
+
* recorded process groups, with liveness + identity checks to avoid killing an
|
|
9
|
+
* unrelated process that happens to have inherited a recycled pid.
|
|
10
|
+
*
|
|
11
|
+
* ## pid-reuse identity guarantee (read this before trusting the kill)
|
|
12
|
+
*
|
|
13
|
+
* macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
|
|
14
|
+
* belong to a completely unrelated process. Before sending any signal we:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
|
|
17
|
+
* exists. If it's dead, we skip (already stopped).
|
|
18
|
+
* 2. **Identity** — we compare the recorded spawn time against the live
|
|
19
|
+
* process's actual start time via `ps -o lstart= -p <pid>`. If the live
|
|
20
|
+
* process started meaningfully later than we recorded, the pid was
|
|
21
|
+
* recycled and we REFUSE to signal it.
|
|
22
|
+
*
|
|
23
|
+
* This is a best-effort guarantee, not a cryptographic one. Its honest limits:
|
|
24
|
+
* - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
|
|
25
|
+
* recycled process that started within that window of the original spawn
|
|
26
|
+
* could theoretically slip through — vanishingly unlikely in practice.
|
|
27
|
+
* - If `ps` is unavailable/fails, we fall back to liveness-only and signal
|
|
28
|
+
* conservatively ONLY the process group (never a bare pid), accepting the
|
|
29
|
+
* small residual risk rather than orphaning the user's processes forever.
|
|
30
|
+
* - We signal the process GROUP (negative pgid) to also reap children the app
|
|
31
|
+
* spawned, matching the foreground SIGINT behavior. A recycled *group*
|
|
32
|
+
* leader is the residual risk the start-time check exists to close.
|
|
33
|
+
*/
|
|
34
|
+
/** Signal-sending surface, injectable so tests never touch real processes. */
|
|
35
|
+
export interface SignalFns {
|
|
36
|
+
/**
|
|
37
|
+
* Mirror of `process.kill`. Sending signal `0` performs a liveness probe
|
|
38
|
+
* (throws ESRCH if the target does not exist). A negative pid targets a
|
|
39
|
+
* process group.
|
|
40
|
+
*/
|
|
41
|
+
kill: (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the live process's start time (epoch ms) for `pid`, or null if the
|
|
44
|
+
* process is gone or the start time can't be determined.
|
|
45
|
+
*/
|
|
46
|
+
startTime: (pid: number) => Promise<number | null>;
|
|
47
|
+
}
|
|
48
|
+
/** Outcome of attempting to stop one recorded process, for logging/tests. */
|
|
49
|
+
export type StopOutcome = {
|
|
50
|
+
component: string;
|
|
51
|
+
result: "stopped";
|
|
52
|
+
} | {
|
|
53
|
+
component: string;
|
|
54
|
+
result: "already-dead";
|
|
55
|
+
} | {
|
|
56
|
+
component: string;
|
|
57
|
+
result: "identity-mismatch";
|
|
58
|
+
} | {
|
|
59
|
+
component: string;
|
|
60
|
+
result: "error";
|
|
61
|
+
error: string;
|
|
62
|
+
};
|
|
63
|
+
/** A short-lived recorded process, structurally matching ProcessState. */
|
|
64
|
+
export interface RecordedProcess {
|
|
65
|
+
pid: number;
|
|
66
|
+
pgid: number;
|
|
67
|
+
startedAt: string;
|
|
68
|
+
command: string;
|
|
69
|
+
}
|
|
70
|
+
/** Default real implementation backed by `process.kill` and `ps`. */
|
|
71
|
+
export declare const realSignalFns: SignalFns;
|
|
72
|
+
/**
|
|
73
|
+
* Verify a recorded process is still the one we started.
|
|
74
|
+
*
|
|
75
|
+
* Returns:
|
|
76
|
+
* - "alive-verified": process exists AND start time is consistent → safe to signal
|
|
77
|
+
* - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
|
|
78
|
+
* - "dead": no such process (ESRCH) → already stopped, skip
|
|
79
|
+
* - "mismatch": process exists but started too late → recycled pid, DO NOT signal
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkIdentity(rec: RecordedProcess, fns: SignalFns): Promise<"alive-verified" | "alive-unverified" | "dead" | "mismatch">;
|
|
82
|
+
/**
|
|
83
|
+
* Stop one recorded process: graceful SIGTERM to the group, then escalate to
|
|
84
|
+
* SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
|
|
85
|
+
* (negative pgid) so child processes the app spawned die too.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stopProcess(component: string, rec: RecordedProcess, fns: SignalFns, opts?: {
|
|
88
|
+
graceMs?: number;
|
|
89
|
+
sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
}): Promise<StopOutcome>;
|
|
91
|
+
/**
|
|
92
|
+
* Stop every recorded process. Pure orchestration over `stopProcess`; returns
|
|
93
|
+
* one outcome per component so the caller can report and tests can assert.
|
|
94
|
+
*/
|
|
95
|
+
export declare function stopRecordedProcesses(processes: Record<string, RecordedProcess>, fns?: SignalFns, opts?: {
|
|
96
|
+
graceMs?: number;
|
|
97
|
+
sleep?: (ms: number) => Promise<void>;
|
|
98
|
+
}): Promise<StopOutcome[]>;
|
|
99
|
+
//# sourceMappingURL=process-stopper.d.ts.map
|