@openparachute/hub 0.7.7-rc.8 → 0.7.7

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 (80) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +359 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/root-serve.test.ts +139 -0
  33. package/src/__tests__/scope-explanations.test.ts +0 -2
  34. package/src/__tests__/serve-boot.test.ts +25 -36
  35. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  36. package/src/__tests__/services-manifest.test.ts +372 -132
  37. package/src/__tests__/setup-wizard.test.ts +301 -22
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/__tests__/wizard-transcription.test.ts +35 -0
  42. package/src/__tests__/wizard.test.ts +79 -0
  43. package/src/account-api.ts +6 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-mint-token.ts +2 -1
  50. package/src/api-modules-ops.ts +2 -1
  51. package/src/api-modules.ts +10 -8
  52. package/src/api-revoke-token.ts +2 -1
  53. package/src/api-settings-hub-origin.ts +2 -1
  54. package/src/api-settings-root-redirect.ts +97 -20
  55. package/src/api-tokens.ts +2 -1
  56. package/src/audience-gate.ts +2 -1
  57. package/src/chrome-strip.ts +16 -4
  58. package/src/commands/hub.ts +104 -0
  59. package/src/commands/install.ts +97 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/commands/wizard-transcription.ts +24 -0
  63. package/src/commands/wizard.ts +34 -1
  64. package/src/help.ts +6 -6
  65. package/src/hub-server.ts +111 -54
  66. package/src/hub-settings.ts +147 -0
  67. package/src/hub.ts +64 -31
  68. package/src/module-ops-client.ts +2 -1
  69. package/src/notes-serve.ts +73 -31
  70. package/src/oauth-handlers.ts +1 -11
  71. package/src/operator-token.ts +0 -1
  72. package/src/origin-check.ts +2 -2
  73. package/src/root-serve.ts +156 -0
  74. package/src/scope-explanations.ts +2 -5
  75. package/src/service-spec.ts +129 -74
  76. package/src/services-manifest.ts +112 -52
  77. package/src/setup-wizard.ts +144 -31
  78. package/src/surface-notes-alias.ts +126 -0
  79. package/src/__tests__/admin-agent-token.test.ts +0 -173
  80. package/src/admin-agent-token.ts +0 -147
@@ -12,30 +12,38 @@ import { join } from "node:path";
12
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
13
  import {
14
14
  DEFAULT_MODULE_INSTALL_CHANNEL,
15
+ DEFAULT_ROOT_MODE,
15
16
  DEFAULT_ROOT_REDIRECT,
16
17
  FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
17
18
  MODULE_INSTALL_CHANNELS,
19
+ PARACHUTE_HUB_ROOT_MODE_ENV,
18
20
  PARACHUTE_HUB_ROOT_REDIRECT_ENV,
19
21
  PARACHUTE_INSTALL_CHANNEL_ENV,
20
22
  PARACHUTE_MODULE_CHANNEL_ENV,
23
+ ROOT_MODES,
21
24
  SETUP_EXPOSE_MODES,
22
25
  consumeFirstClientAutoApproveWindow,
23
26
  deleteSetting,
24
27
  getHubOrigin,
25
28
  getModuleInstallChannel,
29
+ getRootMode,
26
30
  getRootRedirect,
27
31
  getSetting,
28
32
  isFirstClientAutoApproveWindowOpen,
29
33
  isModuleInstallChannel,
30
34
  isNotesRedirectDisabled,
35
+ isRootMode,
31
36
  isSafeRedirectPath,
32
37
  isSetupExposeMode,
33
38
  openFirstClientAutoApproveWindow,
39
+ resolveRootMode,
40
+ resolveRootModeDetailed,
34
41
  resolveRootRedirect,
35
42
  resolveRootRedirectDetailed,
36
43
  setHubOrigin,
37
44
  setModuleInstallChannel,
38
45
  setNotesRedirectDisabled,
46
+ setRootMode,
39
47
  setRootRedirect,
40
48
  setSetting,
41
49
  } from "../hub-settings.ts";
@@ -363,9 +371,9 @@ describe("hub-settings — module install channel bootstrap", () => {
363
371
  // First read with env=rc — env wins, returns "rc". DB stays empty
364
372
  // (no auto-write to DB when env is set, per the design that
365
373
  // preserves the SPA's last-write as the env-unset fallback).
366
- expect(
367
- getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } }),
368
- ).toBe("rc");
374
+ expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
375
+ "rc",
376
+ );
369
377
  // Second read with env=latest — env wins again (would have returned
370
378
  // "rc" under the old DB-after-first-seed behavior).
371
379
  expect(
@@ -386,9 +394,9 @@ describe("hub-settings — module install channel bootstrap", () => {
386
394
  const db = openHubDb(hubDbPath(dir));
387
395
  try {
388
396
  // PARACHUTE_INSTALL_CHANNEL — canonical name (matches install.ts).
389
- expect(
390
- getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } }),
391
- ).toBe("rc");
397
+ expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
398
+ "rc",
399
+ );
392
400
  // PARACHUTE_MODULE_CHANNEL — legacy alias, still recognized.
393
401
  const db2 = openHubDb(join(mkdtempSync(join(tmpdir(), "phub-hsalt-")), "hub.db"));
394
402
  try {
@@ -801,3 +809,99 @@ describe("hub-settings — root_redirect storage + resolution", () => {
801
809
  expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
802
810
  });
803
811
  });
812
+
813
+ describe("hub-settings — root_mode storage + resolution", () => {
814
+ let dir: string;
815
+ beforeEach(() => {
816
+ dir = mkdtempSync(join(tmpdir(), "hub-settings-root-mode-"));
817
+ });
818
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
819
+
820
+ const noEnv: NodeJS.ProcessEnv = {};
821
+ const silent = () => {};
822
+
823
+ test("isRootMode + ROOT_MODES", () => {
824
+ expect(ROOT_MODES).toEqual(["redirect", "serve-app"]);
825
+ expect(isRootMode("redirect")).toBe(true);
826
+ expect(isRootMode("serve-app")).toBe(true);
827
+ expect(isRootMode("serveapp")).toBe(false);
828
+ expect(isRootMode(42)).toBe(false);
829
+ });
830
+
831
+ test("getRootMode round-trips serve-app via setRootMode", () => {
832
+ const db = openHubDb(hubDbPath(dir));
833
+ try {
834
+ expect(getRootMode(db)).toBeNull();
835
+ setRootMode(db, "serve-app");
836
+ expect(getRootMode(db)).toBe("serve-app");
837
+ } finally {
838
+ db.close();
839
+ }
840
+ });
841
+
842
+ test("setRootMode(null) and setRootMode('redirect') both clear the row (default = absence)", () => {
843
+ const db = openHubDb(hubDbPath(dir));
844
+ try {
845
+ setRootMode(db, "serve-app");
846
+ setRootMode(db, null);
847
+ expect(getRootMode(db)).toBeNull();
848
+ setRootMode(db, "serve-app");
849
+ setRootMode(db, "redirect"); // the default is stored as absence
850
+ expect(getRootMode(db)).toBeNull();
851
+ expect(getSetting(db, "root_mode")).toBeUndefined();
852
+ } finally {
853
+ db.close();
854
+ }
855
+ });
856
+
857
+ test("resolves to the redirect default when neither DB nor env is set", () => {
858
+ const db = openHubDb(hubDbPath(dir));
859
+ try {
860
+ expect(resolveRootMode(db, { env: noEnv })).toBe(DEFAULT_ROOT_MODE);
861
+ expect(resolveRootModeDetailed(db, { env: noEnv })).toEqual({
862
+ value: "redirect",
863
+ source: "default",
864
+ });
865
+ } finally {
866
+ db.close();
867
+ }
868
+ });
869
+
870
+ test("env override applies when no DB row; DB overrides env (DB is tier-1)", () => {
871
+ const db = openHubDb(hubDbPath(dir));
872
+ try {
873
+ const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" };
874
+ expect(resolveRootModeDetailed(db, { env })).toEqual({ value: "serve-app", source: "env" });
875
+ setRootMode(db, "serve-app");
876
+ // A DB "serve-app" over an env "redirect" resolves to db.
877
+ expect(
878
+ resolveRootModeDetailed(db, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "redirect" } }),
879
+ ).toEqual({ value: "serve-app", source: "db" });
880
+ } finally {
881
+ db.close();
882
+ }
883
+ });
884
+
885
+ test("an invalid DB row / env value is ignored → falls back to the redirect default", () => {
886
+ const db = openHubDb(hubDbPath(dir));
887
+ try {
888
+ setSetting(db, "root_mode", "bogus"); // hand-edited row bypassing write validation
889
+ expect(resolveRootMode(db, { env: noEnv, warn: silent })).toBe("redirect");
890
+ expect(getRootMode(db)).toBeNull(); // getRootMode also guards the invalid value
891
+ const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "nonsense" };
892
+ expect(resolveRootModeDetailed(db, { env, warn: silent })).toEqual({
893
+ value: "redirect",
894
+ source: "default",
895
+ });
896
+ } finally {
897
+ db.close();
898
+ }
899
+ });
900
+
901
+ test("a null db (no state) resolves from env / default only", () => {
902
+ expect(resolveRootMode(null, { env: noEnv })).toBe("redirect");
903
+ expect(resolveRootMode(null, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" } })).toBe(
904
+ "serve-app",
905
+ );
906
+ });
907
+ });
@@ -239,6 +239,35 @@ describe("renderHub — signed-out slimming (operator feedback)", () => {
239
239
  });
240
240
  });
241
241
 
242
+ describe("renderHub — Get started tiles are uis[]-driven (W2-12)", () => {
243
+ // The tile logic runs client-side inside the discovery IIFE; these pins
244
+ // assert the emitted script's SHAPE (the JS-parses check above guards its
245
+ // validity). The section is uis[]-driven — one tile per active uis[]
246
+ // sub-unit, whichever mount identity (/surface/notes or
247
+ // /surface/parachute) an install serves — with the pre-W2-12 hardcoded
248
+ // "Open Notes" tile demoted to a fallback for surface-host rows that
249
+ // predate uis{} self-registration.
250
+ const html = renderHub({
251
+ session: { displayName: "operator", csrfToken: "csrf-w212" },
252
+ });
253
+
254
+ test("the discovery script iterates uis[] sub-units", () => {
255
+ expect(html).toContain("svc.uis");
256
+ // Inactive sub-units are skipped; absent status counts as active.
257
+ expect(html).toContain("ui.status");
258
+ });
259
+
260
+ test("the legacy 'Open Notes' hardcode survives only as the no-uis fallback", () => {
261
+ // Still present (pre-uis surface-host installs keep their CTA) …
262
+ expect(html).toContain("Open Notes");
263
+ expect(html).toContain("'/surface/notes/'");
264
+ // … but no longer keyed on the bare presence of parachute-surface as
265
+ // the PRIMARY path: the uis[] loop renders first, the hasSurface check
266
+ // only backstops an empty tile list.
267
+ expect(html).toContain("Legacy fallback");
268
+ });
269
+ });
270
+
242
271
  describe("renderHub — signed-in indicator (rc.13)", () => {
243
272
  test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
244
273
  const html = renderHub({
@@ -1,8 +1,17 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import {
8
+ PARACHUTE_HUB_ROOT_MODE_ENV,
9
+ PARACHUTE_HUB_ROOT_REDIRECT_ENV,
10
+ getRootMode,
11
+ getRootRedirect,
12
+ setRootMode,
13
+ setRootRedirect,
14
+ } from "../hub-settings.ts";
6
15
  import { findService, upsertService } from "../services-manifest.ts";
7
16
 
8
17
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -62,6 +71,71 @@ describe("install", () => {
62
71
  }
63
72
  });
64
73
 
74
+ test("refuses `install agent` without invoking bun or the deprecated binary", async () => {
75
+ const { path, cleanup } = makeTempPath();
76
+ try {
77
+ const calls: string[][] = [];
78
+ const logs: string[] = [];
79
+ const code = await install("agent", {
80
+ runner: async (cmd) => {
81
+ calls.push([...cmd]);
82
+ return 0;
83
+ },
84
+ manifestPath: path,
85
+ startService: async () => 0,
86
+ isLinked: () => false,
87
+ portProbe: async () => false,
88
+ log: (line) => logs.push(line),
89
+ });
90
+ expect(code).toBe(1);
91
+ expect(calls).toEqual([]);
92
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
93
+ expect(logs.join("\n")).toMatch(/Vault and Surface/);
94
+ } finally {
95
+ cleanup();
96
+ }
97
+ });
98
+
99
+ test("refuses explicit retired Agent npm package names before invoking bun", async () => {
100
+ for (const packageName of [
101
+ "@openparachute/agent",
102
+ "@openparachute/agent@0.9.0",
103
+ "@openparachute/channel",
104
+ ]) {
105
+ const calls: string[][] = [];
106
+ const logs: string[] = [];
107
+ const code = await install(packageName, {
108
+ runner: async (cmd) => {
109
+ calls.push([...cmd]);
110
+ return 0;
111
+ },
112
+ log: (line) => logs.push(line),
113
+ });
114
+ expect(code).toBe(1);
115
+ expect(calls).toEqual([]);
116
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
117
+ }
118
+ });
119
+
120
+ test("refuses a local checkout whose package metadata is a retired Agent package", async () => {
121
+ const pkgDir = mkdtempSync(join(tmpdir(), "pcli-retired-agent-"));
122
+ try {
123
+ const calls: string[][] = [];
124
+ const code = await install(pkgDir, {
125
+ runner: async (cmd) => {
126
+ calls.push([...cmd]);
127
+ return 0;
128
+ },
129
+ readPackageName: () => "@openparachute/agent",
130
+ log: () => {},
131
+ });
132
+ expect(code).toBe(1);
133
+ expect(calls).toEqual([]);
134
+ } finally {
135
+ rmSync(pkgDir, { recursive: true, force: true });
136
+ }
137
+ });
138
+
65
139
  test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
66
140
  const { path, cleanup } = makeTempPath();
67
141
  try {
@@ -408,7 +482,7 @@ describe("install", () => {
408
482
  test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
409
483
  // Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
410
484
  // vault child running on :1940. The fresh install must reclaim the canonical
411
- // port (adopt-kill the attributable orphan) rather than port-walk to 1944.
485
+ // port (adopt-kill the attributable orphan) rather than port-walk to 1945.
412
486
  const { path, configDir, cleanup } = makeTempPath();
413
487
  try {
414
488
  const logs: string[] = [];
@@ -1636,9 +1710,10 @@ describe("install", () => {
1636
1710
  log: (l) => logs.push(l),
1637
1711
  });
1638
1712
  expect(code).toBe(0);
1639
- // First reservation slot is 1944.
1713
+ // Agent retirement released 1941, so it is now the first unassigned
1714
+ // canonical slot available to the fallback walker.
1640
1715
  const entry = findService("parachute-vault", path);
1641
- expect(entry?.port).toBe(1944);
1716
+ expect(entry?.port).toBe(1941);
1642
1717
  expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1643
1718
  // .env is not touched.
1644
1719
  const envPath = join(configDir, "vault", ".env");
@@ -1865,6 +1940,33 @@ describe("install", () => {
1865
1940
  }
1866
1941
  });
1867
1942
 
1943
+ test("refuses third-party metadata that declares a retired Agent manifest identity", async () => {
1944
+ const { path, cleanup } = makeTempPath();
1945
+ try {
1946
+ const logs: string[] = [];
1947
+ const code = await install("@vendor/agent-wrapper", {
1948
+ runner: async () => 0,
1949
+ manifestPath: path,
1950
+ isLinked: () => false,
1951
+ log: (line) => logs.push(line),
1952
+ readManifest: async () => ({
1953
+ name: "claw",
1954
+ manifestName: "@vendor/agent-wrapper",
1955
+ port: 1952,
1956
+ paths: ["/claw"],
1957
+ health: "/claw/health",
1958
+ }),
1959
+ findGlobalInstall: () =>
1960
+ "/fake/bun-globals/node_modules/@vendor/agent-wrapper/package.json",
1961
+ });
1962
+ expect(code).toBe(1);
1963
+ expect(findService("claw", path)).toBeUndefined();
1964
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
1965
+ } finally {
1966
+ cleanup();
1967
+ }
1968
+ });
1969
+
1868
1970
  test("npm-installed third-party module persists installDir from bun globals", async () => {
1869
1971
  // hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
1870
1972
  // the package.json that findGlobalInstall returns. Without this,
@@ -1940,7 +2042,8 @@ describe("install", () => {
1940
2042
  expect(startCalls).toEqual(["someapp"]);
1941
2043
  // Log lines speak in the canonical short name too. Port comes from
1942
2044
  // assignServicePort (third-party gets the first unassigned canonical
1943
- // slot, currently 1944), not the manifest's port hint.
2045
+ // slot, currently 1945 — 1944 is parachute-app's canonical assigned
2046
+ // slot as of hub-parity P5), not the manifest's port hint.
1944
2047
  const joined = logs.join("\n");
1945
2048
  expect(joined).toMatch(/Seeded services\.json entry for someapp/);
1946
2049
  expect(joined).toMatch(/someapp registered on port \d+/);
@@ -2316,3 +2419,254 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
2316
2419
  expect(opts.log).toBe(log);
2317
2420
  });
2318
2421
  });
2422
+
2423
+ // `parachute install app` defaults the hub's origin root to SERVE the app
2424
+ // (root_mode = serve-app) on first install — SET-IF-UNSET ONLY, and only when
2425
+ // the root behavior is entirely pristine default (no mode row/env AND no
2426
+ // redirect row/env). Supersedes the prior hub-parity P5 behavior (which wrote
2427
+ // root_redirect = /app/). Every test here injects `rootRedirectDb` (a hub.db
2428
+ // opened in the same disposable tempdir `makeTempPath()` already isolates) so no
2429
+ // real `~/.parachute/hub.db` is ever touched — never drive this against the
2430
+ // operator's live install.
2431
+ describe("app-only serve-app set-if-unset default", () => {
2432
+ // The write gates on the RESOLVED mode + redirect being `default` (no DB row,
2433
+ // no env). Neutralize any ambient PARACHUTE_HUB_ROOT_MODE / _REDIRECT (Aaron's
2434
+ // box, CI) so the default-tier tests are deterministic; the env-set tests
2435
+ // below manage their own values inside their own bodies.
2436
+ let savedRedirectEnv: string | undefined;
2437
+ let savedModeEnv: string | undefined;
2438
+ beforeEach(() => {
2439
+ savedRedirectEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2440
+ savedModeEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2441
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2442
+ delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2443
+ });
2444
+ afterEach(() => {
2445
+ for (const [name, saved] of [
2446
+ [PARACHUTE_HUB_ROOT_REDIRECT_ENV, savedRedirectEnv],
2447
+ [PARACHUTE_HUB_ROOT_MODE_ENV, savedModeEnv],
2448
+ ] as const) {
2449
+ if (saved === undefined) delete process.env[name];
2450
+ else process.env[name] = saved;
2451
+ }
2452
+ });
2453
+
2454
+ test("fresh install (pristine root) flips root_mode to serve-app", async () => {
2455
+ const { path, configDir, cleanup } = makeTempPath();
2456
+ try {
2457
+ const db = openHubDb(hubDbPath(configDir));
2458
+ const logs: string[] = [];
2459
+ try {
2460
+ const code = await install("app", {
2461
+ runner: async () => 0,
2462
+ manifestPath: path,
2463
+ configDir,
2464
+ startService: async () => 0,
2465
+ isLinked: () => false,
2466
+ portProbe: async () => false,
2467
+ rootRedirectDb: db,
2468
+ log: (l) => logs.push(l),
2469
+ });
2470
+ expect(code).toBe(0);
2471
+ expect(getRootMode(db)).toBe("serve-app");
2472
+ // The redirect row is NOT written — serve-app takes over `/` directly.
2473
+ expect(getRootRedirect(db)).toBeNull();
2474
+ expect(logs.join("\n")).toMatch(/front page.*now serves the Parachute app/);
2475
+ } finally {
2476
+ db.close();
2477
+ }
2478
+ } finally {
2479
+ cleanup();
2480
+ }
2481
+ });
2482
+
2483
+ test("a pre-set root_redirect is left alone — mode NOT flipped", async () => {
2484
+ const { path, configDir, cleanup } = makeTempPath();
2485
+ try {
2486
+ const db = openHubDb(hubDbPath(configDir));
2487
+ const logs: string[] = [];
2488
+ try {
2489
+ setRootRedirect(db, "/surface/reading-room");
2490
+ const code = await install("app", {
2491
+ runner: async () => 0,
2492
+ manifestPath: path,
2493
+ configDir,
2494
+ startService: async () => 0,
2495
+ isLinked: () => false,
2496
+ portProbe: async () => false,
2497
+ rootRedirectDb: db,
2498
+ log: (l) => logs.push(l),
2499
+ });
2500
+ expect(code).toBe(0);
2501
+ // The operator's prior choice survives byte-for-byte; no mode flip.
2502
+ expect(getRootRedirect(db)).toBe("/surface/reading-room");
2503
+ expect(getRootMode(db)).toBeNull();
2504
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2505
+ } finally {
2506
+ db.close();
2507
+ }
2508
+ } finally {
2509
+ cleanup();
2510
+ }
2511
+ });
2512
+
2513
+ test("an existing install with the old /app/ redirect keeps redirecting (back-compat)", async () => {
2514
+ // A hub that ran the PRIOR app-install code has root_redirect = /app/ in its
2515
+ // DB (source = db → not default). Re-running `install app` must NOT flip it
2516
+ // to serve-app — it keeps 302-ing to /app/, exactly as before. This is the
2517
+ // "existing installs unchanged" guarantee.
2518
+ const { path, configDir, cleanup } = makeTempPath();
2519
+ try {
2520
+ const db = openHubDb(hubDbPath(configDir));
2521
+ const logs: string[] = [];
2522
+ try {
2523
+ setRootRedirect(db, "/app/");
2524
+ const code = await install("app", {
2525
+ runner: async () => 0,
2526
+ manifestPath: path,
2527
+ configDir,
2528
+ startService: async () => 0,
2529
+ isLinked: () => false,
2530
+ portProbe: async () => false,
2531
+ rootRedirectDb: db,
2532
+ log: (l) => logs.push(l),
2533
+ });
2534
+ expect(code).toBe(0);
2535
+ expect(getRootMode(db)).toBeNull();
2536
+ expect(getRootRedirect(db)).toBe("/app/");
2537
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2538
+ } finally {
2539
+ db.close();
2540
+ }
2541
+ } finally {
2542
+ cleanup();
2543
+ }
2544
+ });
2545
+
2546
+ test("installing a different module never touches the root settings", async () => {
2547
+ const { path, configDir, cleanup } = makeTempPath();
2548
+ try {
2549
+ const db = openHubDb(hubDbPath(configDir));
2550
+ try {
2551
+ const code = await install("notes", {
2552
+ runner: async () => 0,
2553
+ manifestPath: path,
2554
+ configDir,
2555
+ startService: async () => 0,
2556
+ isLinked: () => false,
2557
+ portProbe: async () => false,
2558
+ rootRedirectDb: db,
2559
+ log: () => {},
2560
+ });
2561
+ expect(code).toBe(0);
2562
+ expect(getRootRedirect(db)).toBeNull();
2563
+ expect(getRootMode(db)).toBeNull();
2564
+ } finally {
2565
+ db.close();
2566
+ }
2567
+ } finally {
2568
+ cleanup();
2569
+ }
2570
+ });
2571
+
2572
+ test("an ENV-configured redirect (no DB row) is left alone — no mode flip (N1)", async () => {
2573
+ // Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
2574
+ // not a DB row. Flipping to serve-app would silently override their
2575
+ // configured landing (serve-app wins over redirect at `/`). The gate reads
2576
+ // the RESOLVED redirect source; with the env set that's `env`, not
2577
+ // `default`, so we leave it alone.
2578
+ const { path, configDir, cleanup } = makeTempPath();
2579
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2580
+ try {
2581
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = "/surface/team-room";
2582
+ const db = openHubDb(hubDbPath(configDir));
2583
+ const logs: string[] = [];
2584
+ try {
2585
+ const code = await install("app", {
2586
+ runner: async () => 0,
2587
+ manifestPath: path,
2588
+ configDir,
2589
+ startService: async () => 0,
2590
+ isLinked: () => false,
2591
+ portProbe: async () => false,
2592
+ rootRedirectDb: db,
2593
+ log: (l) => logs.push(l),
2594
+ });
2595
+ expect(code).toBe(0);
2596
+ // No mode row written — the env-configured landing survives.
2597
+ expect(getRootMode(db)).toBeNull();
2598
+ expect(getRootRedirect(db)).toBeNull();
2599
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2600
+ } finally {
2601
+ db.close();
2602
+ }
2603
+ } finally {
2604
+ if (prevEnv === undefined) {
2605
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2606
+ } else {
2607
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = prevEnv;
2608
+ }
2609
+ cleanup();
2610
+ }
2611
+ });
2612
+
2613
+ test("an ENV-configured mode (no DB row) is left alone — no clobber", async () => {
2614
+ // PARACHUTE_HUB_ROOT_MODE=redirect on a container that deliberately wants
2615
+ // the redirect front door. The gate sees the resolved mode source as `env`,
2616
+ // not `default`, so it does NOT write a serve-app row.
2617
+ const { path, configDir, cleanup } = makeTempPath();
2618
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2619
+ try {
2620
+ process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = "redirect";
2621
+ const db = openHubDb(hubDbPath(configDir));
2622
+ const logs: string[] = [];
2623
+ try {
2624
+ const code = await install("app", {
2625
+ runner: async () => 0,
2626
+ manifestPath: path,
2627
+ configDir,
2628
+ startService: async () => 0,
2629
+ isLinked: () => false,
2630
+ portProbe: async () => false,
2631
+ rootRedirectDb: db,
2632
+ log: (l) => logs.push(l),
2633
+ });
2634
+ expect(code).toBe(0);
2635
+ expect(getRootMode(db)).toBeNull();
2636
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2637
+ } finally {
2638
+ db.close();
2639
+ }
2640
+ } finally {
2641
+ if (prevEnv === undefined) {
2642
+ delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2643
+ } else {
2644
+ process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = prevEnv;
2645
+ }
2646
+ cleanup();
2647
+ }
2648
+ });
2649
+
2650
+ test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
2651
+ // Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
2652
+ // install.ts: a test with a tempdir manifestPath and no explicit opt-in
2653
+ // must never open the real ~/.parachute/hub.db. There's nothing to open
2654
+ // here at all — the assertion is simply that install still completes
2655
+ // cleanly with the DB-touching branch skipped.
2656
+ const { path, configDir, cleanup } = makeTempPath();
2657
+ try {
2658
+ const code = await install("app", {
2659
+ runner: async () => 0,
2660
+ manifestPath: path,
2661
+ configDir,
2662
+ startService: async () => 0,
2663
+ isLinked: () => false,
2664
+ portProbe: async () => false,
2665
+ log: () => {},
2666
+ });
2667
+ expect(code).toBe(0);
2668
+ } finally {
2669
+ cleanup();
2670
+ }
2671
+ });
2672
+ });
@@ -56,7 +56,9 @@ describe("safelistEntries", () => {
56
56
  expect(s.has("vault")).toBe(true);
57
57
  expect(s.has("notes")).toBe(true);
58
58
  expect(s.has("scribe")).toBe(true);
59
- expect(s.has("agent")).toBe(true);
59
+ expect(s.has("agent")).toBe(false);
60
+ expect(KNOWN_ARCHIVABLE_DIRS.has("agent")).toBe(true);
61
+ expect(KNOWN_ARCHIVABLE_DIRS.has("channel")).toBe(true);
60
62
  // Internal
61
63
  expect(s.has("hub")).toBe(true);
62
64
  // CLI state