@launchfile/macos-dev 0.3.0 → 0.7.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.
@@ -6,13 +6,21 @@
6
6
  * stdout via regex patterns, re-runnable, and reports failures rather than
7
7
  * deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
8
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.
9
+ * The command runs through a POSIX shell (SPEC.md § Command interpretation,
10
+ * PROVIDERS.md §10 item 11), so `&&`, `;`, pipes, redirection, grouping and
11
+ * variable expansion behave as authors write them. It is passed as a single
12
+ * argv element to `/bin/sh -c` via spawn({ shell: false }) — it is never
13
+ * interpolated into a command string on this side, so the only interpreter
14
+ * that sees it is the `sh` this provider spawns.
14
15
  */
15
- import { type CaptureEntry } from "@launchfile/sdk";
16
+ import { parseDurationMs, type CaptureEntry, type NormalizedLaunch, type ResolverContext } from "@launchfile/sdk";
17
+ /** Default budget for a bootstrap command when no `timeout` is declared. */
18
+ export declare const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 120000;
19
+ /**
20
+ * The interpreter every bootstrap command runs under. `/bin/sh` is the POSIX
21
+ * shell guaranteed present on macOS.
22
+ */
23
+ export declare const BOOTSTRAP_SHELL = "/bin/sh";
16
24
  /**
17
25
  * Result of running one bootstrap command. Captures may be empty even on
18
26
  * success (the command may not produce matching output), and may be
@@ -38,8 +46,47 @@ export interface BootstrapResult {
38
46
  * Exported for unit testing.
39
47
  */
40
48
  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;
49
+ /**
50
+ * Parse a simple duration string like "5m", "30s", "1h" into milliseconds
51
+ * using the ratified grammar (D-48). Throws on an unparseable value —
52
+ * PROVIDERS.md §10.10 forbids silently substituting a default. Re-exported
53
+ * for unit testing.
54
+ */
55
+ export declare const parseDuration: typeof parseDurationMs;
56
+ /** One planned bootstrap execution, in component declaration order. */
57
+ export interface BootstrapPlanItem {
58
+ component: string;
59
+ /** The $-resolved command string. */
60
+ command: string;
61
+ /** `["/bin/sh", "-c", command]` — the command as a single argv element. */
62
+ argv: string[];
63
+ timeoutMs: number;
64
+ capture?: Record<string, CaptureEntry>;
65
+ /**
66
+ * Set when the item cannot run at all (empty command, unparseable
67
+ * timeout). Bootstrap failures are reported, never thrown
68
+ * (SPEC.md § Failure semantics), so a bad item stays in the plan and
69
+ * carries its own diagnosis.
70
+ */
71
+ error?: string;
72
+ }
73
+ /**
74
+ * Build the bootstrap plan for a launch. Pure — no I/O — so selection,
75
+ * expression resolution, argv shape and timeout handling are unit-testable.
76
+ */
77
+ export declare function planBootstraps(launch: NormalizedLaunch, context: ResolverContext, opts?: {
78
+ component?: string;
79
+ }): BootstrapPlanItem[];
80
+ /** Minimal exec contract so tests can inject a fake runner. */
81
+ export type BootstrapExec = (cmd: string, args: string[], opts: {
82
+ cwd: string;
83
+ env: Record<string, string>;
84
+ timeoutMs: number;
85
+ }) => Promise<{
86
+ exitCode: number;
87
+ stdout: string;
88
+ stderr: string;
89
+ }>;
43
90
  /**
44
91
  * Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
45
92
  * the resolver context from persisted state (so $app.url resolves to the
@@ -54,5 +101,6 @@ export declare function parseDuration(s: string): number;
54
101
  export declare function launchBootstrap(opts?: {
55
102
  component?: string;
56
103
  projectDir?: string;
104
+ exec?: BootstrapExec;
57
105
  }): Promise<BootstrapResult[]>;
58
106
  //# sourceMappingURL=bootstrap.d.ts.map
package/dist/bootstrap.js CHANGED
@@ -6,19 +6,28 @@
6
6
  * stdout via regex patterns, re-runnable, and reports failures rather than
7
7
  * deploy-failing. Spec: /spec/SPEC.md § Bootstrap stage.
8
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.
9
+ * The command runs through a POSIX shell (SPEC.md § Command interpretation,
10
+ * PROVIDERS.md §10 item 11), so `&&`, `;`, pipes, redirection, grouping and
11
+ * variable expansion behave as authors write them. It is passed as a single
12
+ * argv element to `/bin/sh -c` via spawn({ shell: false }) — it is never
13
+ * interpolated into a command string on this side, so the only interpreter
14
+ * that sees it is the `sh` this provider spawns.
14
15
  */
15
16
  import { readFile } from "node:fs/promises";
16
17
  import { join } from "node:path";
17
18
  import { spawn } from "node:child_process";
18
- import { readLaunch, resolveExpression, } from "@launchfile/sdk";
19
- import { loadState } from "./state.js";
19
+ import { parseDurationMs, readLaunch, resolveExpression, } from "@launchfile/sdk";
20
+ import { loadState, saveState } from "./state.js";
20
21
  import { buildResolverContext, computeAppProperties, resolveComponentEnv, resolveGenerators, } from "./env-writer.js";
22
+ import { redactSecrets } from "./redact.js";
21
23
  import { getProvisioner } from "./resources/index.js";
24
+ /** Default budget for a bootstrap command when no `timeout` is declared. */
25
+ export const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 120_000;
26
+ /**
27
+ * The interpreter every bootstrap command runs under. `/bin/sh` is the POSIX
28
+ * shell guaranteed present on macOS.
29
+ */
30
+ export const BOOTSTRAP_SHELL = "/bin/sh";
22
31
  /**
23
32
  * Strip ANSI escape sequences from captured stdout before regex matching.
24
33
  * CLI tools that detect a TTY will emit color codes that would otherwise
@@ -51,73 +60,117 @@ export function extractCaptures(stdout, captures) {
51
60
  }
52
61
  return result;
53
62
  }
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;
63
+ /**
64
+ * Parse a simple duration string like "5m", "30s", "1h" into milliseconds
65
+ * using the ratified grammar (D-48). Throws on an unparseable value —
66
+ * PROVIDERS.md §10.10 forbids silently substituting a default. Re-exported
67
+ * for unit testing.
68
+ */
69
+ export const parseDuration = parseDurationMs;
70
+ /**
71
+ * Build the bootstrap plan for a launch. Pure — no I/O — so selection,
72
+ * expression resolution, argv shape and timeout handling are unit-testable.
73
+ */
74
+ export function planBootstraps(launch, context, opts = {}) {
75
+ const plan = [];
76
+ for (const [name, component] of Object.entries(launch.components)) {
77
+ if (opts.component && name !== opts.component)
78
+ continue;
79
+ // `bootstrap` is mode-invariant (D-38): the same command runs from
80
+ // source or artifact. A path or binary that differs by mode belongs in
81
+ // storage:/env/PATH, not a separate command.
82
+ const bootstrap = component.commands?.bootstrap;
83
+ if (!bootstrap)
84
+ continue;
85
+ // Resolve $-expressions in the command string (e.g. $app.url) at
86
+ // invocation time. Bootstrap runs after start, so the resolved URL
87
+ // already reflects the actual allocated port. A `$$` escape becomes a
88
+ // literal `$` here and reaches the shell intact (SPEC.md § References).
89
+ const command = resolveExpression(bootstrap.command, context);
90
+ const base = {
91
+ component: name,
92
+ command,
93
+ // `/bin/sh -c <command>` — one argv element, so the shell interprets
94
+ // the command and nothing else (SPEC.md § Command interpretation).
95
+ argv: [BOOTSTRAP_SHELL, "-c", command],
96
+ capture: bootstrap.capture,
97
+ };
98
+ if (command.trim() === "") {
99
+ plan.push({
100
+ ...base,
101
+ timeoutMs: DEFAULT_BOOTSTRAP_TIMEOUT_MS,
102
+ error: "empty command",
103
+ });
104
+ continue;
105
+ }
106
+ let timeoutMs = DEFAULT_BOOTSTRAP_TIMEOUT_MS;
107
+ if (bootstrap.timeout !== undefined) {
108
+ try {
109
+ timeoutMs = parseDuration(bootstrap.timeout);
110
+ }
111
+ catch (err) {
112
+ // An unparseable timeout is surfaced, never silently replaced
113
+ // with a default (PROVIDERS.md §10.10).
114
+ plan.push({
115
+ ...base,
116
+ timeoutMs: DEFAULT_BOOTSTRAP_TIMEOUT_MS,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ continue;
120
+ }
121
+ }
122
+ plan.push({ ...base, timeoutMs });
66
123
  }
124
+ return plan;
67
125
  }
68
126
  /**
69
- * Run one command using argv-split (no-shell) execution. Returns the
127
+ * Run one command. `cmd`/`args` cross the process boundary as an argv vector
128
+ * with shell:false — the command string is a single element of it, never
129
+ * interpolated into a string another shell would re-parse. Returns the
70
130
  * structured result; does not throw on command failure.
71
131
  */
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
- });
132
+ const defaultExec = (cmd, args, opts) => new Promise((resolveP) => {
133
+ const child = spawn(cmd, args, {
134
+ cwd: opts.cwd,
135
+ env: { ...process.env, ...opts.env },
136
+ shell: false,
137
+ stdio: ["ignore", "pipe", "pipe"],
119
138
  });
120
- }
139
+ let stdout = "";
140
+ let stderr = "";
141
+ let settled = false;
142
+ const timer = setTimeout(() => {
143
+ if (!settled) {
144
+ settled = true;
145
+ child.kill("SIGTERM");
146
+ resolveP({
147
+ exitCode: 124,
148
+ stdout,
149
+ stderr: stderr + `\n(killed after ${opts.timeoutMs}ms timeout)`,
150
+ });
151
+ }
152
+ }, opts.timeoutMs);
153
+ child.stdout?.on("data", (chunk) => {
154
+ stdout += String(chunk);
155
+ });
156
+ child.stderr?.on("data", (chunk) => {
157
+ stderr += String(chunk);
158
+ });
159
+ child.on("error", (err) => {
160
+ if (!settled) {
161
+ settled = true;
162
+ clearTimeout(timer);
163
+ resolveP({ exitCode: 1, stdout, stderr: `${stderr}\n${err.message}` });
164
+ }
165
+ });
166
+ child.on("close", (code) => {
167
+ if (!settled) {
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ resolveP({ exitCode: code ?? 1, stdout, stderr });
171
+ }
172
+ });
173
+ });
121
174
  /**
122
175
  * Public entry point for `launch bootstrap`. Loads the Launchfile, rebuilds
123
176
  * the resolver context from persisted state (so $app.url resolves to the
@@ -154,53 +207,74 @@ export async function launchBootstrap(opts = {}) {
154
207
  }
155
208
  const appProperties = computeAppProperties(launch, state.ports);
156
209
  const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
210
+ const exec = opts.exec ?? defaultExec;
211
+ const plan = planBootstraps(launch, context, { component: opts.component });
157
212
  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)
213
+ for (const item of plan) {
214
+ const name = item.component;
215
+ // Bootstrap failures are reported, not thrown (SPEC.md \u00a7 Failure
216
+ // semantics) \u2014 an unparseable timeout is surfaced the same way,
217
+ // never silently replaced with a default (PROVIDERS.md \u00a710.10).
218
+ if (item.error) {
219
+ console.error(` \u2717 Bootstrap [${name}]: ${item.error}`);
220
+ results.push({
221
+ component: name,
222
+ command: item.command,
223
+ ok: false,
224
+ exitCode: 1,
225
+ captures: {},
226
+ captureMeta: item.capture ?? {},
227
+ stdout: "",
228
+ stderr: item.error,
229
+ });
166
230
  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);
231
+ }
171
232
  // 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);
233
+ // the running component. Minted generator values come from the store
234
+ // `up` persists (D-49); a value minted here (a generator declared
235
+ // after the last `up`) is persisted before the command runs, so
236
+ // `up`/`env`/`bootstrap` keep agreeing on it.
237
+ const component = launch.components[name];
238
+ const { env, unsupplied } = resolveComponentEnv(component, context, resourceMap);
239
+ const minted = await resolveGenerators(component, env, name, (state.generatedEnv ??= {}));
240
+ if (minted)
241
+ await saveState(projectDir, state);
242
+ // `up` took its `required:` values from the launching environment; read the
243
+ // same channel so bootstrap really does see the running component's env.
244
+ for (const { key } of unsupplied) {
245
+ const supplied = process.env[key];
246
+ if (supplied !== undefined)
247
+ env[key] = supplied;
248
+ }
175
249
  const port = state.ports[name];
176
250
  if (port && !env.PORT)
177
251
  env.PORT = String(port);
178
252
  console.log(`\n \u2193 Bootstrap [${name}]`);
179
- console.log(` $ ${resolvedCommand}`);
180
- const { exitCode, stdout, stderr } = await runOnce(resolvedCommand, {
253
+ // `$secrets.*` / `$<resource>.password` / `$<resource>.url` resolve to live
254
+ // credentials here, so the echoed command is scrubbed (CWE-532).
255
+ console.log(` $ ${redactSecrets(item.command)}`);
256
+ const [file, ...args] = item.argv;
257
+ const { exitCode, stdout, stderr } = await exec(file, args, {
181
258
  cwd: projectDir,
182
259
  env,
183
- timeoutMs: bootstrap.timeout ? parseDuration(bootstrap.timeout) : 120_000,
260
+ timeoutMs: item.timeoutMs,
184
261
  });
185
- const captures = bootstrap.capture
186
- ? extractCaptures(stdout, bootstrap.capture)
187
- : {};
188
- const result = {
262
+ const captures = item.capture ? extractCaptures(stdout, item.capture) : {};
263
+ results.push({
189
264
  component: name,
190
- command: resolvedCommand,
265
+ command: item.command,
191
266
  ok: exitCode === 0,
192
267
  exitCode,
193
268
  captures,
194
- captureMeta: bootstrap.capture ?? {},
269
+ captureMeta: item.capture ?? {},
195
270
  stdout,
196
271
  stderr,
197
- };
198
- results.push(result);
272
+ });
199
273
  // Print captures inline so the user sees them immediately.
200
274
  if (Object.keys(captures).length > 0) {
201
275
  console.log("\n Captured:");
202
276
  for (const [key, value] of Object.entries(captures)) {
203
- const meta = bootstrap.capture?.[key];
277
+ const meta = item.capture?.[key];
204
278
  const displayValue = meta?.sensitive ? "***" : value;
205
279
  const desc = meta?.description ? ` — ${meta.description}` : "";
206
280
  console.log(` ${key}: ${displayValue}${desc}`);
@@ -209,7 +283,7 @@ export async function launchBootstrap(opts = {}) {
209
283
  if (exitCode !== 0) {
210
284
  console.error(` \u2717 Bootstrap [${name}] failed with exit code ${exitCode}`);
211
285
  if (stderr)
212
- console.error(stderr);
286
+ console.error(redactSecrets(stderr));
213
287
  }
214
288
  else {
215
289
  console.log(` \u2713 Bootstrap [${name}] complete`);
@@ -4,9 +4,9 @@
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 ResolverContext, type Secret } from "@launchfile/sdk";
7
+ import { type NormalizedComponent, type NormalizedLaunch, type ResolverContext, type Secret, type UnsuppliedRequiredEnv } from "@launchfile/sdk";
8
8
  import type { ResourceProperties } from "./resources/types.js";
9
- export type { ResolverContext };
9
+ export type { ResolverContext, UnsuppliedRequiredEnv };
10
10
  /**
11
11
  * Compute the $app.* property set (D-33, D-35) for a Launchfile under the
12
12
  * macos-dev provider. The app's "primary" port comes from the first component
@@ -26,19 +26,45 @@ export declare function computeAppProperties(launch: NormalizedLaunch, component
26
26
  * secrets, and (D-33) the platform-injected app properties.
27
27
  */
28
28
  export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>, app: Record<string, string | number>): ResolverContext;
29
+ /**
30
+ * The resolved environment for one component, plus what the file did not supply.
31
+ */
32
+ export interface ComponentEnvResult {
33
+ /** Keys that actually arrived. An unsupplied `required:` key is ABSENT, never `""`. */
34
+ env: Record<string, string>;
35
+ /**
36
+ * `required:` variables with no `generator:`, no `default:`, and no
37
+ * `set_env:` binding that injected (D-52, PROVIDERS.md §10 rule 8). The
38
+ * caller decides: `up` reads its operator channel then fails by name, `env`
39
+ * reports them without breaking `eval`.
40
+ */
41
+ unsupplied: UnsuppliedRequiredEnv[];
42
+ }
29
43
  /**
30
44
  * Resolve all environment variables for a single component.
31
45
  */
32
- export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, storage?: Record<string, Record<string, string>>): Record<string, string>;
46
+ export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, storage?: Record<string, Record<string, string>>): ComponentEnvResult;
33
47
  /**
34
48
  * Generate all app-wide secrets, reusing values from state when available.
35
49
  */
36
50
  export declare function generateSecrets(secretDefs: Record<string, Secret> | undefined, existingSecrets: Record<string, string>): Promise<Record<string, string>>;
37
51
  /**
38
- * Generate values for env vars that have generators.
39
- * Mutates the env record in place.
52
+ * Resolve values for env vars that declare generators, preserving minted
53
+ * values across runs (D-49: generate once, then preserve).
54
+ *
55
+ * A `secret` or `uuid` value is read from `generatedEnv` when present, and
56
+ * minted and written into it when absent. The store is keyed
57
+ * `<component>.<ENV_NAME>` — one entry per declaration (D-25), so two
58
+ * components declaring the same variable name hold independent values.
59
+ * `generator: port` is exempt: ports have their own preserved home
60
+ * (`state.ports`) and allocator, and a preserved port produces a bind
61
+ * conflict rather than continuity.
62
+ *
63
+ * Mutates `env` and `generatedEnv` in place. Returns true when a new value
64
+ * was minted into `generatedEnv` — the caller must then persist the state
65
+ * before handing the value to anything, so every site that mints persists.
40
66
  */
41
- export declare function resolveGenerators(component: NormalizedComponent, env: Record<string, string>): Promise<void>;
67
+ export declare function resolveGenerators(component: NormalizedComponent, env: Record<string, string>, componentName: string, generatedEnv: Record<string, string>): Promise<boolean>;
42
68
  /**
43
69
  * Write resolved env vars to a .env file.
44
70
  */
@@ -48,5 +74,5 @@ export declare function writeEnvFile(filePath: string, env: Record<string, strin
48
74
  * Single-component → .env.local at project root.
49
75
  * Multi-component → .launchfile/env/<component>.env per component.
50
76
  */
51
- export declare function writeAllEnvFiles(launch: NormalizedLaunch, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, projectDir: string): Promise<Record<string, Record<string, string>>>;
77
+ export declare function writeAllEnvFiles(launch: NormalizedLaunch, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, projectDir: string, generatedEnv: Record<string, string>): Promise<Record<string, Record<string, string>>>;
52
78
  //# sourceMappingURL=env-writer.d.ts.map
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { writeFile, mkdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
- import { deriveAppUrlProperties, resolveExpression, isExpression, } from "@launchfile/sdk";
9
+ import { deriveAppUrlProperties, resolveExpression, isExpression, unsuppliedRequiredEnv, } 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
@@ -24,7 +24,13 @@ import { generateValue } from "./secret-generator.js";
24
24
  export function computeAppProperties(launch, componentPorts) {
25
25
  let primaryPort = 0;
26
26
  for (const [name, component] of Object.entries(launch.components)) {
27
- const hasExposed = component.provides?.some((p) => p.exposed !== false) ?? false;
27
+ // Only endpoints explicitly marked `exposed: true` are reachable from
28
+ // outside the host (D-27), so only they can be the app's public address.
29
+ // This provider has no orchestrator-facing publication channel (#294), so
30
+ // $app.* always comes from its own routing strategy. The docker provider
31
+ // answers by this same rule only when no orchestrator supplies an appUrl
32
+ // (PROVIDERS.md §7).
33
+ const hasExposed = component.provides?.some((p) => p.exposed === true) ?? false;
28
34
  if (hasExposed && componentPorts[name]) {
29
35
  primaryPort = componentPorts[name];
30
36
  break;
@@ -77,6 +83,11 @@ export function resolveComponentEnv(component, context, resourceMap, storage) {
77
83
  const ctx = storage ? { ...context, storage } : context;
78
84
  // 1. Resolve set_env from requires
79
85
  for (const req of component.requires ?? []) {
86
+ // A host capability (D-44) is never provisioned, so it has no properties
87
+ // to resolve against. An ungranted capability's set_env vars are omitted
88
+ // rather than resolved to empty strings.
89
+ if (req.host)
90
+ continue;
80
91
  const resourceName = req.name ?? req.type;
81
92
  const props = resourceMap[resourceName];
82
93
  if (!req.set_env || !props)
@@ -97,6 +108,8 @@ export function resolveComponentEnv(component, context, resourceMap, storage) {
97
108
  }
98
109
  // 2. Resolve set_env from supports (only if resource was provisioned)
99
110
  for (const sup of component.supports ?? []) {
111
+ if (sup.host)
112
+ continue; // capability, not a backing service (D-44)
100
113
  const resourceName = sup.name ?? sup.type;
101
114
  const props = resourceMap[resourceName];
102
115
  if (!sup.set_env || !props)
@@ -119,6 +132,12 @@ export function resolveComponentEnv(component, context, resourceMap, storage) {
119
132
  for (const [key, envVar] of Object.entries(component.env)) {
120
133
  if (env[key] !== undefined)
121
134
  continue; // set_env takes precedence
135
+ // A generator outranks a default (D-49 provenance precedence). Filling
136
+ // the default here would win by arriving first — resolveGenerators
137
+ // skips any key already set — and this provider would mint nothing
138
+ // where docker and aws mint a secret, for the same file.
139
+ if (envVar.generator)
140
+ continue;
122
141
  if (envVar.default !== undefined) {
123
142
  const defaultStr = String(envVar.default);
124
143
  if (isExpression(defaultStr)) {
@@ -128,10 +147,15 @@ export function resolveComponentEnv(component, context, resourceMap, storage) {
128
147
  env[key] = defaultStr;
129
148
  }
130
149
  }
131
- // required + no default + no generator → left unset (provider should prompt)
132
150
  }
133
151
  }
134
- return env;
152
+ // A `required:` var nothing above yielded is left ABSENT and reported, not
153
+ // silently dropped (D-52, PROVIDERS.md §10 rule 8). `generator:` keys were
154
+ // skipped just above but are not unsupplied — `resolveGenerators` mints them
155
+ // — and the shared predicate excludes them for that reason. The test runs
156
+ // after the `set_env` loops so it measures arrival, not declaration.
157
+ const unsupplied = unsuppliedRequiredEnv(component, Object.keys(env));
158
+ return { env, unsupplied };
135
159
  }
136
160
  /**
137
161
  * Generate all app-wide secrets, reusing values from state when available.
@@ -148,19 +172,46 @@ export async function generateSecrets(secretDefs, existingSecrets) {
148
172
  return secrets;
149
173
  }
150
174
  /**
151
- * Generate values for env vars that have generators.
152
- * Mutates the env record in place.
175
+ * Resolve values for env vars that declare generators, preserving minted
176
+ * values across runs (D-49: generate once, then preserve).
177
+ *
178
+ * A `secret` or `uuid` value is read from `generatedEnv` when present, and
179
+ * minted and written into it when absent. The store is keyed
180
+ * `<component>.<ENV_NAME>` — one entry per declaration (D-25), so two
181
+ * components declaring the same variable name hold independent values.
182
+ * `generator: port` is exempt: ports have their own preserved home
183
+ * (`state.ports`) and allocator, and a preserved port produces a bind
184
+ * conflict rather than continuity.
185
+ *
186
+ * Mutates `env` and `generatedEnv` in place. Returns true when a new value
187
+ * was minted into `generatedEnv` — the caller must then persist the state
188
+ * before handing the value to anything, so every site that mints persists.
153
189
  */
154
- export async function resolveGenerators(component, env) {
190
+ export async function resolveGenerators(component, env, componentName, generatedEnv) {
155
191
  if (!component.env)
156
- return;
192
+ return false;
193
+ let minted = false;
157
194
  for (const [key, envVar] of Object.entries(component.env)) {
158
195
  if (env[key] !== undefined)
159
196
  continue;
160
- if (envVar.generator) {
197
+ if (!envVar.generator)
198
+ continue;
199
+ if (envVar.generator === "port") {
161
200
  env[key] = await generateValue(envVar.generator);
201
+ continue;
202
+ }
203
+ const stateKey = `${componentName}.${key}`;
204
+ const existing = generatedEnv[stateKey];
205
+ if (existing !== undefined) {
206
+ env[key] = existing;
207
+ continue;
162
208
  }
209
+ const value = await generateValue(envVar.generator);
210
+ env[key] = value;
211
+ generatedEnv[stateKey] = value;
212
+ minted = true;
163
213
  }
214
+ return minted;
164
215
  }
165
216
  /**
166
217
  * Write resolved env vars to a .env file.
@@ -169,9 +220,12 @@ export async function writeEnvFile(filePath, env) {
169
220
  const lines = Object.entries(env)
170
221
  .sort(([a], [b]) => a.localeCompare(b))
171
222
  .map(([key, value]) => {
172
- // Quote values that contain spaces, #, or newlines
223
+ // Quote values that contain spaces, #, or newlines. Escape backslashes
224
+ // first, then quotes — otherwise a value containing a backslash would
225
+ // produce broken or injectable quoting (CWE-116 incomplete escaping).
173
226
  if (/[\s#\n]/.test(value)) {
174
- return `${key}="${value.replace(/"/g, '\\"')}"`;
227
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
228
+ return `${key}="${escaped}"`;
175
229
  }
176
230
  return `${key}=${value}`;
177
231
  });
@@ -184,13 +238,13 @@ export async function writeEnvFile(filePath, env) {
184
238
  * Single-component → .env.local at project root.
185
239
  * Multi-component → .launchfile/env/<component>.env per component.
186
240
  */
187
- export async function writeAllEnvFiles(launch, context, resourceMap, componentPorts, projectDir) {
241
+ export async function writeAllEnvFiles(launch, context, resourceMap, componentPorts, projectDir, generatedEnv) {
188
242
  const allEnvs = {};
189
243
  const componentNames = Object.keys(launch.components);
190
244
  const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
191
245
  for (const [name, component] of Object.entries(launch.components)) {
192
- const env = resolveComponentEnv(component, context, resourceMap);
193
- await resolveGenerators(component, env);
246
+ const { env } = resolveComponentEnv(component, context, resourceMap);
247
+ await resolveGenerators(component, env, name, generatedEnv);
194
248
  // Inject PORT if not already set and component has provides
195
249
  const port = componentPorts[name];
196
250
  if (port && !env.PORT) {
package/dist/health.d.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Health check polling for components.
3
3
  */
4
- import type { NormalizedHealth } from "@launchfile/sdk";
5
- /** Parse a duration string like "30s", "1m", "500ms" to milliseconds */
4
+ import { type NormalizedHealth } from "@launchfile/sdk";
5
+ /**
6
+ * Parse a health duration against the ratified grammar (D-48). Throws on an
7
+ * unparseable value — PROVIDERS.md §10.10 forbids silently substituting a
8
+ * default, and the previous local parser did exactly that in the worst
9
+ * possible way: it accepted no `h` unit and returned 0, so a spec-valid
10
+ * `interval: "1h"` became a zero-length poll that could never pass.
11
+ */
6
12
  export declare function parseDuration(duration: string): number;
7
13
  /**
8
14
  * Wait for a component to become healthy.