@launchfile/macos-dev 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,8 +26,10 @@ export function computeAppProperties(launch, componentPorts) {
26
26
  for (const [name, component] of Object.entries(launch.components)) {
27
27
  // Only endpoints explicitly marked `exposed: true` are reachable from
28
28
  // outside the host (D-27), so only they can be the app's public address.
29
- // The docker provider derives `$app.*` by the same rule — `$app.url` is a
30
- // portable value and the two providers must not disagree on it (P-5).
29
+ // This provider has no orchestrator-facing publication channel (#294), so
30
+ // $app.* always comes from its own routing strategy. The docker provider
31
+ // answers by this same rule only when no orchestrator supplies an appUrl
32
+ // (PROVIDERS.md §7).
31
33
  const hasExposed = component.provides?.some((p) => p.exposed === true) ?? false;
32
34
  if (hasExposed && componentPorts[name]) {
33
35
  primaryPort = componentPorts[name];
@@ -26,6 +26,12 @@ export interface LaunchUpOpts {
26
26
  * so both yield the identical running topology (P-5). Empty = all components.
27
27
  */
28
28
  components?: string[];
29
+ /**
30
+ * Host paths for `content: operator` volumes (D-50 rule 1), keyed as the
31
+ * operator typed them: `<volume>`, or `<component>.<volume>` where a volume
32
+ * name is ambiguous. Relative paths resolve against the current directory.
33
+ */
34
+ storage?: Record<string, string>;
29
35
  }
30
36
  /**
31
37
  * Components this provider must refuse, mapped to the capabilities it cannot
package/dist/provider.js CHANGED
@@ -4,9 +4,10 @@
4
4
  * Reads a Launchfile, provisions resources, resolves env vars,
5
5
  * installs runtimes, and starts all components.
6
6
  */
7
+ import { accessSync, constants as fsConstants } from "node:fs";
7
8
  import { readFile } from "node:fs/promises";
8
- import { join } from "node:path";
9
- import { readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, unsuppliedRequiredEnv, } from "@launchfile/sdk";
9
+ import { join, resolve as resolvePath } from "node:path";
10
+ import { indexOperatorStoragePaths, MissingOperatorStoragePathError, readLaunch, resolveSourcePrepareCommand, resolveSourceRunCommand, selectionClosure, UnboundOperatorStorageError, unsuppliedRequiredEnv, } from "@launchfile/sdk";
10
11
  import { checkPrereqs } from "./prereqs.js";
11
12
  import { loadState, initState, saveState, ensureDirs } from "./state.js";
12
13
  import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
@@ -44,6 +45,16 @@ function declaredTimeout(timeout, label) {
44
45
  throw new Error(`${label}: ${err instanceof Error ? err.message : String(err)}`);
45
46
  }
46
47
  }
48
+ /** Whether a path exists and this process can read it (D-50 rule 2, row 3). */
49
+ function isReadable(path) {
50
+ try {
51
+ accessSync(path, fsConstants.R_OK);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
47
58
  /**
48
59
  * Components this provider must refuse, mapped to the capabilities it cannot
49
60
  * grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
@@ -245,6 +256,69 @@ export async function launchUp(opts = {}) {
245
256
  console.error(` ${missingRequired[0].key}=<value> launch up`);
246
257
  process.exit(1);
247
258
  }
259
+ // 2d. Operator-supplied storage (D-50 rules 1–2), settled here — before
260
+ // state, directories, resources, ports, runtimes or processes exist, and so
261
+ // before `--dry-run` returns. A marked volume with no path, or with one that
262
+ // is not on disk, fails the launch: an empty directory where the operator's
263
+ // library belongs is D-52's fabrication in storage form, and this provider
264
+ // creates neither.
265
+ //
266
+ // Scoped to the components this provider will actually run. An artifact
267
+ // component is warned about and skipped at step 16, so refusing the whole
268
+ // launch over storage it will never read would be a refusal about nothing —
269
+ // the same reason `@launchfile/docker` only examines components it
270
+ // translates. Its volumes simply stay unprovisioned at step 11.
271
+ //
272
+ // Unlike the host-capability refusal above, this throws rather than dropping
273
+ // the component: `@launchfile/docker` fails the whole launch for the same
274
+ // file, and one Launchfile must not yield two topologies (P-5).
275
+ const suppliedStorage = opts.storage
276
+ ? Object.fromEntries(Object.entries(opts.storage).map(([key, path]) => [key, resolvePath(path)]))
277
+ : undefined;
278
+ const storageIndex = indexOperatorStoragePaths(launch, suppliedStorage);
279
+ const usedStorageKeys = new Set();
280
+ const unboundVolumes = [];
281
+ const storageBinds = [];
282
+ const operatorStorage = {};
283
+ for (const [name, component] of Object.entries(launch.components)) {
284
+ if (!isSourceRunnable(component))
285
+ continue;
286
+ for (const [volName, vol] of Object.entries(component.storage ?? {})) {
287
+ if (vol.content !== "operator")
288
+ continue;
289
+ const supplied = storageIndex.lookup(name, volName);
290
+ if (!supplied) {
291
+ unboundVolumes.push({
292
+ component: name,
293
+ volume: volName,
294
+ flag: storageIndex.flagFor(name, volName),
295
+ });
296
+ continue;
297
+ }
298
+ usedStorageKeys.add(supplied.key);
299
+ storageBinds.push({
300
+ component: name,
301
+ volume: volName,
302
+ key: supplied.key,
303
+ hostPath: supplied.path,
304
+ containerPath: vol.path,
305
+ });
306
+ (operatorStorage[name] ??= {})[volName] = supplied.path;
307
+ }
308
+ }
309
+ if (unboundVolumes.length > 0) {
310
+ throw new UnboundOperatorStorageError(unboundVolumes);
311
+ }
312
+ const unreadableBinds = storageBinds.filter((bind) => !isReadable(bind.hostPath));
313
+ if (unreadableBinds.length > 0) {
314
+ throw new MissingOperatorStoragePathError(unreadableBinds);
315
+ }
316
+ // A supplied key that bound nothing would otherwise vanish without a trace,
317
+ // so a typo'd name surfaces here. Row 4 keeps unmarked volumes untouched, so
318
+ // a key naming one is unused too.
319
+ for (const key of storageIndex.unusedKeys(usedStorageKeys)) {
320
+ console.warn(` Warning: --storage ${key} matches no \`content: operator\` volume — ignored`);
321
+ }
248
322
  // 3. Load or init state
249
323
  let state = await loadState(projectDir);
250
324
  if (!state) {
@@ -348,7 +422,7 @@ export async function launchUp(opts = {}) {
348
422
  // so it can be injected as $storage.<name>.path (D-39). Scoped per component.
349
423
  const componentStorage = {};
350
424
  for (const [name, component] of Object.entries(launch.components)) {
351
- const volumeMap = await provisionStorage(component.storage, name, projectDir);
425
+ const volumeMap = await provisionStorage(component.storage, name, projectDir, operatorStorage[name]);
352
426
  const storageCtx = {};
353
427
  for (const [volName, localPath] of Object.entries(volumeMap)) {
354
428
  storageCtx[volName] = { path: localPath };
@@ -388,6 +462,9 @@ export async function launchUp(opts = {}) {
388
462
  }
389
463
  }
390
464
  // 13. Save state before build (in case build fails, we still have resource state)
465
+ // The bound operator paths ride along so `env` can report the directory the
466
+ // app actually reads (D-50); a later `up` still has to supply them again.
467
+ state.operatorStorage = operatorStorage;
391
468
  await saveState(projectDir, state);
392
469
  if (opts.dryRun) {
393
470
  console.log("\n[dry-run] Would now run build, release, and start commands.");
@@ -590,8 +667,10 @@ export async function launchEnv(opts = {}) {
590
667
  continue;
591
668
  // Resolved storage paths (D-39) — computed, not provisioned (no mkdir):
592
669
  // `launchfile env` only prints, and the dirs already exist from `up`.
670
+ // A `content: operator` volume reads back the host path `up` bound
671
+ // (D-50), so what prints here is what the running app was given.
593
672
  const storageCtx = {};
594
- for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
673
+ for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir, state.operatorStorage?.[name]))) {
595
674
  storageCtx[volName] = { path: localPath };
596
675
  }
597
676
  const { env, unsupplied } = resolveComponentEnv(component, context, resourceMap, storageCtx);
package/dist/state.d.ts CHANGED
@@ -60,6 +60,18 @@ export interface LaunchState {
60
60
  * compatibility: older state files omit it and load as a first run.
61
61
  */
62
62
  generatedEnv?: Record<string, string>;
63
+ /**
64
+ * Host paths bound to `content: operator` volumes on the last successful
65
+ * `up` (D-50), as `component → volume → path`. Recorded so `env` reports
66
+ * the directory the app actually reads, not the `.launchfile/` path an
67
+ * unmarked volume would have taken.
68
+ *
69
+ * It is not a substitute for supplying the paths: a later `up` without them
70
+ * refuses again, matching `@launchfile/docker`, which persists `appUrl` but
71
+ * never its storage paths. Optional for backward compatibility: state files
72
+ * written before this existed omit it.
73
+ */
74
+ operatorStorage?: Record<string, Record<string, string>>;
63
75
  }
64
76
  export declare function hashLaunchfile(content: string): string;
65
77
  /** Load state from disk, or return null if none exists */
package/dist/storage.d.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  * Maps named volumes to local directories under .launchfile/, and exposes the
5
5
  * resolved local path per volume so the provider can inject it as
6
6
  * `$storage.<name>.path` (D-39).
7
+ *
8
+ * A `content: operator` volume (D-50) is the exception: its content comes from
9
+ * a directory the operator already keeps, so this provider names that path and
10
+ * creates nothing. `up` refuses before provisioning when such a volume has no
11
+ * path, or has one that is not there.
7
12
  */
8
13
  import type { StorageVolume } from "@launchfile/sdk";
9
14
  /**
@@ -13,15 +18,18 @@ import type { StorageVolume } from "@launchfile/sdk";
13
18
  * effects, so callers that only need the resolved paths (e.g. `launchfile env`)
14
19
  * can use it without provisioning. Persistent volumes live under
15
20
  * `.launchfile/storage/<component>/<name>`; ephemeral ones under `.../tmp/...`.
21
+ *
22
+ * `operatorPaths` carries the host paths supplied for this component's
23
+ * `content: operator` volumes, keyed by volume name.
16
24
  */
17
- export declare function storagePaths(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string): Record<string, string>;
25
+ export declare function storagePaths(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string, operatorPaths?: Readonly<Record<string, string>>): Record<string, string>;
18
26
  /**
19
27
  * Create the local directories for a component's storage volumes and return the
20
28
  * volume-name → local-path map (the home-#3 value the resolver injects as
21
29
  * `$storage.<name>.path`, D-39). Previously the return was keyed by container
22
30
  * path and discarded at the call site — the path is now captured and delivered.
23
31
  */
24
- export declare function provisionStorage(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string): Promise<Record<string, string>>;
32
+ export declare function provisionStorage(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string, operatorPaths?: Readonly<Record<string, string>>): Promise<Record<string, string>>;
25
33
  /** Clean up ephemeral (non-persistent) storage for a component */
26
34
  export declare function cleanEphemeralStorage(storage: Record<string, StorageVolume> | undefined, componentName: string, projectDir: string): Promise<void>;
27
35
  /** Clean up all storage (persistent + ephemeral) for a component */
package/dist/storage.js CHANGED
@@ -4,6 +4,11 @@
4
4
  * Maps named volumes to local directories under .launchfile/, and exposes the
5
5
  * resolved local path per volume so the provider can inject it as
6
6
  * `$storage.<name>.path` (D-39).
7
+ *
8
+ * A `content: operator` volume (D-50) is the exception: its content comes from
9
+ * a directory the operator already keeps, so this provider names that path and
10
+ * creates nothing. `up` refuses before provisioning when such a volume has no
11
+ * path, or has one that is not there.
7
12
  */
8
13
  import { mkdir, rm } from "node:fs/promises";
9
14
  import { join } from "node:path";
@@ -15,12 +20,26 @@ const STATE_DIR = ".launchfile";
15
20
  * effects, so callers that only need the resolved paths (e.g. `launchfile env`)
16
21
  * can use it without provisioning. Persistent volumes live under
17
22
  * `.launchfile/storage/<component>/<name>`; ephemeral ones under `.../tmp/...`.
23
+ *
24
+ * `operatorPaths` carries the host paths supplied for this component's
25
+ * `content: operator` volumes, keyed by volume name.
18
26
  */
19
- export function storagePaths(storage, componentName, projectDir) {
27
+ export function storagePaths(storage, componentName, projectDir, operatorPaths = {}) {
20
28
  if (!storage)
21
29
  return {};
22
30
  const volumeMap = {};
23
31
  for (const [name, volume] of Object.entries(storage)) {
32
+ if (volume.content === "operator") {
33
+ // D-50 row 1: the operator's directory IS the volume — this provider
34
+ // runs processes on the host, so binding it means naming that path.
35
+ // A marked volume with nothing supplied is left out of the map
36
+ // entirely rather than handed a `.launchfile/` path the operator's
37
+ // content is not in; `up` refuses that case before it gets here.
38
+ const supplied = operatorPaths[name];
39
+ if (supplied)
40
+ volumeMap[name] = supplied;
41
+ continue;
42
+ }
24
43
  const persistent = volume.persistent !== false; // default true
25
44
  const subdir = persistent ? "storage" : "tmp";
26
45
  volumeMap[name] = join(projectDir, STATE_DIR, subdir, componentName, name);
@@ -33,9 +52,15 @@ export function storagePaths(storage, componentName, projectDir) {
33
52
  * `$storage.<name>.path`, D-39). Previously the return was keyed by container
34
53
  * path and discarded at the call site — the path is now captured and delivered.
35
54
  */
36
- export async function provisionStorage(storage, componentName, projectDir) {
37
- const volumeMap = storagePaths(storage, componentName, projectDir);
38
- for (const localPath of Object.values(volumeMap)) {
55
+ export async function provisionStorage(storage, componentName, projectDir, operatorPaths = {}) {
56
+ const volumeMap = storagePaths(storage, componentName, projectDir, operatorPaths);
57
+ for (const [name, localPath] of Object.entries(volumeMap)) {
58
+ // A `content: operator` volume is never created (D-50 rule 2, row 3):
59
+ // an empty directory where the operator's library belongs is the
60
+ // failure the marker exists to catch, and minting one here would
61
+ // reintroduce it through the channel's own flag.
62
+ if (storage?.[name]?.content === "operator")
63
+ continue;
39
64
  await mkdir(localPath, { recursive: true });
40
65
  }
41
66
  return volumeMap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchfile/macos-dev",
3
- "version": "0.4.0",
3
+ "version": "0.7.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.4.0",
39
+ "@launchfile/sdk": "^0.7.0",
40
40
  "semver": "^7.7.4"
41
41
  },
42
42
  "devDependencies": {