@launchfile/macos-dev 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  * installs runtimes, and starts all components.
6
6
  */
7
7
  import { type NormalizedLaunch, type NormalizedComponent } from "@launchfile/sdk";
8
+ import { type LaunchState } from "./state.js";
8
9
  /**
9
10
  * This provider runs apps from source. A component is source-runnable when
10
11
  * {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
@@ -159,7 +160,8 @@ export declare function applyResourceTypeRefusals(launch: NormalizedLaunch): "ok
159
160
  * use its provisioner cannot cover (SPEC.md § Resource uses, D-56 rule 1,
160
161
  * D-64), mapped to `<entry>: <use>` lines. Graded after
161
162
  * {@link refusedResourceTypes}: a type with no provisioner is refused on the
162
- * type, so only entries whose type this provider stands up reach here. A
163
+ * type, so only entries whose type this provider stands up, or accepts
164
+ * through the publication context (`https-origin`), reach here. A
163
165
  * token this provider does not recognise is uncovered — no provider can
164
166
  * claim to cover a use it does not know. `supports:` entries are optional
165
167
  * (D-8) and are not graded here.
@@ -171,7 +173,84 @@ export declare function refusedResourceUses(launch: NormalizedLaunch): Map<strin
171
173
  * removal IS the refusal.
172
174
  */
173
175
  export declare function applyResourceUseRefusals(launch: NormalizedLaunch): "ok" | "none-left";
176
+ /**
177
+ * The mandatory report for every `provides` entry that declares `at:` (D-68
178
+ * rule 5), one line per declaring entry. This provider starts each process on
179
+ * a local port with nothing in front that routes by host name, so every
180
+ * request reaches the listener with its `Host` intact and only name resolution
181
+ * is left to the operator. It launches and reports — never a silent launch,
182
+ * and never a refusal.
183
+ *
184
+ * `ports` maps a component to the local port its process listens on; an
185
+ * absent entry means the port is not known yet. Under a supplied publication
186
+ * URL (D-58) the names are whoever routes that URL's to send here (rule 7).
187
+ */
188
+ export declare function atReports(launch: NormalizedLaunch, ports?: Record<string, number>, suppliedAppUrl?: string): string[];
189
+ /** What {@link runSourcePrepare} needs from the surrounding `up`. */
190
+ export interface SourcePrepareContext {
191
+ projectDir: string;
192
+ /** Mutated in place: successful runs are recorded under `state.prepared`. */
193
+ state: LaunchState;
194
+ /** Resolved environment per component, as `prepare` should see it. */
195
+ envs: Record<string, Record<string, string>>;
196
+ /** Package-manager install command used where a component declares none. */
197
+ fallbackCommand?: string;
198
+ /** Prefix progress lines with the component name (multi-component apps). */
199
+ labelComponents?: boolean;
200
+ /** Runs one command. Injected so tests can observe without a real shell. */
201
+ run?: (command: string, opts: {
202
+ cwd: string;
203
+ env?: Record<string, string>;
204
+ timeout?: number;
205
+ }) => Promise<unknown>;
206
+ /** Persists state whenever a component's `state.prepared` record changes. */
207
+ save?: (projectDir: string, state: LaunchState) => Promise<void>;
208
+ }
209
+ /**
210
+ * Run the source-mode prepare slot (`install ?? build`, D-38) for every
211
+ * component that declares one, **on demand**: on first launch, and afterwards
212
+ * only when the prepare inputs changed.
213
+ *
214
+ * The demand signal is {@link prepareFingerprint} — the command plus the
215
+ * dependency manifests and lockfiles in its working directory. `state.prepared`
216
+ * holds the fingerprint of each component's last successful run, so an
217
+ * unchanged component is skipped rather than reinstalled on every `up`.
218
+ *
219
+ * Components resolving to the same working directory and command share one
220
+ * prepare, so it runs once per `up` (D-38) even on a first launch, when neither
221
+ * has a recorded fingerprint yet.
222
+ *
223
+ * A failing command throws, which fails the launch (SPEC.md § Failure
224
+ * semantics): the prepare slot fails the invocation. Nothing is recorded for
225
+ * it, so the next `up` retries.
226
+ */
227
+ export declare function runSourcePrepare(launch: NormalizedLaunch, ctx: SourcePrepareContext): Promise<void>;
174
228
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
229
+ /**
230
+ * What a printout reads to place the orchestrator-supplied publication URL
231
+ * (D-58): the URL, and the `ports` key it asserts. One URL asserts the
232
+ * primary endpoint only (D-58 rule 4), so every other key keeps this
233
+ * provider's own address; with no URL every key does.
234
+ */
235
+ export type PrintedPublication = Pick<LaunchState, "appUrl" | "primaryEndpoint">;
236
+ /**
237
+ * The address to print for one `ports` key — the single definition `up` and
238
+ * `status` share. The supplied publication URL on the primary endpoint's key,
239
+ * as stored (`normalizeAppUrl` ran when `up` recorded it; nothing runs again
240
+ * here); this provider's own `http://localhost:<port>` on every other key,
241
+ * and on every key when no URL is supplied — byte-identical to a run with
242
+ * none. `up` records `primaryEndpoint` only for an `http`/`https` primary or
243
+ * one a declared `https-origin` names, so a positional `ws`/`tcp`/`udp`/`grpc`
244
+ * primary keeps this provider's own form (§7, `printedPrimaryEndpoint`).
245
+ */
246
+ export declare function componentAddress(key: string, port: number, publication?: PrintedPublication): string;
247
+ /**
248
+ * The "<component> is running at …" lines `up` prints, one per `ports` key.
249
+ * Pure, so the placement of the supplied URL is testable without a launch.
250
+ */
251
+ export declare function summaryLines(appName: string, ports: Record<string, number>, publication?: PrintedPublication): string[];
252
+ /** The "Components:" lines `status` prints, one per `ports` key. */
253
+ export declare function statusLines(ports: Record<string, number>, publication?: PrintedPublication): string[];
175
254
  export declare function launchDown(opts?: {
176
255
  destroy?: boolean;
177
256
  projectDir?: string;
package/dist/provider.js CHANGED
@@ -7,17 +7,19 @@
7
7
  import { accessSync, constants as fsConstants } from "node:fs";
8
8
  import { readFile } from "node:fs/promises";
9
9
  import { join, resolve as resolvePath } from "node:path";
10
- import { CERTIFICATE, certificateBindings, indexOperatorStoragePaths, MissingOperatorStoragePathError, normalizeAppUrl, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, appEndpointReferences, useKeys, } from "@launchfile/sdk";
10
+ import { AT_APP_HOST, atDeclarations, atEntryLabel, buildLaunchErrorContext, CERTIFICATE, certificateBindings, indexOperatorStoragePaths, LaunchError, MissingOperatorStoragePathError, normalizeAppUrl, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, appEndpointReferences, useKeys, } from "@launchfile/sdk";
11
11
  import { checkPrereqs } from "./prereqs.js";
12
- import { HTTPS_ORIGIN, httpsOriginSatisfied, httpsOriginShortfall, } from "./https-origin.js";
13
- import { loadState, initState, saveState, ensureDirs, withRecordedDbIndexes } from "./state.js";
14
- import { declaredUses, registerResource, resolveComponentEnv, resolverContextFor, resourceMapFromState, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
12
+ import { declaredPrimary, HTTPS_ORIGIN, httpsOriginSatisfied, httpsOriginShortfall, uncoveredOriginUses, } from "./https-origin.js";
13
+ import { loadState, initState, saveState, ensureDirs, withRecordedDbIndexes, } from "./state.js";
14
+ import { declaredUses, printedPrimaryEndpoint, registerResource, resolveComponentEnv, resolverContextFor, resourceMapFromState, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
15
15
  import { allocateDbIndexes, getProvisioner, namedDatabases, uncoveredUses, } from "./resources/index.js";
16
16
  import { allocatePorts } from "./port-allocator.js";
17
17
  import { getRuntimeInstaller } from "./runtimes/index.js";
18
18
  import { detectPackageManager } from "./lockfile-detect.js";
19
+ import { prepareFingerprint } from "./prepare-fingerprint.js";
19
20
  import { provisionStorage, storagePaths } from "./storage.js";
20
- import { ProcessManager } from "./process-manager.js";
21
+ import { HealthGateError, ProcessManager } from "./process-manager.js";
22
+ import { redactSecrets } from "./redact.js";
21
23
  import { stopRecordedProcesses } from "./process-stopper.js";
22
24
  import { shellScript } from "./shell.js";
23
25
  import { parseDuration } from "./bootstrap.js";
@@ -266,7 +268,8 @@ export function applyResourceTypeRefusals(launch) {
266
268
  * use its provisioner cannot cover (SPEC.md § Resource uses, D-56 rule 1,
267
269
  * D-64), mapped to `<entry>: <use>` lines. Graded after
268
270
  * {@link refusedResourceTypes}: a type with no provisioner is refused on the
269
- * type, so only entries whose type this provider stands up reach here. A
271
+ * type, so only entries whose type this provider stands up, or accepts
272
+ * through the publication context (`https-origin`), reach here. A
270
273
  * token this provider does not recognise is uncovered — no provider can
271
274
  * claim to cover a use it does not know. `supports:` entries are optional
272
275
  * (D-8) and are not graded here.
@@ -276,7 +279,9 @@ export function refusedResourceUses(launch) {
276
279
  for (const [name, component] of Object.entries(launch.components)) {
277
280
  const entries = [];
278
281
  for (const req of component.requires ?? []) {
279
- if (req.host || req.type === HTTPS_ORIGIN || !req.uses || !getProvisioner(req.type))
282
+ if (req.host || !req.uses)
283
+ continue;
284
+ if (req.type !== HTTPS_ORIGIN && !getProvisioner(req.type))
280
285
  continue;
281
286
  const resourceName = req.name ?? req.type;
282
287
  for (const use of uncoveredUses(req.type, useKeys(req.uses))) {
@@ -306,6 +311,104 @@ export function applyResourceUseRefusals(launch) {
306
311
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
307
312
  return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
308
313
  }
314
+ /**
315
+ * The mandatory report for every `provides` entry that declares `at:` (D-68
316
+ * rule 5), one line per declaring entry. This provider starts each process on
317
+ * a local port with nothing in front that routes by host name, so every
318
+ * request reaches the listener with its `Host` intact and only name resolution
319
+ * is left to the operator. It launches and reports — never a silent launch,
320
+ * and never a refusal.
321
+ *
322
+ * `ports` maps a component to the local port its process listens on; an
323
+ * absent entry means the port is not known yet. Under a supplied publication
324
+ * URL (D-58) the names are whoever routes that URL's to send here (rule 7).
325
+ */
326
+ export function atReports(launch, ports = {}, suppliedAppUrl) {
327
+ const host = suppliedAppUrl === undefined ? "localhost" : new URL(suppliedAppUrl).hostname;
328
+ return atDeclarations(launch).map((declaration) => {
329
+ const names = declaration.values
330
+ .map((value) => (value === AT_APP_HOST ? host : `${value}.${host}`))
331
+ .join(", ");
332
+ const head = `${atEntryLabel(declaration)} answers at ${names} (\`at:\`, D-68)`;
333
+ if (suppliedAppUrl !== undefined) {
334
+ return (`${head} — this provider sets up no host names, and the supplied publication URL ` +
335
+ `(${suppliedAppUrl}) says nothing about them; whatever routes that URL must send each ` +
336
+ "name to this endpoint with the requested `Host` intact");
337
+ }
338
+ const port = ports[declaration.component];
339
+ const target = port === undefined ? "the component's local port" : `localhost:${port}`;
340
+ return (`${head} — this provider starts the process on a local port and sets up no host names. ` +
341
+ `Every request that reaches ${target} reaches the listener with its \`Host\` intact, so ` +
342
+ "map each name that does not resolve to this machine (hosts file or DNS), or use a " +
343
+ "provider that routes host names");
344
+ });
345
+ }
346
+ /** Print {@link atReports} the way this provider prints every warning. */
347
+ function printAtReports(reports) {
348
+ for (const report of reports)
349
+ console.warn(` Warning: ${report}`);
350
+ }
351
+ /**
352
+ * Run the source-mode prepare slot (`install ?? build`, D-38) for every
353
+ * component that declares one, **on demand**: on first launch, and afterwards
354
+ * only when the prepare inputs changed.
355
+ *
356
+ * The demand signal is {@link prepareFingerprint} — the command plus the
357
+ * dependency manifests and lockfiles in its working directory. `state.prepared`
358
+ * holds the fingerprint of each component's last successful run, so an
359
+ * unchanged component is skipped rather than reinstalled on every `up`.
360
+ *
361
+ * Components resolving to the same working directory and command share one
362
+ * prepare, so it runs once per `up` (D-38) even on a first launch, when neither
363
+ * has a recorded fingerprint yet.
364
+ *
365
+ * A failing command throws, which fails the launch (SPEC.md § Failure
366
+ * semantics): the prepare slot fails the invocation. Nothing is recorded for
367
+ * it, so the next `up` retries.
368
+ */
369
+ export async function runSourcePrepare(launch, ctx) {
370
+ const run = ctx.run ?? shellScript;
371
+ const save = ctx.save ?? saveState;
372
+ ctx.state.prepared ??= {};
373
+ const prepared = ctx.state.prepared;
374
+ const ranThisUp = new Set();
375
+ for (const [name, component] of Object.entries(launch.components)) {
376
+ const prepare = resolveSourcePrepareCommand(component);
377
+ const command = prepare?.command ?? ctx.fallbackCommand;
378
+ if (!command)
379
+ continue;
380
+ const label = ctx.labelComponents ? ` [${name}]` : "";
381
+ const sourceDir = join(component.source ?? component.build?.context ?? ".");
382
+ const cwd = join(ctx.projectDir, sourceDir);
383
+ // Identifies a shared prepare: same directory, same command.
384
+ const sharedKey = `${sourceDir}\u0000${command}`;
385
+ const fingerprint = await prepareFingerprint(cwd, command);
386
+ if (prepared[name] === fingerprint || ranThisUp.has(sharedKey)) {
387
+ // A component sharing this prepare is covered by an up-to-date record
388
+ // as much as by a run this `up`.
389
+ ranThisUp.add(sharedKey);
390
+ if (prepared[name] !== fingerprint) {
391
+ prepared[name] = fingerprint;
392
+ await save(ctx.projectDir, ctx.state);
393
+ }
394
+ console.log(` \u2713 Prepare up to date${label} (no dependency change)`);
395
+ continue;
396
+ }
397
+ console.log(` \u2193 Preparing${label}...`);
398
+ await run(command, {
399
+ cwd,
400
+ env: ctx.envs[name],
401
+ // Installs/compiles routinely exceed the 2-minute shell default;
402
+ // honor a declared timeout, else allow 10 minutes.
403
+ timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
404
+ });
405
+ ranThisUp.add(sharedKey);
406
+ // Recorded as each command succeeds: a later component can fail the
407
+ // launch, and that must not discard the record of work already done.
408
+ prepared[name] = fingerprint;
409
+ await save(ctx.projectDir, ctx.state);
410
+ }
411
+ }
309
412
  export async function launchUp(opts = {}) {
310
413
  const projectDir = opts.projectDir ?? process.cwd();
311
414
  // Publication context (D-58): validated and normalized before anything is
@@ -355,6 +458,28 @@ export async function launchUp(opts = {}) {
355
458
  const startSet = new Set(selection.start);
356
459
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
357
460
  }
461
+ // State is loaded ahead of every refusal (loading writes nothing; the
462
+ // first write is `ensureDirs`): the https-origin refusal at 2a-bis reads
463
+ // the recorded publication context, and the declared primary below is
464
+ // read against it.
465
+ let state = await loadState(projectDir);
466
+ if (!state) {
467
+ state = initState(launch.name, launchfileContent);
468
+ }
469
+ // Publication context (D-58), the same preservation rule the docker provider
470
+ // applies: a supplied value replaces the recorded one — the derived $app.*
471
+ // env recomputes below from it (D-49) — while omission keeps what is
472
+ // recorded, so a later plain `up` cannot silently flip a proxied deployment
473
+ // back to localhost.
474
+ if (suppliedAppUrl !== undefined)
475
+ state.appUrl = suppliedAppUrl;
476
+ // The primary an `https-origin` entry declares (D-60 rule 3), read now,
477
+ // while the start-set is whole: the refusals below remove a refused
478
+ // component from `launch.components`, and a primary read after that
479
+ // would fall back to a surviving sibling. Declaration fixes the primary;
480
+ // a refused one keeps its place and resolves the empty address at step
481
+ // 8 (D-72).
482
+ const primary = declaredPrimary(launch, state.appUrl);
358
483
  // 2a. Host capabilities are granted or refused, never provisioned (D-44,
359
484
  // PROVIDERS.md §11). This provider runs processes directly on the host and
360
485
  // grants none of them, so a component with a required capability is
@@ -370,21 +495,8 @@ export async function launchUp(opts = {}) {
370
495
  // 2a-bis. A required `https-origin` (D-60 rule 5, PROVIDERS.md §10 item 5)
371
496
  // is satisfied by the publication context when its scheme is https —
372
497
  // supplied on this run, or recorded by an earlier one and preserved on
373
- // omission, which is why state is loaded here rather than after the
374
- // refusals (loading writes nothing; the first write is `ensureDirs`).
375
- // Otherwise it is refused in the same way as a host capability: starting
376
- // the component anyway is the silent success the type removes.
377
- let state = await loadState(projectDir);
378
- if (!state) {
379
- state = initState(launch.name, launchfileContent);
380
- }
381
- // Publication context (D-58), the same preservation rule the docker provider
382
- // applies: a supplied value replaces the recorded one — the derived $app.*
383
- // env recomputes below from it (D-49) — while omission keeps what is
384
- // recorded, so a later plain `up` cannot silently flip a proxied deployment
385
- // back to localhost.
386
- if (suppliedAppUrl !== undefined)
387
- state.appUrl = suppliedAppUrl;
498
+ // omission. Otherwise it is refused in the same way as a host capability:
499
+ // starting the component anyway is the silent success the type removes.
388
500
  if (applyHttpsOriginRefusals(launch, state.appUrl) === "none-left") {
389
501
  console.error("Every selected component requires a public HTTPS origin this provider cannot supply.");
390
502
  process.exit(1);
@@ -415,6 +527,11 @@ export async function launchUp(opts = {}) {
415
527
  console.error("Every selected component requires a use of a resource this provider cannot cover.");
416
528
  process.exit(1);
417
529
  }
530
+ // 2a-sexies. A `provides` entry declaring `at:` is reported, not refused
531
+ // (D-68 rule 5). The report belongs at the end of provisioning, after the
532
+ // summary; a dry run never gets there, so it prints here.
533
+ if (opts.dryRun)
534
+ printAtReports(atReports(launch, {}, suppliedAppUrl));
418
535
  // An optional capability is not refused — the component runs, degraded.
419
536
  for (const [name, c] of Object.entries(launch.components)) {
420
537
  for (const sup of c.supports ?? []) {
@@ -425,9 +542,22 @@ export async function launchUp(opts = {}) {
425
542
  }
426
543
  // The optional mood of `https-origin` (D-60 rule 6): satisfied, it is
427
544
  // wired like any other resource at step 8; unsatisfied, the component
428
- // still runs, its set_env is absent, and the shortfall is named.
429
- if (httpsOriginSatisfied(state.appUrl))
545
+ // still runs, its set_env is absent, and the shortfall is named. A
546
+ // declared use this provider cannot cover leaves a satisfied entry
547
+ // unfulfilled the same way (D-65 rule 4).
548
+ if (httpsOriginSatisfied(state.appUrl)) {
549
+ for (const sup of c.supports ?? []) {
550
+ if (sup.type !== HTTPS_ORIGIN)
551
+ continue;
552
+ const uncovered = uncoveredOriginUses(sup);
553
+ if (uncovered.length === 0)
554
+ continue;
555
+ console.warn(` Warning: ${name}: optional public HTTPS origin ${sup.name ?? sup.type} not satisfied — ` +
556
+ `this provider does not cover ${uncovered.length === 1 ? "a use" : "uses"} it declares ` +
557
+ `(${uncovered.join(", ")}); running degraded`);
558
+ }
430
559
  continue;
560
+ }
431
561
  for (const sup of c.supports ?? []) {
432
562
  if (sup.type !== HTTPS_ORIGIN)
433
563
  continue;
@@ -568,7 +698,7 @@ export async function launchUp(opts = {}) {
568
698
  for (const key of storageIndex.unusedKeys(usedStorageKeys)) {
569
699
  console.warn(` Warning: --storage ${key} matches no \`content: operator\` volume — ignored`);
570
700
  }
571
- // 3. State: loaded at step 2a-bis, where the https-origin refusal reads it.
701
+ // 3. State: loaded ahead of step 2a, where the refusals read it.
572
702
  // 4. Ensure directories
573
703
  await ensureDirs(projectDir);
574
704
  // 5. Generate secrets
@@ -670,10 +800,17 @@ export async function launchUp(opts = {}) {
670
800
  // 7. Allocate ports
671
801
  const componentPorts = await allocatePorts(launch.components, launch.name, state.ports);
672
802
  state.ports = componentPorts;
803
+ // The key the printouts place a supplied publication URL on (§7, D-58
804
+ // rules 2 and 4), recorded so `status` places it without the Launchfile.
805
+ // `primary` is the declared primary as read before the refusals, so a
806
+ // refused one places the URL on no key rather than a sibling's (D-72).
807
+ state.primaryEndpoint = printedPrimaryEndpoint(launch, componentPorts, state.appUrl, primary);
673
808
  // 8. Build resolver context (including $app.* properties from D-33). A
674
809
  // satisfied `https-origin` registers `url` — the same string as `$app.url`
675
810
  // (D-60 rule 4) — so its set_env resolves like any provisioned resource's.
676
- const context = resolverContextFor(launch, resourceMap, state);
811
+ // `primary` is the declared primary as read before the refusals, so a
812
+ // refused one resolves the empty address rather than a sibling's (D-72).
813
+ const context = resolverContextFor(launch, resourceMap, state, primary);
677
814
  // `$app.endpoints.<name>.*` resolves "" here (D-63 rule 4, #294). Said
678
815
  // once per `up`, and only when the file asks, so the empty value is not a
679
816
  // silent one (PROVIDERS.md §10 item 8).
@@ -761,25 +898,20 @@ export async function launchUp(opts = {}) {
761
898
  await saveState(projectDir, state);
762
899
  if (opts.dryRun) {
763
900
  console.log("\n[dry-run] Would now run build, release, and start commands.");
764
- printSummary(launch, componentPorts, resourceMap);
901
+ printSummary(launch.name, componentPorts, state);
765
902
  return;
766
903
  }
767
- // 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
904
+ // 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand.
905
+ // `--no-build` stays the explicit opt-out; it records nothing, since nothing
906
+ // ran, so a later `up` without it still prepares.
768
907
  if (!opts.noBuild) {
769
- for (const [name, component] of Object.entries(launch.components)) {
770
- const prepare = resolveSourcePrepareCommand(component);
771
- const cmd = prepare?.command ?? pm?.installCommand;
772
- if (cmd) {
773
- console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
774
- await shellScript(cmd, {
775
- cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
776
- env: allEnvs[name],
777
- // Installs/compiles routinely exceed the 2-minute shell default;
778
- // honor a declared timeout, else allow 10 minutes.
779
- timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
780
- });
781
- }
782
- }
908
+ await runSourcePrepare(launch, {
909
+ projectDir,
910
+ state,
911
+ envs: allEnvs,
912
+ fallbackCommand: pm?.installCommand,
913
+ labelComponents: componentNames.length > 1,
914
+ });
783
915
  }
784
916
  // 15. Run release commands (migrations) \u2014 mode-invariant (D-38)
785
917
  for (const [name, component] of Object.entries(launch.components)) {
@@ -823,23 +955,77 @@ export async function launchUp(opts = {}) {
823
955
  await saveState(projectDir, finalState);
824
956
  process.exit(0);
825
957
  });
826
- await pm2.startAll();
958
+ try {
959
+ await pm2.startAll();
960
+ }
961
+ catch (err) {
962
+ // A failed health gate fails the invocation with the `health` phase
963
+ // (SPEC.md § Failure semantics), the same record the docker provider
964
+ // raises: the CLI registers the deployment as unhealthy so `down`
965
+ // reaches the processes left running, and `diagnose` finds the record.
966
+ if (err instanceof HealthGateError) {
967
+ throw new LaunchError(buildLaunchErrorContext({
968
+ phase: "health",
969
+ provider: "macos-dev",
970
+ key: launch.name,
971
+ app: launch.name,
972
+ message: err.message,
973
+ }, redactSecrets));
974
+ }
975
+ throw err;
976
+ }
977
+ finally {
978
+ // Record spawned pids so `launch down` can stop them from another shell or
979
+ // after this foreground session ends (closes #49). Backward compatible: the
980
+ // field is optional and absent in pre-existing state files. Recorded on
981
+ // the failure path too: a component that never became healthy fails the
982
+ // invocation (SPEC.md § Failure semantics) but its process is left
983
+ // running, and `status`/`logs`/`down` must still reach it.
984
+ state.processes = pm2.getRecordedProcesses();
985
+ await saveState(projectDir, state);
986
+ }
827
987
  console.log("");
828
988
  console.log(` \u2713 All components started`);
829
- // Record spawned pids so `launch down` can stop them from another shell or
830
- // after this foreground session ends (closes #49). Backward compatible: the
831
- // field is optional and absent in pre-existing state files.
832
- state.processes = pm2.getRecordedProcesses();
833
989
  // 17. Print summary
834
- printSummary(launch, componentPorts, resourceMap);
835
- // Save final state (now including recorded pids)
836
- await saveState(projectDir, state);
990
+ printSummary(launch.name, componentPorts, state);
991
+ printAtReports(atReports(launch, componentPorts, suppliedAppUrl));
992
+ }
993
+ /**
994
+ * The address to print for one `ports` key — the single definition `up` and
995
+ * `status` share. The supplied publication URL on the primary endpoint's key,
996
+ * as stored (`normalizeAppUrl` ran when `up` recorded it; nothing runs again
997
+ * here); this provider's own `http://localhost:<port>` on every other key,
998
+ * and on every key when no URL is supplied — byte-identical to a run with
999
+ * none. `up` records `primaryEndpoint` only for an `http`/`https` primary or
1000
+ * one a declared `https-origin` names, so a positional `ws`/`tcp`/`udp`/`grpc`
1001
+ * primary keeps this provider's own form (§7, `printedPrimaryEndpoint`).
1002
+ */
1003
+ export function componentAddress(key, port, publication) {
1004
+ if (publication?.appUrl !== undefined &&
1005
+ publication.primaryEndpoint !== undefined &&
1006
+ key === publication.primaryEndpoint) {
1007
+ return publication.appUrl;
1008
+ }
1009
+ return `http://localhost:${port}`;
1010
+ }
1011
+ /**
1012
+ * The "<component> is running at …" lines `up` prints, one per `ports` key.
1013
+ * Pure, so the placement of the supplied URL is testable without a launch.
1014
+ */
1015
+ export function summaryLines(appName, ports, publication) {
1016
+ return Object.entries(ports).map(([name, port]) => {
1017
+ const label = name === "default" ? appName : name;
1018
+ return ` ${label} is running at ${componentAddress(name, port, publication)}`;
1019
+ });
1020
+ }
1021
+ /** The "Components:" lines `status` prints, one per `ports` key. */
1022
+ export function statusLines(ports, publication) {
1023
+ return Object.entries(ports).map(([name, port]) => ` ${name}: ${componentAddress(name, port, publication)}`);
837
1024
  }
838
- function printSummary(launch, ports, _resources) {
1025
+ function printSummary(appName, ports, publication) {
839
1026
  console.log("");
840
- for (const [name, port] of Object.entries(ports)) {
841
- const label = name === "default" ? launch.name : name;
842
- console.log(` ${label} is running at http://localhost:${port}`);
1027
+ for (const line of summaryLines(appName, ports, publication)) {
1028
+ console.log(line);
843
1029
  }
844
1030
  console.log("\n Press Ctrl+C to stop all processes.");
845
1031
  }
@@ -914,8 +1100,8 @@ export async function launchStatus(opts = {}) {
914
1100
  console.log(`Updated: ${state.updatedAt}`);
915
1101
  if (Object.keys(state.ports).length > 0) {
916
1102
  console.log("\nComponents:");
917
- for (const [name, port] of Object.entries(state.ports)) {
918
- console.log(` ${name}: http://localhost:${port}`);
1103
+ for (const line of statusLines(state.ports, state)) {
1104
+ console.log(line);
919
1105
  }
920
1106
  }
921
1107
  if (Object.keys(state.resources).length > 0) {
package/dist/redact.js CHANGED
@@ -73,7 +73,19 @@ export function clearRegisteredSecrets() {
73
73
  // `scheme://user:password@host` — the password group is everything between the
74
74
  // first `:` after the userinfo and the `@`. Userinfo cannot contain `/`, `@`,
75
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;
76
+ //
77
+ // The scheme repetition is bounded rather than `*`: unbounded, every starting
78
+ // offset in a long run of scheme-legal characters rescans that whole run
79
+ // looking for `://`, which is quadratic in the input and lets a log line DoS
80
+ // the redactor that is supposed to protect it (CWE-1333).
81
+ //
82
+ // The bound excludes no URL, and not because schemes are short — RFC 3986 sets
83
+ // no ceiling, and `microsoft.windows.camera.multipicker` is 36 characters. It
84
+ // is because the pattern is unanchored: against a longer scheme the match
85
+ // simply starts further into it and the password still redacts. Raising the
86
+ // bound to "fit the longest scheme" would restore the quadratic scan for no
87
+ // gain.
88
+ const CREDENTIAL_URL = /([a-zA-Z][a-zA-Z0-9+.-]{0,31}:\/\/[^\s/:@]+:)([^\s/@]+)(@)/g;
77
89
  /**
78
90
  * Scrub registered secrets and URL-embedded credentials out of `text`.
79
91
  *
package/dist/state.d.ts CHANGED
@@ -108,6 +108,32 @@ export interface LaunchState {
108
108
  * answers.
109
109
  */
110
110
  appUrl?: string;
111
+ /**
112
+ * The `ports` key — this provider allocates one port per component, so a
113
+ * component name — of the app's primary endpoint, the one `$app.*` reads,
114
+ * when a declared `https-origin` names that endpoint (any protocol, D-60
115
+ * rule 4) or its effective listener is `http` or `https`; absent for a
116
+ * positional `ws`/`tcp`/`udp`/`grpc` primary (§7, D-58 rule 2). See
117
+ * `printedPrimaryEndpoint`. Recorded at `up` beside `appUrl`
118
+ * so `status`, which never reads the Launchfile, prints the supplied URL on
119
+ * that one key and no other (D-58 rule 4). Optional for backward
120
+ * compatibility: a state file without it prints this provider's own address
121
+ * on every key.
122
+ */
123
+ primaryEndpoint?: string;
124
+ /**
125
+ * Fingerprint of the prepare inputs (`install ?? build` command plus the
126
+ * dependency manifests and lockfiles in its working directory) at the last
127
+ * successful prepare, keyed by component name. It is what makes prepare run
128
+ * on demand rather than on every `up` (D-38): a component whose current
129
+ * fingerprint matches its recorded one has nothing to install.
130
+ *
131
+ * An entry is written only after its command exits zero, so a failed prepare
132
+ * is retried on the next `up`. Optional for backward compatibility: a state
133
+ * file written before this existed has no entries, so the next `up` prepares
134
+ * every component once and records them.
135
+ */
136
+ prepared?: Record<string, string>;
111
137
  }
112
138
  export declare function hashLaunchfile(content: string): string;
113
139
  /** Load state from disk, or return null if none exists */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "macOS dev provider for Launchfile — run apps locally via brew services and native runtimes",
5
5
  "os": [
6
6
  "darwin"
@@ -36,14 +36,14 @@
36
36
  "directory": "providers/macos-dev"
37
37
  },
38
38
  "dependencies": {
39
- "@launchfile/sdk": "^0.11.0",
39
+ "@launchfile/sdk": "^0.13.0",
40
40
  "semver": "^7.7.4"
41
41
  },
42
42
  "devDependencies": {
43
- "@biomejs/biome": "^2.4.10",
44
- "@types/bun": "^1.3.11",
43
+ "@biomejs/biome": "^2.5.14",
44
+ "@types/bun": "^1.4.2",
45
45
  "@types/semver": "^7.7.1",
46
46
  "typescript": "^7.0.2",
47
- "vitest": "^4.1.3"
47
+ "vitest": "^5.0.2"
48
48
  }
49
49
  }