@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.
Files changed (55) hide show
  1. package/dist/bootstrap.d.ts +56 -8
  2. package/dist/bootstrap.js +170 -93
  3. package/dist/env-writer.d.ts +40 -11
  4. package/dist/env-writer.js +84 -23
  5. package/dist/health.d.ts +8 -2
  6. package/dist/health.js +11 -17
  7. package/dist/prereqs.js +2 -2
  8. package/dist/process-manager.d.ts +13 -0
  9. package/dist/process-manager.js +59 -3
  10. package/dist/process-stopper.d.ts +99 -0
  11. package/dist/process-stopper.js +165 -0
  12. package/dist/provider.d.ts +50 -0
  13. package/dist/provider.js +337 -28
  14. package/dist/redact.d.ts +55 -0
  15. package/dist/redact.js +92 -0
  16. package/dist/resources/identifiers.d.ts +22 -0
  17. package/dist/resources/identifiers.js +31 -0
  18. package/dist/resources/index.d.ts +3 -3
  19. package/dist/resources/index.js +21 -10
  20. package/dist/resources/mysql.d.ts +3 -1
  21. package/dist/resources/mysql.js +49 -11
  22. package/dist/resources/postgres.d.ts +4 -1
  23. package/dist/resources/postgres.js +63 -17
  24. package/dist/resources/redis.d.ts +3 -1
  25. package/dist/resources/redis.js +14 -4
  26. package/dist/resources/sqlite.js +2 -2
  27. package/dist/resources/types.d.ts +21 -3
  28. package/dist/runtimes/bun.js +2 -2
  29. package/dist/runtimes/installed-versions.d.ts +12 -0
  30. package/dist/runtimes/installed-versions.js +21 -0
  31. package/dist/runtimes/node.js +13 -8
  32. package/dist/runtimes/python.js +14 -7
  33. package/dist/runtimes/ruby.js +13 -8
  34. package/dist/secret-generator.js +11 -3
  35. package/dist/shell.d.ts +28 -6
  36. package/dist/shell.js +83 -28
  37. package/dist/state.d.ts +38 -0
  38. package/dist/state.js +12 -1
  39. package/dist/storage.d.ts +16 -3
  40. package/dist/storage.js +23 -6
  41. package/package.json +7 -4
  42. package/dist/__tests__/bootstrap.test.d.ts +0 -2
  43. package/dist/__tests__/bootstrap.test.js +0 -90
  44. package/dist/__tests__/dry-run.test.d.ts +0 -2
  45. package/dist/__tests__/dry-run.test.js +0 -141
  46. package/dist/__tests__/env-writer.test.d.ts +0 -2
  47. package/dist/__tests__/env-writer.test.js +0 -201
  48. package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
  49. package/dist/__tests__/lockfile-detect.test.js +0 -75
  50. package/dist/__tests__/port-allocator.test.d.ts +0 -2
  51. package/dist/__tests__/port-allocator.test.js +0 -53
  52. package/dist/__tests__/secret-generator.test.d.ts +0 -2
  53. package/dist/__tests__/secret-generator.test.js +0 -26
  54. package/dist/__tests__/state.test.d.ts +0 -2
  55. package/dist/__tests__/state.test.js +0 -30
@@ -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 macos-dev
13
- * provider. The app's "primary" port comes from the first component (in
14
- * declaration order) that has at least one `exposed: true` provides entry.
15
- * Apps with no exposed component get `port: 0` and `url: ""`.
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
- const hasExposed = component.provides?.some((p) => p.exposed !== false) ?? false;
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: primaryPort > 0 ? `http://localhost:${primaryPort}` : "",
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
- ...context,
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
- ...context,
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, context);
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
- return env;
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
- * Generate values for env vars that have generators.
143
- * Mutates the env record in place.
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
- return `${key}="${value.replace(/"/g, '\\"')}"`;
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 { NormalizedHealth } from "@launchfile/sdk";
5
- /** Parse a duration string like "30s", "1m", "500ms" to milliseconds */
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 { shell } from "./shell.js";
5
- /** Parse a duration string like "30s", "1m", "500ms" to milliseconds */
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
- const match = /^(\d+)(ms|s|m)$/.exec(duration);
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 shell(health.command, { timeout: checkTimeout, silent: true });
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;
@@ -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
- proc.process?.kill("SIGKILL");
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
- proc.process.kill("SIGTERM");
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