@openparachute/hub 0.7.7-rc.8 → 0.7.7-rc.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.8",
3
+ "version": "0.7.7-rc.9",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -1,8 +1,14 @@
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_REDIRECT_ENV,
9
+ getRootRedirect,
10
+ setRootRedirect,
11
+ } from "../hub-settings.ts";
6
12
  import { findService, upsertService } from "../services-manifest.ts";
7
13
 
8
14
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -408,7 +414,7 @@ describe("install", () => {
408
414
  test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
409
415
  // Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
410
416
  // 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.
417
+ // port (adopt-kill the attributable orphan) rather than port-walk to 1945.
412
418
  const { path, configDir, cleanup } = makeTempPath();
413
419
  try {
414
420
  const logs: string[] = [];
@@ -1636,9 +1642,10 @@ describe("install", () => {
1636
1642
  log: (l) => logs.push(l),
1637
1643
  });
1638
1644
  expect(code).toBe(0);
1639
- // First reservation slot is 1944.
1645
+ // First reservation slot is now 1945 — 1944 is parachute-app's
1646
+ // canonical (assigned, non-walkable) slot as of hub-parity P5.
1640
1647
  const entry = findService("parachute-vault", path);
1641
- expect(entry?.port).toBe(1944);
1648
+ expect(entry?.port).toBe(1945);
1642
1649
  expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1643
1650
  // .env is not touched.
1644
1651
  const envPath = join(configDir, "vault", ".env");
@@ -1940,7 +1947,8 @@ describe("install", () => {
1940
1947
  expect(startCalls).toEqual(["someapp"]);
1941
1948
  // Log lines speak in the canonical short name too. Port comes from
1942
1949
  // assignServicePort (third-party gets the first unassigned canonical
1943
- // slot, currently 1944), not the manifest's port hint.
1950
+ // slot, currently 1945 — 1944 is parachute-app's canonical assigned
1951
+ // slot as of hub-parity P5), not the manifest's port hint.
1944
1952
  const joined = logs.join("\n");
1945
1953
  expect(joined).toMatch(/Seeded services\.json entry for someapp/);
1946
1954
  expect(joined).toMatch(/someapp registered on port \d+/);
@@ -2316,3 +2324,177 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
2316
2324
  expect(opts.log).toBe(log);
2317
2325
  });
2318
2326
  });
2327
+
2328
+ // hub-parity P5 (2026-07-11): `parachute install app` defaults the hub's
2329
+ // bare `/` redirect to the app's front door — SET-IF-UNSET ONLY. Every test
2330
+ // here injects `rootRedirectDb` (a hub.db opened in the same disposable
2331
+ // tempdir `makeTempPath()` already isolates) so no real
2332
+ // `~/.parachute/hub.db` is ever touched — never drive this against the
2333
+ // operator's live install.
2334
+ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2335
+ // The write gates on `resolveRootRedirectDetailed(db).source === "default"`,
2336
+ // which consults `PARACHUTE_HUB_ROOT_REDIRECT`. Neutralize any ambient value
2337
+ // (Aaron's box, CI) so the default-tier tests are deterministic; the
2338
+ // env-set test below manages its own value inside its own body.
2339
+ let savedEnv: string | undefined;
2340
+ beforeEach(() => {
2341
+ savedEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2342
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2343
+ });
2344
+ afterEach(() => {
2345
+ if (savedEnv === undefined) {
2346
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2347
+ } else {
2348
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = savedEnv;
2349
+ }
2350
+ });
2351
+
2352
+ test("fresh install (no prior root_redirect) sets it to /app/", async () => {
2353
+ const { path, configDir, cleanup } = makeTempPath();
2354
+ try {
2355
+ const db = openHubDb(hubDbPath(configDir));
2356
+ const logs: string[] = [];
2357
+ try {
2358
+ const code = await install("app", {
2359
+ runner: async () => 0,
2360
+ manifestPath: path,
2361
+ configDir,
2362
+ startService: async () => 0,
2363
+ isLinked: () => false,
2364
+ portProbe: async () => false,
2365
+ rootRedirectDb: db,
2366
+ log: (l) => logs.push(l),
2367
+ });
2368
+ expect(code).toBe(0);
2369
+ expect(getRootRedirect(db)).toBe("/app/");
2370
+ expect(logs.join("\n")).toMatch(/front page.*now opens the app at \/app\//);
2371
+ } finally {
2372
+ db.close();
2373
+ }
2374
+ } finally {
2375
+ cleanup();
2376
+ }
2377
+ });
2378
+
2379
+ test("a pre-set root_redirect is left alone — never clobbered", async () => {
2380
+ const { path, configDir, cleanup } = makeTempPath();
2381
+ try {
2382
+ const db = openHubDb(hubDbPath(configDir));
2383
+ const logs: string[] = [];
2384
+ try {
2385
+ setRootRedirect(db, "/surface/reading-room");
2386
+ const code = await install("app", {
2387
+ runner: async () => 0,
2388
+ manifestPath: path,
2389
+ configDir,
2390
+ startService: async () => 0,
2391
+ isLinked: () => false,
2392
+ portProbe: async () => false,
2393
+ rootRedirectDb: db,
2394
+ log: (l) => logs.push(l),
2395
+ });
2396
+ expect(code).toBe(0);
2397
+ // The operator's prior choice survives byte-for-byte.
2398
+ expect(getRootRedirect(db)).toBe("/surface/reading-room");
2399
+ // The set-if-unset write's OWN log line (distinct from the static
2400
+ // postInstallFooter boilerplate, which always prints "now opens the
2401
+ // app too, unless you've already..." regardless of whether the write
2402
+ // fired) must NOT appear — the write itself was skipped.
2403
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2404
+ } finally {
2405
+ db.close();
2406
+ }
2407
+ } finally {
2408
+ cleanup();
2409
+ }
2410
+ });
2411
+
2412
+ test("installing a different module never touches root_redirect", async () => {
2413
+ const { path, configDir, cleanup } = makeTempPath();
2414
+ try {
2415
+ const db = openHubDb(hubDbPath(configDir));
2416
+ try {
2417
+ const code = await install("notes", {
2418
+ runner: async () => 0,
2419
+ manifestPath: path,
2420
+ configDir,
2421
+ startService: async () => 0,
2422
+ isLinked: () => false,
2423
+ portProbe: async () => false,
2424
+ rootRedirectDb: db,
2425
+ log: () => {},
2426
+ });
2427
+ expect(code).toBe(0);
2428
+ expect(getRootRedirect(db)).toBeNull();
2429
+ } finally {
2430
+ db.close();
2431
+ }
2432
+ } finally {
2433
+ cleanup();
2434
+ }
2435
+ });
2436
+
2437
+ test("an ENV-configured redirect (no DB row) is left alone — never clobbered (N1)", async () => {
2438
+ // Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
2439
+ // not a DB row. Gating on `getRootRedirect(db) === null` (DB only) would
2440
+ // miss this and write `/app/`, which — since the DB row wins over env on
2441
+ // read — silently overrides their configured landing. The fix gates on
2442
+ // `resolveRootRedirectDetailed(db).source === "default"`, which sees the
2443
+ // env tier. So: env set → no DB row written.
2444
+ const { path, configDir, cleanup } = makeTempPath();
2445
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2446
+ try {
2447
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = "/surface/team-room";
2448
+ const db = openHubDb(hubDbPath(configDir));
2449
+ const logs: string[] = [];
2450
+ try {
2451
+ const code = await install("app", {
2452
+ runner: async () => 0,
2453
+ manifestPath: path,
2454
+ configDir,
2455
+ startService: async () => 0,
2456
+ isLinked: () => false,
2457
+ portProbe: async () => false,
2458
+ rootRedirectDb: db,
2459
+ log: (l) => logs.push(l),
2460
+ });
2461
+ expect(code).toBe(0);
2462
+ // No DB row written — the env-configured landing survives.
2463
+ expect(getRootRedirect(db)).toBeNull();
2464
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2465
+ } finally {
2466
+ db.close();
2467
+ }
2468
+ } finally {
2469
+ if (prevEnv === undefined) {
2470
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2471
+ } else {
2472
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = prevEnv;
2473
+ }
2474
+ cleanup();
2475
+ }
2476
+ });
2477
+
2478
+ test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
2479
+ // Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
2480
+ // install.ts: a test with a tempdir manifestPath and no explicit opt-in
2481
+ // must never open the real ~/.parachute/hub.db. There's nothing to open
2482
+ // here at all — the assertion is simply that install still completes
2483
+ // cleanly with the DB-touching branch skipped.
2484
+ const { path, configDir, cleanup } = makeTempPath();
2485
+ try {
2486
+ const code = await install("app", {
2487
+ runner: async () => 0,
2488
+ manifestPath: path,
2489
+ configDir,
2490
+ startService: async () => 0,
2491
+ isLinked: () => false,
2492
+ portProbe: async () => false,
2493
+ log: () => {},
2494
+ });
2495
+ expect(code).toBe(0);
2496
+ } finally {
2497
+ cleanup();
2498
+ }
2499
+ });
2500
+ });
@@ -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
+ });
@@ -3,7 +3,26 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { assignPort, assignServicePort } from "../port-assign.ts";
6
- import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN } from "../service-spec.ts";
6
+ import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "../service-spec.ts";
7
+
8
+ describe("PORT_RESERVATIONS (registry shape)", () => {
9
+ test("every port in the canonical range appears exactly once", () => {
10
+ const ports = PORT_RESERVATIONS.map((r) => r.port);
11
+ expect(new Set(ports).size).toBe(ports.length);
12
+ expect(ports.slice().sort((a, b) => a - b)).toEqual(ports);
13
+ for (const p of ports) {
14
+ expect(p).toBeGreaterThanOrEqual(CANONICAL_PORT_MIN);
15
+ expect(p).toBeLessThanOrEqual(CANONICAL_PORT_MAX);
16
+ }
17
+ });
18
+
19
+ test("1944 (parachute-app, hub-parity P5) doesn't collide with any other reservation", () => {
20
+ const owners = PORT_RESERVATIONS.filter((r) => r.port === 1944);
21
+ expect(owners).toHaveLength(1);
22
+ expect(owners[0]?.name).toBe("parachute-app");
23
+ expect(owners[0]?.status).toBe("assigned");
24
+ });
25
+ });
7
26
 
8
27
  function makeTempDir(): { dir: string; cleanup: () => void } {
9
28
  const dir = mkdtempSync(join(tmpdir(), "pcli-port-assign-"));
@@ -25,16 +44,20 @@ describe("assignPort (pure)", () => {
25
44
  });
26
45
 
27
46
  test("walks the unassigned reservation range when canonical is occupied", () => {
28
- // 1940 is taken; canonical reserved range starts at 1944 → first hit.
47
+ // 1940 is taken. 1944 is now `parachute-app`'s canonical slot (hub-parity
48
+ // P5, status "assigned") so the walker skips it; the first RESERVED
49
+ // (walkable) slot is 1945.
29
50
  const result = assignPort(1940, [1940]);
30
- expect(result.port).toBe(1944);
51
+ expect(result.port).toBe(1945);
31
52
  expect(result.source).toBe("fallback-in-range");
32
53
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
33
- expect(result.warning).toMatch(/1944/);
54
+ expect(result.warning).toMatch(/1945/);
34
55
  });
35
56
 
36
57
  test("skips reservations that are also occupied", () => {
37
- // Canonical 1940 + the first three reserved slots are all in use.
58
+ // Canonical 1940 is in use. 1944 is assigned (parachute-app, skipped
59
+ // regardless of `occupied`); 1945 is reserved-but-occupied; 1946 is
60
+ // assigned (parachute-surface, skipped); 1947 is reserved-and-free.
38
61
  const result = assignPort(1940, [1940, 1944, 1945, 1946]);
39
62
  expect(result.port).toBe(1947);
40
63
  expect(result.source).toBe("fallback-in-range");
@@ -60,19 +83,21 @@ describe("assignPort (pure)", () => {
60
83
  });
61
84
 
62
85
  test("third-party (no canonical slot) jumps straight to the reservation range", () => {
86
+ // 1944 is assigned (parachute-app, hub-parity P5) so the walker skips it;
87
+ // the first RESERVED (walkable) slot is 1945.
63
88
  const result = assignPort(undefined, []);
64
- expect(result.port).toBe(1944);
89
+ expect(result.port).toBe(1945);
65
90
  expect(result.source).toBe("fallback-in-range");
66
91
  expect(result.warning).toMatch(/no canonical slot/);
67
- expect(result.warning).toMatch(/1944/);
92
+ expect(result.warning).toMatch(/1945/);
68
93
  });
69
94
 
70
95
  test("third-party with reservations occupied walks further in the range", () => {
71
- // PORT_RESERVATIONS post-hub#323: 1944 reserved, 1945 reserved, 1946
72
- // assigned (parachute-appKNOWN_MODULES carries the canonical slot
73
- // so the fallback walker doesn't hand it to a third party), 1947
74
- // reserved. With 1944 + 1945 occupied, the walker skips the assigned
75
- // 1946 slot and lands on the next reserved-and-free port — 1947.
96
+ // PORT_RESERVATIONS: 1944 assigned (parachute-app), 1945 reserved, 1946
97
+ // assigned (parachute-surfaceboth carry canonical slots so the
98
+ // fallback walker doesn't hand them to a third party), 1947 reserved.
99
+ // With 1944 + 1945 occupied, the walker skips the assigned 1946 slot and
100
+ // lands on the next reserved-and-free port — 1947.
76
101
  const result = assignPort(undefined, [1944, 1945]);
77
102
  expect(result.port).toBe(1947);
78
103
  expect(result.source).toBe("fallback-in-range");
@@ -145,7 +170,9 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
145
170
  canonical: 1940,
146
171
  occupied: [1940],
147
172
  });
148
- expect(result.port).toBe(1944);
173
+ // 1944 is parachute-app's assigned (non-walkable) slot as of
174
+ // hub-parity P5 — the first walkable reserved slot is 1945.
175
+ expect(result.port).toBe(1945);
149
176
  expect(result.source).toBe("fallback-in-range");
150
177
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
151
178
  // .env stays bit-for-bit identical.
@@ -162,7 +189,9 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
162
189
  const result = assignServicePort({
163
190
  occupied: [],
164
191
  });
165
- expect(result.port).toBe(1944);
192
+ // 1944 is parachute-app's assigned (non-walkable) slot as of
193
+ // hub-parity P5 — the first walkable reserved slot is 1945.
194
+ expect(result.port).toBe(1945);
166
195
  expect(result.source).toBe("fallback-in-range");
167
196
  expect(existsSync(envPath)).toBe(false);
168
197
  } finally {