@openparachute/hub 0.5.10 → 0.5.12-rc.2

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.
@@ -2300,3 +2300,395 @@ describe("typed vault name (hub#267)", () => {
2300
2300
  expect(html).toContain('id="preview-vault-name">BAD<');
2301
2301
  });
2302
2302
  });
2303
+
2304
+ // --- bootstrap token gate (first-boot-path hardening, Issue 1) -----------
2305
+
2306
+ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2307
+ let h: Harness;
2308
+ beforeEach(async () => {
2309
+ h = makeHarness();
2310
+ _resetOperationsRegistryForTests();
2311
+ const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
2312
+ _resetBootstrapTokenForTests();
2313
+ });
2314
+ afterEach(async () => {
2315
+ h.cleanup();
2316
+ const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
2317
+ _resetBootstrapTokenForTests();
2318
+ });
2319
+
2320
+ test("GET /admin/setup renders the bootstrap-token field when a token is active", async () => {
2321
+ const { generateBootstrapToken } = await import("../bootstrap-token.ts");
2322
+ generateBootstrapToken();
2323
+ const db = openHubDb(hubDbPath(h.dir));
2324
+ try {
2325
+ const res = handleSetupGet(req("/admin/setup"), {
2326
+ db,
2327
+ manifestPath: h.manifestPath,
2328
+ configDir: h.dir,
2329
+ issuer: "https://hub.example",
2330
+ registry: getDefaultOperationsRegistry(),
2331
+ });
2332
+ const html = await res.text();
2333
+ expect(html).toContain('name="bootstrap_token"');
2334
+ // The callout names what the field is + where to find the value.
2335
+ expect(html).toContain("Bootstrap token");
2336
+ expect(html).toContain("startup logs");
2337
+ // Form action unchanged.
2338
+ expect(html).toContain('action="/admin/setup/account"');
2339
+ } finally {
2340
+ db.close();
2341
+ }
2342
+ });
2343
+
2344
+ test("GET /admin/setup omits the bootstrap-token field when no token is active", () => {
2345
+ // No `generateBootstrapToken` call this test — the in-memory slot is
2346
+ // undefined, mirroring on-box CLI mode (no `parachute serve` wizard).
2347
+ const db = openHubDb(hubDbPath(h.dir));
2348
+ try {
2349
+ const res = handleSetupGet(req("/admin/setup"), {
2350
+ db,
2351
+ manifestPath: h.manifestPath,
2352
+ configDir: h.dir,
2353
+ issuer: "https://hub.example",
2354
+ registry: getDefaultOperationsRegistry(),
2355
+ });
2356
+ const html = res.text() as unknown as Promise<string>;
2357
+ return html.then((body) => {
2358
+ expect(body).not.toContain('name="bootstrap_token"');
2359
+ // Bootstrap callout is also absent — operator gets the
2360
+ // pre-hardening shape on the on-box CLI surface.
2361
+ expect(body).not.toContain("Bootstrap token");
2362
+ expect(body).toContain('action="/admin/setup/account"');
2363
+ });
2364
+ } finally {
2365
+ db.close();
2366
+ }
2367
+ });
2368
+
2369
+ test("POST /admin/setup/account with correct bootstrap_token creates admin + consumes token", async () => {
2370
+ const { generateBootstrapToken, getBootstrapToken } = await import("../bootstrap-token.ts");
2371
+ const token = generateBootstrapToken();
2372
+ const db = openHubDb(hubDbPath(h.dir));
2373
+ try {
2374
+ const get = handleSetupGet(req("/admin/setup"), {
2375
+ db,
2376
+ manifestPath: h.manifestPath,
2377
+ configDir: h.dir,
2378
+ issuer: "https://hub.example",
2379
+ registry: getDefaultOperationsRegistry(),
2380
+ });
2381
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2382
+ const form = formBody({
2383
+ bootstrap_token: token,
2384
+ username: "ops",
2385
+ password: "correct horse battery",
2386
+ password_confirm: "correct horse battery",
2387
+ [CSRF_FIELD_NAME]: csrf,
2388
+ });
2389
+ const post = await handleSetupAccountPost(
2390
+ req("/admin/setup/account", {
2391
+ method: "POST",
2392
+ body: form.body,
2393
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2394
+ }),
2395
+ {
2396
+ db,
2397
+ manifestPath: h.manifestPath,
2398
+ configDir: h.dir,
2399
+ issuer: "https://hub.example",
2400
+ registry: getDefaultOperationsRegistry(),
2401
+ },
2402
+ );
2403
+ expect(post.status).toBe(303);
2404
+ expect(userCount(db)).toBe(1);
2405
+ // Token consumed: in-memory slot is undefined.
2406
+ expect(getBootstrapToken()).toBeUndefined();
2407
+ } finally {
2408
+ db.close();
2409
+ }
2410
+ });
2411
+
2412
+ test("POST /admin/setup/account with wrong bootstrap_token returns 401 + no admin row", async () => {
2413
+ const { generateBootstrapToken } = await import("../bootstrap-token.ts");
2414
+ generateBootstrapToken();
2415
+ const db = openHubDb(hubDbPath(h.dir));
2416
+ try {
2417
+ const get = handleSetupGet(req("/admin/setup"), {
2418
+ db,
2419
+ manifestPath: h.manifestPath,
2420
+ configDir: h.dir,
2421
+ issuer: "https://hub.example",
2422
+ registry: getDefaultOperationsRegistry(),
2423
+ });
2424
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2425
+ const form = formBody({
2426
+ bootstrap_token: "parachute-bootstrap-WRONG-WRONG-WRONG-WRONG-WRONG-WRONG-x",
2427
+ username: "ops",
2428
+ password: "correct horse battery",
2429
+ password_confirm: "correct horse battery",
2430
+ [CSRF_FIELD_NAME]: csrf,
2431
+ });
2432
+ const post = await handleSetupAccountPost(
2433
+ req("/admin/setup/account", {
2434
+ method: "POST",
2435
+ body: form.body,
2436
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2437
+ }),
2438
+ {
2439
+ db,
2440
+ manifestPath: h.manifestPath,
2441
+ configDir: h.dir,
2442
+ issuer: "https://hub.example",
2443
+ registry: getDefaultOperationsRegistry(),
2444
+ },
2445
+ );
2446
+ expect(post.status).toBe(401);
2447
+ // The form re-renders with the token field present + an error
2448
+ // banner; the wrong token value is NOT echoed back.
2449
+ const html = await post.text();
2450
+ expect(html).toContain('name="bootstrap_token"');
2451
+ expect(html).toContain("Wrong bootstrap token");
2452
+ expect(html).not.toContain("WRONG-WRONG-WRONG");
2453
+ // Username is preserved so the operator doesn't have to retype.
2454
+ expect(html).toContain('value="ops"');
2455
+ // No admin row was created.
2456
+ expect(userCount(db)).toBe(0);
2457
+ } finally {
2458
+ db.close();
2459
+ }
2460
+ });
2461
+
2462
+ test("POST /admin/setup/account with MISSING bootstrap_token returns 401", async () => {
2463
+ const { generateBootstrapToken } = await import("../bootstrap-token.ts");
2464
+ generateBootstrapToken();
2465
+ const db = openHubDb(hubDbPath(h.dir));
2466
+ try {
2467
+ const get = handleSetupGet(req("/admin/setup"), {
2468
+ db,
2469
+ manifestPath: h.manifestPath,
2470
+ configDir: h.dir,
2471
+ issuer: "https://hub.example",
2472
+ registry: getDefaultOperationsRegistry(),
2473
+ });
2474
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2475
+ const form = formBody({
2476
+ // Deliberately omit `bootstrap_token`.
2477
+ username: "ops",
2478
+ password: "correct horse battery",
2479
+ password_confirm: "correct horse battery",
2480
+ [CSRF_FIELD_NAME]: csrf,
2481
+ });
2482
+ const post = await handleSetupAccountPost(
2483
+ req("/admin/setup/account", {
2484
+ method: "POST",
2485
+ body: form.body,
2486
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2487
+ }),
2488
+ {
2489
+ db,
2490
+ manifestPath: h.manifestPath,
2491
+ configDir: h.dir,
2492
+ issuer: "https://hub.example",
2493
+ registry: getDefaultOperationsRegistry(),
2494
+ },
2495
+ );
2496
+ expect(post.status).toBe(401);
2497
+ expect(userCount(db)).toBe(0);
2498
+ } finally {
2499
+ db.close();
2500
+ }
2501
+ });
2502
+
2503
+ test("POST /admin/setup/account after admin already claimed returns 410 Gone", async () => {
2504
+ const { generateBootstrapToken } = await import("../bootstrap-token.ts");
2505
+ // First claim: generate token + create admin. Then a stale POST
2506
+ // arrives — the token has been consumed AND an admin row exists.
2507
+ generateBootstrapToken();
2508
+ const db = openHubDb(hubDbPath(h.dir));
2509
+ try {
2510
+ await createUser(db, "first-admin", "strong-password", { passwordChanged: true });
2511
+ // Re-mint a fresh bootstrap token to simulate the case where the
2512
+ // operator restarts the hub after admin creation. (Normally the
2513
+ // serve.ts gate prevents this — we test the defensive layer.)
2514
+ generateBootstrapToken();
2515
+ const get = handleSetupGet(req("/admin/setup"), {
2516
+ db,
2517
+ manifestPath: h.manifestPath,
2518
+ configDir: h.dir,
2519
+ issuer: "https://hub.example",
2520
+ registry: getDefaultOperationsRegistry(),
2521
+ });
2522
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2523
+ const form = formBody({
2524
+ bootstrap_token: "parachute-bootstrap-doesnt-matter-admin-already-exists-xxx",
2525
+ username: "interloper",
2526
+ password: "another password",
2527
+ password_confirm: "another password",
2528
+ [CSRF_FIELD_NAME]: csrf,
2529
+ });
2530
+ const post = await handleSetupAccountPost(
2531
+ req("/admin/setup/account", {
2532
+ method: "POST",
2533
+ body: form.body,
2534
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2535
+ }),
2536
+ {
2537
+ db,
2538
+ manifestPath: h.manifestPath,
2539
+ configDir: h.dir,
2540
+ issuer: "https://hub.example",
2541
+ registry: getDefaultOperationsRegistry(),
2542
+ },
2543
+ );
2544
+ expect(post.status).toBe(410);
2545
+ const html = await post.text();
2546
+ expect(html).toContain("Admin already claimed");
2547
+ // No second user row was created.
2548
+ expect(userCount(db)).toBe(1);
2549
+ } finally {
2550
+ db.close();
2551
+ }
2552
+ });
2553
+
2554
+ test("on-box CLI flow (no token) creates admin normally — historical shape preserved", async () => {
2555
+ // Critical back-compat: when no token has been generated (the
2556
+ // historical wizard path: `parachute expose` doesn't enter wizard
2557
+ // mode), the account POST works exactly as before. This pins the
2558
+ // existing behavior post-refactor.
2559
+ const db = openHubDb(hubDbPath(h.dir));
2560
+ try {
2561
+ const get = handleSetupGet(req("/admin/setup"), {
2562
+ db,
2563
+ manifestPath: h.manifestPath,
2564
+ configDir: h.dir,
2565
+ issuer: "https://hub.example",
2566
+ registry: getDefaultOperationsRegistry(),
2567
+ });
2568
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2569
+ const form = formBody({
2570
+ username: "ops",
2571
+ password: "correct horse battery",
2572
+ password_confirm: "correct horse battery",
2573
+ [CSRF_FIELD_NAME]: csrf,
2574
+ });
2575
+ const post = await handleSetupAccountPost(
2576
+ req("/admin/setup/account", {
2577
+ method: "POST",
2578
+ body: form.body,
2579
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2580
+ }),
2581
+ {
2582
+ db,
2583
+ manifestPath: h.manifestPath,
2584
+ configDir: h.dir,
2585
+ issuer: "https://hub.example",
2586
+ registry: getDefaultOperationsRegistry(),
2587
+ },
2588
+ );
2589
+ expect(post.status).toBe(303);
2590
+ expect(userCount(db)).toBe(1);
2591
+ } finally {
2592
+ db.close();
2593
+ }
2594
+ });
2595
+
2596
+ // hub#297 reviewer-nit fold 3: pin the concurrent-claim race property.
2597
+ //
2598
+ // The wizard's account-claim POST has two layers of race-protection
2599
+ // (chain documented inline below). The vault-POST analogue (idempotent
2600
+ // short-circuit when supervisor.start is already running) has a test
2601
+ // at setup-wizard.test.ts:N2 (`handleSetupVaultPost` — idempotent on
2602
+ // concurrent POSTs); this is the missing partner pin for the account
2603
+ // step.
2604
+ //
2605
+ // Race-protection chain:
2606
+ // 1. First POST takes the token via verifyBootstrapToken (constant-
2607
+ // time check returns true), enters the createUser branch, and
2608
+ // consumes the token via consumeBootstrapToken AFTER the row
2609
+ // commits.
2610
+ // 2. Second POST (if it arrives before the first finishes):
2611
+ // a. If the first POST hasn't consumed the token yet, both
2612
+ // POSTs pass verifyBootstrapToken. They race into createUser;
2613
+ // SQLite's UNIQUE constraint on `users.username` makes the
2614
+ // second `INSERT INTO users` throw. The handler's `catch`
2615
+ // block re-renders the form with a 400 + "username may
2616
+ // already be taken" banner — and crucially does NOT create
2617
+ // a second admin row.
2618
+ // b. If the first POST has already consumed the token, the
2619
+ // second POST's verifyBootstrapToken returns false (token
2620
+ // slot is undefined). 401 + form re-render.
2621
+ // 3. Either way: exactly one admin row at rest, token consumed.
2622
+ test("concurrent claim with the same token + username yields exactly one admin row (race property)", async () => {
2623
+ const { generateBootstrapToken, getBootstrapToken } = await import("../bootstrap-token.ts");
2624
+ const token = generateBootstrapToken();
2625
+ const db = openHubDb(hubDbPath(h.dir));
2626
+ try {
2627
+ const get = handleSetupGet(req("/admin/setup"), {
2628
+ db,
2629
+ manifestPath: h.manifestPath,
2630
+ configDir: h.dir,
2631
+ issuer: "https://hub.example",
2632
+ registry: getDefaultOperationsRegistry(),
2633
+ });
2634
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2635
+ const formA = formBody({
2636
+ bootstrap_token: token,
2637
+ username: "ops",
2638
+ password: "correct horse battery",
2639
+ password_confirm: "correct horse battery",
2640
+ [CSRF_FIELD_NAME]: csrf,
2641
+ });
2642
+ // Two POSTs share the same body, same CSRF, same token, same
2643
+ // username. Promise.all fires them as concurrently as the bun
2644
+ // runtime allows; the deterministic interleaving covered here
2645
+ // is: both pass CSRF (same cookie), both pass token verify (if
2646
+ // they race before the first one's consume), both reach
2647
+ // createUser, and exactly one INSERT wins.
2648
+ const deps = {
2649
+ db,
2650
+ manifestPath: h.manifestPath,
2651
+ configDir: h.dir,
2652
+ issuer: "https://hub.example",
2653
+ registry: getDefaultOperationsRegistry(),
2654
+ };
2655
+ const post = (label: string) =>
2656
+ handleSetupAccountPost(
2657
+ req(`/admin/setup/account?race=${label}`, {
2658
+ method: "POST",
2659
+ body: formA.body,
2660
+ headers: { ...formA.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
2661
+ }),
2662
+ deps,
2663
+ );
2664
+
2665
+ const [resA, resB] = await Promise.all([post("a"), post("b")]);
2666
+
2667
+ // Property 1: exactly one POST landed at the 303 success branch.
2668
+ const successes = [resA, resB].filter((r) => r.status === 303);
2669
+ const failures = [resA, resB].filter((r) => r.status !== 303);
2670
+ expect(successes.length).toBe(1);
2671
+ expect(failures.length).toBe(1);
2672
+
2673
+ // Property 2: the failure is either a 400 (UNIQUE collision via
2674
+ // createUser → catch block) OR a 401 (token already consumed by
2675
+ // the first POST → verifyBootstrapToken returned false on this
2676
+ // one). Both are valid race outcomes; we don't pin which —
2677
+ // the schedule is non-deterministic at the bun runtime layer.
2678
+ const failStatus = failures[0]?.status;
2679
+ expect(failStatus === 400 || failStatus === 401).toBe(true);
2680
+
2681
+ // Property 3: exactly one admin row exists in the users table.
2682
+ expect(userCount(db)).toBe(1);
2683
+
2684
+ // Property 4: the bootstrap token is consumed (cannot be reused
2685
+ // by a later POST). Even in the schedule where the second POST
2686
+ // failed via UNIQUE (token wasn't consumed by the failing path —
2687
+ // it was consumed by the succeeding path), the token slot is
2688
+ // empty after both promises settle.
2689
+ expect(getBootstrapToken()).toBeUndefined();
2690
+ } finally {
2691
+ db.close();
2692
+ }
2693
+ });
2694
+ });
@@ -34,12 +34,14 @@
34
34
 
35
35
  import type { Database } from "bun:sqlite";
36
36
  import { randomUUID } from "node:crypto";
37
+ import { dirname } from "node:path";
37
38
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
38
39
  import { getModuleInstallChannel } from "./hub-settings.ts";
39
40
  import { validateAccessToken } from "./jwt-sign.ts";
40
41
  import { FIRST_PARTY_FALLBACKS, type ServiceSpec, composeServiceSpec } from "./service-spec.ts";
41
- import { findService, readManifest, removeService } from "./services-manifest.ts";
42
+ import { findService, readManifest, removeService, upsertService } from "./services-manifest.ts";
42
43
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
44
+ import { regenerateWellKnown } from "./well-known.ts";
43
45
 
44
46
  /**
45
47
  * Scope required for every POST + operation-poll endpoint here.
@@ -186,6 +188,20 @@ export interface ApiModulesOpsDeps {
186
188
  * inside `Bun.spawn` at child spawn time; we don't mutate `process.env`.
187
189
  */
188
190
  spawnEnv?: Record<string, string>;
191
+ /**
192
+ * Override the on-disk path for the regenerated `/.well-known/parachute.json`
193
+ * (test seam). Production writes to `WELL_KNOWN_PATH`; tests point at a
194
+ * tmp file so assertions can read the resulting doc without touching the
195
+ * operator's real config dir.
196
+ */
197
+ wellKnownPath?: string;
198
+ /**
199
+ * Reader for `<installDir>/.parachute/module.json` used by the well-known
200
+ * regen. Production reads from disk; tests inject a fake. Mirrors the
201
+ * hub-server `readModuleManifest` seam so the disk regen and the
202
+ * per-request HTTP build stay aligned.
203
+ */
204
+ readModuleManifest?: Parameters<typeof regenerateWellKnown>[0]["readModuleManifest"];
189
205
  }
190
206
 
191
207
  interface PathMatch {
@@ -266,6 +282,61 @@ function defaultRun(cmd: readonly string[]): Promise<number> {
266
282
  return proc.exited;
267
283
  }
268
284
 
285
+ /**
286
+ * Stamp `installDir` on the services.json row for `spec.manifestName` when
287
+ * the package's globally-installed path can be resolved. Idempotent —
288
+ * no-ops when the row already carries the same `installDir`, when no row
289
+ * exists, or when `findGlobalInstall` can't locate the package (e.g. in
290
+ * tests with no global install).
291
+ *
292
+ * Mirrors the same stamp `parachute install <svc>` does in
293
+ * `commands/install.ts`. Without it, the discovery page's
294
+ * `loadServiceUiMetadata` resolver in `hub-server.ts` skips the entry (it
295
+ * reads `module.json` from `installDir`), so `uiUrl` never lands on the
296
+ * row + the new module's tile never renders on `/`.
297
+ */
298
+ function stampInstallDir(spec: ServiceSpec, deps: ApiModulesOpsDeps): void {
299
+ const findGlobalInstall = deps.findGlobalInstall;
300
+ if (!findGlobalInstall) return;
301
+ const pkgJson = findGlobalInstall(spec.package);
302
+ if (!pkgJson) return;
303
+ const installDir = dirname(pkgJson);
304
+ const entry = findService(spec.manifestName, deps.manifestPath);
305
+ if (!entry || entry.installDir === installDir) return;
306
+ upsertService({ ...entry, installDir }, deps.manifestPath);
307
+ }
308
+
309
+ /**
310
+ * Regenerate `/.well-known/parachute.json` on disk after a state-changing
311
+ * module op (install / upgrade / uninstall). Wraps `regenerateWellKnown`
312
+ * with the deps-aware defaults — issuer for canonicalOrigin, deps-overridable
313
+ * paths + manifest reader. Errors land in the operation log rather than
314
+ * throwing back to the caller; the on-disk doc is a debug / inspection
315
+ * artifact, not the live discovery source, so a regen failure shouldn't
316
+ * mask the op's actual outcome.
317
+ */
318
+ async function regenAfterOp(
319
+ opId: string,
320
+ registry: OperationsRegistry,
321
+ deps: ApiModulesOpsDeps,
322
+ ): Promise<void> {
323
+ try {
324
+ const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
325
+ manifestPath: deps.manifestPath,
326
+ canonicalOrigin: deps.issuer,
327
+ };
328
+ if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
329
+ if (deps.readModuleManifest !== undefined) {
330
+ regenOpts.readModuleManifest = deps.readModuleManifest;
331
+ }
332
+ const { path } = await regenerateWellKnown(regenOpts);
333
+ registry.update(opId, {}, `regenerated ${path}`);
334
+ } catch (err) {
335
+ const msg = err instanceof Error ? err.message : String(err);
336
+ registry.update(opId, {}, `well-known regen failed: ${msg}`);
337
+ }
338
+ }
339
+
269
340
  /**
270
341
  * Spawn the supervised child for `short`, using the spec's startCmd
271
342
  * and the current services.json entry (so notes' port-derived
@@ -392,6 +463,14 @@ export async function runInstall(
392
463
  }
393
464
  }
394
465
 
466
+ // Stamp installDir on the row so the discovery page's `uiUrl` /
467
+ // `displayName` resolver can find `<installDir>/.parachute/module.json`.
468
+ // Mirrors `parachute install <svc>` — without this, the new module's
469
+ // tile never renders on `/` because `loadServiceUiMetadata` skips
470
+ // installDir-less rows. (Doing this BEFORE the spawn so the supervisor
471
+ // also sees the updated row if it consults services.json post-spawn.)
472
+ stampInstallDir(spec, deps);
473
+
395
474
  // Spawn the child via the supervisor. Boot-spawn semantics apply.
396
475
  const state = await spawnSupervised(short, spec, deps);
397
476
  if (!state) {
@@ -402,6 +481,14 @@ export async function runInstall(
402
481
  );
403
482
  return;
404
483
  }
484
+
485
+ // Regenerate the on-disk well-known doc so the inspection artifact
486
+ // tracks the just-installed module's row. The HTTP path serves a
487
+ // per-request build, so the discovery page picks up the new module
488
+ // either way; this keeps `~/.parachute/well-known/parachute.json` in
489
+ // sync for tooling that reads it directly.
490
+ await regenAfterOp(opId, registry, deps);
491
+
405
492
  registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
406
493
  }
407
494
 
@@ -485,6 +572,11 @@ async function runUpgrade(
485
572
  registry.update(opId, {}, `bun add reported exit ${code} but package landed at ${probed}`);
486
573
  }
487
574
 
575
+ // Re-stamp installDir after upgrade — a major-version bump may relocate
576
+ // the package on disk (e.g. node_modules layout change). Idempotent
577
+ // when the path is stable.
578
+ stampInstallDir(spec, deps);
579
+
488
580
  const state = await deps.supervisor.restart(short);
489
581
  if (!state) {
490
582
  registry.update(
@@ -494,6 +586,13 @@ async function runUpgrade(
494
586
  );
495
587
  return;
496
588
  }
589
+
590
+ // Refresh the on-disk well-known so the version field on the upgraded
591
+ // module's row reflects the new install. The HTTP path rebuilds per
592
+ // request, so the discovery page tracks the new version regardless;
593
+ // this keeps the inspection artifact aligned.
594
+ await regenAfterOp(opId, registry, deps);
595
+
497
596
  registry.update(
498
597
  opId,
499
598
  { status: "succeeded" },
@@ -540,6 +639,26 @@ export async function handleUninstall(
540
639
  const code = await run(["bun", "remove", "-g", spec.package]);
541
640
  log.push(`bun remove -g ${spec.package} exited ${code}`);
542
641
 
642
+ // 4. Refresh the on-disk well-known so the uninstalled module no
643
+ // longer appears in the inspection artifact. The HTTP path rebuilds
644
+ // per request, so live discovery drops the entry immediately; this
645
+ // is the disk-side equivalent.
646
+ try {
647
+ const regenOpts: Parameters<typeof regenerateWellKnown>[0] = {
648
+ manifestPath: deps.manifestPath,
649
+ canonicalOrigin: deps.issuer,
650
+ };
651
+ if (deps.wellKnownPath !== undefined) regenOpts.wellKnownPath = deps.wellKnownPath;
652
+ if (deps.readModuleManifest !== undefined) {
653
+ regenOpts.readModuleManifest = deps.readModuleManifest;
654
+ }
655
+ const { path } = await regenerateWellKnown(regenOpts);
656
+ log.push(`regenerated ${path}`);
657
+ } catch (err) {
658
+ const msg = err instanceof Error ? err.message : String(err);
659
+ log.push(`well-known regen failed: ${msg}`);
660
+ }
661
+
543
662
  return jsonOk({ short, log });
544
663
  }
545
664