@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.
Files changed (124) hide show
  1. package/dist/bootstrap.d.ts +58 -0
  2. package/dist/bootstrap.js +224 -0
  3. package/dist/env-writer.d.ts +19 -13
  4. package/dist/env-writer.js +44 -8
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +1 -0
  7. package/dist/process-manager.d.ts +13 -0
  8. package/dist/process-manager.js +57 -2
  9. package/dist/process-stopper.d.ts +99 -0
  10. package/dist/process-stopper.js +165 -0
  11. package/dist/provider.d.ts +18 -0
  12. package/dist/provider.js +176 -70
  13. package/dist/shell.js +1 -1
  14. package/dist/state.d.ts +28 -0
  15. package/dist/storage.d.ts +16 -3
  16. package/dist/storage.js +23 -6
  17. package/package.json +9 -4
  18. package/CLAUDE.md +0 -37
  19. package/dist/__tests__/dry-run.test.d.ts +0 -2
  20. package/dist/__tests__/dry-run.test.d.ts.map +0 -1
  21. package/dist/__tests__/dry-run.test.js +0 -129
  22. package/dist/__tests__/dry-run.test.js.map +0 -1
  23. package/dist/__tests__/env-writer.test.d.ts +0 -2
  24. package/dist/__tests__/env-writer.test.d.ts.map +0 -1
  25. package/dist/__tests__/env-writer.test.js +0 -127
  26. package/dist/__tests__/env-writer.test.js.map +0 -1
  27. package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
  28. package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
  29. package/dist/__tests__/lockfile-detect.test.js +0 -75
  30. package/dist/__tests__/lockfile-detect.test.js.map +0 -1
  31. package/dist/__tests__/port-allocator.test.d.ts +0 -2
  32. package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
  33. package/dist/__tests__/port-allocator.test.js +0 -53
  34. package/dist/__tests__/port-allocator.test.js.map +0 -1
  35. package/dist/__tests__/secret-generator.test.d.ts +0 -2
  36. package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
  37. package/dist/__tests__/secret-generator.test.js +0 -26
  38. package/dist/__tests__/secret-generator.test.js.map +0 -1
  39. package/dist/__tests__/state.test.d.ts +0 -2
  40. package/dist/__tests__/state.test.d.ts.map +0 -1
  41. package/dist/__tests__/state.test.js +0 -30
  42. package/dist/__tests__/state.test.js.map +0 -1
  43. package/dist/cli.d.ts.map +0 -1
  44. package/dist/cli.js.map +0 -1
  45. package/dist/env-writer.d.ts.map +0 -1
  46. package/dist/env-writer.js.map +0 -1
  47. package/dist/health.d.ts.map +0 -1
  48. package/dist/health.js.map +0 -1
  49. package/dist/index.d.ts.map +0 -1
  50. package/dist/index.js.map +0 -1
  51. package/dist/lockfile-detect.d.ts.map +0 -1
  52. package/dist/lockfile-detect.js.map +0 -1
  53. package/dist/port-allocator.d.ts.map +0 -1
  54. package/dist/port-allocator.js.map +0 -1
  55. package/dist/prereqs.d.ts.map +0 -1
  56. package/dist/prereqs.js.map +0 -1
  57. package/dist/process-manager.d.ts.map +0 -1
  58. package/dist/process-manager.js.map +0 -1
  59. package/dist/provider.d.ts.map +0 -1
  60. package/dist/provider.js.map +0 -1
  61. package/dist/resources/index.d.ts.map +0 -1
  62. package/dist/resources/index.js.map +0 -1
  63. package/dist/resources/mysql.d.ts.map +0 -1
  64. package/dist/resources/mysql.js.map +0 -1
  65. package/dist/resources/postgres.d.ts.map +0 -1
  66. package/dist/resources/postgres.js.map +0 -1
  67. package/dist/resources/redis.d.ts.map +0 -1
  68. package/dist/resources/redis.js.map +0 -1
  69. package/dist/resources/sqlite.d.ts.map +0 -1
  70. package/dist/resources/sqlite.js.map +0 -1
  71. package/dist/resources/types.d.ts.map +0 -1
  72. package/dist/resources/types.js.map +0 -1
  73. package/dist/runtimes/bun.d.ts.map +0 -1
  74. package/dist/runtimes/bun.js.map +0 -1
  75. package/dist/runtimes/index.d.ts.map +0 -1
  76. package/dist/runtimes/index.js.map +0 -1
  77. package/dist/runtimes/node.d.ts.map +0 -1
  78. package/dist/runtimes/node.js.map +0 -1
  79. package/dist/runtimes/python.d.ts.map +0 -1
  80. package/dist/runtimes/python.js.map +0 -1
  81. package/dist/runtimes/ruby.d.ts.map +0 -1
  82. package/dist/runtimes/ruby.js.map +0 -1
  83. package/dist/runtimes/types.d.ts.map +0 -1
  84. package/dist/runtimes/types.js.map +0 -1
  85. package/dist/secret-generator.d.ts.map +0 -1
  86. package/dist/secret-generator.js.map +0 -1
  87. package/dist/shell.d.ts.map +0 -1
  88. package/dist/shell.js.map +0 -1
  89. package/dist/state.d.ts.map +0 -1
  90. package/dist/state.js.map +0 -1
  91. package/dist/storage.d.ts.map +0 -1
  92. package/dist/storage.js.map +0 -1
  93. package/src/__tests__/dry-run.test.ts +0 -157
  94. package/src/__tests__/env-writer.test.ts +0 -156
  95. package/src/__tests__/lockfile-detect.test.ts +0 -74
  96. package/src/__tests__/port-allocator.test.ts +0 -59
  97. package/src/__tests__/secret-generator.test.ts +0 -31
  98. package/src/__tests__/state.test.ts +0 -33
  99. package/src/cli.ts +0 -78
  100. package/src/env-writer.ts +0 -231
  101. package/src/health.ts +0 -80
  102. package/src/index.ts +0 -9
  103. package/src/lockfile-detect.ts +0 -48
  104. package/src/port-allocator.ts +0 -97
  105. package/src/prereqs.ts +0 -32
  106. package/src/process-manager.ts +0 -242
  107. package/src/provider.ts +0 -415
  108. package/src/resources/index.ts +0 -29
  109. package/src/resources/mysql.ts +0 -98
  110. package/src/resources/postgres.ts +0 -133
  111. package/src/resources/redis.ts +0 -58
  112. package/src/resources/sqlite.ts +0 -52
  113. package/src/resources/types.ts +0 -47
  114. package/src/runtimes/bun.ts +0 -25
  115. package/src/runtimes/index.ts +0 -24
  116. package/src/runtimes/node.ts +0 -71
  117. package/src/runtimes/python.ts +0 -42
  118. package/src/runtimes/ruby.ts +0 -55
  119. package/src/runtimes/types.ts +0 -16
  120. package/src/secret-generator.ts +0 -32
  121. package/src/shell.ts +0 -70
  122. package/src/state.ts +0 -92
  123. package/src/storage.ts +0 -67
  124. 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
@@ -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
- * Context for the SDK's resolveExpression().
11
- * Mirrors the interface from sdk/src/resolver.ts since it's not exported.
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 interface ResolverContext {
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, and secrets.
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>): Record<string, string>;
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
  */
@@ -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
- * Build a ResolverContext from provisioned resources, component ports, and secrets.
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
- ...context,
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
- ...context,
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, context);
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,4 +5,5 @@
5
5
  * and native runtimes for the app itself.
6
6
  */
7
7
  export { launchUp, launchDown, launchStatus, launchEnv } from "./provider.js";
8
+ export { launchBootstrap } from "./bootstrap.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -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;
@@ -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
- proc.process?.kill("SIGKILL");
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
- proc.process.kill("SIGTERM");
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