@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
@@ -11,7 +11,15 @@ import {
11
11
  } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { migrate, migrateNotice, planArchive, safelistEntries } from "../commands/migrate.ts";
14
+ import {
15
+ KNOWN_ARCHIVABLE_DIRS,
16
+ listRunningServices,
17
+ migrate,
18
+ migrateNotice,
19
+ planArchive,
20
+ safelistEntries,
21
+ } from "../commands/migrate.ts";
22
+ import { writePid } from "../process-state.ts";
15
23
 
16
24
  interface Harness {
17
25
  configDir: string;
@@ -42,27 +50,42 @@ function seedSafelist(configDir: string): void {
42
50
  }
43
51
 
44
52
  describe("safelistEntries", () => {
45
- test("covers service dirs, hub, state files, and well-known", () => {
53
+ test("covers service dirs, hub, state files, hub.db family, well-known, cloudflared", () => {
46
54
  const s = safelistEntries();
47
55
  // Service dirs from SERVICE_SPECS
48
56
  expect(s.has("vault")).toBe(true);
49
57
  expect(s.has("notes")).toBe(true);
50
58
  expect(s.has("scribe")).toBe(true);
51
59
  expect(s.has("channel")).toBe(true);
52
- // Legacy — kept across the Notes→Lens→Notes rename round-trip
53
- // (Apr 19 → Apr 22) so existing ~/.parachute/lens/ dirs from the
54
- // brief Lens window don't get archived on upgrade.
55
- expect(s.has("lens")).toBe(true);
56
60
  // Internal
57
61
  expect(s.has("hub")).toBe(true);
58
62
  // CLI state
59
63
  expect(s.has("services.json")).toBe(true);
60
64
  expect(s.has("expose-state.json")).toBe(true);
65
+ expect(s.has("cloudflared-state.json")).toBe(true);
61
66
  expect(s.has("well-known")).toBe(true);
67
+ // hub.db family — the trigger for the 2026-05-27 redesign was hub.db
68
+ // not being recognized; the allowlist now defaults to "leave unknown
69
+ // alone," but hub.db is explicitly safelisted so it doesn't even show
70
+ // up as "unknown" in the plan.
71
+ expect(s.has("hub.db")).toBe(true);
72
+ expect(s.has("hub.db-wal")).toBe(true);
73
+ expect(s.has("hub.db-shm")).toBe(true);
74
+ // cloudflared per-tunnel config dir
75
+ expect(s.has("cloudflared")).toBe(true);
76
+ });
77
+
78
+ test("`lens` is in the archivable-dirs set (not safelist) — sweep, don't preserve", () => {
79
+ // The Notes→Lens→Notes rename round-trip (Apr 19 → Apr 22) left some
80
+ // installs with `~/.parachute/lens/`. Under the new allowlist model,
81
+ // lens/ is explicitly archivable rather than safelisted — we want
82
+ // operators upgrading to the post-rename world to actually clean it up.
83
+ expect(KNOWN_ARCHIVABLE_DIRS.has("lens")).toBe(true);
84
+ expect(safelistEntries().has("lens")).toBe(false);
62
85
  });
63
86
  });
64
87
 
65
- describe("planArchive", () => {
88
+ describe("planArchive — allowlist behavior", () => {
66
89
  test("clean ecosystem root produces an empty plan", () => {
67
90
  const h = makeHarness();
68
91
  try {
@@ -71,31 +94,83 @@ describe("planArchive", () => {
71
94
  expect(plan.items).toEqual([]);
72
95
  expect(plan.totalBytes).toBe(0);
73
96
  expect(plan.archiveDirName).toBe(".archive-2026-04-19");
97
+ expect(plan.hasLiveDb).toBe(false);
74
98
  } finally {
75
99
  h.cleanup();
76
100
  }
77
101
  });
78
102
 
79
- test("pre-restructure cruft is identified and sized", () => {
103
+ test("known-cruft is archived, unknowns are recorded but not archived", () => {
80
104
  const h = makeHarness();
81
105
  try {
82
106
  seedSafelist(h.configDir);
107
+ // Known cruft — archives.
83
108
  touch(join(h.configDir, "daily.db"), "X".repeat(100));
84
109
  touch(join(h.configDir, "daily.db-shm"), "S");
85
110
  touch(join(h.configDir, "server.yaml"), "port: 1940\n");
86
111
  mkdirSync(join(h.configDir, "logs"), { recursive: true });
87
112
  touch(join(h.configDir, "logs", "old.log"), "old-entry\n");
113
+ // Unknown — left alone (under old shape this would have been swept).
88
114
  touch(join(h.configDir, "random-note.txt"), "mystery content");
115
+ mkdirSync(join(h.configDir, "future-module"), { recursive: true });
116
+ touch(join(h.configDir, "future-module", "state.json"), "{}");
89
117
 
90
118
  const plan = planArchive(h.configDir, APRIL_19);
91
- const names = plan.items.map((i) => i.name).sort();
92
- expect(names).toEqual(["daily.db", "daily.db-shm", "logs", "random-note.txt", "server.yaml"]);
119
+ const archivable = plan.items.filter((i) => i.archive).map((i) => i.name);
120
+ const skipped = plan.items.filter((i) => !i.archive).map((i) => i.name);
121
+
122
+ expect(archivable.sort()).toEqual(["daily.db", "daily.db-shm", "logs", "server.yaml"]);
123
+ expect(skipped.sort()).toEqual(["future-module", "random-note.txt"]);
124
+
125
+ // Archivable totals reflect known-cruft only — unknowns contribute 0.
93
126
  expect(plan.totalBytes).toBeGreaterThan(100);
94
- // known-cruft annotation is attached
127
+
128
+ // Known-cruft has a friendly annotation; unknowns have none.
95
129
  expect(plan.items.find((i) => i.name === "daily.db")?.annotation).toMatch(/daily/i);
96
130
  expect(plan.items.find((i) => i.name === "logs")?.annotation).toMatch(/logs/i);
97
- // unknown files get no annotation
98
131
  expect(plan.items.find((i) => i.name === "random-note.txt")?.annotation).toBeUndefined();
132
+ expect(plan.items.find((i) => i.name === "future-module")?.annotation).toBeUndefined();
133
+ } finally {
134
+ h.cleanup();
135
+ }
136
+ });
137
+
138
+ test("`lens` directory is archivable per KNOWN_ARCHIVABLE_DIRS", () => {
139
+ const h = makeHarness();
140
+ try {
141
+ seedSafelist(h.configDir);
142
+ mkdirSync(join(h.configDir, "lens"), { recursive: true });
143
+ touch(join(h.configDir, "lens", "config.json"), "{}");
144
+
145
+ const plan = planArchive(h.configDir, APRIL_19);
146
+ const lens = plan.items.find((i) => i.name === "lens");
147
+ expect(lens?.archive).toBe(true);
148
+ expect(lens?.risk).toBe("safe");
149
+ expect(lens?.annotation).toMatch(/legacy/i);
150
+ } finally {
151
+ h.cleanup();
152
+ }
153
+ });
154
+
155
+ test("SQLite-shape files carry the live-db risk label", () => {
156
+ const h = makeHarness();
157
+ try {
158
+ seedSafelist(h.configDir);
159
+ touch(join(h.configDir, "daily.db"), "X".repeat(50));
160
+ touch(join(h.configDir, "daily.db-wal"), "W");
161
+ touch(join(h.configDir, "daily.db-shm"), "S");
162
+ touch(join(h.configDir, "server.yaml"), "p: 1\n");
163
+
164
+ const plan = planArchive(h.configDir, APRIL_19);
165
+ const db = plan.items.find((i) => i.name === "daily.db");
166
+ const wal = plan.items.find((i) => i.name === "daily.db-wal");
167
+ const shm = plan.items.find((i) => i.name === "daily.db-shm");
168
+ const yaml = plan.items.find((i) => i.name === "server.yaml");
169
+ expect(db?.risk).toBe("live-db");
170
+ expect(wal?.risk).toBe("live-db");
171
+ expect(shm?.risk).toBe("live-db");
172
+ expect(yaml?.risk).toBe("safe");
173
+ expect(plan.hasLiveDb).toBe(true);
99
174
  } finally {
100
175
  h.cleanup();
101
176
  }
@@ -117,20 +192,41 @@ describe("planArchive", () => {
117
192
  }
118
193
  });
119
194
 
120
- test("directory sizes are summed recursively", () => {
195
+ test("directory sizes for archivable entries are summed recursively", () => {
121
196
  const h = makeHarness();
122
197
  try {
123
198
  seedSafelist(h.configDir);
124
- const nested = join(h.configDir, "old-tree", "a", "b");
199
+ // `logs` is known cruft its size should be summed.
200
+ const nested = join(h.configDir, "logs", "a", "b");
125
201
  mkdirSync(nested, { recursive: true });
126
202
  touch(join(nested, "inner.dat"), "Z".repeat(500));
127
- touch(join(h.configDir, "old-tree", "top.dat"), "Q".repeat(300));
203
+ touch(join(h.configDir, "logs", "top.dat"), "Q".repeat(300));
204
+
205
+ const plan = planArchive(h.configDir, APRIL_19);
206
+ const logsItem = plan.items.find((i) => i.name === "logs");
207
+ expect(logsItem?.archive).toBe(true);
208
+ expect(logsItem?.kind).toBe("dir");
209
+ expect(logsItem?.bytes).toBe(800);
210
+ } finally {
211
+ h.cleanup();
212
+ }
213
+ });
214
+
215
+ test("unknown directories do not pay the recursive sizeOf cost", () => {
216
+ const h = makeHarness();
217
+ try {
218
+ seedSafelist(h.configDir);
219
+ const deep = join(h.configDir, "future-module", "a", "b");
220
+ mkdirSync(deep, { recursive: true });
221
+ touch(join(deep, "inner.dat"), "Z".repeat(500));
128
222
 
129
223
  const plan = planArchive(h.configDir, APRIL_19);
130
- const oldTree = plan.items.find((i) => i.name === "old-tree");
131
- expect(oldTree).toBeDefined();
132
- expect(oldTree?.kind).toBe("dir");
133
- expect(oldTree?.bytes).toBe(800);
224
+ const item = plan.items.find((i) => i.name === "future-module");
225
+ expect(item?.archive).toBe(false);
226
+ // Unknowns get bytes=0 even when the directory tree is non-empty —
227
+ // we don't walk something we're not going to touch.
228
+ expect(item?.bytes).toBe(0);
229
+ expect(plan.totalBytes).toBe(0);
134
230
  } finally {
135
231
  h.cleanup();
136
232
  }
@@ -156,6 +252,8 @@ describe("planArchive", () => {
156
252
  const plan = planArchive(h.configDir, APRIL_19);
157
253
  const item = plan.items.find((i) => i.name === "external-backup");
158
254
  expect(item).toBeDefined();
255
+ // It's an unknown name — left alone.
256
+ expect(item?.archive).toBe(false);
159
257
  expect(item?.bytes).toBe(0);
160
258
  expect(item?.kind).toBe("file");
161
259
  expect(plan.totalBytes).toBe(0);
@@ -164,9 +262,27 @@ describe("planArchive", () => {
164
262
  targetHarness.cleanup();
165
263
  }
166
264
  });
265
+
266
+ test("plan sort order — archivable first, then skipped, alphabetical within each group", () => {
267
+ const h = makeHarness();
268
+ try {
269
+ seedSafelist(h.configDir);
270
+ // Mix known-cruft and unknowns; assert ordering.
271
+ touch(join(h.configDir, "zzz-unknown"), "");
272
+ touch(join(h.configDir, "aaa-unknown"), "");
273
+ touch(join(h.configDir, "server.yaml"), "");
274
+ touch(join(h.configDir, "daily.db"), "");
275
+
276
+ const plan = planArchive(h.configDir, APRIL_19);
277
+ const names = plan.items.map((i) => i.name);
278
+ expect(names).toEqual(["daily.db", "server.yaml", "aaa-unknown", "zzz-unknown"]);
279
+ } finally {
280
+ h.cleanup();
281
+ }
282
+ });
167
283
  });
168
284
 
169
- describe("migrate", () => {
285
+ describe("migrate — interactive + flag behavior", () => {
170
286
  test("no-op on a clean root with exit 0", async () => {
171
287
  const h = makeHarness();
172
288
  try {
@@ -179,6 +295,7 @@ describe("migrate", () => {
179
295
  prompt: async () => {
180
296
  throw new Error("prompt must not be called");
181
297
  },
298
+ isTty: true,
182
299
  });
183
300
  expect(code).toBe(0);
184
301
  expect(logs.join("\n")).toMatch(/nothing to archive/i);
@@ -188,12 +305,15 @@ describe("migrate", () => {
188
305
  }
189
306
  });
190
307
 
191
- test("dry-run prints plan, makes no changes, no prompt", async () => {
308
+ test("--list prints plan, makes no changes, no prompt, no running-service check", async () => {
192
309
  const h = makeHarness();
193
310
  try {
194
311
  seedSafelist(h.configDir);
195
312
  touch(join(h.configDir, "daily.db"), "X");
196
313
  touch(join(h.configDir, "random"), "Y");
314
+ // A running vault should NOT block a read-only --list.
315
+ mkdirSync(join(h.configDir, "vault", "run"), { recursive: true });
316
+ writePid("vault", process.pid, h.configDir);
197
317
 
198
318
  const logs: string[] = [];
199
319
  const code = await migrate({
@@ -203,10 +323,11 @@ describe("migrate", () => {
203
323
  prompt: async () => {
204
324
  throw new Error("prompt must not be called");
205
325
  },
206
- dryRun: true,
326
+ list: true,
327
+ isTty: true,
207
328
  });
208
329
  expect(code).toBe(0);
209
- expect(logs.join("\n")).toMatch(/dry-run/i);
330
+ expect(logs.join("\n")).toMatch(/--list — no changes made/);
210
331
  expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
211
332
  expect(existsSync(join(h.configDir, "random"))).toBe(true);
212
333
  expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
@@ -215,14 +336,42 @@ describe("migrate", () => {
215
336
  }
216
337
  });
217
338
 
218
- test("--yes archives without prompting, safelist untouched", async () => {
339
+ test("--dry-run is a synonym for --list (back-compat)", async () => {
340
+ const h = makeHarness();
341
+ try {
342
+ seedSafelist(h.configDir);
343
+ touch(join(h.configDir, "daily.db"), "X");
344
+ const logs: string[] = [];
345
+ const code = await migrate({
346
+ configDir: h.configDir,
347
+ now: () => APRIL_19,
348
+ log: (l) => logs.push(l),
349
+ prompt: async () => {
350
+ throw new Error("prompt must not be called");
351
+ },
352
+ dryRun: true,
353
+ isTty: true,
354
+ });
355
+ expect(code).toBe(0);
356
+ expect(logs.join("\n")).toMatch(/dry-run/);
357
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
358
+ } finally {
359
+ h.cleanup();
360
+ }
361
+ });
362
+
363
+ test("--yes archives known cruft; unknowns are preserved", async () => {
219
364
  const h = makeHarness();
220
365
  try {
221
366
  seedSafelist(h.configDir);
367
+ // Known cruft (archives)
222
368
  touch(join(h.configDir, "daily.db"), "X".repeat(50));
223
369
  touch(join(h.configDir, "server.yaml"), "port: 1\n");
224
370
  mkdirSync(join(h.configDir, "logs"), { recursive: true });
225
371
  touch(join(h.configDir, "logs", "a.log"), "a");
372
+ // Unknown (must NOT move)
373
+ touch(join(h.configDir, "my-notes.txt"), "operator-owned content");
374
+ mkdirSync(join(h.configDir, "future-module"), { recursive: true });
226
375
 
227
376
  const logs: string[] = [];
228
377
  const code = await migrate({
@@ -233,6 +382,7 @@ describe("migrate", () => {
233
382
  throw new Error("prompt must not be called");
234
383
  },
235
384
  yes: true,
385
+ isTty: false,
236
386
  });
237
387
  expect(code).toBe(0);
238
388
  const archive = join(h.configDir, ".archive-2026-04-19");
@@ -245,22 +395,132 @@ describe("migrate", () => {
245
395
  expect(existsSync(join(h.configDir, "services.json"))).toBe(true);
246
396
  expect(existsSync(join(h.configDir, "well-known"))).toBe(true);
247
397
  expect(existsSync(join(h.configDir, "hub"))).toBe(true);
248
- // originals gone
398
+ // archivable originals gone
249
399
  expect(existsSync(join(h.configDir, "daily.db"))).toBe(false);
250
400
  expect(existsSync(join(h.configDir, "server.yaml"))).toBe(false);
251
401
  expect(existsSync(join(h.configDir, "logs"))).toBe(false);
402
+ // unknowns preserved!
403
+ expect(existsSync(join(h.configDir, "my-notes.txt"))).toBe(true);
404
+ expect(existsSync(join(h.configDir, "future-module"))).toBe(true);
405
+ } finally {
406
+ h.cleanup();
407
+ }
408
+ });
409
+
410
+ test("refuses while a service is running", async () => {
411
+ const h = makeHarness();
412
+ try {
413
+ seedSafelist(h.configDir);
414
+ // Seed a running hub (use the current test process pid — guaranteed alive).
415
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
416
+ writePid("hub", process.pid, h.configDir);
417
+ touch(join(h.configDir, "daily.db"), "X");
418
+
419
+ const logs: string[] = [];
420
+ const code = await migrate({
421
+ configDir: h.configDir,
422
+ now: () => APRIL_19,
423
+ log: (l) => logs.push(l),
424
+ prompt: async () => {
425
+ throw new Error("prompt must not be called");
426
+ },
427
+ yes: true,
428
+ isTty: true,
429
+ });
430
+ expect(code).toBe(1);
431
+ const joined = logs.join("\n");
432
+ expect(joined).toMatch(/services are currently running/i);
433
+ expect(joined).toMatch(/- hub/);
434
+ // No archive happened.
435
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
436
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
437
+ } finally {
438
+ h.cleanup();
439
+ }
440
+ });
441
+
442
+ test("refuses non-TTY without --yes (CI / pipe safety)", async () => {
443
+ const h = makeHarness();
444
+ try {
445
+ seedSafelist(h.configDir);
446
+ touch(join(h.configDir, "daily.db"), "X");
447
+
448
+ const logs: string[] = [];
449
+ const code = await migrate({
450
+ configDir: h.configDir,
451
+ now: () => APRIL_19,
452
+ log: (l) => logs.push(l),
453
+ prompt: async () => {
454
+ throw new Error("prompt must not be called");
455
+ },
456
+ isTty: false,
457
+ });
458
+ expect(code).toBe(1);
459
+ expect(logs.join("\n")).toMatch(/refusing to sweep without a TTY/i);
460
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
461
+ } finally {
462
+ h.cleanup();
463
+ }
464
+ });
465
+
466
+ test("live-db items pull an extra confirmation; declining aborts", async () => {
467
+ const h = makeHarness();
468
+ try {
469
+ seedSafelist(h.configDir);
470
+ touch(join(h.configDir, "daily.db"), "X");
471
+ touch(join(h.configDir, "daily.db-wal"), "W");
472
+
473
+ const answers = ["y", "n"]; // first y to proceed, then n on the live-db gate.
474
+ const code = await migrate({
475
+ configDir: h.configDir,
476
+ now: () => APRIL_19,
477
+ log: () => {},
478
+ prompt: async () => answers.shift() ?? "n",
479
+ isTty: true,
480
+ });
481
+ expect(code).toBe(1);
482
+ // Aborted before any rename — daily.db still there.
483
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
484
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
485
+ } finally {
486
+ h.cleanup();
487
+ }
488
+ });
489
+
490
+ test("live-db items pull an extra confirmation; accepting both proceeds", async () => {
491
+ const h = makeHarness();
492
+ try {
493
+ seedSafelist(h.configDir);
494
+ touch(join(h.configDir, "daily.db"), "X");
495
+ touch(join(h.configDir, "daily.db-wal"), "W");
496
+
497
+ const answers = ["y", "y"];
498
+ const code = await migrate({
499
+ configDir: h.configDir,
500
+ now: () => APRIL_19,
501
+ log: () => {},
502
+ prompt: async () => answers.shift() ?? "y",
503
+ isTty: true,
504
+ });
505
+ expect(code).toBe(0);
506
+ const archive = join(h.configDir, ".archive-2026-04-19");
507
+ expect(existsSync(join(archive, "daily.db"))).toBe(true);
508
+ expect(existsSync(join(archive, "daily.db-wal"))).toBe(true);
252
509
  } finally {
253
510
  h.cleanup();
254
511
  }
255
512
  });
256
513
 
257
- test("--yes archives a symlink by moving the link, not the target", async () => {
514
+ test("--yes archives a symlink (if known-archivable name) by moving the link, not the target", async () => {
515
+ // Reorient the symlink regression test against the new shape: only
516
+ // archivable names actually move. We synthesize a known-cruft symlink:
517
+ // `logs` (directory cruft) pointed at an external target.
258
518
  const targetHarness = makeHarness();
259
519
  const h = makeHarness();
260
520
  try {
261
521
  seedSafelist(h.configDir);
262
522
  touch(join(targetHarness.configDir, "huge.bin"), "X".repeat(10_000));
263
- const linkPath = join(h.configDir, "external-backup");
523
+ const linkPath = join(h.configDir, "logs");
264
524
  symlinkSync(targetHarness.configDir, linkPath);
265
525
 
266
526
  const code = await migrate({
@@ -271,9 +531,10 @@ describe("migrate", () => {
271
531
  throw new Error("prompt must not be called");
272
532
  },
273
533
  yes: true,
534
+ isTty: false,
274
535
  });
275
536
  expect(code).toBe(0);
276
- const archivedLink = join(h.configDir, ".archive-2026-04-19", "external-backup");
537
+ const archivedLink = join(h.configDir, ".archive-2026-04-19", "logs");
277
538
  expect(lstatSync(archivedLink).isSymbolicLink()).toBe(true);
278
539
  // Target tree untouched
279
540
  expect(existsSync(join(targetHarness.configDir, "huge.bin"))).toBe(true);
@@ -285,6 +546,39 @@ describe("migrate", () => {
285
546
  }
286
547
  });
287
548
 
549
+ test("unknown symlink is preserved (under new allowlist)", async () => {
550
+ const targetHarness = makeHarness();
551
+ const h = makeHarness();
552
+ try {
553
+ seedSafelist(h.configDir);
554
+ touch(join(targetHarness.configDir, "huge.bin"), "X".repeat(10_000));
555
+ const linkPath = join(h.configDir, "external-backup");
556
+ symlinkSync(targetHarness.configDir, linkPath);
557
+
558
+ const logs: string[] = [];
559
+ const code = await migrate({
560
+ configDir: h.configDir,
561
+ now: () => APRIL_19,
562
+ log: (l) => logs.push(l),
563
+ prompt: async () => {
564
+ throw new Error("prompt must not be called");
565
+ },
566
+ yes: true,
567
+ isTty: false,
568
+ });
569
+ expect(code).toBe(0);
570
+ // The "nothing recognized" exit branch — no archive directory created.
571
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
572
+ // The unknown symlink stays at the root.
573
+ expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
574
+ // Plan was still printed.
575
+ expect(logs.join("\n")).toMatch(/Leaving alone/);
576
+ } finally {
577
+ h.cleanup();
578
+ targetHarness.cleanup();
579
+ }
580
+ });
581
+
288
582
  test("prompt 'n' aborts with exit 1, no changes", async () => {
289
583
  const h = makeHarness();
290
584
  try {
@@ -296,6 +590,7 @@ describe("migrate", () => {
296
590
  now: () => APRIL_19,
297
591
  log: (l) => logs.push(l),
298
592
  prompt: async () => "n",
593
+ isTty: true,
299
594
  });
300
595
  expect(code).toBe(1);
301
596
  expect(logs.join("\n")).toMatch(/aborted/i);
@@ -306,19 +601,20 @@ describe("migrate", () => {
306
601
  }
307
602
  });
308
603
 
309
- test("prompt 'y' (and 'yes') proceeds", async () => {
604
+ test("prompt 'y' proceeds for non-live-db items", async () => {
310
605
  const h = makeHarness();
311
606
  try {
312
607
  seedSafelist(h.configDir);
313
- touch(join(h.configDir, "cruft.txt"), "Z");
608
+ touch(join(h.configDir, "server.yaml"), "Z"); // known cruft, not live-db
314
609
  const code = await migrate({
315
610
  configDir: h.configDir,
316
611
  now: () => APRIL_19,
317
612
  log: () => {},
318
613
  prompt: async () => "y",
614
+ isTty: true,
319
615
  });
320
616
  expect(code).toBe(0);
321
- expect(existsSync(join(h.configDir, ".archive-2026-04-19", "cruft.txt"))).toBe(true);
617
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
322
618
  } finally {
323
619
  h.cleanup();
324
620
  }
@@ -328,24 +624,26 @@ describe("migrate", () => {
328
624
  const h = makeHarness();
329
625
  try {
330
626
  seedSafelist(h.configDir);
331
- touch(join(h.configDir, "first.txt"), "1");
627
+ touch(join(h.configDir, "server.yaml"), "1");
332
628
  await migrate({
333
629
  configDir: h.configDir,
334
630
  now: () => APRIL_19,
335
631
  log: () => {},
336
632
  yes: true,
633
+ isTty: false,
337
634
  });
338
635
  // Add more cruft and sweep again the same day
339
- touch(join(h.configDir, "second.txt"), "2");
636
+ touch(join(h.configDir, "channel.log"), "2");
340
637
  await migrate({
341
638
  configDir: h.configDir,
342
639
  now: () => APRIL_19,
343
640
  log: () => {},
344
641
  yes: true,
642
+ isTty: false,
345
643
  });
346
644
  const archive = join(h.configDir, ".archive-2026-04-19");
347
- expect(existsSync(join(archive, "first.txt"))).toBe(true);
348
- expect(existsSync(join(archive, "second.txt"))).toBe(true);
645
+ expect(existsSync(join(archive, "server.yaml"))).toBe(true);
646
+ expect(existsSync(join(archive, "channel.log"))).toBe(true);
349
647
  // Only one archive dir at root
350
648
  const archiveDirs = readdirSync(h.configDir).filter((n) => n.startsWith(".archive-"));
351
649
  expect(archiveDirs).toEqual([".archive-2026-04-19"]);
@@ -358,12 +656,24 @@ describe("migrate", () => {
358
656
  const h = makeHarness();
359
657
  try {
360
658
  seedSafelist(h.configDir);
361
- touch(join(h.configDir, "day1.txt"), "1");
362
- await migrate({ configDir: h.configDir, now: () => APRIL_19, log: () => {}, yes: true });
363
- touch(join(h.configDir, "day2.txt"), "2");
364
- await migrate({ configDir: h.configDir, now: () => APRIL_20, log: () => {}, yes: true });
365
- expect(existsSync(join(h.configDir, ".archive-2026-04-19", "day1.txt"))).toBe(true);
366
- expect(existsSync(join(h.configDir, ".archive-2026-04-20", "day2.txt"))).toBe(true);
659
+ touch(join(h.configDir, "server.yaml"), "1");
660
+ await migrate({
661
+ configDir: h.configDir,
662
+ now: () => APRIL_19,
663
+ log: () => {},
664
+ yes: true,
665
+ isTty: false,
666
+ });
667
+ touch(join(h.configDir, "channel.log"), "2");
668
+ await migrate({
669
+ configDir: h.configDir,
670
+ now: () => APRIL_20,
671
+ log: () => {},
672
+ yes: true,
673
+ isTty: false,
674
+ });
675
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
676
+ expect(existsSync(join(h.configDir, ".archive-2026-04-20", "channel.log"))).toBe(true);
367
677
  } finally {
368
678
  h.cleanup();
369
679
  }
@@ -373,21 +683,89 @@ describe("migrate", () => {
373
683
  const h = makeHarness();
374
684
  try {
375
685
  seedSafelist(h.configDir);
376
- // Pre-existing archive with a same-name entry.
686
+ // Pre-existing archive with a same-name entry. server.yaml is known cruft.
377
687
  mkdirSync(join(h.configDir, ".archive-2026-04-19"), { recursive: true });
378
- touch(join(h.configDir, ".archive-2026-04-19", "notes.md"), "old");
379
- // New cruft with the same name.
380
- touch(join(h.configDir, "notes.md"), "new");
688
+ touch(join(h.configDir, ".archive-2026-04-19", "server.yaml"), "old");
689
+ touch(join(h.configDir, "server.yaml"), "new");
381
690
  await migrate({
382
691
  configDir: h.configDir,
383
692
  now: () => APRIL_19,
384
693
  log: () => {},
385
694
  yes: true,
695
+ isTty: false,
386
696
  });
387
697
  const archive = join(h.configDir, ".archive-2026-04-19");
388
698
  const contents = readdirSync(archive);
389
- expect(contents).toContain("notes.md");
390
- expect(contents.some((n) => n.startsWith("notes.md.dup-"))).toBe(true);
699
+ expect(contents).toContain("server.yaml");
700
+ expect(contents.some((n) => n.startsWith("server.yaml.dup-"))).toBe(true);
701
+ } finally {
702
+ h.cleanup();
703
+ }
704
+ });
705
+ });
706
+
707
+ describe("listRunningServices", () => {
708
+ test("empty when no pidfiles exist", () => {
709
+ const h = makeHarness();
710
+ try {
711
+ seedSafelist(h.configDir);
712
+ const running = listRunningServices(
713
+ h.configDir,
714
+ join(h.configDir, "services.json"),
715
+ () => false,
716
+ );
717
+ expect(running).toEqual([]);
718
+ } finally {
719
+ h.cleanup();
720
+ }
721
+ });
722
+
723
+ test("reports hub when its PID is live", () => {
724
+ const h = makeHarness();
725
+ try {
726
+ seedSafelist(h.configDir);
727
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
728
+ writePid("hub", 12345, h.configDir);
729
+ const running = listRunningServices(
730
+ h.configDir,
731
+ join(h.configDir, "services.json"),
732
+ () => true,
733
+ );
734
+ expect(running).toContain("hub");
735
+ } finally {
736
+ h.cleanup();
737
+ }
738
+ });
739
+
740
+ test("reports services from services.json when pidfiles are live", () => {
741
+ const h = makeHarness();
742
+ try {
743
+ seedSafelist(h.configDir);
744
+ writeFileSync(
745
+ join(h.configDir, "services.json"),
746
+ JSON.stringify({
747
+ services: [
748
+ {
749
+ name: "parachute-vault",
750
+ version: "0.5.0",
751
+ port: 1940,
752
+ paths: ["/vault/default"],
753
+ health: "/health",
754
+ icon: "/icon.svg",
755
+ auth: { type: "none" },
756
+ mcp: {},
757
+ },
758
+ ],
759
+ }),
760
+ );
761
+ mkdirSync(join(h.configDir, "vault", "run"), { recursive: true });
762
+ writePid("vault", 23456, h.configDir);
763
+ const running = listRunningServices(
764
+ h.configDir,
765
+ join(h.configDir, "services.json"),
766
+ () => true,
767
+ );
768
+ expect(running).toContain("vault");
391
769
  } finally {
392
770
  h.cleanup();
393
771
  }
@@ -395,26 +773,30 @@ describe("migrate", () => {
395
773
  });
396
774
 
397
775
  describe("migrateNotice", () => {
398
- test("undefined when nothing to archive", () => {
776
+ test("undefined when nothing archivable", () => {
399
777
  const h = makeHarness();
400
778
  try {
401
779
  seedSafelist(h.configDir);
780
+ // Even with unknowns at the root, no notice — unknowns aren't candidates.
781
+ touch(join(h.configDir, "operator-owned.md"), "hi");
402
782
  expect(migrateNotice(h.configDir, APRIL_19)).toBeUndefined();
403
783
  } finally {
404
784
  h.cleanup();
405
785
  }
406
786
  });
407
787
 
408
- test("returns a single line with the count when cruft exists", () => {
788
+ test("returns a single line with the count when archivable cruft exists", () => {
409
789
  const h = makeHarness();
410
790
  try {
411
791
  seedSafelist(h.configDir);
412
792
  touch(join(h.configDir, "daily.db"), "x");
413
- touch(join(h.configDir, "stray"), "y");
793
+ touch(join(h.configDir, "server.yaml"), "y");
794
+ // An unknown — must NOT count.
795
+ touch(join(h.configDir, "stray"), "z");
414
796
  const notice = migrateNotice(h.configDir, APRIL_19);
415
797
  expect(notice).toBeDefined();
416
798
  expect(notice).toMatch(/parachute migrate/);
417
- expect(notice).toMatch(/2 unrecognized/);
799
+ expect(notice).toMatch(/2 archivable/);
418
800
  } finally {
419
801
  h.cleanup();
420
802
  }