@launchfile/macos-dev 0.12.0 → 0.14.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.
@@ -184,7 +186,73 @@ export declare function applyResourceUseRefusals(launch: NormalizedLaunch): "ok"
184
186
  * URL (D-58) the names are whoever routes that URL's to send here (rule 7).
185
187
  */
186
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>;
187
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
+ * `verb` is the phrase between the label and the address; a dry run passes
250
+ * "would be reachable at" because nothing has started.
251
+ * Pure, so the placement of the supplied URL is testable without a launch.
252
+ */
253
+ export declare function summaryLines(appName: string, ports: Record<string, number>, publication?: PrintedPublication, verb?: string): string[];
254
+ /** The "Components:" lines `status` prints, one per `ports` key. */
255
+ export declare function statusLines(ports: Record<string, number>, publication?: PrintedPublication): string[];
188
256
  export declare function launchDown(opts?: {
189
257
  destroy?: boolean;
190
258
  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 { AT_APP_HOST, atDeclarations, atEntryLabel, 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))) {
@@ -343,6 +348,67 @@ function printAtReports(reports) {
343
348
  for (const report of reports)
344
349
  console.warn(` Warning: ${report}`);
345
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
+ }
346
412
  export async function launchUp(opts = {}) {
347
413
  const projectDir = opts.projectDir ?? process.cwd();
348
414
  // Publication context (D-58): validated and normalized before anything is
@@ -392,6 +458,28 @@ export async function launchUp(opts = {}) {
392
458
  const startSet = new Set(selection.start);
393
459
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
394
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);
395
483
  // 2a. Host capabilities are granted or refused, never provisioned (D-44,
396
484
  // PROVIDERS.md §11). This provider runs processes directly on the host and
397
485
  // grants none of them, so a component with a required capability is
@@ -407,21 +495,8 @@ export async function launchUp(opts = {}) {
407
495
  // 2a-bis. A required `https-origin` (D-60 rule 5, PROVIDERS.md §10 item 5)
408
496
  // is satisfied by the publication context when its scheme is https —
409
497
  // supplied on this run, or recorded by an earlier one and preserved on
410
- // omission, which is why state is loaded here rather than after the
411
- // refusals (loading writes nothing; the first write is `ensureDirs`).
412
- // Otherwise it is refused in the same way as a host capability: starting
413
- // the component anyway is the silent success the type removes.
414
- let state = await loadState(projectDir);
415
- if (!state) {
416
- state = initState(launch.name, launchfileContent);
417
- }
418
- // Publication context (D-58), the same preservation rule the docker provider
419
- // applies: a supplied value replaces the recorded one — the derived $app.*
420
- // env recomputes below from it (D-49) — while omission keeps what is
421
- // recorded, so a later plain `up` cannot silently flip a proxied deployment
422
- // back to localhost.
423
- if (suppliedAppUrl !== undefined)
424
- 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.
425
500
  if (applyHttpsOriginRefusals(launch, state.appUrl) === "none-left") {
426
501
  console.error("Every selected component requires a public HTTPS origin this provider cannot supply.");
427
502
  process.exit(1);
@@ -467,9 +542,22 @@ export async function launchUp(opts = {}) {
467
542
  }
468
543
  // The optional mood of `https-origin` (D-60 rule 6): satisfied, it is
469
544
  // wired like any other resource at step 8; unsatisfied, the component
470
- // still runs, its set_env is absent, and the shortfall is named.
471
- 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
+ }
472
559
  continue;
560
+ }
473
561
  for (const sup of c.supports ?? []) {
474
562
  if (sup.type !== HTTPS_ORIGIN)
475
563
  continue;
@@ -610,7 +698,7 @@ export async function launchUp(opts = {}) {
610
698
  for (const key of storageIndex.unusedKeys(usedStorageKeys)) {
611
699
  console.warn(` Warning: --storage ${key} matches no \`content: operator\` volume — ignored`);
612
700
  }
613
- // 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.
614
702
  // 4. Ensure directories
615
703
  await ensureDirs(projectDir);
616
704
  // 5. Generate secrets
@@ -712,10 +800,17 @@ export async function launchUp(opts = {}) {
712
800
  // 7. Allocate ports
713
801
  const componentPorts = await allocatePorts(launch.components, launch.name, state.ports);
714
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);
715
808
  // 8. Build resolver context (including $app.* properties from D-33). A
716
809
  // satisfied `https-origin` registers `url` — the same string as `$app.url`
717
810
  // (D-60 rule 4) — so its set_env resolves like any provisioned resource's.
718
- 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);
719
814
  // `$app.endpoints.<name>.*` resolves "" here (D-63 rule 4, #294). Said
720
815
  // once per `up`, and only when the file asks, so the empty value is not a
721
816
  // silent one (PROVIDERS.md §10 item 8).
@@ -803,25 +898,20 @@ export async function launchUp(opts = {}) {
803
898
  await saveState(projectDir, state);
804
899
  if (opts.dryRun) {
805
900
  console.log("\n[dry-run] Would now run build, release, and start commands.");
806
- printSummary(launch, componentPorts, resourceMap);
901
+ printSummary(launch.name, componentPorts, state, "would be reachable at");
807
902
  return;
808
903
  }
809
- // 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.
810
907
  if (!opts.noBuild) {
811
- for (const [name, component] of Object.entries(launch.components)) {
812
- const prepare = resolveSourcePrepareCommand(component);
813
- const cmd = prepare?.command ?? pm?.installCommand;
814
- if (cmd) {
815
- console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
816
- await shellScript(cmd, {
817
- cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
818
- env: allEnvs[name],
819
- // Installs/compiles routinely exceed the 2-minute shell default;
820
- // honor a declared timeout, else allow 10 minutes.
821
- timeout: declaredTimeout(prepare?.timeout, `prepare [${name}]`) ?? 600_000,
822
- });
823
- }
824
- }
908
+ await runSourcePrepare(launch, {
909
+ projectDir,
910
+ state,
911
+ envs: allEnvs,
912
+ fallbackCommand: pm?.installCommand,
913
+ labelComponents: componentNames.length > 1,
914
+ });
825
915
  }
826
916
  // 15. Run release commands (migrations) \u2014 mode-invariant (D-38)
827
917
  for (const [name, component] of Object.entries(launch.components)) {
@@ -865,24 +955,79 @@ export async function launchUp(opts = {}) {
865
955
  await saveState(projectDir, finalState);
866
956
  process.exit(0);
867
957
  });
868
- 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
+ }
869
987
  console.log("");
870
988
  console.log(` \u2713 All components started`);
871
- // Record spawned pids so `launch down` can stop them from another shell or
872
- // after this foreground session ends (closes #49). Backward compatible: the
873
- // field is optional and absent in pre-existing state files.
874
- state.processes = pm2.getRecordedProcesses();
875
989
  // 17. Print summary
876
- printSummary(launch, componentPorts, resourceMap);
990
+ printSummary(launch.name, componentPorts, state);
877
991
  printAtReports(atReports(launch, componentPorts, suppliedAppUrl));
878
- // Save final state (now including recorded pids)
879
- await saveState(projectDir, state);
880
992
  }
881
- function printSummary(launch, ports, _resources) {
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
+ * `verb` is the phrase between the label and the address; a dry run passes
1014
+ * "would be reachable at" because nothing has started.
1015
+ * Pure, so the placement of the supplied URL is testable without a launch.
1016
+ */
1017
+ export function summaryLines(appName, ports, publication, verb = "is running at") {
1018
+ return Object.entries(ports).map(([name, port]) => {
1019
+ const label = name === "default" ? appName : name;
1020
+ return ` ${label} ${verb} ${componentAddress(name, port, publication)}`;
1021
+ });
1022
+ }
1023
+ /** The "Components:" lines `status` prints, one per `ports` key. */
1024
+ export function statusLines(ports, publication) {
1025
+ return Object.entries(ports).map(([name, port]) => ` ${name}: ${componentAddress(name, port, publication)}`);
1026
+ }
1027
+ function printSummary(appName, ports, publication, verb) {
882
1028
  console.log("");
883
- for (const [name, port] of Object.entries(ports)) {
884
- const label = name === "default" ? launch.name : name;
885
- console.log(` ${label} is running at http://localhost:${port}`);
1029
+ for (const line of summaryLines(appName, ports, publication, verb)) {
1030
+ console.log(line);
886
1031
  }
887
1032
  console.log("\n Press Ctrl+C to stop all processes.");
888
1033
  }
@@ -957,8 +1102,8 @@ export async function launchStatus(opts = {}) {
957
1102
  console.log(`Updated: ${state.updatedAt}`);
958
1103
  if (Object.keys(state.ports).length > 0) {
959
1104
  console.log("\nComponents:");
960
- for (const [name, port] of Object.entries(state.ports)) {
961
- console.log(` ${name}: http://localhost:${port}`);
1105
+ for (const line of statusLines(state.ports, state)) {
1106
+ console.log(line);
962
1107
  }
963
1108
  }
964
1109
  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/shell.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * own app (`commands:`, `health:`, `release:`), where shell syntax is the
13
13
  * documented contract. Never build one of these by interpolation.
14
14
  */
15
- import { exec as cpExec, execFile as cpExecFile, } from "node:child_process";
15
+ import { execFile as cpExecFile, } from "node:child_process";
16
16
  import { redactSecrets } from "./redact.js";
17
17
  const DEFAULT_TIMEOUT_MS = 120_000;
18
18
  const MAX_BUFFER = 10 * 1024 * 1024;
@@ -35,11 +35,10 @@ function failure(display, result) {
35
35
  return Object.assign(new Error(`Command failed: ${redactSecrets(display)}\n${redactSecrets(result.stderr)}`), { result });
36
36
  }
37
37
  /**
38
- * Run a command with an argument array. Arguments reach the OS directly, so
39
- * they are never parsed as shell syntax.
38
+ * Spawn `cmd` with `args` via `execFile` and settle with a structured result.
39
+ * `display` is what the user sees echoed and what a failure message names.
40
40
  */
41
- export async function shell(cmd, args, opts = {}) {
42
- const display = [cmd, ...args].join(" ");
41
+ function run(cmd, args, display, opts) {
43
42
  if (!opts.silent) {
44
43
  // An argument can carry a resolved secret (a generated DB password, a
45
44
  // credential-bearing URL). Scrub before echo.
@@ -61,6 +60,13 @@ export async function shell(cmd, args, opts = {}) {
61
60
  });
62
61
  });
63
62
  }
63
+ /**
64
+ * Run a command with an argument array. Arguments reach the OS directly, so
65
+ * they are never parsed as shell syntax.
66
+ */
67
+ export async function shell(cmd, args, opts = {}) {
68
+ return run(cmd, args, [cmd, ...args].join(" "), opts);
69
+ }
64
70
  /** Run a command with an argument array, return true if exit code is 0. */
65
71
  export async function shellOk(cmd, args, opts) {
66
72
  const result = await shell(cmd, args, {
@@ -79,23 +85,9 @@ export async function shellOk(cmd, args, opts) {
79
85
  * verbatim — building one by interpolating a value makes it injectable.
80
86
  */
81
87
  export async function shellScript(command, opts = {}) {
82
- if (!opts.silent) {
83
- console.log(` $ ${redactSecrets(command)}`);
84
- }
85
- const execOpts = {
86
- cwd: opts.cwd,
87
- env: opts.env ? { ...process.env, ...opts.env } : undefined,
88
- timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
89
- maxBuffer: MAX_BUFFER,
90
- };
91
- return new Promise((resolve, reject) => {
92
- cpExec(command, execOpts, (error, stdout, stderr) => {
93
- const result = toResult(error, stdout, stderr);
94
- if (error && !opts.allowFailure)
95
- reject(failure(command, result));
96
- else
97
- resolve(result);
98
- });
99
- });
88
+ // The command string is one argv element of an explicit `/bin/sh -c`, the
89
+ // same shape `@launchfile/docker` uses. The shell parses the author's
90
+ // string; nothing this provider adds is ever spliced into it.
91
+ return run("/bin/sh", ["-c", command], command, opts);
100
92
  }
101
93
  //# sourceMappingURL=shell.js.map
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.12.0",
3
+ "version": "0.14.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,15 @@
36
36
  "directory": "providers/macos-dev"
37
37
  },
38
38
  "dependencies": {
39
- "@launchfile/sdk": "^0.12.0",
39
+ "@launchfile/sdk": "^0.14.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.15",
44
+ "@launchfile/docker": "^0.14.0",
45
+ "@types/bun": "^1.4.2",
45
46
  "@types/semver": "^7.7.1",
46
47
  "typescript": "^7.0.2",
47
- "vitest": "^4.1.3"
48
+ "vitest": "^5.0.3"
48
49
  }
49
50
  }