@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,50 +1,108 @@
1
1
  import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
- import { CONFIG_DIR } from "../config.ts";
5
- import { knownServices } from "../service-spec.ts";
4
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
5
+ import { HUB_SVC } from "../hub-control.ts";
6
+ import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
7
+ import { knownServices, shortNameForManifest } from "../service-spec.ts";
8
+ import { readManifestLenient } from "../services-manifest.ts";
6
9
 
7
10
  /**
8
- * `parachute migrate` — sweep unrecognized entries at the ecosystem root
11
+ * `parachute migrate` — sweep known-cruft entries at the ecosystem root
9
12
  * (`~/.parachute/`) into a dated archive directory so pre-restructure cruft
10
13
  * doesn't confuse beta installs.
11
14
  *
15
+ * Allowlist-of-what-to-archive (the original 2026-05-27 redesign closed
16
+ * the prior blocklist-with-safelist shape, hub#440). The risk with the
17
+ * older shape: anything new appearing at the ecosystem root (a future
18
+ * module's dir, `hub.db`, a user's own dotfile, etc.) would get swept by
19
+ * default unless someone remembered to add it to the safelist. Aaron
20
+ * caught a near-miss where `hub.db` was missing from the safelist; that
21
+ * was the trigger for flipping the model.
22
+ *
23
+ * The new model:
24
+ *
25
+ * - `KNOWN_CRUFT` is the *actual archive criterion* — only entries that
26
+ * match a rule there get archived.
27
+ * - `KNOWN_ARCHIVABLE_DIRS` covers directories we explicitly sweep (the
28
+ * `lens` legacy dir from the Notes→Lens→Notes rename round-trip).
29
+ * - Everything else — including new modules' dirs, future state files,
30
+ * and the user's own stray files — gets a `[unknown — skipping]`
31
+ * annotation and is left in place. The user can remove unknowns
32
+ * manually if they want.
33
+ *
12
34
  * Archive, never delete: moved under `.archive-<YYYY-MM-DD>/` so anything
13
- * swept is recoverable. Dotfiles and the recognized top-level entries
14
- * (service dirs, services.json, expose-state.json, well-known/) are left
15
- * alone. Content *inside* service dirs is owned by that service's own
16
- * migration.
35
+ * swept is recoverable. Dotfiles at root are still left alone (the user's
36
+ * own `.env`, `.DS_Store`, prior `.archive-*` dirs).
37
+ *
38
+ * Cut-2 safety procedures (also 2026-05-27):
39
+ *
40
+ * - Refuses to sweep while any service in `services.json` (plus the hub)
41
+ * is currently running — moving a path a daemon owns would corrupt
42
+ * state.
43
+ * - SQLite-shaped files (`*.db`, `*.db-wal`, `*.db-shm`) get a `[live-db]`
44
+ * risk label and trigger an extra confirmation noting wal/shm
45
+ * consistency.
46
+ * - The printed plan annotates each item `[safe]` / `[live-db]` /
47
+ * `[unknown — skipping]`, sorts skipped items last.
48
+ * - Non-TTY invocations refuse without `--yes` so a pipe from CI can't
49
+ * accidentally archive a real install.
50
+ * - `--list` shows what would happen without prompting (friendlier
51
+ * phrasing of `--dry-run`).
17
52
  */
18
53
 
19
54
  export const ARCHIVE_PREFIX = ".archive-";
20
55
 
21
56
  /**
22
- * Top-level names we keep in place. Service dirs derive from
23
- * `knownServices()` so adding a service doesn't require touching migrate;
24
- * `hub` is added explicitly since it's an internal-only lifecycle dir not
25
- * in SERVICE_SPECS.
57
+ * Top-level names we leave in place. Service dirs derive from
58
+ * `knownServices()`; `hub` is added explicitly since it's an internal-only
59
+ * lifecycle dir not in SERVICE_SPECS. Stays in step with the rest of the
60
+ * hub's view of what belongs at the root, so `migrateNotice` and
61
+ * `parachute install`'s post-install hint don't false-positive on the
62
+ * latest legitimate root entries.
26
63
  *
27
- * `lens` is kept across the Notes→Lens→Notes rename round-trip
28
- * (Apr 19 Apr 22): users who installed during the brief Lens window
29
- * have `~/.parachute/lens/` dirs that shouldn't get swept into
30
- * `.archive-*` on upgrade. Safe to remove once launch users have all
31
- * had a chance to re-install under the restored name.
64
+ * Note: under the allowlist model the safelist is now informational —
65
+ * archiving only ever happens for entries that match an explicit rule
66
+ * (`KNOWN_CRUFT` / `KNOWN_ARCHIVABLE_DIRS`). The safelist is still
67
+ * retained so `migrateNotice`'s count of "things at the root that aren't
68
+ * recognized" stays meaningful, and so safelisted entries don't show up
69
+ * as `[unknown — skipping]` noise in the plan.
32
70
  */
33
71
  export function safelistEntries(): Set<string> {
34
72
  return new Set<string>([
35
73
  ...knownServices(),
36
- "lens",
37
74
  "hub",
38
75
  "services.json",
39
76
  "expose-state.json",
77
+ "cloudflared-state.json",
78
+ "hub.db",
79
+ "hub.db-wal",
80
+ "hub.db-shm",
40
81
  "well-known",
82
+ "cloudflared",
41
83
  ]);
42
84
  }
43
85
 
44
86
  /**
45
- * Friendly labels for entries we've seen in the wild. Matched by exact name
46
- * or (for sqlite companion files) by prefix. Purely cosmetic drives the
47
- * annotation column in the plan printout.
87
+ * Allowlist of known-archivable directories. Distinct from `KNOWN_CRUFT`
88
+ * (which matches by name/prefix predicate)directories listed here are
89
+ * always treated as `safe` to archive.
90
+ *
91
+ * `lens` is the only entry: a legacy directory left over from the brief
92
+ * Notes→Lens→Notes rename round-trip (2026-04-19 → 2026-04-22). Users who
93
+ * installed during that window have `~/.parachute/lens/` dirs that should
94
+ * be archived now that the rename is finished. Safe to drop from this
95
+ * list once enough operator cycles have passed to be confident no
96
+ * install is still on the Lens-era code path.
97
+ */
98
+ export const KNOWN_ARCHIVABLE_DIRS: ReadonlySet<string> = new Set<string>(["lens"]);
99
+
100
+ /**
101
+ * Known-cruft rules. Each rule names a label (the friendly description in
102
+ * the plan) and a predicate that matches by name or prefix. Under the
103
+ * 2026-05-27 redesign this is the *primary* archive criterion: an entry
104
+ * at the ecosystem root that doesn't match here (or `KNOWN_ARCHIVABLE_DIRS`)
105
+ * is treated as unknown and left in place.
48
106
  */
49
107
  const KNOWN_CRUFT: Array<{ match: (name: string) => boolean; label: string }> = [
50
108
  {
@@ -61,24 +119,49 @@ const KNOWN_CRUFT: Array<{ match: (name: string) => boolean; label: string }> =
61
119
  },
62
120
  ];
63
121
 
64
- function annotationFor(name: string): string | undefined {
122
+ function cruftLabel(name: string): string | undefined {
65
123
  for (const rule of KNOWN_CRUFT) if (rule.match(name)) return rule.label;
66
124
  return undefined;
67
125
  }
68
126
 
69
- export interface ArchiveItem {
127
+ /**
128
+ * SQLite shape — `.db` plus its WAL/SHM companions. Files with this shape
129
+ * carry the `live-db` risk label and pull a second confirmation in the
130
+ * interactive path because the three files are only consistent as a set.
131
+ */
132
+ function isSqliteShape(name: string): boolean {
133
+ return /\.db(?:-wal|-shm)?$/.test(name);
134
+ }
135
+
136
+ export type RiskLabel = "safe" | "live-db" | "unknown";
137
+
138
+ export interface PlanItem {
70
139
  name: string;
71
140
  absPath: string;
72
141
  kind: "file" | "dir";
73
142
  bytes: number;
143
+ /** Friendly description (e.g. "legacy parachute-daily state"). */
74
144
  annotation?: string;
145
+ risk: RiskLabel;
146
+ /** True iff this item will actually be moved into the archive. */
147
+ archive: boolean;
75
148
  }
76
149
 
150
+ /**
151
+ * Kept as an alias for back-compat with callers / older tests that import
152
+ * `ArchiveItem`. Same shape as `PlanItem`.
153
+ */
154
+ export type ArchiveItem = PlanItem;
155
+
77
156
  export interface ArchivePlan {
78
157
  archiveDirName: string;
79
158
  archiveDir: string;
80
- items: ArchiveItem[];
159
+ /** Every entry encountered at the root (archivable + skipped). */
160
+ items: PlanItem[];
161
+ /** Total bytes across `archive: true` items only. */
81
162
  totalBytes: number;
163
+ /** True iff at least one `archive: true` item is a SQLite-shape file. */
164
+ hasLiveDb: boolean;
82
165
  }
83
166
 
84
167
  function sizeOf(path: string): number {
@@ -95,12 +178,36 @@ function archiveDirName(now: Date): string {
95
178
  return `${ARCHIVE_PREFIX}${now.toISOString().slice(0, 10)}`;
96
179
  }
97
180
 
181
+ /**
182
+ * Classify a single root entry against the allowlist + known-cruft rules.
183
+ * `safelist` is the recognized-and-leave-alone set; nothing on it ever
184
+ * reaches the plan at all (filtered out upstream).
185
+ */
186
+ function classify(name: string): { risk: RiskLabel; annotation?: string; archive: boolean } {
187
+ const cruft = cruftLabel(name);
188
+ if (cruft) {
189
+ const risk: RiskLabel = isSqliteShape(name) ? "live-db" : "safe";
190
+ return { risk, annotation: cruft, archive: true };
191
+ }
192
+ if (KNOWN_ARCHIVABLE_DIRS.has(name)) {
193
+ return { risk: "safe", annotation: "legacy directory", archive: true };
194
+ }
195
+ return { risk: "unknown", archive: false };
196
+ }
197
+
98
198
  /**
99
199
  * Inspect the ecosystem root and build a plan. Pure-ish: reads filesystem
100
- * but never mutates. Returns zero-length items when nothing is archivable.
200
+ * but never mutates. Returns zero-length items when nothing is at the root
201
+ * beyond the safelist.
101
202
  *
102
203
  * Rule: skip anything starting with "." (dotfiles are the user's — `.env`,
103
204
  * `.DS_Store`, prior `.archive-*` dirs, etc.) and anything in the safelist.
205
+ * Everything else goes into the plan with a classification; only items
206
+ * with `archive: true` are actually swept.
207
+ *
208
+ * Sort order: archivable items first (alphabetical), then skipped items
209
+ * last — keeps the "what will happen" reading at the top of the plan
210
+ * printout.
104
211
  */
105
212
  export function planArchive(configDir: string, now: Date): ArchivePlan {
106
213
  const dirName = archiveDirName(now);
@@ -110,6 +217,7 @@ export function planArchive(configDir: string, now: Date): ArchivePlan {
110
217
  archiveDir,
111
218
  items: [],
112
219
  totalBytes: 0,
220
+ hasLiveDb: false,
113
221
  };
114
222
  if (!existsSync(configDir)) return plan;
115
223
 
@@ -117,35 +225,50 @@ export function planArchive(configDir: string, now: Date): ArchivePlan {
117
225
  const entries = readdirSync(configDir, { withFileTypes: true }).sort((a, b) =>
118
226
  a.name.localeCompare(b.name),
119
227
  );
228
+ const collected: PlanItem[] = [];
120
229
  for (const entry of entries) {
121
230
  if (entry.name.startsWith(".")) continue;
122
231
  if (safelist.has(entry.name)) continue;
123
232
  const abs = join(configDir, entry.name);
233
+ const cls = classify(entry.name);
124
234
  // Dirent.isDirectory() follows symlinks on macOS/Linux — so a link
125
235
  // pointing at an external tree would get sized via sizeOf() (bogus
126
236
  // byte count, and potentially a slow walk through /mnt/... or similar).
127
237
  // Classify the link itself as a zero-byte "file"; renameSync moves the
128
238
  // link, not the target, which is the behavior we want.
129
239
  if (entry.isSymbolicLink()) {
130
- plan.items.push({
240
+ const item: PlanItem = {
131
241
  name: entry.name,
132
242
  absPath: abs,
133
243
  kind: "file",
134
244
  bytes: 0,
135
- annotation: annotationFor(entry.name),
136
- });
245
+ risk: cls.risk,
246
+ archive: cls.archive,
247
+ };
248
+ if (cls.annotation !== undefined) item.annotation = cls.annotation;
249
+ collected.push(item);
137
250
  continue;
138
251
  }
139
- const bytes = sizeOf(abs);
140
- plan.items.push({
252
+ const bytes = cls.archive ? sizeOf(abs) : 0;
253
+ const item: PlanItem = {
141
254
  name: entry.name,
142
255
  absPath: abs,
143
256
  kind: entry.isDirectory() ? "dir" : "file",
144
257
  bytes,
145
- annotation: annotationFor(entry.name),
146
- });
147
- plan.totalBytes += bytes;
258
+ risk: cls.risk,
259
+ archive: cls.archive,
260
+ };
261
+ if (cls.annotation !== undefined) item.annotation = cls.annotation;
262
+ collected.push(item);
263
+ if (cls.archive) plan.totalBytes += bytes;
148
264
  }
265
+ // Sort: archivable first (alpha), then skipped (alpha). Stable across runs.
266
+ collected.sort((a, b) => {
267
+ if (a.archive !== b.archive) return a.archive ? -1 : 1;
268
+ return a.name.localeCompare(b.name);
269
+ });
270
+ plan.items = collected;
271
+ plan.hasLiveDb = collected.some((i) => i.archive && i.risk === "live-db");
149
272
  return plan;
150
273
  }
151
274
 
@@ -161,15 +284,35 @@ function formatBytes(bytes: number): string {
161
284
  return `${v.toFixed(1)} ${units[i]}`;
162
285
  }
163
286
 
287
+ function riskTag(item: PlanItem): string {
288
+ if (!item.archive) return "[unknown — skipping]";
289
+ if (item.risk === "live-db") return "[live-db]";
290
+ return "[safe]";
291
+ }
292
+
164
293
  function formatPlan(plan: ArchivePlan): string[] {
165
294
  const lines: string[] = [];
295
+ const archivable = plan.items.filter((i) => i.archive);
296
+ const skipped = plan.items.filter((i) => !i.archive);
166
297
  lines.push(
167
- `Will archive: ${plan.items.length} item${plan.items.length === 1 ? "" : "s"} (${formatBytes(plan.totalBytes)}) → ${plan.archiveDirName}/`,
298
+ `Will archive: ${archivable.length} item${archivable.length === 1 ? "" : "s"} (${formatBytes(plan.totalBytes)}) → ${plan.archiveDirName}/`,
168
299
  );
169
- for (const item of plan.items) {
300
+ for (const item of archivable) {
170
301
  const kindMark = item.kind === "dir" ? "/" : "";
171
302
  const note = item.annotation ? ` — ${item.annotation}` : "";
172
- lines.push(` ${item.name}${kindMark} (${formatBytes(item.bytes)})${note}`);
303
+ lines.push(` ${riskTag(item)} ${item.name}${kindMark} (${formatBytes(item.bytes)})${note}`);
304
+ }
305
+ if (skipped.length > 0) {
306
+ lines.push("");
307
+ lines.push(
308
+ `Leaving alone: ${skipped.length} unknown entr${skipped.length === 1 ? "y" : "ies"} at the root.`,
309
+ );
310
+ lines.push(" (I don't recognize these — they may be from a module hub doesn't know about,");
311
+ lines.push(" or from your own setup. If they're safe to remove, you can do it manually.)");
312
+ for (const item of skipped) {
313
+ const kindMark = item.kind === "dir" ? "/" : "";
314
+ lines.push(` ${riskTag(item)} ${item.name}${kindMark}`);
315
+ }
173
316
  }
174
317
  return lines;
175
318
  }
@@ -192,24 +335,91 @@ export async function defaultPrompt(question: string): Promise<string> {
192
335
  return answer;
193
336
  }
194
337
 
338
+ /**
339
+ * Probe whether any managed service (or the hub) is currently running.
340
+ * Returns the list of short names that are live; an empty list means the
341
+ * sweep is safe to proceed.
342
+ *
343
+ * Reads services.json leniently so a malformed entry doesn't block the
344
+ * pre-flight (better to surface "running: vault" + a corrupt-entry warning
345
+ * elsewhere than to refuse migration on an unrelated parsing problem).
346
+ */
347
+ export function listRunningServices(
348
+ configDir: string,
349
+ manifestPath: string,
350
+ alive: AliveFn,
351
+ ): string[] {
352
+ const running: string[] = [];
353
+ const hubState = processState(HUB_SVC, configDir, alive);
354
+ if (hubState.status === "running") running.push(HUB_SVC);
355
+ let manifest: ReturnType<typeof readManifestLenient>;
356
+ try {
357
+ manifest = readManifestLenient(manifestPath);
358
+ } catch {
359
+ return running;
360
+ }
361
+ for (const entry of manifest.services) {
362
+ const short = shortNameForManifest(entry.name) ?? entry.name;
363
+ if (running.includes(short)) continue;
364
+ const state = processState(short, configDir, alive);
365
+ if (state.status === "running") running.push(short);
366
+ }
367
+ return running;
368
+ }
369
+
195
370
  export interface MigrateOpts {
196
371
  configDir?: string;
372
+ manifestPath?: string;
197
373
  now?: () => Date;
198
374
  log?: (line: string) => void;
199
375
  prompt?: (question: string) => Promise<string>;
200
376
  dryRun?: boolean;
201
377
  yes?: boolean;
378
+ /** `--list` — synonym for `--dry-run` with a more discoverable flag name. */
379
+ list?: boolean;
380
+ /** Test seam: process-liveness check used by `listRunningServices`. */
381
+ alive?: AliveFn;
382
+ /**
383
+ * Test seam: override the TTY check. Production reads
384
+ * `process.stdin.isTTY`; tests pass `true`/`false` to drive the
385
+ * non-interactive guard without manipulating real fds.
386
+ */
387
+ isTty?: boolean;
202
388
  }
203
389
 
204
390
  export async function migrate(opts: MigrateOpts = {}): Promise<number> {
205
391
  const configDir = opts.configDir ?? CONFIG_DIR;
392
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
206
393
  const now = (opts.now ?? (() => new Date()))();
207
394
  const log = opts.log ?? ((line) => console.log(line));
208
395
  const prompt = opts.prompt ?? defaultPrompt;
209
- const dryRun = opts.dryRun ?? false;
396
+ const dryRun = (opts.dryRun ?? false) || (opts.list ?? false);
210
397
  const yes = opts.yes ?? false;
398
+ const alive = opts.alive ?? defaultAlive;
399
+ const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
400
+
401
+ // Refuse-while-running: archiving a path a live daemon owns can corrupt
402
+ // its state. Print the runners and bail before we read the directory.
403
+ // `--list` and `--dry-run` are read-only and skip this guard; the
404
+ // operator may explicitly want to see what would move while things are
405
+ // up.
406
+ if (!dryRun) {
407
+ const running = listRunningServices(configDir, manifestPath, alive);
408
+ if (running.length > 0) {
409
+ log("parachute migrate: services are currently running — refusing to sweep:");
410
+ for (const short of running) log(` - ${short}`);
411
+ log("");
412
+ log("Stop them first, then re-run:");
413
+ log(" parachute stop");
414
+ log("");
415
+ log("Or preview the plan without changing anything:");
416
+ log(" parachute migrate --list");
417
+ return 1;
418
+ }
419
+ }
211
420
 
212
421
  const plan = planArchive(configDir, now);
422
+ const archivable = plan.items.filter((i) => i.archive);
213
423
  if (plan.items.length === 0) {
214
424
  log(`Nothing to archive. ${configDir} is already clean.`);
215
425
  return 0;
@@ -217,26 +427,63 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
217
427
 
218
428
  for (const line of formatPlan(plan)) log(line);
219
429
 
430
+ if (archivable.length === 0) {
431
+ // Only unknowns at the root; nothing to do.
432
+ log("");
433
+ log("Nothing recognized to archive.");
434
+ return 0;
435
+ }
436
+
220
437
  if (dryRun) {
221
- log("(dry-run — no changes made)");
438
+ log("");
439
+ log(opts.list ? "(--list — no changes made)" : "(dry-run — no changes made)");
222
440
  return 0;
223
441
  }
224
442
 
443
+ // Non-TTY without `--yes` is a hard refuse. A pipe from CI or a launchd
444
+ // wrapper that lost its terminal shouldn't be able to silently archive
445
+ // user state — prefer an actionable error.
446
+ if (!isTty && !yes) {
447
+ log("");
448
+ log("parachute migrate: refusing to sweep without a TTY.");
449
+ log("Run interactively, or pass `--yes` if you're certain. To preview:");
450
+ log(" parachute migrate --list");
451
+ return 1;
452
+ }
453
+
225
454
  if (!yes) {
226
455
  const answer = (await prompt("Proceed? [y/N] ")).trim().toLowerCase();
227
456
  if (answer !== "y" && answer !== "yes") {
228
457
  log("Aborted.");
229
458
  return 1;
230
459
  }
460
+ // Extra confirmation when a SQLite-shape file is in the plan. The
461
+ // wal/shm companions are only consistent with their .db when the
462
+ // owning process is stopped (we already refused-while-running above,
463
+ // so they are), but operators sometimes have ad-hoc backup tooling
464
+ // that watches these — extra y/N is a cheap insurance against
465
+ // surprise.
466
+ if (plan.hasLiveDb) {
467
+ log("");
468
+ log("⚠ The plan includes SQLite-shape files (`*.db`, `*.db-wal`, `*.db-shm`).");
469
+ log(" These three are only consistent as a set — the archive moves them together,");
470
+ log(" but if anything outside Parachute is reading them (backup tooling, etc.),");
471
+ log(" that consumer will see a missing file after the sweep.");
472
+ const dbAnswer = (await prompt("Archive the live-db files too? [y/N] ")).trim().toLowerCase();
473
+ if (dbAnswer !== "y" && dbAnswer !== "yes") {
474
+ log("Aborted.");
475
+ return 1;
476
+ }
477
+ }
231
478
  }
232
479
 
233
480
  mkdirSync(plan.archiveDir, { recursive: true });
234
- for (const item of plan.items) {
481
+ for (const item of archivable) {
235
482
  const dest = resolveDest(plan.archiveDir, item.name, now);
236
483
  renameSync(item.absPath, dest);
237
484
  }
238
485
  log(
239
- `✓ Archived ${plan.items.length} item${plan.items.length === 1 ? "" : "s"} to ${plan.archiveDirName}/`,
486
+ `✓ Archived ${archivable.length} item${archivable.length === 1 ? "" : "s"} to ${plan.archiveDirName}/`,
240
487
  );
241
488
  return 0;
242
489
  }
@@ -245,9 +492,14 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
245
492
  * One-line notice for contexts where migrate is *not* what the user
246
493
  * asked for (e.g., after `parachute install`). Returns undefined when
247
494
  * there's nothing archivable so callers can branch on truthy/falsy.
495
+ *
496
+ * Counts only `archive: true` items — unknowns at the root don't trip
497
+ * the notice (they're not actually candidates for sweeping; the user
498
+ * sees them only when they explicitly run `parachute migrate`).
248
499
  */
249
500
  export function migrateNotice(configDir: string, now: Date): string | undefined {
250
501
  const plan = planArchive(configDir, now);
251
- if (plan.items.length === 0) return undefined;
252
- return `parachute migrate: ${plan.items.length} unrecognized entr${plan.items.length === 1 ? "y" : "ies"} at ecosystem root — run 'parachute migrate' to archive.`;
502
+ const archivable = plan.items.filter((i) => i.archive);
503
+ if (archivable.length === 0) return undefined;
504
+ return `parachute migrate: ${archivable.length} archivable entr${archivable.length === 1 ? "y" : "ies"} at ecosystem root — run 'parachute migrate' to archive.`;
253
505
  }
@@ -146,6 +146,14 @@ interface StatusRow {
146
146
  * stale-after-rebuild row without comparing columns by eye.
147
147
  */
148
148
  staleNote?: string;
149
+ /**
150
+ * Persisted last-start failure (`lastStartError`, written by the lifecycle
151
+ * start preflight when a startCmd binary is missing). Surfaced on a
152
+ * continuation line so a *later* `parachute status` explains why the row
153
+ * isn't active — "failed to start: <binary> not installed" — rather than
154
+ * just showing it inactive. Cleared on the next successful start.
155
+ */
156
+ startErrorNote?: string;
149
157
  }
150
158
 
151
159
  /**
@@ -264,6 +272,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
264
272
  ? `STALE: services.json cached ${entry.version}; live package.json ${source.livePackageVersion}`
265
273
  : undefined;
266
274
 
275
+ // Persisted last-start failure (lifecycle preflight wrote a missing-
276
+ // dependency wire). Surface a one-line summary; the full install recipe
277
+ // lives in services.json + the admin SPA card. Keeps `parachute status`
278
+ // scannable while still telling the operator "this is why it's down."
279
+ const startErrorNote =
280
+ entry.lastStartError !== undefined
281
+ ? entry.lastStartError.binary !== undefined
282
+ ? `failed to start: ${entry.lastStartError.binary} not installed — run \`parachute status\` detail or see /admin/modules for install steps`
283
+ : `failed to start: ${entry.lastStartError.error_description.split("\n")[0]}`
284
+ : undefined;
285
+
267
286
  // Only skip probe when we know the process is dead (PID file was
268
287
  // present but kill(pid, 0) failed). "unknown" status (no PID file)
269
288
  // still probes — externally-managed services should report health.
@@ -287,6 +306,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
287
306
  skipped: true,
288
307
  driftWarning,
289
308
  staleNote,
309
+ startErrorNote,
290
310
  };
291
311
  }
292
312
 
@@ -324,6 +344,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
324
344
  skipped: false,
325
345
  driftWarning,
326
346
  staleNote,
347
+ startErrorNote,
327
348
  };
328
349
  }),
329
350
  );
@@ -378,6 +399,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
378
399
  }
379
400
  if (row.driftWarning) print(` ! ${row.driftWarning}`);
380
401
  if (row.staleNote) print(` ! ${row.staleNote}`);
402
+ if (row.startErrorNote) print(` ! ${row.startErrorNote}`);
381
403
  }
382
404
 
383
405
  /**
@@ -72,25 +72,69 @@ export interface UpgradeRunner {
72
72
  ): Promise<{ code: number; stdout: string }>;
73
73
  }
74
74
 
75
+ /**
76
+ * Exit code we synthesize when a binary can't be spawned at all. 127 is the
77
+ * POSIX shell convention for "command not found" — it lets every git call
78
+ * degrade to a normal non-zero result instead of crashing the whole command.
79
+ */
80
+ const SPAWN_NOT_FOUND_CODE = 127;
81
+
82
+ /**
83
+ * True when an error thrown by `Bun.spawn` means "the executable doesn't
84
+ * exist on this host" (ENOENT). On a minimal server with no `git` installed —
85
+ * a legitimate, common shape for a published-npm install on the canonical
86
+ * install path — `Bun.spawn(["git", ...])` throws *synchronously* with this
87
+ * shape. We catch it so `parachute upgrade` degrades to the npm path rather
88
+ * than dying with an uncaught `Executable not found in $PATH: "git"`.
89
+ */
90
+ function isSpawnNotFound(err: unknown): boolean {
91
+ if (typeof err !== "object" || err === null) return false;
92
+ const code = (err as { code?: unknown }).code;
93
+ const message = (err as { message?: unknown }).message;
94
+ return (
95
+ code === "ENOENT" ||
96
+ (typeof message === "string" && message.includes("Executable not found in $PATH"))
97
+ );
98
+ }
99
+
75
100
  export const defaultRunner: UpgradeRunner = {
76
101
  async run(cmd, opts) {
77
102
  // Inherit env so `bun add -g` etc. see TMPDIR, BUN_INSTALL, PATH, HOME.
78
103
  // Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
79
- const proc = Bun.spawn([...cmd], {
80
- cwd: opts?.cwd,
81
- stdio: ["inherit", "inherit", "inherit"],
82
- env: process.env,
83
- });
104
+ let proc: Bun.Subprocess;
105
+ try {
106
+ proc = Bun.spawn([...cmd], {
107
+ cwd: opts?.cwd,
108
+ stdio: ["inherit", "inherit", "inherit"],
109
+ env: process.env,
110
+ });
111
+ } catch (err) {
112
+ // Binary not on this host (e.g. no `git` on a minimal server). Degrade
113
+ // to a non-zero exit rather than letting the throw crash the command.
114
+ if (isSpawnNotFound(err)) return SPAWN_NOT_FOUND_CODE;
115
+ throw err;
116
+ }
84
117
  return await proc.exited;
85
118
  },
86
119
  async capture(cmd, opts) {
87
120
  // Inherit env — same rationale as `run` above.
88
- const proc = Bun.spawn([...cmd], {
89
- cwd: opts?.cwd,
90
- stdout: "pipe",
91
- stderr: "pipe",
92
- env: process.env,
93
- });
121
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
122
+ try {
123
+ proc = Bun.spawn([...cmd], {
124
+ cwd: opts?.cwd,
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ env: process.env,
128
+ });
129
+ } catch (err) {
130
+ // See `run` above: ENOENT (binary-not-found) becomes a captured
131
+ // non-zero result so every git call degrades to "command failed".
132
+ if (isSpawnNotFound(err)) {
133
+ const bin = cmd[0] ?? "command";
134
+ return { code: SPAWN_NOT_FOUND_CODE, stdout: `${bin}: not found on this host\n` };
135
+ }
136
+ throw err;
137
+ }
94
138
  const [stdout, stderr] = await Promise.all([
95
139
  new Response(proc.stdout).text(),
96
140
  new Response(proc.stderr).text(),