@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
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Cross-session app-process termination for `launch down`.
3
+ *
4
+ * The foreground `launch up` session kills its children directly via the
5
+ * ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
6
+ * (or after the up-session has exited), so it has no live ChildProcess handles —
7
+ * only the pids recorded in `.launchfile/state.json`. This module signals those
8
+ * recorded process groups, with liveness + identity checks to avoid killing an
9
+ * unrelated process that happens to have inherited a recycled pid.
10
+ *
11
+ * ## pid-reuse identity guarantee (read this before trusting the kill)
12
+ *
13
+ * macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
14
+ * belong to a completely unrelated process. Before sending any signal we:
15
+ *
16
+ * 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
17
+ * exists. If it's dead, we skip (already stopped).
18
+ * 2. **Identity** — we compare the recorded spawn time against the live
19
+ * process's actual start time via `ps -o lstart= -p <pid>`. If the live
20
+ * process started meaningfully later than we recorded, the pid was
21
+ * recycled and we REFUSE to signal it.
22
+ *
23
+ * This is a best-effort guarantee, not a cryptographic one. Its honest limits:
24
+ * - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
25
+ * recycled process that started within that window of the original spawn
26
+ * could theoretically slip through — vanishingly unlikely in practice.
27
+ * - If `ps` is unavailable/fails, we fall back to liveness-only and signal
28
+ * conservatively ONLY the process group (never a bare pid), accepting the
29
+ * small residual risk rather than orphaning the user's processes forever.
30
+ * - We signal the process GROUP (negative pgid) to also reap children the app
31
+ * spawned, matching the foreground SIGINT behavior. A recycled *group*
32
+ * leader is the residual risk the start-time check exists to close.
33
+ */
34
+ import { execFile } from "node:child_process";
35
+ /**
36
+ * Identity tolerance: how much later than `startedAt` a live process may report
37
+ * having started before we treat it as a recycled pid. `ps lstart` rounds to
38
+ * whole seconds and there's scheduling slop between our `Date.now()` snapshot
39
+ * and the kernel's recorded start, so we allow a few seconds of forward drift.
40
+ */
41
+ const START_TIME_TOLERANCE_MS = 3000;
42
+ /** Default real implementation backed by `process.kill` and `ps`. */
43
+ export const realSignalFns = {
44
+ kill: (pid, signal) => {
45
+ process.kill(pid, signal);
46
+ },
47
+ startTime: (pid) => queryStartTime(pid),
48
+ };
49
+ /**
50
+ * Query a process's start time via `ps -o lstart= -p <pid>` using array args
51
+ * (no shell, no interpolation). Returns epoch ms, or null on any failure.
52
+ */
53
+ function queryStartTime(pid) {
54
+ return new Promise((resolve) => {
55
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], (error, stdout) => {
56
+ if (error) {
57
+ resolve(null);
58
+ return;
59
+ }
60
+ const text = stdout.trim();
61
+ if (!text) {
62
+ resolve(null);
63
+ return;
64
+ }
65
+ const parsed = Date.parse(text);
66
+ resolve(Number.isNaN(parsed) ? null : parsed);
67
+ });
68
+ });
69
+ }
70
+ /**
71
+ * Verify a recorded process is still the one we started.
72
+ *
73
+ * Returns:
74
+ * - "alive-verified": process exists AND start time is consistent → safe to signal
75
+ * - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
76
+ * - "dead": no such process (ESRCH) → already stopped, skip
77
+ * - "mismatch": process exists but started too late → recycled pid, DO NOT signal
78
+ */
79
+ export async function checkIdentity(rec, fns) {
80
+ // Liveness probe.
81
+ try {
82
+ fns.kill(rec.pid, 0);
83
+ }
84
+ catch (err) {
85
+ if (isErrno(err) && err.code === "ESRCH")
86
+ return "dead";
87
+ // EPERM means it exists but we can't signal it — treat as alive-unverified.
88
+ if (isErrno(err) && err.code === "EPERM")
89
+ return "alive-unverified";
90
+ return "dead";
91
+ }
92
+ const liveStart = await fns.startTime(rec.pid);
93
+ if (liveStart === null)
94
+ return "alive-unverified";
95
+ const recordedStart = Date.parse(rec.startedAt);
96
+ if (Number.isNaN(recordedStart))
97
+ return "alive-unverified";
98
+ // If the live process started meaningfully *after* we recorded the spawn,
99
+ // the original exited and the pid was recycled. Refuse to signal it.
100
+ if (liveStart > recordedStart + START_TIME_TOLERANCE_MS)
101
+ return "mismatch";
102
+ return "alive-verified";
103
+ }
104
+ /**
105
+ * Stop one recorded process: graceful SIGTERM to the group, then escalate to
106
+ * SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
107
+ * (negative pgid) so child processes the app spawned die too.
108
+ */
109
+ export async function stopProcess(component, rec, fns, opts = {}) {
110
+ const graceMs = opts.graceMs ?? 5000;
111
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
112
+ const identity = await checkIdentity(rec, fns);
113
+ if (identity === "dead")
114
+ return { component, result: "already-dead" };
115
+ if (identity === "mismatch")
116
+ return { component, result: "identity-mismatch" };
117
+ // Target the process group. pgid is the leader pid because we spawned detached.
118
+ const groupTarget = -rec.pgid;
119
+ try {
120
+ fns.kill(groupTarget, "SIGTERM");
121
+ }
122
+ catch (err) {
123
+ if (isErrno(err) && err.code === "ESRCH")
124
+ return { component, result: "already-dead" };
125
+ return { component, result: "error", error: errMessage(err) };
126
+ }
127
+ // Wait for graceful exit, then escalate if the leader is still alive.
128
+ await sleep(graceMs);
129
+ let stillAlive = true;
130
+ try {
131
+ fns.kill(rec.pid, 0);
132
+ }
133
+ catch {
134
+ stillAlive = false;
135
+ }
136
+ if (stillAlive) {
137
+ try {
138
+ fns.kill(groupTarget, "SIGKILL");
139
+ }
140
+ catch (err) {
141
+ if (!(isErrno(err) && err.code === "ESRCH")) {
142
+ return { component, result: "error", error: errMessage(err) };
143
+ }
144
+ }
145
+ }
146
+ return { component, result: "stopped" };
147
+ }
148
+ /**
149
+ * Stop every recorded process. Pure orchestration over `stopProcess`; returns
150
+ * one outcome per component so the caller can report and tests can assert.
151
+ */
152
+ export async function stopRecordedProcesses(processes, fns = realSignalFns, opts = {}) {
153
+ const outcomes = [];
154
+ for (const [component, rec] of Object.entries(processes)) {
155
+ outcomes.push(await stopProcess(component, rec, fns, opts));
156
+ }
157
+ return outcomes;
158
+ }
159
+ function isErrno(err) {
160
+ return typeof err === "object" && err !== null && "code" in err;
161
+ }
162
+ function errMessage(err) {
163
+ return err instanceof Error ? err.message : String(err);
164
+ }
165
+ //# sourceMappingURL=process-stopper.js.map
@@ -4,13 +4,63 @@
4
4
  * Reads a Launchfile, provisions resources, resolves env vars,
5
5
  * installs runtimes, and starts all components.
6
6
  */
7
+ import { type NormalizedLaunch, type NormalizedComponent } from "@launchfile/sdk";
8
+ /**
9
+ * This provider runs apps from source. A component is source-runnable when
10
+ * {@link resolveSourceRunCommand} (D-38) resolves a command — declares `dev`,
11
+ * or a `start` with no `image`. An `image` without a `dev` override stays
12
+ * artifact-mode, which this source-only provider can't launch.
13
+ */
14
+ export declare function isSourceRunnable(component: NormalizedComponent): boolean;
7
15
  export interface LaunchUpOpts {
8
16
  withOptional?: boolean;
9
17
  noBuild?: boolean;
10
18
  detach?: boolean;
11
19
  dryRun?: boolean;
12
20
  projectDir?: string;
21
+ /**
22
+ * Component selector (#77): if non-empty, these components plus their
23
+ * transitive downward `depends_on` closure are started (D-41), and the
24
+ * `requires` of every closure member are provisioned. The start-set is the
25
+ * SDK's `selectionClosure` — the same definition the Docker provider uses —
26
+ * so both yield the identical running topology (P-5). Empty = all components.
27
+ */
28
+ components?: string[];
13
29
  }
30
+ /**
31
+ * Components this provider must refuse, mapped to the capabilities it cannot
32
+ * grant (D-44, PROVIDERS.md §11). Both spellings fold together so the `host:`
33
+ * entry form and the legacy top-level block produce the same outcome.
34
+ *
35
+ * A refusal must remove the component from the run, not merely report it —
36
+ * this provider grants no host capabilities, so anything listed here cannot
37
+ * be installed, wired, registered, or started.
38
+ */
39
+ export declare function refusedHostCapabilities(launch: NormalizedLaunch): Map<string, string[]>;
40
+ /**
41
+ * The launch-time notice a provider without a scheduler owes for a declared
42
+ * `schedule` (D-51, PROVIDERS.md §10 item 8).
43
+ *
44
+ * States what *this provider* does, not what will happen to the app: a
45
+ * component may schedule itself — `catalog/drafts/diun` sets its own
46
+ * `DIUN_WATCH_SCHEDULE`, and nextcloud's `cron.sh` is a foreground `crond` —
47
+ * so claiming the job will not run would be false about those apps, and a
48
+ * warning that misstates the user's app is worse than the silence it replaces.
49
+ */
50
+ export declare function scheduleWarning(component: string, schedule: string): string;
51
+ /**
52
+ * Remove every component this provider must refuse, and say so on stderr.
53
+ *
54
+ * The removal is the refusal (D-44, PROVIDERS.md §11): a component left in the
55
+ * map goes on to be installed, env-wired, registered with the process manager
56
+ * and started, so logging alone would have the provider assert a refusal it
57
+ * did not perform. Mutates `launch.components` for exactly that reason —
58
+ * everything downstream reads it.
59
+ *
60
+ * Returns "none-left" when nothing survives, so the caller can fail rather than
61
+ * report success over an empty set.
62
+ */
63
+ export declare function applyHostCapabilityRefusals(launch: NormalizedLaunch): "ok" | "none-left";
14
64
  export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
15
65
  export declare function launchDown(opts?: {
16
66
  destroy?: boolean;