@launchfile/macos-dev 0.10.0 → 0.12.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.
@@ -59,8 +59,13 @@ export declare function computeAppEndpoints(launch: NormalizedLaunch): Record<st
59
59
  * every named published endpoint resolving `""` — and the `uses` each
60
60
  * resource entry declares (`declaredUses`), which the resolver reads to
61
61
  * resolve `$<resource>.<use>.<property>` strictly.
62
+ *
63
+ * `declared` carries the declared components, whose `provides` entries are
64
+ * what the named-endpoint form `$components.<name>.<endpoint>.<prop>`
65
+ * (D-6, D-66) resolves against. Omit it and only the primary
66
+ * `url`/`host`/`port` are registered.
62
67
  */
63
- export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>, app: Record<string, string | number>, appEndpoints?: Record<string, AppEndpointProperties>, uses?: Record<string, readonly string[]>): ResolverContext;
68
+ export declare function buildResolverContext(resourceMap: Record<string, ResourceProperties>, componentPorts: Record<string, number>, secrets: Record<string, string>, app: Record<string, string | number>, appEndpoints?: Record<string, AppEndpointProperties>, uses?: Record<string, readonly string[]>, declared?: Record<string, NormalizedComponent>): ResolverContext;
64
69
  /**
65
70
  * The use keys each resource entry declares (`db`, `db.cache`), keyed like
66
71
  * the resource namespace (`name ?? type`, app-global). Same-name entries pool
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { writeFile, mkdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
- import { deriveAppUrlProperties, resolveExpression, isExpression, parseUseKey, suppliedAppProperties, UNPUBLISHED_APP_ENDPOINT, unsuppliedRequiredEnv, useKeys, } from "@launchfile/sdk";
9
+ import { deriveAppUrlProperties, endpointProperties, resolveExpression, isExpression, parseUseKey, suppliedAppProperties, UNPUBLISHED_APP_ENDPOINT, unsuppliedRequiredEnv, useKeys, } from "@launchfile/sdk";
10
10
  import { declaredPrimaryComponent, wireHttpsOrigins } from "./https-origin.js";
11
11
  import { getProvisioner } from "./resources/index.js";
12
12
  import { coveredUses, namedDatabases, withCoveredUses } from "./resources/uses.js";
@@ -95,6 +95,25 @@ export function computeAppEndpoints(launch) {
95
95
  }
96
96
  return endpoints;
97
97
  }
98
+ /**
99
+ * The `provides` entries this provider can name a reachable port for, or
100
+ * `undefined` when it can name none.
101
+ *
102
+ * macos-dev allocates exactly one host port per component and hands it to the
103
+ * process as `PORT`. That port belongs to whichever declared endpoint the
104
+ * allocator anchored on; the rest are unallocated, and the process binds the
105
+ * ports they declare. When the component's preferred port was taken, the
106
+ * allocator moves it to a free port outside the declared set — then no declared
107
+ * endpoint can be named at that port, so the component reports no per-endpoint
108
+ * properties rather than an endpoint address nothing listens on (L-4). A
109
+ * container provider has no such collapse: it binds every declared port.
110
+ */
111
+ function allocatedEndpoints(component, allocatedPort) {
112
+ const provides = component?.provides;
113
+ if (!provides?.some((entry) => entry.port === allocatedPort))
114
+ return undefined;
115
+ return provides;
116
+ }
98
117
  /**
99
118
  * Build a ResolverContext from provisioned resources, component ports,
100
119
  * secrets, (D-33) the platform-injected app properties, (D-63) the
@@ -102,8 +121,13 @@ export function computeAppEndpoints(launch) {
102
121
  * every named published endpoint resolving `""` — and the `uses` each
103
122
  * resource entry declares (`declaredUses`), which the resolver reads to
104
123
  * resolve `$<resource>.<use>.<property>` strictly.
124
+ *
125
+ * `declared` carries the declared components, whose `provides` entries are
126
+ * what the named-endpoint form `$components.<name>.<endpoint>.<prop>`
127
+ * (D-6, D-66) resolves against. Omit it and only the primary
128
+ * `url`/`host`/`port` are registered.
105
129
  */
106
- export function buildResolverContext(resourceMap, componentPorts, secrets, app, appEndpoints = {}, uses = {}) {
130
+ export function buildResolverContext(resourceMap, componentPorts, secrets, app, appEndpoints = {}, uses = {}, declared) {
107
131
  // Build components map from ports
108
132
  const components = {};
109
133
  for (const [name, port] of Object.entries(componentPorts)) {
@@ -111,6 +135,7 @@ export function buildResolverContext(resourceMap, componentPorts, secrets, app,
111
135
  url: `http://localhost:${port}`,
112
136
  host: "localhost",
113
137
  port,
138
+ ...endpointProperties(allocatedEndpoints(declared?.[name], port), "localhost"),
114
139
  };
115
140
  }
116
141
  // Build named resources map
@@ -195,7 +220,7 @@ export async function resourceMapFromState(launch, state, projectDir) {
195
220
  export function resolverContextFor(launch, resourceMap, state) {
196
221
  const appProperties = computeAppProperties(launch, state.ports, state.appUrl);
197
222
  wireHttpsOrigins(launch, resourceMap, state.appUrl);
198
- return buildResolverContext(resourceMap, state.ports, state.secrets, appProperties, computeAppEndpoints(launch), declaredUses(launch));
223
+ return buildResolverContext(resourceMap, state.ports, state.secrets, appProperties, computeAppEndpoints(launch), declaredUses(launch), launch.components);
199
224
  }
200
225
  /**
201
226
  * Resolve all environment variables for a single component.
@@ -16,9 +16,12 @@
16
16
  * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
17
  * exists. If it's dead, we skip (already stopped).
18
18
  * 2. **Identity** — we compare the recorded spawn time against the live
19
- * process's actual start time via `ps -o lstart= -p <pid>`. If the live
20
- * process started meaningfully later than we recorded, the pid was
21
- * recycled and we REFUSE to signal it.
19
+ * process's actual start time via `ps -o lstart= -p <pid>`. If the two
20
+ * are further apart than the tolerance window, in either direction, the
21
+ * record does not describe this process and we REFUSE to signal it. A
22
+ * later live start means the pid was recycled; an earlier one means the
23
+ * record itself is untrustworthy (a backward clock jump between spawn and
24
+ * `down`, or a state file carried over from another run).
22
25
  *
23
26
  * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
27
  * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
@@ -76,7 +79,8 @@ export declare const realSignalFns: SignalFns;
76
79
  * - "alive-verified": process exists AND start time is consistent → safe to signal
77
80
  * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
78
81
  * - "dead": no such process (ESRCH) → already stopped, skip
79
- * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
82
+ * - "mismatch": process exists but its start time is outside the tolerance
83
+ * window on either side → the record does not describe it, DO NOT signal
80
84
  */
81
85
  export declare function checkIdentity(rec: RecordedProcess, fns: SignalFns): Promise<"alive-verified" | "alive-unverified" | "dead" | "mismatch">;
82
86
  /**
@@ -16,9 +16,12 @@
16
16
  * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
17
  * exists. If it's dead, we skip (already stopped).
18
18
  * 2. **Identity** — we compare the recorded spawn time against the live
19
- * process's actual start time via `ps -o lstart= -p <pid>`. If the live
20
- * process started meaningfully later than we recorded, the pid was
21
- * recycled and we REFUSE to signal it.
19
+ * process's actual start time via `ps -o lstart= -p <pid>`. If the two
20
+ * are further apart than the tolerance window, in either direction, the
21
+ * record does not describe this process and we REFUSE to signal it. A
22
+ * later live start means the pid was recycled; an earlier one means the
23
+ * record itself is untrustworthy (a backward clock jump between spawn and
24
+ * `down`, or a state file carried over from another run).
22
25
  *
23
26
  * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
27
  * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
@@ -33,10 +36,12 @@
33
36
  */
34
37
  import { execFile } from "node:child_process";
35
38
  /**
36
- * Identity tolerance: how much later than `startedAt` a live process may report
37
- * having started before we treat it as a recycled pid. `ps lstart` rounds to
38
- * whole seconds and there's scheduling slop between our `Date.now()` snapshot
39
- * and the kernel's recorded start, so we allow a few seconds of forward drift.
39
+ * Identity tolerance: how far a live process's reported start time may sit from
40
+ * `startedAt`, on either side, before we stop believing the record describes
41
+ * that process. `spawn()` forks before we timestamp the record and `ps lstart`
42
+ * rounds down to whole seconds, so a genuine record reads a second or so
43
+ * *earlier* than `startedAt`; scheduling slop covers the rest. The window is
44
+ * symmetric because a record can be wrong in either direction.
40
45
  */
41
46
  const START_TIME_TOLERANCE_MS = 3000;
42
47
  /** Default real implementation backed by `process.kill` and `ps`. */
@@ -74,7 +79,8 @@ function queryStartTime(pid) {
74
79
  * - "alive-verified": process exists AND start time is consistent → safe to signal
75
80
  * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
76
81
  * - "dead": no such process (ESRCH) → already stopped, skip
77
- * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
82
+ * - "mismatch": process exists but its start time is outside the tolerance
83
+ * window on either side → the record does not describe it, DO NOT signal
78
84
  */
79
85
  export async function checkIdentity(rec, fns) {
80
86
  // Liveness probe.
@@ -95,9 +101,11 @@ export async function checkIdentity(rec, fns) {
95
101
  const recordedStart = Date.parse(rec.startedAt);
96
102
  if (Number.isNaN(recordedStart))
97
103
  return "alive-unverified";
98
- // If the live process started meaningfully *after* we recorded the spawn,
99
- // the original exited and the pid was recycled. Refuse to signal it.
100
- if (liveStart > recordedStart + START_TIME_TOLERANCE_MS)
104
+ // The live start time must sit within tolerance of the record on both sides.
105
+ // Later than the record: the original exited and the pid was recycled.
106
+ // Earlier than the record: the record cannot be describing this process.
107
+ // Either way the pid is not ours to signal.
108
+ if (Math.abs(liveStart - recordedStart) > START_TIME_TOLERANCE_MS)
101
109
  return "mismatch";
102
110
  return "alive-verified";
103
111
  }
@@ -171,6 +171,19 @@ export declare function refusedResourceUses(launch: NormalizedLaunch): Map<strin
171
171
  * removal IS the refusal.
172
172
  */
173
173
  export declare function applyResourceUseRefusals(launch: NormalizedLaunch): "ok" | "none-left";
174
+ /**
175
+ * The mandatory report for every `provides` entry that declares `at:` (D-68
176
+ * rule 5), one line per declaring entry. This provider starts each process on
177
+ * a local port with nothing in front that routes by host name, so every
178
+ * request reaches the listener with its `Host` intact and only name resolution
179
+ * is left to the operator. It launches and reports — never a silent launch,
180
+ * and never a refusal.
181
+ *
182
+ * `ports` maps a component to the local port its process listens on; an
183
+ * absent entry means the port is not known yet. Under a supplied publication
184
+ * URL (D-58) the names are whoever routes that URL's to send here (rule 7).
185
+ */
186
+ export declare function atReports(launch: NormalizedLaunch, ports?: Record<string, number>, suppliedAppUrl?: string): string[];
174
187
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
175
188
  export declare function launchDown(opts?: {
176
189
  destroy?: boolean;
package/dist/provider.js CHANGED
@@ -7,7 +7,7 @@
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, CERTIFICATE, certificateBindings, indexOperatorStoragePaths, MissingOperatorStoragePathError, normalizeAppUrl, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, appEndpointReferences, useKeys, } from "@launchfile/sdk";
11
11
  import { checkPrereqs } from "./prereqs.js";
12
12
  import { HTTPS_ORIGIN, httpsOriginSatisfied, httpsOriginShortfall, } from "./https-origin.js";
13
13
  import { loadState, initState, saveState, ensureDirs, withRecordedDbIndexes } from "./state.js";
@@ -306,6 +306,43 @@ export function applyResourceUseRefusals(launch) {
306
306
  launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => !refused.has(n)));
307
307
  return Object.keys(launch.components).length === 0 ? "none-left" : "ok";
308
308
  }
309
+ /**
310
+ * The mandatory report for every `provides` entry that declares `at:` (D-68
311
+ * rule 5), one line per declaring entry. This provider starts each process on
312
+ * a local port with nothing in front that routes by host name, so every
313
+ * request reaches the listener with its `Host` intact and only name resolution
314
+ * is left to the operator. It launches and reports — never a silent launch,
315
+ * and never a refusal.
316
+ *
317
+ * `ports` maps a component to the local port its process listens on; an
318
+ * absent entry means the port is not known yet. Under a supplied publication
319
+ * URL (D-58) the names are whoever routes that URL's to send here (rule 7).
320
+ */
321
+ export function atReports(launch, ports = {}, suppliedAppUrl) {
322
+ const host = suppliedAppUrl === undefined ? "localhost" : new URL(suppliedAppUrl).hostname;
323
+ return atDeclarations(launch).map((declaration) => {
324
+ const names = declaration.values
325
+ .map((value) => (value === AT_APP_HOST ? host : `${value}.${host}`))
326
+ .join(", ");
327
+ const head = `${atEntryLabel(declaration)} answers at ${names} (\`at:\`, D-68)`;
328
+ if (suppliedAppUrl !== undefined) {
329
+ return (`${head} — this provider sets up no host names, and the supplied publication URL ` +
330
+ `(${suppliedAppUrl}) says nothing about them; whatever routes that URL must send each ` +
331
+ "name to this endpoint with the requested `Host` intact");
332
+ }
333
+ const port = ports[declaration.component];
334
+ const target = port === undefined ? "the component's local port" : `localhost:${port}`;
335
+ return (`${head} — this provider starts the process on a local port and sets up no host names. ` +
336
+ `Every request that reaches ${target} reaches the listener with its \`Host\` intact, so ` +
337
+ "map each name that does not resolve to this machine (hosts file or DNS), or use a " +
338
+ "provider that routes host names");
339
+ });
340
+ }
341
+ /** Print {@link atReports} the way this provider prints every warning. */
342
+ function printAtReports(reports) {
343
+ for (const report of reports)
344
+ console.warn(` Warning: ${report}`);
345
+ }
309
346
  export async function launchUp(opts = {}) {
310
347
  const projectDir = opts.projectDir ?? process.cwd();
311
348
  // Publication context (D-58): validated and normalized before anything is
@@ -415,6 +452,11 @@ export async function launchUp(opts = {}) {
415
452
  console.error("Every selected component requires a use of a resource this provider cannot cover.");
416
453
  process.exit(1);
417
454
  }
455
+ // 2a-sexies. A `provides` entry declaring `at:` is reported, not refused
456
+ // (D-68 rule 5). The report belongs at the end of provisioning, after the
457
+ // summary; a dry run never gets there, so it prints here.
458
+ if (opts.dryRun)
459
+ printAtReports(atReports(launch, {}, suppliedAppUrl));
418
460
  // An optional capability is not refused — the component runs, degraded.
419
461
  for (const [name, c] of Object.entries(launch.components)) {
420
462
  for (const sup of c.supports ?? []) {
@@ -832,6 +874,7 @@ export async function launchUp(opts = {}) {
832
874
  state.processes = pm2.getRecordedProcesses();
833
875
  // 17. Print summary
834
876
  printSummary(launch, componentPorts, resourceMap);
877
+ printAtReports(atReports(launch, componentPorts, suppliedAppUrl));
835
878
  // Save final state (now including recorded pids)
836
879
  await saveState(projectDir, state);
837
880
  }
@@ -866,7 +909,7 @@ export async function launchDown(opts = {}) {
866
909
  console.log(` ${o.component} was not running`);
867
910
  break;
868
911
  case "identity-mismatch":
869
- console.log(` Skipped ${o.component} (pid recycled — left untouched)`);
912
+ console.log(` Skipped ${o.component} (live process start time does not match the recorded one — left untouched)`);
870
913
  break;
871
914
  case "error":
872
915
  console.log(` Failed to stop ${o.component}: ${o.error}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.10.0",
3
+ "version": "0.12.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,7 +36,7 @@
36
36
  "directory": "providers/macos-dev"
37
37
  },
38
38
  "dependencies": {
39
- "@launchfile/sdk": "^0.10.0",
39
+ "@launchfile/sdk": "^0.12.0",
40
40
  "semver": "^7.7.4"
41
41
  },
42
42
  "devDependencies": {