@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.
- package/dist/bootstrap.d.ts +58 -0
- package/dist/bootstrap.js +224 -0
- package/dist/env-writer.d.ts +19 -13
- package/dist/env-writer.js +44 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +57 -2
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.js +176 -70
- package/dist/shell.js +1 -1
- package/dist/state.d.ts +28 -0
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +9 -4
- package/CLAUDE.md +0 -37
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.d.ts.map +0 -1
- package/dist/__tests__/dry-run.test.js +0 -129
- package/dist/__tests__/dry-run.test.js.map +0 -1
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.d.ts.map +0 -1
- package/dist/__tests__/env-writer.test.js +0 -127
- package/dist/__tests__/env-writer.test.js.map +0 -1
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.d.ts.map +0 -1
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/lockfile-detect.test.js.map +0 -1
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.d.ts.map +0 -1
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/port-allocator.test.js.map +0 -1
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.d.ts.map +0 -1
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/secret-generator.test.js.map +0 -1
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.d.ts.map +0 -1
- package/dist/__tests__/state.test.js +0 -30
- package/dist/__tests__/state.test.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/env-writer.d.ts.map +0 -1
- package/dist/env-writer.js.map +0 -1
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lockfile-detect.d.ts.map +0 -1
- package/dist/lockfile-detect.js.map +0 -1
- package/dist/port-allocator.d.ts.map +0 -1
- package/dist/port-allocator.js.map +0 -1
- package/dist/prereqs.d.ts.map +0 -1
- package/dist/prereqs.js.map +0 -1
- package/dist/process-manager.d.ts.map +0 -1
- package/dist/process-manager.js.map +0 -1
- package/dist/provider.d.ts.map +0 -1
- package/dist/provider.js.map +0 -1
- package/dist/resources/index.d.ts.map +0 -1
- package/dist/resources/index.js.map +0 -1
- package/dist/resources/mysql.d.ts.map +0 -1
- package/dist/resources/mysql.js.map +0 -1
- package/dist/resources/postgres.d.ts.map +0 -1
- package/dist/resources/postgres.js.map +0 -1
- package/dist/resources/redis.d.ts.map +0 -1
- package/dist/resources/redis.js.map +0 -1
- package/dist/resources/sqlite.d.ts.map +0 -1
- package/dist/resources/sqlite.js.map +0 -1
- package/dist/resources/types.d.ts.map +0 -1
- package/dist/resources/types.js.map +0 -1
- package/dist/runtimes/bun.d.ts.map +0 -1
- package/dist/runtimes/bun.js.map +0 -1
- package/dist/runtimes/index.d.ts.map +0 -1
- package/dist/runtimes/index.js.map +0 -1
- package/dist/runtimes/node.d.ts.map +0 -1
- package/dist/runtimes/node.js.map +0 -1
- package/dist/runtimes/python.d.ts.map +0 -1
- package/dist/runtimes/python.js.map +0 -1
- package/dist/runtimes/ruby.d.ts.map +0 -1
- package/dist/runtimes/ruby.js.map +0 -1
- package/dist/runtimes/types.d.ts.map +0 -1
- package/dist/runtimes/types.js.map +0 -1
- package/dist/secret-generator.d.ts.map +0 -1
- package/dist/secret-generator.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/storage.js.map +0 -1
- package/src/__tests__/dry-run.test.ts +0 -157
- package/src/__tests__/env-writer.test.ts +0 -156
- package/src/__tests__/lockfile-detect.test.ts +0 -74
- package/src/__tests__/port-allocator.test.ts +0 -59
- package/src/__tests__/secret-generator.test.ts +0 -31
- package/src/__tests__/state.test.ts +0 -33
- package/src/cli.ts +0 -78
- package/src/env-writer.ts +0 -231
- package/src/health.ts +0 -80
- package/src/index.ts +0 -9
- package/src/lockfile-detect.ts +0 -48
- package/src/port-allocator.ts +0 -97
- package/src/prereqs.ts +0 -32
- package/src/process-manager.ts +0 -242
- package/src/provider.ts +0 -415
- package/src/resources/index.ts +0 -29
- package/src/resources/mysql.ts +0 -98
- package/src/resources/postgres.ts +0 -133
- package/src/resources/redis.ts +0 -58
- package/src/resources/sqlite.ts +0 -52
- package/src/resources/types.ts +0 -47
- package/src/runtimes/bun.ts +0 -25
- package/src/runtimes/index.ts +0 -24
- package/src/runtimes/node.ts +0 -71
- package/src/runtimes/python.ts +0 -42
- package/src/runtimes/ruby.ts +0 -55
- package/src/runtimes/types.ts +0 -16
- package/src/secret-generator.ts +0 -32
- package/src/shell.ts +0 -70
- package/src/state.ts +0 -92
- package/src/storage.ts +0 -67
- 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
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
console.log(
|
|
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
|
|
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(`
|
|
192
|
+
console.log(` \u2193 Installing ${component.runtime} ${version}... done`);
|
|
135
193
|
await installer.install(version);
|
|
136
194
|
}
|
|
137
195
|
else {
|
|
138
|
-
console.log(`
|
|
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
|
-
|
|
144
|
-
|
|
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(`
|
|
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(`
|
|
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
|
|
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
|
|
188
|
-
const cmd =
|
|
248
|
+
const prepare = component.commands?.install ?? component.commands?.build;
|
|
249
|
+
const cmd = prepare?.command ?? pm?.installCommand;
|
|
189
250
|
if (cmd) {
|
|
190
|
-
console.log(
|
|
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
|
|
201
|
-
if (
|
|
202
|
-
console.log(
|
|
203
|
-
await shell(
|
|
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.
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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,
|
|
241
|
-
console.log("
|
|
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
|
|
246
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
};
|