@launchfile/macos-dev 0.3.0 → 0.4.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/health.js CHANGED
@@ -1,23 +1,17 @@
1
1
  /**
2
2
  * Health check polling for components.
3
3
  */
4
- import { shell } from "./shell.js";
5
- /** Parse a duration string like "30s", "1m", "500ms" to milliseconds */
4
+ import { parseDurationMs } from "@launchfile/sdk";
5
+ import { shellScript } from "./shell.js";
6
+ /**
7
+ * Parse a health duration against the ratified grammar (D-48). Throws on an
8
+ * unparseable value — PROVIDERS.md §10.10 forbids silently substituting a
9
+ * default, and the previous local parser did exactly that in the worst
10
+ * possible way: it accepted no `h` unit and returned 0, so a spec-valid
11
+ * `interval: "1h"` became a zero-length poll that could never pass.
12
+ */
6
13
  export function parseDuration(duration) {
7
- const match = /^(\d+)(ms|s|m)$/.exec(duration);
8
- if (!match)
9
- return 0;
10
- const value = Number.parseInt(match[1], 10);
11
- switch (match[2]) {
12
- case "ms":
13
- return value;
14
- case "s":
15
- return value * 1000;
16
- case "m":
17
- return value * 60_000;
18
- default:
19
- return 0;
20
- }
14
+ return parseDurationMs(duration);
21
15
  }
22
16
  /**
23
17
  * Wait for a component to become healthy.
@@ -45,7 +39,7 @@ export async function waitForHealthy(name, health, port, overallTimeout = 60_000
45
39
  }
46
40
  }
47
41
  else if (health.command) {
48
- await shell(health.command, { timeout: checkTimeout, silent: true });
42
+ await shellScript(health.command, { timeout: checkTimeout, silent: true });
49
43
  console.log(` [${name}] Healthy`);
50
44
  return true;
51
45
  }
package/dist/prereqs.js CHANGED
@@ -6,11 +6,11 @@ export async function checkPrereqs() {
6
6
  const missing = [];
7
7
  const warnings = [];
8
8
  // Homebrew is required
9
- if (!(await shellOk("which brew"))) {
9
+ if (!(await shellOk("which", ["brew"]))) {
10
10
  missing.push("Homebrew — install from https://brew.sh");
11
11
  }
12
12
  // Git is required (for cloning)
13
- if (!(await shellOk("which git"))) {
13
+ if (!(await shellOk("which", ["git"]))) {
14
14
  missing.push("git — install via: brew install git");
15
15
  }
16
16
  return {
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import { createWriteStream, mkdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { waitForHealthy } from "./health.js";
11
+ import { redactSecrets } from "./redact.js";
11
12
  // ANSI colors for log prefixing
12
13
  const COLORS = [
13
14
  "\x1b[36m", // cyan
@@ -89,7 +90,7 @@ export class ProcessManager {
89
90
  // For "started" condition, the process is already spawned by the time we get here
90
91
  }
91
92
  proc.status = "starting";
92
- console.log(` [${name}] Starting: ${proc.command}`);
93
+ console.log(` [${name}] Starting: ${redactSecrets(proc.command)}`);
93
94
  const logFile = createWriteStream(join(this.logDir, `${name}.log`), { flags: "a" });
94
95
  const colorIdx = [...this.processes.keys()].indexOf(name) % COLORS.length;
95
96
  const color = COLORS[colorIdx];
@@ -4,16 +4,14 @@
4
4
  * Reads a Launchfile, provisions resources, resolves env vars,
5
5
  * installs runtimes, and starts all components.
6
6
  */
7
- import { type NormalizedComponent } from "@launchfile/sdk";
7
+ import { type NormalizedLaunch, type NormalizedComponent } from "@launchfile/sdk";
8
8
  /**
9
- * Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
10
- * This provider runs apps from source. A component is source-runnable when it
11
- * declares `dev`, or a `start` with no `image` — an `image` without a `dev`
12
- * override stays artifact-mode, which this source-only provider can't launch.
9
+ * This provider runs apps from source. A component is source-runnable when
10
+ * {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
11
+ * or a `start` with no `image`. An `image` without a `dev` override stays
12
+ * artifact-mode, which this source-only provider can't launch.
13
13
  */
14
14
  export declare function isSourceRunnable(component: NormalizedComponent): boolean;
15
- /** The command run from source, or undefined if the component resolves to its artifact. */
16
- export declare function sourceRunCommand(component: NormalizedComponent): string | undefined;
17
15
  export interface LaunchUpOpts {
18
16
  withOptional?: boolean;
19
17
  noBuild?: boolean;
@@ -29,6 +27,40 @@ export interface LaunchUpOpts {
29
27
  */
30
28
  components?: string[];
31
29
  }
30
+ /**
31
+ * Components this provider must refuse, mapped to the capabilities it cannot
32
+ * grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
33
+ * entry form and the legacy top-level block produce the same outcome.
34
+ *
35
+ * A refusal must remove the component from the run, not merely report it —
36
+ * this provider grants no host capabilities, so anything listed here cannot
37
+ * be installed, wired, registered, or started.
38
+ */
39
+ export declare function refusedHostCapabilities(launch: NormalizedLaunch): Map<string, string[]>;
40
+ /**
41
+ * The launch-time notice a provider without a scheduler owes for a declared
42
+ * `schedule` (D-51, PROVIDERS.md §10 item 8).
43
+ *
44
+ * States what *this provider* does, not what will happen to the app: a
45
+ * component may schedule itself — `catalog/drafts/diun` sets its own
46
+ * `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
47
+ * so claiming the job will not run would be false about those apps, and a
48
+ * warning that misstates the user's app is worse than the silence it replaces.
49
+ */
50
+ export declare function scheduleWarning(component: string, schedule: string): string;
51
+ /**
52
+ * Remove every component this provider must refuse, and say so on stderr.
53
+ *
54
+ * The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
55
+ * map goes on to be installed, env-wired, registered with the process manager
56
+ * and started, so logging alone would have the provider assert a refusal it
57
+ * did not perform. Mutates `launch.components` for exactly that reason —
58
+ * everything downstream reads it.
59
+ *
60
+ * Returns "none-left" when nothing survives, so the caller can fail rather than
61
+ * report success over an empty set.
62
+ */
63
+ export declare function applyHostCapabilityRefusals(launch: NormalizedLaunch): "ok" | "none-left";
32
64
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
33
65
  export declare function launchDown(opts?: {
34
66
  destroy?: boolean;
package/dist/provider.js CHANGED
@@ -6,24 +6,7 @@
6
6
  */
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
- import { readLaunch, selectionClosure } from "@launchfile/sdk";
10
- /**
11
- * Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
12
- * This provider runs apps from source. A component is source-runnable when it
13
- * declares `dev`, or a `start` with no `image` — an `image` without a `dev`
14
- * override stays artifact-mode, which this source-only provider can't launch.
15
- */
16
- export function isSourceRunnable(component) {
17
- return Boolean(component.commands?.dev || (component.commands?.start && !component.image));
18
- }
19
- /** The command run from source, or undefined if the component resolves to its artifact. */
20
- export function sourceRunCommand(component) {
21
- if (component.commands?.dev?.command)
22
- return component.commands.dev.command;
23
- if (component.image)
24
- return undefined; // image, no `dev` override → artifact
25
- return component.commands?.start?.command;
26
- }
9
+ import { readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, unsuppliedRequiredEnv, } from "@launchfile/sdk";
27
10
  import { checkPrereqs } from "./prereqs.js";
28
11
  import { loadState, initState, saveState, ensureDirs } from "./state.js";
29
12
  import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
@@ -34,8 +17,99 @@ import { detectPackageManager } from "./lockfile-detect.js";
34
17
  import { provisionStorage, storagePaths } from "./storage.js";
35
18
  import { ProcessManager } from "./process-manager.js";
36
19
  import { stopRecordedProcesses } from "./process-stopper.js";
37
- import { shell } from "./shell.js";
20
+ import { shellScript } from "./shell.js";
38
21
  import { parseDuration } from "./bootstrap.js";
22
+ /**
23
+ * This provider runs apps from source. A component is source-runnable when
24
+ * {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
25
+ * or a `start` with no `image`. An `image` without a `dev` override stays
26
+ * artifact-mode, which this source-only provider can't launch.
27
+ */
28
+ export function isSourceRunnable(component) {
29
+ return resolveSourceRunCommand(component) !== undefined;
30
+ }
31
+ /**
32
+ * Parse a declared timeout, adding the stage/component label to the error.
33
+ * An unparseable duration is surfaced — it fails the stage that declared it
34
+ * (PROVIDERS.md §10.10) — never silently replaced with a default. Undefined
35
+ * passes through so callers keep their own default budgets.
36
+ */
37
+ function declaredTimeout(timeout, label) {
38
+ if (timeout === undefined)
39
+ return undefined;
40
+ try {
41
+ return parseDuration(timeout);
42
+ }
43
+ catch (err) {
44
+ throw new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`);
45
+ }
46
+ }
47
+ /**
48
+ * Components this provider must refuse, mapped to the capabilities it cannot
49
+ * grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
50
+ * entry form and the legacy top-level block produce the same outcome.
51
+ *
52
+ * A refusal must remove the component from the run, not merely report it —
53
+ * this provider grants no host capabilities, so anything listed here cannot
54
+ * be installed, wired, registered, or started.
55
+ */
56
+ export function refusedHostCapabilities(launch) {
57
+ const refused = new Map();
58
+ for (const [name, c] of Object.entries(launch.components)) {
59
+ const caps = [];
60
+ for (const req of c.requires ?? []) {
61
+ for (const [capability, value] of Object.entries(req.host ?? {})) {
62
+ caps.push(`${capability}=${String(value)}`);
63
+ }
64
+ }
65
+ if (c.host?.docker === "required")
66
+ caps.push("container_runtime=docker (host.docker)");
67
+ if (c.host?.network === "host")
68
+ caps.push("network=host (host.network)");
69
+ if (c.host?.privileged)
70
+ caps.push("privileged=true (host.privileged)");
71
+ if (caps.length > 0)
72
+ refused.set(name, caps);
73
+ }
74
+ return refused;
75
+ }
76
+ /**
77
+ * The launch-time notice a provider without a scheduler owes for a declared
78
+ * `schedule` (D-51, PROVIDERS.md §10 item 8).
79
+ *
80
+ * States what *this provider* does, not what will happen to the app: a
81
+ * component may schedule itself — `catalog/drafts/diun` sets its own
82
+ * `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
83
+ * so claiming the job will not run would be false about those apps, and a
84
+ * warning that misstates the user's app is worse than the silence it replaces.
85
+ */
86
+ export function scheduleWarning(component, schedule) {
87
+ return (`[${component}] declares \`schedule: ${schedule}\` — this provider will not ` +
88
+ "run it on a timer. If the component does not schedule itself, the job will not run.");
89
+ }
90
+ /**
91
+ * Remove every component this provider must refuse, and say so on stderr.
92
+ *
93
+ * The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
94
+ * map goes on to be installed, env-wired, registered with the process manager
95
+ * and started, so logging alone would have the provider assert a refusal it
96
+ * did not perform. Mutates `launch.components` for exactly that reason —
97
+ * everything downstream reads it.
98
+ *
99
+ * Returns "none-left" when nothing survives, so the caller can fail rather than
100
+ * report success over an empty set.
101
+ */
102
+ export function applyHostCapabilityRefusals(launch) {
103
+ const refused = refusedHostCapabilities(launch);
104
+ for (const [name, caps] of refused) {
105
+ console.error(` Refused: ${name} requires host capabilities this provider cannot grant ` +
106
+ `(${caps.join("; ")}) — component not started`);
107
+ }
108
+ if (refused.size === 0)
109
+ return "ok";
110
+ launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
111
+ return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
112
+ }
39
113
  export async function launchUp(opts = {}) {
40
114
  const projectDir = opts.projectDir ?? process.cwd();
41
115
  // 1. Check prerequisites
@@ -80,6 +154,27 @@ export async function launchUp(opts = {}) {
80
154
  const startSet = new Set(selection.start);
81
155
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
82
156
  }
157
+ // 2a. Host capabilities are granted or refused, never provisioned (D-44,
158
+ // PROVIDERS.md §11). This provider runs processes directly on the host and
159
+ // grants none of them, so a component with a required capability is
160
+ // DECLINED — removed from the map here so nothing downstream installs a
161
+ // runtime, wires env, registers with pm2, or starts it. Logging alone would
162
+ // leave the provider asserting a refusal it did not perform.
163
+ // Both spellings fold together so they land identically (§11 equivalence):
164
+ // the `host:` entry form and the legacy top-level block.
165
+ if (applyHostCapabilityRefusals(launch) === "none-left") {
166
+ console.error("Every selected component requires a host capability this provider cannot grant.");
167
+ process.exit(1);
168
+ }
169
+ // An optional capability is not refused — the component runs, degraded.
170
+ for (const [name, c] of Object.entries(launch.components)) {
171
+ for (const sup of c.supports ?? []) {
172
+ for (const [capability, value] of Object.entries(sup.host ?? {})) {
173
+ console.warn(` Warning: ${name}: optional host capability ` +
174
+ `${capability}=${String(value)} not granted — running degraded`);
175
+ }
176
+ }
177
+ }
83
178
  const componentNames = Object.keys(launch.components);
84
179
  // 2b. Source-mode guard (D-38) — fail fast before provisioning anything.
85
180
  // Run precedence is `dev` > `image` > `start`: a component runs from source
@@ -102,6 +197,53 @@ export async function launchUp(opts = {}) {
102
197
  console.warn(` ! [${name}] has an image and no \`dev\` override — runs as an artifact, ` +
103
198
  "skipped in source mode; use `launchfile up` to run it.");
104
199
  }
200
+ // PROVIDERS.md conformance rule 8 (D-51): a provider that does not
201
+ // execute `schedule` MUST say so at launch. Staying silent leaves an
202
+ // author believing a declared cron job is running — the one outcome
203
+ // worse than not supporting it. Wording stays start-agnostic: artifact
204
+ // components with a schedule reach this loop too, and they are skipped
205
+ // entirely in source mode.
206
+ if (c.schedule) {
207
+ console.warn(` ! ${scheduleWarning(name, c.schedule)}`);
208
+ }
209
+ }
210
+ // 2c. Unsupplied `required:` environment variables (D-52, PROVIDERS.md §10
211
+ // rule 8, deploying branch). This provider's operator channel is the
212
+ // launching environment, read EXPLICITLY here — the `...process.env` spread
213
+ // on the pm2 registration below is incidental inheritance that never reaches
214
+ // `release` and is invisible to `env`, so it cannot serve as the channel.
215
+ // Values found are carried in `operatorEnv` and merged into `allEnvs` at
216
+ // step 12, which puts them on both `release` and `start` and makes them
217
+ // visible to `launch env`. Anything still missing fails HERE — before
218
+ // directories, resources, ports, runtimes, or processes exist. No prompt: a
219
+ // non-interactive invocation must fail by name, not hang on stdin.
220
+ const operatorEnv = {};
221
+ const missingRequired = [];
222
+ for (const [name, component] of Object.entries(launch.components)) {
223
+ // A `requires:` binding injects only when this provider can provision the
224
+ // resource behind it; `supports:` is provisioned only under --with-optional
225
+ // and is never credited (SPEC.md §Supports).
226
+ const arriving = new Set((component.requires ?? [])
227
+ .filter((req) => !req.host && getProvisioner(req.type))
228
+ .flatMap((req) => Object.keys(req.set_env ?? {})));
229
+ for (const { key, sensitive } of unsuppliedRequiredEnv(component, arriving)) {
230
+ const supplied = process.env[key];
231
+ if (supplied !== undefined) {
232
+ (operatorEnv[name] ??= {})[key] = supplied;
233
+ continue;
234
+ }
235
+ missingRequired.push({ component: name, key, sensitive });
236
+ }
237
+ }
238
+ if (missingRequired.length > 0) {
239
+ console.error(`\nCannot launch: ${missingRequired.length} required environment variable${missingRequired.length === 1 ? "" : "s"} had no value.`);
240
+ for (const { component, key, sensitive } of missingRequired) {
241
+ console.error(` - ${component}: ${key}${sensitive ? " (sensitive)" : ""}`);
242
+ }
243
+ console.error("\nThe Launchfile declares them `required:` with no `default:`, `generator:`, or resource");
244
+ console.error("binding, so you supply them. Set them in the environment and run `up` again, e.g.");
245
+ console.error(` ${missingRequired[0].key}=<value> launch up`);
246
+ process.exit(1);
105
247
  }
106
248
  // 3. Load or init state
107
249
  let state = await loadState(projectDir);
@@ -116,6 +258,8 @@ export async function launchUp(opts = {}) {
116
258
  const resourceMap = {};
117
259
  for (const [_compName, component] of Object.entries(launch.components)) {
118
260
  for (const req of component.requires ?? []) {
261
+ if (req.host)
262
+ continue; // capability, not a backing service (D-44)
119
263
  const resourceName = req.name ?? req.type;
120
264
  if (resourceMap[resourceName])
121
265
  continue; // Already provisioned
@@ -139,6 +283,8 @@ export async function launchUp(opts = {}) {
139
283
  // Optional supports resources
140
284
  if (opts.withOptional) {
141
285
  for (const sup of component.supports ?? []) {
286
+ if (sup.host)
287
+ continue; // capability, not a backing service (D-44)
142
288
  const resourceName = sup.name ?? sup.type;
143
289
  if (resourceMap[resourceName])
144
290
  continue;
@@ -212,9 +358,15 @@ export async function launchUp(opts = {}) {
212
358
  // 12. Resolve env vars and write .env files
213
359
  const allEnvs = {};
214
360
  const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
361
+ // Minted env-level generator values live in state (D-49) so a redeploy
362
+ // reuses them; saveState below (step 13) persists anything minted here.
363
+ const generatedEnv = (state.generatedEnv ??= {});
215
364
  for (const [name, component] of Object.entries(launch.components)) {
216
- const env = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
217
- await resolveGenerators(component, env);
365
+ const { env } = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
366
+ await resolveGenerators(component, env, name, generatedEnv);
367
+ // Operator-supplied `required:` values (step 2c) join the resolved set, so
368
+ // they reach `release` and `start` alike and show up in `launch env`.
369
+ Object.assign(env, operatorEnv[name] ?? {});
218
370
  const port = componentPorts[name];
219
371
  if (port && !env.PORT) {
220
372
  env.PORT = String(port);
@@ -245,16 +397,16 @@ export async function launchUp(opts = {}) {
245
397
  // 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
246
398
  if (!opts.noBuild) {
247
399
  for (const [name, component] of Object.entries(launch.components)) {
248
- const prepare = component.commands?.install ?? component.commands?.build;
400
+ const prepare = resolveSourcePrepareCommand(component);
249
401
  const cmd = prepare?.command ?? pm?.installCommand;
250
402
  if (cmd) {
251
403
  console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
252
- await shell(cmd, {
404
+ await shellScript(cmd, {
253
405
  cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
254
406
  env: allEnvs[name],
255
407
  // Installs/compiles routinely exceed the 2-minute shell default;
256
408
  // honor a declared timeout, else allow 10 minutes.
257
- timeout: prepare?.timeout ? parseDuration(prepare.timeout) : 600_000,
409
+ timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
258
410
  });
259
411
  }
260
412
  }
@@ -264,10 +416,10 @@ export async function launchUp(opts = {}) {
264
416
  const release = component.commands?.release;
265
417
  if (release?.command) {
266
418
  console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
267
- await shell(release.command, {
419
+ await shellScript(release.command, {
268
420
  cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
269
421
  env: allEnvs[name],
270
- timeout: release.timeout ? parseDuration(release.timeout) : undefined,
422
+ timeout: declaredTimeout(release.timeout, `release [${name}]`),
271
423
  });
272
424
  }
273
425
  }
@@ -278,7 +430,7 @@ export async function launchUp(opts = {}) {
278
430
  // Resolve the source-mode run command (D-38 precedence `dev` > `image` >
279
431
  // `start`). Artifact components (image, no `dev` override) resolve to
280
432
  // undefined — they were warned by the guard; skip them.
281
- const startCmd = sourceRunCommand(component);
433
+ const startCmd = resolveSourceRunCommand(component)?.command;
282
434
  if (!startCmd)
283
435
  continue;
284
436
  pm2.register(name, {
@@ -425,6 +577,14 @@ export async function launchEnv(opts = {}) {
425
577
  }
426
578
  const appProperties = computeAppProperties(launch, state.ports);
427
579
  const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
580
+ // `env` reports what the running app has, so it reads minted generator
581
+ // values from the same store `up` persists to (D-49). A value can still be
582
+ // minted here — a generator declared after the last `up` — and then it is
583
+ // persisted below, before printing, so `up`, `env`, and `bootstrap` all
584
+ // keep answering with the same value.
585
+ const generatedEnv = (state.generatedEnv ??= {});
586
+ let minted = false;
587
+ const resolvedEnvs = [];
428
588
  for (const [name, component] of Object.entries(launch.components)) {
429
589
  if (opts.component && name !== opts.component)
430
590
  continue;
@@ -434,15 +594,40 @@ export async function launchEnv(opts = {}) {
434
594
  for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
435
595
  storageCtx[volName] = { path: localPath };
436
596
  }
437
- const env = resolveComponentEnv(component, context, resourceMap, storageCtx);
438
- await resolveGenerators(component, env);
597
+ const { env, unsupplied } = resolveComponentEnv(component, context, resourceMap, storageCtx);
598
+ minted = (await resolveGenerators(component, env, name, generatedEnv)) || minted;
599
+ // The operator channel `up` reads (the launching environment) answers here
600
+ // too, so a var supplied at launch time prints as a real value rather than
601
+ // being reported missing.
602
+ for (const { key } of unsupplied) {
603
+ const supplied = process.env[key];
604
+ if (supplied !== undefined)
605
+ env[key] = supplied;
606
+ }
439
607
  const port = state.ports[name];
440
608
  if (port && !env.PORT)
441
609
  env.PORT = String(port);
610
+ resolvedEnvs.push([name, env, unsupplied]);
611
+ }
612
+ if (minted) {
613
+ await saveState(projectDir, state);
614
+ }
615
+ for (const [name, env, unsupplied] of resolvedEnvs) {
442
616
  console.log(`\n# ${name}`);
443
617
  for (const [key, value] of Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) {
444
618
  console.log(`${key}=${value}`);
445
619
  }
620
+ // PROVIDERS.md §10 rule 8, `env` branch: report an unsupplied required var
621
+ // rather than dropping it — this is where an operator comes to find out
622
+ // what is missing. It goes out as a `#` comment, never a bare `KEY=` line,
623
+ // because this output is designed to be `eval`'d (§2): a bare line would
624
+ // export an empty value and re-create the failure the rule exists to stop.
625
+ for (const { key, sensitive } of unsupplied.sort((a, b) => a.key.localeCompare(b.key))) {
626
+ if (env[key] !== undefined)
627
+ continue;
628
+ console.log(`# ${key}: unsupplied — required, no default/generator/binding` +
629
+ `${sensitive ? ", sensitive" : ""}. Supply it in the environment.`);
630
+ }
446
631
  }
447
632
  }
448
633
  //# sourceMappingURL=provider.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Secret redaction for anything this provider prints or embeds in an error.
3
+ *
4
+ * Two independent layers, because either alone leaves a hole:
5
+ *
6
+ * 1. A registry of exact secret values. Every generated secret, every
7
+ * persisted `state.secrets` entry, and every resource password registers
8
+ * itself at creation/load time; `redactSecrets` then scrubs those literals
9
+ * out of any string on its way to stdout/stderr or an Error message.
10
+ * 2. A pattern scrub for credentials embedded in URLs
11
+ * (`scheme://user:pass@host`), which catches secrets that never passed
12
+ * through this provider — e.g. a connection string written literally in a
13
+ * Launchfile `env:` value and interpolated into a bootstrap command.
14
+ *
15
+ * The registry is process-global on purpose: a command string is assembled in
16
+ * one module and printed in another, so the scrub has to be reachable from the
17
+ * sink without threading a context object through every call site.
18
+ */
19
+ export declare const REDACTED = "[REDACTED]";
20
+ /**
21
+ * Register a value this provider *inferred* is a secret: one it minted itself,
22
+ * or read back out of its own state. Values below `MIN_SECRET_LENGTH` are
23
+ * dropped — nothing declared them sensitive, so a coincidental match would
24
+ * corrupt output for no gain.
25
+ */
26
+ export declare function registerSecret(value: string | undefined | null): void;
27
+ /**
28
+ * Register a value something *declared* is a secret: an `env:` literal marked
29
+ * `sensitive: true` (D-18), or a value handed over on the operator channel
30
+ * (D-52). No length floor applies.
31
+ *
32
+ * `sensitive: true` on a six-digit PIN is the author stating that value must be
33
+ * masked. Dropping it for being short writes the PIN to disk in plaintext
34
+ * (CWE-532) — the exact failure this registry exists to prevent. Honouring the
35
+ * declaration costs an over-redacted diagnostic where the value also occurs by
36
+ * chance, which is recoverable; the alternative is a leaked credential, which
37
+ * is not.
38
+ *
39
+ * The empty string is rejected: it is not a credential, and an empty separator
40
+ * would splice `[REDACTED]` between every character of the text.
41
+ */
42
+ export declare function registerDeclaredSecret(value: string | undefined | null): void;
43
+ /** Register many secret values at once. Non-string entries are ignored. */
44
+ export declare function registerSecrets(values: Iterable<string | undefined | null>): void;
45
+ /** Drop every registered secret. Exists for test isolation. */
46
+ export declare function clearRegisteredSecrets(): void;
47
+ /**
48
+ * Scrub registered secrets and URL-embedded credentials out of `text`.
49
+ *
50
+ * Longest registered values are replaced first so that a secret which is a
51
+ * substring of another (a password inside its own connection URL) cannot leave
52
+ * a partial value behind.
53
+ */
54
+ export declare function redactSecrets(text: string): string;
55
+ //# sourceMappingURL=redact.d.ts.map
package/dist/redact.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Secret redaction for anything this provider prints or embeds in an error.
3
+ *
4
+ * Two independent layers, because either alone leaves a hole:
5
+ *
6
+ * 1. A registry of exact secret values. Every generated secret, every
7
+ * persisted `state.secrets` entry, and every resource password registers
8
+ * itself at creation/load time; `redactSecrets` then scrubs those literals
9
+ * out of any string on its way to stdout/stderr or an Error message.
10
+ * 2. A pattern scrub for credentials embedded in URLs
11
+ * (`scheme://user:pass@host`), which catches secrets that never passed
12
+ * through this provider — e.g. a connection string written literally in a
13
+ * Launchfile `env:` value and interpolated into a bootstrap command.
14
+ *
15
+ * The registry is process-global on purpose: a command string is assembled in
16
+ * one module and printed in another, so the scrub has to be reachable from the
17
+ * sink without threading a context object through every call site.
18
+ */
19
+ export const REDACTED = "[REDACTED]";
20
+ /**
21
+ * Values shorter than this are not registered *by inference*. Short strings
22
+ * appear inside unrelated text by coincidence, and scrubbing them would corrupt
23
+ * the output it is meant to protect. Every secret this provider mints is far
24
+ * longer, so the floor never binds on a minted value.
25
+ *
26
+ * The floor is a heuristic, and a heuristic does not overrule an explicit
27
+ * declaration — see `registerDeclaredSecret`.
28
+ */
29
+ const MIN_SECRET_LENGTH = 8;
30
+ const registry = new Set();
31
+ /**
32
+ * Register a value this provider *inferred* is a secret: one it minted itself,
33
+ * or read back out of its own state. Values below `MIN_SECRET_LENGTH` are
34
+ * dropped — nothing declared them sensitive, so a coincidental match would
35
+ * corrupt output for no gain.
36
+ */
37
+ export function registerSecret(value) {
38
+ if (typeof value !== "string")
39
+ return;
40
+ if (value.length < MIN_SECRET_LENGTH)
41
+ return;
42
+ registry.add(value);
43
+ }
44
+ /**
45
+ * Register a value something *declared* is a secret: an `env:` literal marked
46
+ * `sensitive: true` (D-18), or a value handed over on the operator channel
47
+ * (D-52). No length floor applies.
48
+ *
49
+ * `sensitive: true` on a six-digit PIN is the author stating that value must be
50
+ * masked. Dropping it for being short writes the PIN to disk in plaintext
51
+ * (CWE-532) — the exact failure this registry exists to prevent. Honouring the
52
+ * declaration costs an over-redacted diagnostic where the value also occurs by
53
+ * chance, which is recoverable; the alternative is a leaked credential, which
54
+ * is not.
55
+ *
56
+ * The empty string is rejected: it is not a credential, and an empty separator
57
+ * would splice `[REDACTED]` between every character of the text.
58
+ */
59
+ export function registerDeclaredSecret(value) {
60
+ if (typeof value !== "string" || value === "")
61
+ return;
62
+ registry.add(value);
63
+ }
64
+ /** Register many secret values at once. Non-string entries are ignored. */
65
+ export function registerSecrets(values) {
66
+ for (const value of values)
67
+ registerSecret(value);
68
+ }
69
+ /** Drop every registered secret. Exists for test isolation. */
70
+ export function clearRegisteredSecrets() {
71
+ registry.clear();
72
+ }
73
+ // `scheme://user:password@host` — the password group is everything between the
74
+ // first `:` after the userinfo and the `@`. Userinfo cannot contain `/`, `@`,
75
+ // or whitespace, which bounds the match to a single URL.
76
+ const CREDENTIAL_URL = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/:@]+:)([^\s/@]+)(@)/g;
77
+ /**
78
+ * Scrub registered secrets and URL-embedded credentials out of `text`.
79
+ *
80
+ * Longest registered values are replaced first so that a secret which is a
81
+ * substring of another (a password inside its own connection URL) cannot leave
82
+ * a partial value behind.
83
+ */
84
+ export function redactSecrets(text) {
85
+ let out = text;
86
+ const values = [...registry].sort((a, b) => b.length - a.length);
87
+ for (const value of values) {
88
+ out = out.split(value).join(REDACTED);
89
+ }
90
+ return out.replace(CREDENTIAL_URL, `$1${REDACTED}$3`);
91
+ }
92
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Guards for values that reach a SQL statement by interpolation.
3
+ *
4
+ * On a first run these values are safe by construction: the database and user
5
+ * names derive from a schema-validated app name, and `generatePassword()`
6
+ * emits base64url. Every later run reuses whatever `.launchfile/state.json`
7
+ * holds, and `loadState()` (state.ts) `JSON.parse`s that file with no
8
+ * validation. The file sits inside the cloned repo, so on the reuse path every
9
+ * value below is attacker-controlled.
10
+ *
11
+ * Argv execution keeps the shell out of these commands; these checks keep the
12
+ * SQL parser out of them. Both are needed: `mysql -e` runs `;`-separated
13
+ * statements and connects as root, so a quote that escapes an identifier or a
14
+ * password literal is a full statement injection with no shell involved.
15
+ */
16
+ /** Alphanumeric + underscore — safe as a SQL identifier and as a shell arg. */
17
+ export declare const SAFE_IDENTIFIER: RegExp;
18
+ /** base64url, the exact alphabet `generatePassword()` emits. No quote fits. */
19
+ export declare const SAFE_PASSWORD: RegExp;
20
+ export declare function assertSafeIdentifier(value: string, label: string): void;
21
+ export declare function assertSafePassword(value: string): void;
22
+ //# sourceMappingURL=identifiers.d.ts.map