@openparachute/hub 0.7.6 → 0.7.7-rc.13
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 +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -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
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
@@ -149,6 +149,124 @@ describe("notesFetch with empty mount (root deployment)", () => {
|
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
describe("notesFetch /health (2026-07-11, hub-parity P5)", () => {
|
|
153
|
+
test("GET /notes/health answers 2xx explicitly, not the SPA shell", async () => {
|
|
154
|
+
const h = makeHarness();
|
|
155
|
+
try {
|
|
156
|
+
const res = notesFetch(h.dir, "/notes")(req("/notes/health"));
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
// Explicit JSON, not the index.html SPA-shell fallback — proves the
|
|
159
|
+
// health path is a real handler, not an accident of the catch-all.
|
|
160
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
161
|
+
expect(await res.text()).not.toContain("notes spa");
|
|
162
|
+
} finally {
|
|
163
|
+
h.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("GET /health answers 2xx at the mount root too (empty mount)", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const res = notesFetch(h.dir, "")(req("/health"));
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
173
|
+
} finally {
|
|
174
|
+
h.cleanup();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("survives a missing dist/index.html — health doesn't depend on the SPA shell existing", async () => {
|
|
179
|
+
// A harness with NO index.html written — the SPA-shell fallback would
|
|
180
|
+
// throw/404 on this dist, but /health is answered before that code path
|
|
181
|
+
// is ever reached.
|
|
182
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-notes-serve-empty-"));
|
|
183
|
+
try {
|
|
184
|
+
const res = notesFetch(dir, "/app")(req("/app/health"));
|
|
185
|
+
expect(res.status).toBe(200);
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(dir, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// hub-parity P5 (2026-07-11): the shim generalized beyond notes to serve
|
|
193
|
+
// @openparachute/parachute-app (mount `/app`, port 1944) via the same
|
|
194
|
+
// FIRST_PARTY_FALLBACKS startCmd shape (`--package @openparachute/parachute-app`).
|
|
195
|
+
// These tests re-run the load-bearing PWA regression (sw.js / manifest
|
|
196
|
+
// content-type, SPA fallback, mount-strip) for a NON-notes package/mount to
|
|
197
|
+
// prove the generalization didn't accidentally hardcode "notes" anywhere in
|
|
198
|
+
// the serving path (only `resolveNotesDistFrom`'s package resolution is
|
|
199
|
+
// notes-specific, and that's parameterized separately below).
|
|
200
|
+
describe("notesFetch generalized for a non-notes package (hub-parity P5 — the app mount)", () => {
|
|
201
|
+
function makeAppHarness(): Harness {
|
|
202
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-app-serve-"));
|
|
203
|
+
writeFileSync(join(dir, "index.html"), "<html><body>app spa</body></html>");
|
|
204
|
+
writeFileSync(join(dir, "sw.js"), "self.addEventListener('install', () => {});");
|
|
205
|
+
writeFileSync(join(dir, "manifest.webmanifest"), '{"name":"Parachute","start_url":"/app/"}');
|
|
206
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("GET /app/sw.js serves the SW with JS content-type, not text/html", async () => {
|
|
210
|
+
const h = makeAppHarness();
|
|
211
|
+
try {
|
|
212
|
+
const res = notesFetch(h.dir, "/app")(req("/app/sw.js"));
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
215
|
+
expect(ct).not.toContain("text/html");
|
|
216
|
+
expect(ct).toMatch(/javascript/);
|
|
217
|
+
} finally {
|
|
218
|
+
h.cleanup();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("GET /app/manifest.webmanifest serves application/manifest+json", async () => {
|
|
223
|
+
const h = makeAppHarness();
|
|
224
|
+
try {
|
|
225
|
+
const res = notesFetch(h.dir, "/app")(req("/app/manifest.webmanifest"));
|
|
226
|
+
expect(res.status).toBe(200);
|
|
227
|
+
expect(res.headers.get("content-type")).toBe("application/manifest+json");
|
|
228
|
+
expect(await res.text()).toContain('"name":"Parachute"');
|
|
229
|
+
} finally {
|
|
230
|
+
h.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("GET /app/ serves the SPA shell", async () => {
|
|
235
|
+
const h = makeAppHarness();
|
|
236
|
+
try {
|
|
237
|
+
const res = notesFetch(h.dir, "/app")(req("/app/"));
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
240
|
+
expect(await res.text()).toContain("app spa");
|
|
241
|
+
} finally {
|
|
242
|
+
h.cleanup();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("GET /app/some/deep/route falls back to the SPA shell (client-side routing)", async () => {
|
|
247
|
+
const h = makeAppHarness();
|
|
248
|
+
try {
|
|
249
|
+
const res = notesFetch(h.dir, "/app")(req("/app/some/deep/route"));
|
|
250
|
+
expect(res.status).toBe(200);
|
|
251
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
252
|
+
expect(await res.text()).toContain("app spa");
|
|
253
|
+
} finally {
|
|
254
|
+
h.cleanup();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("GET /appendix/foo (mount-prefix collision) is not stripped", async () => {
|
|
259
|
+
const h = makeAppHarness();
|
|
260
|
+
try {
|
|
261
|
+
const res = notesFetch(h.dir, "/app")(req("/appendix/foo"));
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
264
|
+
} finally {
|
|
265
|
+
h.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
152
270
|
describe("notesDistCandidates", () => {
|
|
153
271
|
test("returns cwd, then global node_modules, then global root", () => {
|
|
154
272
|
const cands = notesDistCandidates("/some/cwd", "/home/user");
|
|
@@ -295,3 +413,101 @@ describe("resolveNotesDistFrom (hub#194)", () => {
|
|
|
295
413
|
).toThrow(/has no dist\/ directory/);
|
|
296
414
|
});
|
|
297
415
|
});
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `--package` generalization (hub-parity P5, 2026-07-11). `resolveNotesDistFrom`
|
|
419
|
+
* defaults `pkg` to `@openparachute/notes` (every test above omits it and
|
|
420
|
+
* still resolves notes — back-compat), but a caller like the `app`
|
|
421
|
+
* FIRST_PARTY_FALLBACKS entry passes a different package name. These tests
|
|
422
|
+
* pin the resolver against a real on-disk fixture for a NON-notes package,
|
|
423
|
+
* proving the specifier passed to `Bun.resolveSync` (and every error message)
|
|
424
|
+
* is the caller's `pkg`, not a hardcoded "notes" string.
|
|
425
|
+
*/
|
|
426
|
+
describe("resolveNotesDistFrom --package (hub-parity P5)", () => {
|
|
427
|
+
const APP_PKG = "@openparachute/parachute-app";
|
|
428
|
+
|
|
429
|
+
function makeAppFixture(): { home: string; cleanup: () => void; dist: string } {
|
|
430
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-app-resolve-")));
|
|
431
|
+
const home = join(root, "home");
|
|
432
|
+
const pkgRoot = join(home, ".bun/install/global/node_modules", APP_PKG);
|
|
433
|
+
mkdirSync(pkgRoot, { recursive: true });
|
|
434
|
+
const dist = join(pkgRoot, "dist");
|
|
435
|
+
mkdirSync(dist, { recursive: true });
|
|
436
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: APP_PKG }));
|
|
437
|
+
return { home, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
test("resolves a non-notes package's dist/ via the global node_modules fallback", () => {
|
|
441
|
+
const f = makeAppFixture();
|
|
442
|
+
try {
|
|
443
|
+
const out = resolveNotesDistFrom({
|
|
444
|
+
cwd: "/hub-repo-cwd-without-app",
|
|
445
|
+
home: f.home,
|
|
446
|
+
pkg: APP_PKG,
|
|
447
|
+
resolveSync: (specifier, base) => {
|
|
448
|
+
if (base === "/hub-repo-cwd-without-app") {
|
|
449
|
+
throw new Error(`Cannot find module '${specifier}' from '${base}'`);
|
|
450
|
+
}
|
|
451
|
+
return Bun.resolveSync(specifier, base);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
expect(out).toBe(f.dist);
|
|
455
|
+
} finally {
|
|
456
|
+
f.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("default pkg (no --package) still resolves @openparachute/notes — back-compat", () => {
|
|
461
|
+
// Every earlier describe block already exercises this implicitly (none
|
|
462
|
+
// pass `pkg`); this test pins it explicitly against the specifier the
|
|
463
|
+
// resolver hands to `resolveSync`.
|
|
464
|
+
const specifiers: string[] = [];
|
|
465
|
+
expect(() =>
|
|
466
|
+
resolveNotesDistFrom({
|
|
467
|
+
cwd: "/cwd",
|
|
468
|
+
home: "/h",
|
|
469
|
+
resolveSync: (specifier) => {
|
|
470
|
+
specifiers.push(specifier);
|
|
471
|
+
throw new Error("not found");
|
|
472
|
+
},
|
|
473
|
+
}),
|
|
474
|
+
).toThrow();
|
|
475
|
+
expect(specifiers).toEqual([
|
|
476
|
+
"@openparachute/notes/package.json",
|
|
477
|
+
"@openparachute/notes/package.json",
|
|
478
|
+
"@openparachute/notes/package.json",
|
|
479
|
+
]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("error message names the caller's package, not a hardcoded 'notes'", () => {
|
|
483
|
+
let caught: unknown;
|
|
484
|
+
try {
|
|
485
|
+
resolveNotesDistFrom({
|
|
486
|
+
cwd: "/probe-cwd",
|
|
487
|
+
home: "/probe-home",
|
|
488
|
+
pkg: APP_PKG,
|
|
489
|
+
resolveSync: () => {
|
|
490
|
+
throw new Error("nope");
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
caught = err;
|
|
495
|
+
}
|
|
496
|
+
const msg = (caught as Error).message;
|
|
497
|
+
expect(msg).toContain(`Could not resolve ${APP_PKG}`);
|
|
498
|
+
expect(msg).toContain(`bun add -g ${APP_PKG}`);
|
|
499
|
+
expect(msg).not.toContain("@openparachute/notes");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("no-dist/ hard error names the caller's package", () => {
|
|
503
|
+
expect(() =>
|
|
504
|
+
resolveNotesDistFrom({
|
|
505
|
+
cwd: "/cwd-with-app",
|
|
506
|
+
home: "/h",
|
|
507
|
+
pkg: APP_PKG,
|
|
508
|
+
resolveSync: () => "/cwd-with-app/node_modules/@openparachute/parachute-app/package.json",
|
|
509
|
+
existsSync: () => false,
|
|
510
|
+
}),
|
|
511
|
+
).toThrow(new RegExp(`${APP_PKG.replace("/", "\\/")} resolved at .* has no dist/ directory`));
|
|
512
|
+
});
|
|
513
|
+
});
|