@openparachute/hub 0.7.7-rc.7 → 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 +1 -1
- package/src/__tests__/account-api.test.ts +287 -27
- package/src/__tests__/install.test.ts +187 -5
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/port-assign.test.ts +43 -14
- package/src/__tests__/services-manifest.test.ts +350 -40
- package/src/__tests__/setup.test.ts +5 -1
- package/src/account-api.ts +111 -66
- package/src/api-invites.ts +19 -0
- package/src/api-modules.ts +1 -1
- package/src/commands/install.ts +44 -0
- package/src/commands/setup.ts +9 -4
- package/src/help.ts +6 -5
- package/src/hub-server.ts +11 -4
- package/src/hub-settings.ts +15 -1
- package/src/invites.ts +42 -0
- package/src/notes-serve.ts +73 -31
- package/src/service-spec.ts +113 -29
- package/src/services-manifest.ts +104 -11
|
@@ -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
|
|
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(
|
|
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(/
|
|
54
|
+
expect(result.warning).toMatch(/1945/);
|
|
34
55
|
});
|
|
35
56
|
|
|
36
57
|
test("skips reservations that are also occupied", () => {
|
|
37
|
-
// Canonical 1940
|
|
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(
|
|
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(/
|
|
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
|
|
72
|
-
// assigned (parachute-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
96
|
+
// PORT_RESERVATIONS: 1944 assigned (parachute-app), 1945 reserved, 1946
|
|
97
|
+
// assigned (parachute-surface — both 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
|
-
|
|
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
|
-
|
|
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 {
|