@launchfile/macos-dev 0.2.0 → 0.4.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 +56 -8
- package/dist/bootstrap.js +170 -93
- package/dist/env-writer.d.ts +40 -11
- package/dist/env-writer.js +84 -23
- package/dist/health.d.ts +8 -2
- package/dist/health.js +11 -17
- package/dist/prereqs.js +2 -2
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +59 -3
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +50 -0
- package/dist/provider.js +337 -28
- package/dist/redact.d.ts +55 -0
- package/dist/redact.js +92 -0
- package/dist/resources/identifiers.d.ts +22 -0
- package/dist/resources/identifiers.js +31 -0
- package/dist/resources/index.d.ts +3 -3
- package/dist/resources/index.js +21 -10
- package/dist/resources/mysql.d.ts +3 -1
- package/dist/resources/mysql.js +49 -11
- package/dist/resources/postgres.d.ts +4 -1
- package/dist/resources/postgres.js +63 -17
- package/dist/resources/redis.d.ts +3 -1
- package/dist/resources/redis.js +14 -4
- package/dist/resources/sqlite.js +2 -2
- package/dist/resources/types.d.ts +21 -3
- package/dist/runtimes/bun.js +2 -2
- package/dist/runtimes/installed-versions.d.ts +12 -0
- package/dist/runtimes/installed-versions.js +21 -0
- package/dist/runtimes/node.js +13 -8
- package/dist/runtimes/python.js +14 -7
- package/dist/runtimes/ruby.js +13 -8
- package/dist/secret-generator.js +11 -3
- package/dist/shell.d.ts +28 -6
- package/dist/shell.js +83 -28
- package/dist/state.d.ts +38 -0
- package/dist/state.js +12 -1
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +7 -4
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.js +0 -90
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.js +0 -141
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.js +0 -201
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.js +0 -30
package/dist/env-writer.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { resolveExpression, isExpression, } from "@launchfile/sdk";
|
|
9
|
+
import { deriveAppUrlProperties, resolveExpression, isExpression, unsuppliedRequiredEnv, } from "@launchfile/sdk";
|
|
10
10
|
import { generateValue } from "./secret-generator.js";
|
|
11
11
|
/**
|
|
12
|
-
* Compute the $app.* property set for a Launchfile under the
|
|
13
|
-
* provider. The app's "primary" port comes from the first component
|
|
14
|
-
* declaration order) that has at least one `exposed: true` provides entry.
|
|
15
|
-
*
|
|
12
|
+
* Compute the $app.* property set (D-33, D-35) for a Launchfile under the
|
|
13
|
+
* macos-dev provider. The app's "primary" port comes from the first component
|
|
14
|
+
* (in declaration order) that has at least one `exposed: true` provides entry.
|
|
15
|
+
* The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
|
|
16
|
+
* SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
|
|
17
|
+
* Apps with no exposed component get `port: 0` and `url: ""` (and empty
|
|
18
|
+
* authority/scheme/tls).
|
|
16
19
|
*
|
|
17
20
|
* For multi-exposed-component apps that need a specific component's URL,
|
|
18
21
|
* use `$components.<name>.url` instead — `$app.*` always points at the
|
|
@@ -21,17 +24,23 @@ import { generateValue } from "./secret-generator.js";
|
|
|
21
24
|
export function computeAppProperties(launch, componentPorts) {
|
|
22
25
|
let primaryPort = 0;
|
|
23
26
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
24
|
-
|
|
27
|
+
// Only endpoints explicitly marked `exposed: true` are reachable from
|
|
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).
|
|
31
|
+
const hasExposed = component.provides?.some((p) => p.exposed === true) ?? false;
|
|
25
32
|
if (hasExposed && componentPorts[name]) {
|
|
26
33
|
primaryPort = componentPorts[name];
|
|
27
34
|
break;
|
|
28
35
|
}
|
|
29
36
|
}
|
|
37
|
+
const url = primaryPort > 0 ? `http://localhost:${primaryPort}` : "";
|
|
30
38
|
return {
|
|
31
39
|
name: launch.name,
|
|
32
40
|
host: "localhost",
|
|
33
41
|
port: primaryPort,
|
|
34
|
-
url
|
|
42
|
+
url,
|
|
43
|
+
...deriveAppUrlProperties(url),
|
|
35
44
|
};
|
|
36
45
|
}
|
|
37
46
|
/**
|
|
@@ -64,10 +73,19 @@ export function buildResolverContext(resourceMap, componentPorts, secrets, app)
|
|
|
64
73
|
/**
|
|
65
74
|
* Resolve all environment variables for a single component.
|
|
66
75
|
*/
|
|
67
|
-
export function resolveComponentEnv(component, context, resourceMap) {
|
|
76
|
+
export function resolveComponentEnv(component, context, resourceMap, storage) {
|
|
68
77
|
const env = {};
|
|
78
|
+
// This component's provider-resolved storage paths (D-39). Scoped per
|
|
79
|
+
// component because volume names are component-local — component A's
|
|
80
|
+
// `$storage.cache.path` must not see component B's `cache`.
|
|
81
|
+
const ctx = storage ? { ...context, storage } : context;
|
|
69
82
|
// 1. Resolve set_env from requires
|
|
70
83
|
for (const req of component.requires ?? []) {
|
|
84
|
+
// A host capability (D-44) is never provisioned, so it has no properties
|
|
85
|
+
// to resolve against. An ungranted capability's set_env vars are omitted
|
|
86
|
+
// rather than resolved to empty strings.
|
|
87
|
+
if (req.host)
|
|
88
|
+
continue;
|
|
71
89
|
const resourceName = req.name ?? req.type;
|
|
72
90
|
const props = resourceMap[resourceName];
|
|
73
91
|
if (!req.set_env || !props)
|
|
@@ -79,7 +97,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
79
97
|
resourceRecord[k] = v;
|
|
80
98
|
}
|
|
81
99
|
const scopedContext = {
|
|
82
|
-
...
|
|
100
|
+
...ctx,
|
|
83
101
|
resource: resourceRecord,
|
|
84
102
|
};
|
|
85
103
|
for (const [envKey, expr] of Object.entries(req.set_env)) {
|
|
@@ -88,6 +106,8 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
88
106
|
}
|
|
89
107
|
// 2. Resolve set_env from supports (only if resource was provisioned)
|
|
90
108
|
for (const sup of component.supports ?? []) {
|
|
109
|
+
if (sup.host)
|
|
110
|
+
continue; // capability, not a backing service (D-44)
|
|
91
111
|
const resourceName = sup.name ?? sup.type;
|
|
92
112
|
const props = resourceMap[resourceName];
|
|
93
113
|
if (!sup.set_env || !props)
|
|
@@ -98,7 +118,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
98
118
|
resourceRecord[k] = v;
|
|
99
119
|
}
|
|
100
120
|
const scopedContext = {
|
|
101
|
-
...
|
|
121
|
+
...ctx,
|
|
102
122
|
resource: resourceRecord,
|
|
103
123
|
};
|
|
104
124
|
for (const [envKey, expr] of Object.entries(sup.set_env)) {
|
|
@@ -110,19 +130,30 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
110
130
|
for (const [key, envVar] of Object.entries(component.env)) {
|
|
111
131
|
if (env[key] !== undefined)
|
|
112
132
|
continue; // set_env takes precedence
|
|
133
|
+
// A generator outranks a default (D-49 provenance precedence). Filling
|
|
134
|
+
// the default here would win by arriving first — resolveGenerators
|
|
135
|
+
// skips any key already set — and this provider would mint nothing
|
|
136
|
+
// where docker and aws mint a secret, for the same file.
|
|
137
|
+
if (envVar.generator)
|
|
138
|
+
continue;
|
|
113
139
|
if (envVar.default !== undefined) {
|
|
114
140
|
const defaultStr = String(envVar.default);
|
|
115
141
|
if (isExpression(defaultStr)) {
|
|
116
|
-
env[key] = resolveExpression(defaultStr,
|
|
142
|
+
env[key] = resolveExpression(defaultStr, ctx);
|
|
117
143
|
}
|
|
118
144
|
else {
|
|
119
145
|
env[key] = defaultStr;
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
|
-
// required + no default + no generator → left unset (provider should prompt)
|
|
123
148
|
}
|
|
124
149
|
}
|
|
125
|
-
|
|
150
|
+
// A `required:` var nothing above yielded is left ABSENT and reported, not
|
|
151
|
+
// silently dropped (D-52, PROVIDERS.md §10 rule 8). `generator:` keys were
|
|
152
|
+
// skipped just above but are not unsupplied — `resolveGenerators` mints them
|
|
153
|
+
// — and the shared predicate excludes them for that reason. The test runs
|
|
154
|
+
// after the `set_env` loops so it measures arrival, not declaration.
|
|
155
|
+
const unsupplied = unsuppliedRequiredEnv(component, Object.keys(env));
|
|
156
|
+
return { env, unsupplied };
|
|
126
157
|
}
|
|
127
158
|
/**
|
|
128
159
|
* Generate all app-wide secrets, reusing values from state when available.
|
|
@@ -139,19 +170,46 @@ export async function generateSecrets(secretDefs, existingSecrets) {
|
|
|
139
170
|
return secrets;
|
|
140
171
|
}
|
|
141
172
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
173
|
+
* Resolve values for env vars that declare generators, preserving minted
|
|
174
|
+
* values across runs (D-49: generate once, then preserve).
|
|
175
|
+
*
|
|
176
|
+
* A `secret` or `uuid` value is read from `generatedEnv` when present, and
|
|
177
|
+
* minted and written into it when absent. The store is keyed
|
|
178
|
+
* `<component>.<ENV_NAME>` — one entry per declaration (D-25), so two
|
|
179
|
+
* components declaring the same variable name hold independent values.
|
|
180
|
+
* `generator: port` is exempt: ports have their own preserved home
|
|
181
|
+
* (`state.ports`) and allocator, and a preserved port produces a bind
|
|
182
|
+
* conflict rather than continuity.
|
|
183
|
+
*
|
|
184
|
+
* Mutates `env` and `generatedEnv` in place. Returns true when a new value
|
|
185
|
+
* was minted into `generatedEnv` — the caller must then persist the state
|
|
186
|
+
* before handing the value to anything, so every site that mints persists.
|
|
144
187
|
*/
|
|
145
|
-
export async function resolveGenerators(component, env) {
|
|
188
|
+
export async function resolveGenerators(component, env, componentName, generatedEnv) {
|
|
146
189
|
if (!component.env)
|
|
147
|
-
return;
|
|
190
|
+
return false;
|
|
191
|
+
let minted = false;
|
|
148
192
|
for (const [key, envVar] of Object.entries(component.env)) {
|
|
149
193
|
if (env[key] !== undefined)
|
|
150
194
|
continue;
|
|
151
|
-
if (envVar.generator)
|
|
195
|
+
if (!envVar.generator)
|
|
196
|
+
continue;
|
|
197
|
+
if (envVar.generator === "port") {
|
|
152
198
|
env[key] = await generateValue(envVar.generator);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const stateKey = `${componentName}.${key}`;
|
|
202
|
+
const existing = generatedEnv[stateKey];
|
|
203
|
+
if (existing !== undefined) {
|
|
204
|
+
env[key] = existing;
|
|
205
|
+
continue;
|
|
153
206
|
}
|
|
207
|
+
const value = await generateValue(envVar.generator);
|
|
208
|
+
env[key] = value;
|
|
209
|
+
generatedEnv[stateKey] = value;
|
|
210
|
+
minted = true;
|
|
154
211
|
}
|
|
212
|
+
return minted;
|
|
155
213
|
}
|
|
156
214
|
/**
|
|
157
215
|
* Write resolved env vars to a .env file.
|
|
@@ -160,9 +218,12 @@ export async function writeEnvFile(filePath, env) {
|
|
|
160
218
|
const lines = Object.entries(env)
|
|
161
219
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
162
220
|
.map(([key, value]) => {
|
|
163
|
-
// Quote values that contain spaces, #, or newlines
|
|
221
|
+
// Quote values that contain spaces, #, or newlines. Escape backslashes
|
|
222
|
+
// first, then quotes — otherwise a value containing a backslash would
|
|
223
|
+
// produce broken or injectable quoting (CWE-116 incomplete escaping).
|
|
164
224
|
if (/[\s#\n]/.test(value)) {
|
|
165
|
-
|
|
225
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
226
|
+
return `${key}="${escaped}"`;
|
|
166
227
|
}
|
|
167
228
|
return `${key}=${value}`;
|
|
168
229
|
});
|
|
@@ -175,13 +236,13 @@ export async function writeEnvFile(filePath, env) {
|
|
|
175
236
|
* Single-component → .env.local at project root.
|
|
176
237
|
* Multi-component → .launchfile/env/<component>.env per component.
|
|
177
238
|
*/
|
|
178
|
-
export async function writeAllEnvFiles(launch, context, resourceMap, componentPorts, projectDir) {
|
|
239
|
+
export async function writeAllEnvFiles(launch, context, resourceMap, componentPorts, projectDir, generatedEnv) {
|
|
179
240
|
const allEnvs = {};
|
|
180
241
|
const componentNames = Object.keys(launch.components);
|
|
181
242
|
const isSingleComponent = componentNames.length === 1 && componentNames[0] === "default";
|
|
182
243
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
183
|
-
const env = resolveComponentEnv(component, context, resourceMap);
|
|
184
|
-
await resolveGenerators(component, env);
|
|
244
|
+
const { env } = resolveComponentEnv(component, context, resourceMap);
|
|
245
|
+
await resolveGenerators(component, env, name, generatedEnv);
|
|
185
246
|
// Inject PORT if not already set and component has provides
|
|
186
247
|
const port = componentPorts[name];
|
|
187
248
|
if (port && !env.PORT) {
|
package/dist/health.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Health check polling for components.
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
5
|
-
/**
|
|
4
|
+
import { type NormalizedHealth } from "@launchfile/sdk";
|
|
5
|
+
/**
|
|
6
|
+
* Parse a health duration against the ratified grammar (D-48). Throws on an
|
|
7
|
+
* unparseable value — PROVIDERS.md §10.10 forbids silently substituting a
|
|
8
|
+
* default, and the previous local parser did exactly that in the worst
|
|
9
|
+
* possible way: it accepted no `h` unit and returned 0, so a spec-valid
|
|
10
|
+
* `interval: "1h"` became a zero-length poll that could never pass.
|
|
11
|
+
*/
|
|
6
12
|
export declare function parseDuration(duration: string): number;
|
|
7
13
|
/**
|
|
8
14
|
* Wait for a component to become healthy.
|
package/dist/health.js
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Health check polling for components.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { parseDurationMs } from "@launchfile/sdk";
|
|
5
|
+
import { shellScript } from "./shell.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse a health duration against the ratified grammar (D-48). Throws on an
|
|
8
|
+
* unparseable value — PROVIDERS.md §10.10 forbids silently substituting a
|
|
9
|
+
* default, and the previous local parser did exactly that in the worst
|
|
10
|
+
* possible way: it accepted no `h` unit and returned 0, so a spec-valid
|
|
11
|
+
* `interval: "1h"` became a zero-length poll that could never pass.
|
|
12
|
+
*/
|
|
6
13
|
export function parseDuration(duration) {
|
|
7
|
-
|
|
8
|
-
if (!match)
|
|
9
|
-
return 0;
|
|
10
|
-
const value = Number.parseInt(match[1], 10);
|
|
11
|
-
switch (match[2]) {
|
|
12
|
-
case "ms":
|
|
13
|
-
return value;
|
|
14
|
-
case "s":
|
|
15
|
-
return value * 1000;
|
|
16
|
-
case "m":
|
|
17
|
-
return value * 60_000;
|
|
18
|
-
default:
|
|
19
|
-
return 0;
|
|
20
|
-
}
|
|
14
|
+
return parseDurationMs(duration);
|
|
21
15
|
}
|
|
22
16
|
/**
|
|
23
17
|
* Wait for a component to become healthy.
|
|
@@ -45,7 +39,7 @@ export async function waitForHealthy(name, health, port, overallTimeout = 60_000
|
|
|
45
39
|
}
|
|
46
40
|
}
|
|
47
41
|
else if (health.command) {
|
|
48
|
-
await
|
|
42
|
+
await shellScript(health.command, { timeout: checkTimeout, silent: true });
|
|
49
43
|
console.log(` [${name}] Healthy`);
|
|
50
44
|
return true;
|
|
51
45
|
}
|
package/dist/prereqs.js
CHANGED
|
@@ -6,11 +6,11 @@ export async function checkPrereqs() {
|
|
|
6
6
|
const missing = [];
|
|
7
7
|
const warnings = [];
|
|
8
8
|
// Homebrew is required
|
|
9
|
-
if (!(await shellOk("which brew"))) {
|
|
9
|
+
if (!(await shellOk("which", ["brew"]))) {
|
|
10
10
|
missing.push("Homebrew — install from https://brew.sh");
|
|
11
11
|
}
|
|
12
12
|
// Git is required (for cloning)
|
|
13
|
-
if (!(await shellOk("which git"))) {
|
|
13
|
+
if (!(await shellOk("which", ["git"]))) {
|
|
14
14
|
missing.push("git — install via: brew install git");
|
|
15
15
|
}
|
|
16
16
|
return {
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* health check waits, and graceful shutdown.
|
|
6
6
|
*/
|
|
7
7
|
import type { NormalizedHealth, NormalizedDependsOnEntry } from "@launchfile/sdk";
|
|
8
|
+
/** A spawned component process recorded for cross-session shutdown. */
|
|
9
|
+
export interface RecordedProcessInfo {
|
|
10
|
+
pid: number;
|
|
11
|
+
pgid: number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
command: string;
|
|
14
|
+
}
|
|
8
15
|
export declare class ProcessManager {
|
|
9
16
|
private processes;
|
|
10
17
|
private logDir;
|
|
@@ -32,6 +39,12 @@ export declare class ProcessManager {
|
|
|
32
39
|
* Returns batches of component names that can start concurrently.
|
|
33
40
|
*/
|
|
34
41
|
private topologicalSort;
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
44
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
45
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
46
|
+
*/
|
|
47
|
+
getRecordedProcesses(): Record<string, RecordedProcessInfo>;
|
|
35
48
|
/** Get status summary for all processes */
|
|
36
49
|
getStatus(): Array<{
|
|
37
50
|
name: string;
|
package/dist/process-manager.js
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { createWriteStream, mkdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { waitForHealthy } from "./health.js";
|
|
11
|
+
import { redactSecrets } from "./redact.js";
|
|
11
12
|
// ANSI colors for log prefixing
|
|
12
13
|
const COLORS = [
|
|
13
14
|
"\x1b[36m", // cyan
|
|
@@ -18,6 +19,28 @@ const COLORS = [
|
|
|
18
19
|
"\x1b[31m", // red
|
|
19
20
|
];
|
|
20
21
|
const RESET = "\x1b[0m";
|
|
22
|
+
/**
|
|
23
|
+
* Signal a detached child's whole process group (negative pid). Falls back to
|
|
24
|
+
* signaling just the child handle if the group signal fails (e.g. the group is
|
|
25
|
+
* already gone). Best-effort: swallows ESRCH so shutdown stays idempotent.
|
|
26
|
+
*/
|
|
27
|
+
function killGroupOrSelf(proc, pid, signal) {
|
|
28
|
+
if (pid !== undefined) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(-pid, signal);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Group gone or not a group leader — fall through to handle kill.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
proc?.kill(signal);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Already exited.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
21
44
|
export class ProcessManager {
|
|
22
45
|
processes = new Map();
|
|
23
46
|
logDir;
|
|
@@ -67,17 +90,26 @@ export class ProcessManager {
|
|
|
67
90
|
// For "started" condition, the process is already spawned by the time we get here
|
|
68
91
|
}
|
|
69
92
|
proc.status = "starting";
|
|
70
|
-
console.log(` [${name}] Starting: ${proc.command}`);
|
|
93
|
+
console.log(` [${name}] Starting: ${redactSecrets(proc.command)}`);
|
|
71
94
|
const logFile = createWriteStream(join(this.logDir, `${name}.log`), { flags: "a" });
|
|
72
95
|
const colorIdx = [...this.processes.keys()].indexOf(name) % COLORS.length;
|
|
73
96
|
const color = COLORS[colorIdx];
|
|
74
97
|
const maxNameLen = Math.max(...[...this.processes.keys()].map((n) => n.length));
|
|
75
98
|
const paddedName = name.padEnd(maxNameLen);
|
|
99
|
+
// `detached: true` makes the child the leader of a new process group
|
|
100
|
+
// (pgid === pid). That lets `launch down` signal the whole group later via
|
|
101
|
+
// a negative pid, killing the app AND any children it spawned — matching
|
|
102
|
+
// the foreground SIGINT behavior across sessions. We still keep the handle
|
|
103
|
+
// so the foreground session can kill it directly on Ctrl+C.
|
|
76
104
|
proc.process = spawn("sh", ["-c", proc.command], {
|
|
77
105
|
env: { ...process.env, ...proc.env },
|
|
78
106
|
cwd: proc.cwd,
|
|
79
107
|
stdio: ["ignore", "pipe", "pipe"],
|
|
108
|
+
detached: true,
|
|
80
109
|
});
|
|
110
|
+
if (proc.process.pid !== undefined) {
|
|
111
|
+
proc.startedAt = new Date().toISOString();
|
|
112
|
+
}
|
|
81
113
|
// Pipe stdout with prefix
|
|
82
114
|
proc.process.stdout?.on("data", (data) => {
|
|
83
115
|
const lines = data.toString().split("\n");
|
|
@@ -125,15 +157,19 @@ export class ProcessManager {
|
|
|
125
157
|
resolve();
|
|
126
158
|
return;
|
|
127
159
|
}
|
|
160
|
+
const pid = proc.process.pid;
|
|
128
161
|
const timeout = setTimeout(() => {
|
|
129
|
-
|
|
162
|
+
// Escalate to the whole group so stray children die too.
|
|
163
|
+
killGroupOrSelf(proc.process, pid, "SIGKILL");
|
|
130
164
|
}, 10_000);
|
|
131
165
|
proc.process.once("exit", () => {
|
|
132
166
|
clearTimeout(timeout);
|
|
133
167
|
proc.status = "stopped";
|
|
134
168
|
resolve();
|
|
135
169
|
});
|
|
136
|
-
|
|
170
|
+
// Children are spawned detached (own process group), so signal the
|
|
171
|
+
// group (negative pid) to reap any grandchildren too.
|
|
172
|
+
killGroupOrSelf(proc.process, pid, "SIGTERM");
|
|
137
173
|
});
|
|
138
174
|
}
|
|
139
175
|
/**
|
|
@@ -175,6 +211,26 @@ export class ProcessManager {
|
|
|
175
211
|
}
|
|
176
212
|
return batches;
|
|
177
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
216
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
217
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
218
|
+
*/
|
|
219
|
+
getRecordedProcesses() {
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const [name, proc] of this.processes) {
|
|
222
|
+
const pid = proc.process?.pid;
|
|
223
|
+
if (pid === undefined || proc.startedAt === undefined)
|
|
224
|
+
continue;
|
|
225
|
+
out[name] = {
|
|
226
|
+
pid,
|
|
227
|
+
pgid: pid,
|
|
228
|
+
startedAt: proc.startedAt,
|
|
229
|
+
command: proc.command,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
178
234
|
/** Get status summary for all processes */
|
|
179
235
|
getStatus() {
|
|
180
236
|
return [...this.processes.entries()].map(([name, proc]) => ({
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
/** Signal-sending surface, injectable so tests never touch real processes. */
|
|
35
|
+
export interface SignalFns {
|
|
36
|
+
/**
|
|
37
|
+
* Mirror of `process.kill`. Sending signal `0` performs a liveness probe
|
|
38
|
+
* (throws ESRCH if the target does not exist). A negative pid targets a
|
|
39
|
+
* process group.
|
|
40
|
+
*/
|
|
41
|
+
kill: (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the live process's start time (epoch ms) for `pid`, or null if the
|
|
44
|
+
* process is gone or the start time can't be determined.
|
|
45
|
+
*/
|
|
46
|
+
startTime: (pid: number) => Promise<number | null>;
|
|
47
|
+
}
|
|
48
|
+
/** Outcome of attempting to stop one recorded process, for logging/tests. */
|
|
49
|
+
export type StopOutcome = {
|
|
50
|
+
component: string;
|
|
51
|
+
result: "stopped";
|
|
52
|
+
} | {
|
|
53
|
+
component: string;
|
|
54
|
+
result: "already-dead";
|
|
55
|
+
} | {
|
|
56
|
+
component: string;
|
|
57
|
+
result: "identity-mismatch";
|
|
58
|
+
} | {
|
|
59
|
+
component: string;
|
|
60
|
+
result: "error";
|
|
61
|
+
error: string;
|
|
62
|
+
};
|
|
63
|
+
/** A short-lived recorded process, structurally matching ProcessState. */
|
|
64
|
+
export interface RecordedProcess {
|
|
65
|
+
pid: number;
|
|
66
|
+
pgid: number;
|
|
67
|
+
startedAt: string;
|
|
68
|
+
command: string;
|
|
69
|
+
}
|
|
70
|
+
/** Default real implementation backed by `process.kill` and `ps`. */
|
|
71
|
+
export declare const realSignalFns: SignalFns;
|
|
72
|
+
/**
|
|
73
|
+
* Verify a recorded process is still the one we started.
|
|
74
|
+
*
|
|
75
|
+
* Returns:
|
|
76
|
+
* - "alive-verified": process exists AND start time is consistent → safe to signal
|
|
77
|
+
* - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
|
|
78
|
+
* - "dead": no such process (ESRCH) → already stopped, skip
|
|
79
|
+
* - "mismatch": process exists but started too late → recycled pid, DO NOT signal
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkIdentity(rec: RecordedProcess, fns: SignalFns): Promise<"alive-verified" | "alive-unverified" | "dead" | "mismatch">;
|
|
82
|
+
/**
|
|
83
|
+
* Stop one recorded process: graceful SIGTERM to the group, then escalate to
|
|
84
|
+
* SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
|
|
85
|
+
* (negative pgid) so child processes the app spawned die too.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stopProcess(component: string, rec: RecordedProcess, fns: SignalFns, opts?: {
|
|
88
|
+
graceMs?: number;
|
|
89
|
+
sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
}): Promise<StopOutcome>;
|
|
91
|
+
/**
|
|
92
|
+
* Stop every recorded process. Pure orchestration over `stopProcess`; returns
|
|
93
|
+
* one outcome per component so the caller can report and tests can assert.
|
|
94
|
+
*/
|
|
95
|
+
export declare function stopRecordedProcesses(processes: Record<string, RecordedProcess>, fns?: SignalFns, opts?: {
|
|
96
|
+
graceMs?: number;
|
|
97
|
+
sleep?: (ms: number) => Promise<void>;
|
|
98
|
+
}): Promise<StopOutcome[]>;
|
|
99
|
+
//# sourceMappingURL=process-stopper.d.ts.map
|