@launchfile/macos-dev 0.1.4 → 0.3.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.
Files changed (124) hide show
  1. package/dist/bootstrap.d.ts +58 -0
  2. package/dist/bootstrap.js +224 -0
  3. package/dist/env-writer.d.ts +19 -13
  4. package/dist/env-writer.js +44 -8
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +1 -0
  7. package/dist/process-manager.d.ts +13 -0
  8. package/dist/process-manager.js +57 -2
  9. package/dist/process-stopper.d.ts +99 -0
  10. package/dist/process-stopper.js +165 -0
  11. package/dist/provider.d.ts +18 -0
  12. package/dist/provider.js +176 -70
  13. package/dist/shell.js +1 -1
  14. package/dist/state.d.ts +28 -0
  15. package/dist/storage.d.ts +16 -3
  16. package/dist/storage.js +23 -6
  17. package/package.json +9 -4
  18. package/CLAUDE.md +0 -37
  19. package/dist/__tests__/dry-run.test.d.ts +0 -2
  20. package/dist/__tests__/dry-run.test.d.ts.map +0 -1
  21. package/dist/__tests__/dry-run.test.js +0 -129
  22. package/dist/__tests__/dry-run.test.js.map +0 -1
  23. package/dist/__tests__/env-writer.test.d.ts +0 -2
  24. package/dist/__tests__/env-writer.test.d.ts.map +0 -1
  25. package/dist/__tests__/env-writer.test.js +0 -127
  26. package/dist/__tests__/env-writer.test.js.map +0 -1
  27. package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
  28. package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
  29. package/dist/__tests__/lockfile-detect.test.js +0 -75
  30. package/dist/__tests__/lockfile-detect.test.js.map +0 -1
  31. package/dist/__tests__/port-allocator.test.d.ts +0 -2
  32. package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
  33. package/dist/__tests__/port-allocator.test.js +0 -53
  34. package/dist/__tests__/port-allocator.test.js.map +0 -1
  35. package/dist/__tests__/secret-generator.test.d.ts +0 -2
  36. package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
  37. package/dist/__tests__/secret-generator.test.js +0 -26
  38. package/dist/__tests__/secret-generator.test.js.map +0 -1
  39. package/dist/__tests__/state.test.d.ts +0 -2
  40. package/dist/__tests__/state.test.d.ts.map +0 -1
  41. package/dist/__tests__/state.test.js +0 -30
  42. package/dist/__tests__/state.test.js.map +0 -1
  43. package/dist/cli.d.ts.map +0 -1
  44. package/dist/cli.js.map +0 -1
  45. package/dist/env-writer.d.ts.map +0 -1
  46. package/dist/env-writer.js.map +0 -1
  47. package/dist/health.d.ts.map +0 -1
  48. package/dist/health.js.map +0 -1
  49. package/dist/index.d.ts.map +0 -1
  50. package/dist/index.js.map +0 -1
  51. package/dist/lockfile-detect.d.ts.map +0 -1
  52. package/dist/lockfile-detect.js.map +0 -1
  53. package/dist/port-allocator.d.ts.map +0 -1
  54. package/dist/port-allocator.js.map +0 -1
  55. package/dist/prereqs.d.ts.map +0 -1
  56. package/dist/prereqs.js.map +0 -1
  57. package/dist/process-manager.d.ts.map +0 -1
  58. package/dist/process-manager.js.map +0 -1
  59. package/dist/provider.d.ts.map +0 -1
  60. package/dist/provider.js.map +0 -1
  61. package/dist/resources/index.d.ts.map +0 -1
  62. package/dist/resources/index.js.map +0 -1
  63. package/dist/resources/mysql.d.ts.map +0 -1
  64. package/dist/resources/mysql.js.map +0 -1
  65. package/dist/resources/postgres.d.ts.map +0 -1
  66. package/dist/resources/postgres.js.map +0 -1
  67. package/dist/resources/redis.d.ts.map +0 -1
  68. package/dist/resources/redis.js.map +0 -1
  69. package/dist/resources/sqlite.d.ts.map +0 -1
  70. package/dist/resources/sqlite.js.map +0 -1
  71. package/dist/resources/types.d.ts.map +0 -1
  72. package/dist/resources/types.js.map +0 -1
  73. package/dist/runtimes/bun.d.ts.map +0 -1
  74. package/dist/runtimes/bun.js.map +0 -1
  75. package/dist/runtimes/index.d.ts.map +0 -1
  76. package/dist/runtimes/index.js.map +0 -1
  77. package/dist/runtimes/node.d.ts.map +0 -1
  78. package/dist/runtimes/node.js.map +0 -1
  79. package/dist/runtimes/python.d.ts.map +0 -1
  80. package/dist/runtimes/python.js.map +0 -1
  81. package/dist/runtimes/ruby.d.ts.map +0 -1
  82. package/dist/runtimes/ruby.js.map +0 -1
  83. package/dist/runtimes/types.d.ts.map +0 -1
  84. package/dist/runtimes/types.js.map +0 -1
  85. package/dist/secret-generator.d.ts.map +0 -1
  86. package/dist/secret-generator.js.map +0 -1
  87. package/dist/shell.d.ts.map +0 -1
  88. package/dist/shell.js.map +0 -1
  89. package/dist/state.d.ts.map +0 -1
  90. package/dist/state.js.map +0 -1
  91. package/dist/storage.d.ts.map +0 -1
  92. package/dist/storage.js.map +0 -1
  93. package/src/__tests__/dry-run.test.ts +0 -157
  94. package/src/__tests__/env-writer.test.ts +0 -156
  95. package/src/__tests__/lockfile-detect.test.ts +0 -74
  96. package/src/__tests__/port-allocator.test.ts +0 -59
  97. package/src/__tests__/secret-generator.test.ts +0 -31
  98. package/src/__tests__/state.test.ts +0 -33
  99. package/src/cli.ts +0 -78
  100. package/src/env-writer.ts +0 -231
  101. package/src/health.ts +0 -80
  102. package/src/index.ts +0 -9
  103. package/src/lockfile-detect.ts +0 -48
  104. package/src/port-allocator.ts +0 -97
  105. package/src/prereqs.ts +0 -32
  106. package/src/process-manager.ts +0 -242
  107. package/src/provider.ts +0 -415
  108. package/src/resources/index.ts +0 -29
  109. package/src/resources/mysql.ts +0 -98
  110. package/src/resources/postgres.ts +0 -133
  111. package/src/resources/redis.ts +0 -58
  112. package/src/resources/sqlite.ts +0 -52
  113. package/src/resources/types.ts +0 -47
  114. package/src/runtimes/bun.ts +0 -25
  115. package/src/runtimes/index.ts +0 -24
  116. package/src/runtimes/node.ts +0 -71
  117. package/src/runtimes/python.ts +0 -42
  118. package/src/runtimes/ruby.ts +0 -55
  119. package/src/runtimes/types.ts +0 -16
  120. package/src/secret-generator.ts +0 -32
  121. package/src/shell.ts +0 -70
  122. package/src/state.ts +0 -92
  123. package/src/storage.ts +0 -67
  124. package/tsconfig.json +0 -18
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Cross-session app-process termination for `launch down`.
3
+ *
4
+ * The foreground `launch up` session kills its children directly via the
5
+ * ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
6
+ * (or after the up-session has exited), so it has no live ChildProcess handles —
7
+ * only the pids recorded in `.launchfile/state.json`. This module signals those
8
+ * recorded process groups, with liveness + identity checks to avoid killing an
9
+ * unrelated process that happens to have inherited a recycled pid.
10
+ *
11
+ * ## pid-reuse identity guarantee (read this before trusting the kill)
12
+ *
13
+ * macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
14
+ * belong to a completely unrelated process. Before sending any signal we:
15
+ *
16
+ * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
+ * exists. If it's dead, we skip (already stopped).
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.
22
+ *
23
+ * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
+ * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
25
+ * recycled process that started within that window of the original spawn
26
+ * could theoretically slip through — vanishingly unlikely in practice.
27
+ * - If `ps` is unavailable/fails, we fall back to liveness-only and signal
28
+ * conservatively ONLY the process group (never a bare pid), accepting the
29
+ * small residual risk rather than orphaning the user's processes forever.
30
+ * - We signal the process GROUP (negative pgid) to also reap children the app
31
+ * spawned, matching the foreground SIGINT behavior. A recycled *group*
32
+ * leader is the residual risk the start-time check exists to close.
33
+ */
34
+ import { execFile } from "node:child_process";
35
+ /**
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.
40
+ */
41
+ const START_TIME_TOLERANCE_MS = 3000;
42
+ /** Default real implementation backed by `process.kill` and `ps`. */
43
+ export const realSignalFns = {
44
+ kill: (pid, signal) => {
45
+ process.kill(pid, signal);
46
+ },
47
+ startTime: (pid) => queryStartTime(pid),
48
+ };
49
+ /**
50
+ * Query a process's start time via `ps -o lstart= -p <pid>` using array args
51
+ * (no shell, no interpolation). Returns epoch ms, or null on any failure.
52
+ */
53
+ function queryStartTime(pid) {
54
+ return new Promise((resolve) => {
55
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], (error, stdout) => {
56
+ if (error) {
57
+ resolve(null);
58
+ return;
59
+ }
60
+ const text = stdout.trim();
61
+ if (!text) {
62
+ resolve(null);
63
+ return;
64
+ }
65
+ const parsed = Date.parse(text);
66
+ resolve(Number.isNaN(parsed) ? null : parsed);
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * Verify a recorded process is still the one we started.
72
+ *
73
+ * Returns:
74
+ * - "alive-verified": process exists AND start time is consistent → safe to signal
75
+ * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
76
+ * - "dead": no such process (ESRCH) → already stopped, skip
77
+ * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
78
+ */
79
+ export async function checkIdentity(rec, fns) {
80
+ // Liveness probe.
81
+ try {
82
+ fns.kill(rec.pid, 0);
83
+ }
84
+ catch (err) {
85
+ if (isErrno(err) && err.code === "ESRCH")
86
+ return "dead";
87
+ // EPERM means it exists but we can't signal it — treat as alive-unverified.
88
+ if (isErrno(err) && err.code === "EPERM")
89
+ return "alive-unverified";
90
+ return "dead";
91
+ }
92
+ const liveStart = await fns.startTime(rec.pid);
93
+ if (liveStart === null)
94
+ return "alive-unverified";
95
+ const recordedStart = Date.parse(rec.startedAt);
96
+ if (Number.isNaN(recordedStart))
97
+ 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)
101
+ return "mismatch";
102
+ return "alive-verified";
103
+ }
104
+ /**
105
+ * Stop one recorded process: graceful SIGTERM to the group, then escalate to
106
+ * SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
107
+ * (negative pgid) so child processes the app spawned die too.
108
+ */
109
+ export async function stopProcess(component, rec, fns, opts = {}) {
110
+ const graceMs = opts.graceMs ?? 5000;
111
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
112
+ const identity = await checkIdentity(rec, fns);
113
+ if (identity === "dead")
114
+ return { component, result: "already-dead" };
115
+ if (identity === "mismatch")
116
+ return { component, result: "identity-mismatch" };
117
+ // Target the process group. pgid is the leader pid because we spawned detached.
118
+ const groupTarget = -rec.pgid;
119
+ try {
120
+ fns.kill(groupTarget, "SIGTERM");
121
+ }
122
+ catch (err) {
123
+ if (isErrno(err) && err.code === "ESRCH")
124
+ return { component, result: "already-dead" };
125
+ return { component, result: "error", error: errMessage(err) };
126
+ }
127
+ // Wait for graceful exit, then escalate if the leader is still alive.
128
+ await sleep(graceMs);
129
+ let stillAlive = true;
130
+ try {
131
+ fns.kill(rec.pid, 0);
132
+ }
133
+ catch {
134
+ stillAlive = false;
135
+ }
136
+ if (stillAlive) {
137
+ try {
138
+ fns.kill(groupTarget, "SIGKILL");
139
+ }
140
+ catch (err) {
141
+ if (!(isErrno(err) && err.code === "ESRCH")) {
142
+ return { component, result: "error", error: errMessage(err) };
143
+ }
144
+ }
145
+ }
146
+ return { component, result: "stopped" };
147
+ }
148
+ /**
149
+ * Stop every recorded process. Pure orchestration over `stopProcess`; returns
150
+ * one outcome per component so the caller can report and tests can assert.
151
+ */
152
+ export async function stopRecordedProcesses(processes, fns = realSignalFns, opts = {}) {
153
+ const outcomes = [];
154
+ for (const [component, rec] of Object.entries(processes)) {
155
+ outcomes.push(await stopProcess(component, rec, fns, opts));
156
+ }
157
+ return outcomes;
158
+ }
159
+ function isErrno(err) {
160
+ return typeof err === "object" && err !== null && "code" in err;
161
+ }
162
+ function errMessage(err) {
163
+ return err instanceof Error ? err.message : String(err);
164
+ }
165
+ //# sourceMappingURL=process-stopper.js.map
@@ -4,12 +4,30 @@
4
4
  * Reads a Launchfile, provisions resources, resolves env vars,
5
5
  * installs runtimes, and starts all components.
6
6
  */
7
+ import { type NormalizedComponent } from "@launchfile/sdk";
8
+ /**
9
+ * Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
10
+ * This provider runs apps from source. A component is source-runnable when it
11
+ * declares `dev`, or a `start` with no `image` — an `image` without a `dev`
12
+ * override stays artifact-mode, which this source-only provider can't launch.
13
+ */
14
+ export declare function isSourceRunnable(component: NormalizedComponent): boolean;
15
+ /** The command run from source, or undefined if the component resolves to its artifact. */
16
+ export declare function sourceRunCommand(component: NormalizedComponent): string | undefined;
7
17
  export interface LaunchUpOpts {
8
18
  withOptional?: boolean;
9
19
  noBuild?: boolean;
10
20
  detach?: boolean;
11
21
  dryRun?: boolean;
12
22
  projectDir?: string;
23
+ /**
24
+ * Component selector (#77): if non-empty, these components plus their
25
+ * transitive downward `depends_on` closure are started (D-41), and the
26
+ * `requires` of every closure member are provisioned. The start-set is the
27
+ * SDK's `selectionClosure` — the same definition the Docker provider uses —
28
+ * so both yield the identical running topology (P-5). Empty = all components.
29
+ */
30
+ components?: string[];
13
31
  }
14
32
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
15
33
  export declare function launchDown(opts?: {
package/dist/provider.js CHANGED
@@ -6,21 +6,39 @@
6
6
  */
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
- import { readLaunch } from "@launchfile/sdk";
9
+ import { readLaunch, selectionClosure } from "@launchfile/sdk";
10
+ /**
11
+ * Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
12
+ * This provider runs apps from source. A component is source-runnable when it
13
+ * declares `dev`, or a `start` with no `image` — an `image` without a `dev`
14
+ * override stays artifact-mode, which this source-only provider can't launch.
15
+ */
16
+ export function isSourceRunnable(component) {
17
+ return Boolean(component.commands?.dev || (component.commands?.start && !component.image));
18
+ }
19
+ /** The command run from source, or undefined if the component resolves to its artifact. */
20
+ export function sourceRunCommand(component) {
21
+ if (component.commands?.dev?.command)
22
+ return component.commands.dev.command;
23
+ if (component.image)
24
+ return undefined; // image, no `dev` override → artifact
25
+ return component.commands?.start?.command;
26
+ }
10
27
  import { checkPrereqs } from "./prereqs.js";
11
28
  import { loadState, initState, saveState, ensureDirs } from "./state.js";
12
- import { buildResolverContext, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
29
+ import { buildResolverContext, computeAppProperties, resolveComponentEnv, generateSecrets, resolveGenerators, writeEnvFile, } from "./env-writer.js";
13
30
  import { getProvisioner } from "./resources/index.js";
14
31
  import { allocatePorts } from "./port-allocator.js";
15
32
  import { getRuntimeInstaller } from "./runtimes/index.js";
16
33
  import { detectPackageManager } from "./lockfile-detect.js";
17
- import { provisionStorage } from "./storage.js";
34
+ import { provisionStorage, storagePaths } from "./storage.js";
18
35
  import { ProcessManager } from "./process-manager.js";
36
+ import { stopRecordedProcesses } from "./process-stopper.js";
19
37
  import { shell } from "./shell.js";
38
+ import { parseDuration } from "./bootstrap.js";
20
39
  export async function launchUp(opts = {}) {
21
40
  const projectDir = opts.projectDir ?? process.cwd();
22
41
  // 1. Check prerequisites
23
- console.log("Checking prerequisites...");
24
42
  const prereqs = await checkPrereqs();
25
43
  if (!prereqs.ok) {
26
44
  console.error("Missing prerequisites:");
@@ -29,7 +47,6 @@ export async function launchUp(opts = {}) {
29
47
  process.exit(1);
30
48
  }
31
49
  // 2. Read and parse Launchfile
32
- console.log("Reading Launchfile...");
33
50
  const launchfilePath = join(projectDir, "Launchfile");
34
51
  let launchfileContent;
35
52
  try {
@@ -40,9 +57,52 @@ export async function launchUp(opts = {}) {
40
57
  process.exit(1);
41
58
  }
42
59
  const launch = readLaunch(launchfileContent);
43
- console.log(` App: ${launch.name}`);
60
+ const allComponentNames = Object.keys(launch.components);
61
+ // Resolve component selector (#77) into its D-41 start-set: selected
62
+ // components + their transitive downward `depends_on` closure (and, by
63
+ // narrowing to that set, every closure member's `requires`). Narrowing
64
+ // launch.components here makes every downstream phase loop in launchUp honor
65
+ // the closure without per-loop edits — dropping it would hand back an app
66
+ // missing the very dependencies a selected component needs to start (D-16).
67
+ // down/status/env re-read the file, so they are unaffected.
68
+ const selection = selectionClosure(launch, opts.components ?? []);
69
+ if (selection.unknown.length > 0 || selection.resources.length > 0) {
70
+ console.error(`\nCannot select: ${[...selection.unknown, ...selection.resources].join(", ")}`);
71
+ for (const r of selection.resources) {
72
+ console.error(` - "${r}" is a backing resource, not a component; select the component that requires it.`);
73
+ }
74
+ for (const u of selection.unknown) {
75
+ console.error(` - "${u}" matches no component. Available: ${allComponentNames.join(", ")}`);
76
+ }
77
+ process.exit(1);
78
+ }
79
+ if (opts.components && opts.components.length > 0) {
80
+ const startSet = new Set(selection.start);
81
+ launch.components = Object.fromEntries(Object.entries(launch.components).filter(([n]) => startSet.has(n)));
82
+ }
44
83
  const componentNames = Object.keys(launch.components);
45
- console.log(` Components: ${componentNames.join(", ")}`);
84
+ // 2b. Source-mode guard (D-38) — fail fast before provisioning anything.
85
+ // Run precedence is `dev` > `image` > `start`: a component runs from source
86
+ // when it declares `dev`, or a `start` with no `image`. An `image` without a
87
+ // `dev` override stays artifact-mode — which this source-only provider can't
88
+ // launch. If nothing is source-runnable, the app belongs on `launchfile up`.
89
+ const sourceRunnable = Object.values(launch.components).filter(isSourceRunnable);
90
+ if (sourceRunnable.length === 0) {
91
+ const hasImage = Object.values(launch.components).some((c) => c.image);
92
+ console.error("Nothing to run from source: no component declares `dev` (or a `start` without an `image`).");
93
+ console.error(hasImage
94
+ ? "This app runs from an image — use `launchfile up` to launch the built artifact."
95
+ : "Add a `dev` (or `start`) command to run it from source.");
96
+ process.exit(1);
97
+ }
98
+ // Mixed app: warn about artifact components (image, no `dev` override) that
99
+ // this source-only provider can't launch — they need `launchfile up`.
100
+ for (const [name, c] of Object.entries(launch.components)) {
101
+ if (!isSourceRunnable(c) && c.image) {
102
+ console.warn(` ! [${name}] has an image and no \`dev\` override — runs as an artifact, ` +
103
+ "skipped in source mode; use `launchfile up` to run it.");
104
+ }
105
+ }
46
106
  // 3. Load or init state
47
107
  let state = await loadState(projectDir);
48
108
  if (!state) {
@@ -51,10 +111,8 @@ export async function launchUp(opts = {}) {
51
111
  // 4. Ensure directories
52
112
  await ensureDirs(projectDir);
53
113
  // 5. Generate secrets
54
- console.log("Generating secrets...");
55
114
  state.secrets = await generateSecrets(launch.secrets, state.secrets);
56
115
  // 6. Provision required resources
57
- console.log("Provisioning resources...");
58
116
  const resourceMap = {};
59
117
  for (const [_compName, component] of Object.entries(launch.components)) {
60
118
  for (const req of component.requires ?? []) {
@@ -63,7 +121,7 @@ export async function launchUp(opts = {}) {
63
121
  continue; // Already provisioned
64
122
  const provisioner = getProvisioner(req.type);
65
123
  if (!provisioner) {
66
- console.warn(` No provisioner for resource type: ${req.type} (skipping)`);
124
+ console.warn(` ! No provisioner for resource type: ${req.type} (skipping)`);
67
125
  continue;
68
126
  }
69
127
  if (opts.dryRun) {
@@ -71,11 +129,12 @@ export async function launchUp(opts = {}) {
71
129
  resourceMap[resourceName] = { url: "", host: "localhost", port: 0 }; // placeholder for dedup
72
130
  continue;
73
131
  }
74
- console.log(` Provisioning ${req.type} (${resourceName})...`);
132
+ process.stdout.write(` \u2193 Provisioning ${req.type}...`);
75
133
  const existing = state.resources[resourceName];
76
134
  const result = await provisioner.provision(req, { appName: launch.name, projectDir }, existing);
77
135
  resourceMap[resourceName] = result.properties;
78
136
  state.resources[resourceName] = result.state;
137
+ console.log(" done");
79
138
  }
80
139
  // Optional supports resources
81
140
  if (opts.withOptional) {
@@ -84,45 +143,44 @@ export async function launchUp(opts = {}) {
84
143
  if (resourceMap[resourceName])
85
144
  continue;
86
145
  const provisioner = getProvisioner(sup.type);
87
- if (!provisioner) {
88
- console.log(` Optional resource ${sup.type} not supported (skipping)`);
146
+ if (!provisioner)
89
147
  continue;
90
- }
91
148
  if (opts.dryRun) {
92
149
  console.log(` [dry-run] Would provision optional ${sup.type} as "${resourceName}"`);
93
150
  resourceMap[resourceName] = { url: "", host: "localhost", port: 0 };
94
151
  continue;
95
152
  }
96
153
  try {
97
- console.log(` Provisioning optional ${sup.type} (${resourceName})...`);
154
+ process.stdout.write(` \u2193 Provisioning ${sup.type} (optional)...`);
98
155
  const existing = state.resources[resourceName];
99
156
  const result = await provisioner.provision(sup, { appName: launch.name, projectDir }, existing);
100
157
  resourceMap[resourceName] = result.properties;
101
158
  state.resources[resourceName] = result.state;
159
+ console.log(" done");
102
160
  }
103
- catch (err) {
104
- console.log(` Optional resource ${sup.type} failed to provision (skipping): ${err}`);
161
+ catch {
162
+ console.log(" skipped");
105
163
  }
106
164
  }
107
165
  }
108
166
  }
109
167
  // 7. Allocate ports
110
- console.log("Allocating ports...");
111
168
  const componentPorts = await allocatePorts(launch.components, launch.name, state.ports);
112
169
  state.ports = componentPorts;
113
- // 8. Build resolver context
114
- const context = buildResolverContext(resourceMap, componentPorts, state.secrets);
170
+ // 8. Build resolver context (including $app.* properties from D-33)
171
+ const appProperties = computeAppProperties(launch, componentPorts);
172
+ const context = buildResolverContext(resourceMap, componentPorts, state.secrets, appProperties);
115
173
  // 9. Install runtimes
116
174
  for (const [name, component] of Object.entries(launch.components)) {
117
175
  if (!component.runtime) {
118
176
  if (!component.image) {
119
- console.warn(` [${name}] No runtime declared — cannot run natively`);
177
+ console.warn(` ! [${name}] No runtime declared — cannot run natively`);
120
178
  }
121
179
  continue;
122
180
  }
123
181
  const installer = getRuntimeInstaller(component.runtime);
124
182
  if (!installer) {
125
- console.warn(` [${name}] No installer for runtime: ${component.runtime}`);
183
+ console.warn(` ! [${name}] No installer for runtime: ${component.runtime}`);
126
184
  continue;
127
185
  }
128
186
  if (opts.dryRun) {
@@ -131,28 +189,31 @@ export async function launchUp(opts = {}) {
131
189
  }
132
190
  const version = await installer.detectVersion(projectDir);
133
191
  if (version) {
134
- console.log(` [${name}] Runtime ${component.runtime} ${version}`);
192
+ console.log(` \u2193 Installing ${component.runtime} ${version}... done`);
135
193
  await installer.install(version);
136
194
  }
137
195
  else {
138
- console.log(` [${name}] Runtime ${component.runtime} (using system default)`);
196
+ console.log(` \u2193 Using system ${component.runtime}`);
139
197
  }
140
198
  }
141
199
  // 10. Detect package manager
142
200
  const pm = await detectPackageManager(projectDir);
143
- if (pm) {
144
- console.log(` Package manager: ${pm.name} (${pm.lockfile})`);
145
- }
146
- // 11. Create storage directories
201
+ // 11. Create storage directories, capturing each volume's resolved local path
202
+ // so it can be injected as $storage.<name>.path (D-39). Scoped per component.
203
+ const componentStorage = {};
147
204
  for (const [name, component] of Object.entries(launch.components)) {
148
- await provisionStorage(component.storage, name, projectDir);
205
+ const volumeMap = await provisionStorage(component.storage, name, projectDir);
206
+ const storageCtx = {};
207
+ for (const [volName, localPath] of Object.entries(volumeMap)) {
208
+ storageCtx[volName] = { path: localPath };
209
+ }
210
+ componentStorage[name] = storageCtx;
149
211
  }
150
212
  // 12. Resolve env vars and write .env files
151
- console.log("Resolving environment variables...");
152
213
  const allEnvs = {};
153
214
  const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
154
215
  for (const [name, component] of Object.entries(launch.components)) {
155
- const env = resolveComponentEnv(component, context, resourceMap);
216
+ const env = resolveComponentEnv(component, context, resourceMap, componentStorage[name]);
156
217
  await resolveGenerators(component, env);
157
218
  const port = componentPorts[name];
158
219
  if (port && !env.PORT) {
@@ -165,13 +226,13 @@ export async function launchUp(opts = {}) {
165
226
  }
166
227
  if (isSingleComponent) {
167
228
  await writeEnvFile(join(projectDir, ".env.local"), env);
168
- console.log(` Wrote .env.local (${Object.keys(env).length} vars)`);
229
+ console.log(` \u2193 Wiring environment variables... done (${Object.keys(env).length} vars)`);
169
230
  }
170
231
  else {
171
232
  const { mkdir } = await import("node:fs/promises");
172
233
  await mkdir(join(projectDir, ".launchfile", "env"), { recursive: true });
173
234
  await writeEnvFile(join(projectDir, ".launchfile", "env", `${name}.env`), env);
174
- console.log(` Wrote .launchfile/env/${name}.env (${Object.keys(env).length} vars)`);
235
+ console.log(` \u2193 Wiring ${name} environment... done (${Object.keys(env).length} vars)`);
175
236
  }
176
237
  }
177
238
  // 13. Save state before build (in case build fails, we still have resource state)
@@ -181,80 +242,84 @@ export async function launchUp(opts = {}) {
181
242
  printSummary(launch, componentPorts, resourceMap);
182
243
  return;
183
244
  }
184
- // 14. Run build commands
245
+ // 14. Run source-mode prepare \u2014 `install ?? build` (D-38), on demand
185
246
  if (!opts.noBuild) {
186
247
  for (const [name, component] of Object.entries(launch.components)) {
187
- const buildCmd = component.commands?.build?.command;
188
- const cmd = buildCmd ?? pm?.installCommand;
248
+ const prepare = component.commands?.install ?? component.commands?.build;
249
+ const cmd = prepare?.command ?? pm?.installCommand;
189
250
  if (cmd) {
190
- console.log(`\n [${name}] Building: ${cmd}`);
251
+ console.log(` \u2193 Preparing${componentNames.length > 1 ? ` [${name}]` : ""}...`);
191
252
  await shell(cmd, {
192
- cwd: projectDir,
253
+ cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
193
254
  env: allEnvs[name],
255
+ // Installs/compiles routinely exceed the 2-minute shell default;
256
+ // honor a declared timeout, else allow 10 minutes.
257
+ timeout: prepare?.timeout ? parseDuration(prepare.timeout) : 600_000,
194
258
  });
195
259
  }
196
260
  }
197
261
  }
198
- // 15. Run release commands (migrations)
262
+ // 15. Run release commands (migrations) \u2014 mode-invariant (D-38)
199
263
  for (const [name, component] of Object.entries(launch.components)) {
200
- const releaseCmd = component.commands?.release?.command;
201
- if (releaseCmd) {
202
- console.log(`\n [${name}] Release: ${releaseCmd}`);
203
- await shell(releaseCmd, {
204
- cwd: projectDir,
264
+ const release = component.commands?.release;
265
+ if (release?.command) {
266
+ console.log(` \u2193 Running release${componentNames.length > 1 ? ` [${name}]` : ""}...`);
267
+ await shell(release.command, {
268
+ cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
205
269
  env: allEnvs[name],
270
+ timeout: release.timeout ? parseDuration(release.timeout) : undefined,
206
271
  });
207
272
  }
208
273
  }
209
- // 16. Start all components
210
- console.log("\nStarting components...");
274
+ // 16. Run components from source \u2014 `dev` over `start` (D-38; this provider ignores `image`)
275
+ process.stdout.write(` \u2193 Starting services...`);
211
276
  const pm2 = new ProcessManager(projectDir);
212
277
  for (const [name, component] of Object.entries(launch.components)) {
213
- const startCmd = component.commands?.start?.command;
214
- if (!startCmd) {
215
- console.warn(` [${name}] No start command — skipping`);
278
+ // Resolve the source-mode run command (D-38 precedence `dev` > `image` >
279
+ // `start`). Artifact components (image, no `dev` override) resolve to
280
+ // undefined — they were warned by the guard; skip them.
281
+ const startCmd = sourceRunCommand(component);
282
+ if (!startCmd)
216
283
  continue;
217
- }
218
284
  pm2.register(name, {
219
285
  command: startCmd,
220
286
  env: { ...process.env, ...allEnvs[name] },
221
- cwd: projectDir,
287
+ cwd: join(projectDir, component.source ?? component.build?.context ?? "."),
222
288
  dependsOn: component.depends_on,
223
289
  health: component.health,
224
290
  port: componentPorts[name],
225
291
  });
226
292
  }
227
293
  // Handle Ctrl+C gracefully
294
+ const finalState = state;
228
295
  process.on("SIGINT", async () => {
229
296
  console.log("\n\nShutting down...");
230
297
  await pm2.stopAll();
231
- await saveState(projectDir, state);
298
+ // Processes are now dead; clear the recorded pids so a later `launch down`
299
+ // doesn't try to signal stale (and possibly recycled) pids.
300
+ finalState.processes = {};
301
+ await saveState(projectDir, finalState);
232
302
  process.exit(0);
233
303
  });
234
304
  await pm2.startAll();
305
+ console.log("");
306
+ console.log(` \u2713 All components started`);
307
+ // Record spawned pids so `launch down` can stop them from another shell or
308
+ // after this foreground session ends (closes #49). Backward compatible: the
309
+ // field is optional and absent in pre-existing state files.
310
+ state.processes = pm2.getRecordedProcesses();
235
311
  // 17. Print summary
236
312
  printSummary(launch, componentPorts, resourceMap);
237
- // Save final state
313
+ // Save final state (now including recorded pids)
238
314
  await saveState(projectDir, state);
239
315
  }
240
- function printSummary(launch, ports, resources) {
241
- console.log("\n" + "=".repeat(60));
242
- console.log(` ${launch.name} is running`);
243
- console.log("=".repeat(60));
316
+ function printSummary(launch, ports, _resources) {
317
+ console.log("");
244
318
  for (const [name, port] of Object.entries(ports)) {
245
- const component = launch.components[name];
246
- const exposed = component?.provides?.some((p) => p.exposed);
247
- const label = exposed ? "(public)" : "(internal)";
248
- console.log(` ${name}: http://localhost:${port} ${label}`);
249
- }
250
- if (Object.keys(resources).length > 0) {
251
- console.log("\n Resources:");
252
- for (const [name, props] of Object.entries(resources)) {
253
- console.log(` ${name}: ${props.url}`);
254
- }
319
+ const label = name === "default" ? launch.name : name;
320
+ console.log(` ${label} is running at http://localhost:${port}`);
255
321
  }
256
322
  console.log("\n Press Ctrl+C to stop all processes.");
257
- console.log("=".repeat(60));
258
323
  }
259
324
  export async function launchDown(opts = {}) {
260
325
  const projectDir = opts.projectDir ?? process.cwd();
@@ -263,6 +328,35 @@ export async function launchDown(opts = {}) {
263
328
  console.log("No active launch state found.");
264
329
  return;
265
330
  }
331
+ // Stop recorded app processes (closes #49). Backward compatible: state files
332
+ // written before pid persistence simply have no `processes`, so we skip this
333
+ // and behave exactly as before (resources-only down).
334
+ const recorded = state.processes ?? {};
335
+ if (Object.keys(recorded).length > 0) {
336
+ console.log("Stopping app processes...");
337
+ const outcomes = await stopRecordedProcesses(recorded);
338
+ for (const o of outcomes) {
339
+ switch (o.result) {
340
+ case "stopped":
341
+ console.log(` Stopped ${o.component}`);
342
+ break;
343
+ case "already-dead":
344
+ console.log(` ${o.component} was not running`);
345
+ break;
346
+ case "identity-mismatch":
347
+ console.log(` Skipped ${o.component} (pid recycled — left untouched)`);
348
+ break;
349
+ case "error":
350
+ console.log(` Failed to stop ${o.component}: ${o.error}`);
351
+ break;
352
+ }
353
+ }
354
+ // Clear recorded pids now that we've handled them.
355
+ state.processes = {};
356
+ if (!opts.destroy) {
357
+ await saveState(projectDir, state);
358
+ }
359
+ }
266
360
  if (opts.destroy) {
267
361
  console.log("Destroying resources...");
268
362
  for (const [name, resourceState] of Object.entries(state.resources)) {
@@ -281,6 +375,11 @@ export async function launchDown(opts = {}) {
281
375
  console.log("Stopped. Resources are still running (use --destroy to remove them).");
282
376
  }
283
377
  }
378
+ // --detach is intentionally left as a follow-up: persisting pids (this PR) is
379
+ // the prerequisite for it. With pids now recorded and a working cross-session
380
+ // `down`, detach becomes "spawn detached + unref + don't install the SIGINT
381
+ // foreground loop, then return" — a self-contained change best done separately
382
+ // so the kill-path fix lands reviewable on its own.
284
383
  export async function launchStatus(opts = {}) {
285
384
  const projectDir = opts.projectDir ?? process.cwd();
286
385
  const state = await loadState(projectDir);
@@ -324,11 +423,18 @@ export async function launchEnv(opts = {}) {
324
423
  resourceMap[name] = result.properties;
325
424
  }
326
425
  }
327
- const context = buildResolverContext(resourceMap, state.ports, state.secrets);
426
+ const appProperties = computeAppProperties(launch, state.ports);
427
+ const context = buildResolverContext(resourceMap, state.ports, state.secrets, appProperties);
328
428
  for (const [name, component] of Object.entries(launch.components)) {
329
429
  if (opts.component && name !== opts.component)
330
430
  continue;
331
- const env = resolveComponentEnv(component, context, resourceMap);
431
+ // Resolved storage paths (D-39) — computed, not provisioned (no mkdir):
432
+ // `launchfile env` only prints, and the dirs already exist from `up`.
433
+ const storageCtx = {};
434
+ for (const [volName, localPath] of Object.entries(storagePaths(component.storage, name, projectDir))) {
435
+ storageCtx[volName] = { path: localPath };
436
+ }
437
+ const env = resolveComponentEnv(component, context, resourceMap, storageCtx);
332
438
  await resolveGenerators(component, env);
333
439
  const port = state.ports[name];
334
440
  if (port && !env.PORT)
package/dist/shell.js CHANGED
@@ -19,7 +19,7 @@ export async function shell(command, opts = {}) {
19
19
  return new Promise((resolve, reject) => {
20
20
  cpExec(command, execOpts, (error, stdout, stderr) => {
21
21
  const result = {
22
- exitCode: error?.code ?? (typeof error?.code === "number" ? error.code : 0),
22
+ exitCode: typeof error?.code === "number" ? error.code : 0,
23
23
  stdout: typeof stdout === "string" ? stdout : "",
24
24
  stderr: typeof stderr === "string" ? stderr : "",
25
25
  };