@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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 {
|
|
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,
|
|
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("
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
|
|
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("
|
|
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
|
-
|
|
326
|
+
list: true,
|
|
327
|
+
isTty: true,
|
|
207
328
|
});
|
|
208
329
|
expect(code).toBe(0);
|
|
209
|
-
expect(logs.join("\n")).toMatch(/
|
|
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("--
|
|
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, "
|
|
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", "
|
|
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'
|
|
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, "
|
|
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", "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
348
|
-
expect(existsSync(join(archive, "
|
|
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, "
|
|
362
|
-
await migrate({
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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", "
|
|
379
|
-
|
|
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("
|
|
390
|
-
expect(contents.some((n) => n.startsWith("
|
|
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
|
|
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, "
|
|
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
|
|
799
|
+
expect(notice).toMatch(/2 archivable/);
|
|
418
800
|
} finally {
|
|
419
801
|
h.cleanup();
|
|
420
802
|
}
|