@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.
@@ -1,7 +1,8 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { RETIRED_MODULES } from "../service-spec.ts";
5
6
  import {
6
7
  type ServiceEntry,
7
8
  ServicesManifestError,
@@ -1024,6 +1025,14 @@ describe("claw → agent migration", () => {
1024
1025
  // file so the next read is clean.
1025
1026
  describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1026
1027
  test("drops the short-name row when a same-port manifestName row exists", () => {
1028
+ // Fixture uses `surface` (the ACTUAL short-name twin of `parachute-surface`
1029
+ // per the structural rule: b.name === `parachute-${a.name}`) rather than
1030
+ // `app`. Pre-hub-parity-P5 this test used `app` here, but that only
1031
+ // passed because `app` was ALSO in RETIRED_MODULES at the time — the
1032
+ // unconditional retired-module GC (not this structural short-name rule)
1033
+ // was doing the dropping. Now that `app`/`parachute-app` are un-retired
1034
+ // (hub-parity P5, a new unrelated module claims them), this fixture is
1035
+ // corrected to actually exercise `dropLegacyShortNameRows`.
1027
1036
  const { path, cleanup } = makeTempPath();
1028
1037
  try {
1029
1038
  writeFileSync(
@@ -1038,7 +1047,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1038
1047
  version: "0.2.0",
1039
1048
  },
1040
1049
  {
1041
- name: "app",
1050
+ name: "surface",
1042
1051
  port: 1946,
1043
1052
  paths: ["/surface"],
1044
1053
  health: "/surface/healthz",
@@ -1195,6 +1204,9 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1195
1204
  });
1196
1205
 
1197
1206
  test("idempotent — second read leaves the cleaned file alone", () => {
1207
+ // See the fixture note on the first test in this describe block — `app`
1208
+ // is renamed to `surface` here for the same reason (un-retired 2026-07-11,
1209
+ // hub-parity P5).
1198
1210
  const { path, cleanup } = makeTempPath();
1199
1211
  try {
1200
1212
  writeFileSync(
@@ -1209,7 +1221,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1209
1221
  version: "0.2.0",
1210
1222
  },
1211
1223
  {
1212
- name: "app",
1224
+ name: "surface",
1213
1225
  port: 1946,
1214
1226
  paths: ["/surface"],
1215
1227
  health: "/surface/healthz",
@@ -1232,21 +1244,37 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1232
1244
 
1233
1245
  // Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
1234
1246
  // 2026-05-22). His services.json originally carried a stale `agent` row at
1235
- // 1946 colliding with `parachute-app`'s canonical slot. NOTE: post the
1236
- // 2026-06-17 channel→agent rename, `agent` is a LIVE module again, so these
1237
- // fixtures use the still-retired `app` (parachute-app) name to exercise the
1238
- // same GC. The legacy-short-name de-dupe doesn't help a retired short
1239
- // isn't the short-name twin of the colliding row. The retired-module GC
1240
- // fires unconditionally on rows whose name appears in `RETIRED_MODULES`,
1241
- // regardless of port collision.
1247
+ // 1946 colliding with `parachute-app`'s canonical slot. The legacy-short-name
1248
+ // de-dupe doesn't help a retired short isn't the short-name twin of the
1249
+ // colliding row. The retired-module GC fires unconditionally on rows whose
1250
+ // name appears in `RETIRED_MODULES`, regardless of port collision.
1251
+ //
1252
+ // NOTE (2026-07-11, hub-parity P5): the original fixtures used `agent` —
1253
+ // which was a RETIRED_MODULES entry until the 2026-06-17 channel→agent
1254
+ // rename re-assigned that name to the live (renamed-from-channel) module —
1255
+ // and were later updated to use `app` (parachute-app, retired 2026-05-27).
1256
+ // `app`/`parachute-app` are now THEMSELVES un-retired (a new, unrelated
1257
+ // module — the real parachute-app super-surface — claims the name for
1258
+ // real; see service-spec.ts's RETIRED_MODULES comment). So these tests now
1259
+ // inject a throwaway synthetic retired entry via a describe-scoped
1260
+ // beforeEach/afterEach rather than depending on production RETIRED_MODULES
1261
+ // having a spare non-live name to spend on test fixtures — this mechanism
1262
+ // has now been renamed out from under these tests twice.
1242
1263
  describe("retired-module row de-dupe (hub#334)", () => {
1243
- // NOTE: the original fixtures used `agent` — which was a RETIRED_MODULES
1244
- // entry until the 2026-06-17 channel→agent rename re-assigned that name to
1245
- // the live (renamed-from-channel) module. These tests now use `app`
1246
- // (parachute-app, retired 2026-05-27 — still in RETIRED_MODULES) so they
1247
- // keep exercising the retired-module GC mechanism on a name that is still
1248
- // genuinely retired.
1249
- test("drops a row whose name is in RETIRED_MODULES (app)", () => {
1264
+ const RETIRED_FIXTURE_NAME = "legacy-test-retired-module";
1265
+
1266
+ beforeEach(() => {
1267
+ RETIRED_MODULES[RETIRED_FIXTURE_NAME] = {
1268
+ retiredAt: "2020-01-01",
1269
+ replacement: "n/a — test-only fixture, not a real retirement",
1270
+ };
1271
+ });
1272
+
1273
+ afterEach(() => {
1274
+ delete RETIRED_MODULES[RETIRED_FIXTURE_NAME];
1275
+ });
1276
+
1277
+ test("drops a row whose name is in RETIRED_MODULES", () => {
1250
1278
  const { path, cleanup } = makeTempPath();
1251
1279
  try {
1252
1280
  writeFileSync(
@@ -1254,10 +1282,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1254
1282
  JSON.stringify({
1255
1283
  services: [
1256
1284
  {
1257
- name: "app",
1285
+ name: RETIRED_FIXTURE_NAME,
1258
1286
  port: 1946,
1259
- paths: ["/app"],
1260
- health: "/app/health",
1287
+ paths: ["/legacy"],
1288
+ health: "/legacy/health",
1261
1289
  version: "0.1.4",
1262
1290
  },
1263
1291
  ],
@@ -1275,7 +1303,7 @@ describe("retired-module row de-dupe (hub#334)", () => {
1275
1303
 
1276
1304
  test("retirement is unconditional — no other rows required", () => {
1277
1305
  // Verifies dropRetiredModuleRows doesn't depend on a collision
1278
- // partner (unlike dropLegacyShortNameRows). An app row sitting
1306
+ // partner (unlike dropLegacyShortNameRows). A retired row sitting
1279
1307
  // alone is still stale.
1280
1308
  const { path, cleanup } = makeTempPath();
1281
1309
  try {
@@ -1284,10 +1312,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1284
1312
  JSON.stringify({
1285
1313
  services: [
1286
1314
  {
1287
- name: "app",
1315
+ name: RETIRED_FIXTURE_NAME,
1288
1316
  port: 9999,
1289
- paths: ["/app"],
1290
- health: "/app/health",
1317
+ paths: ["/legacy"],
1318
+ health: "/legacy/health",
1291
1319
  version: "0.1.4",
1292
1320
  },
1293
1321
  ],
@@ -1329,10 +1357,9 @@ describe("retired-module row de-dupe (hub#334)", () => {
1329
1357
  });
1330
1358
 
1331
1359
  test("Aaron's reproducer — retired row + parachute-surface at same port resolves cleanly", () => {
1332
- // The motivating bug for hub#334 (originally an `agent` row; `agent` is
1333
- // a live module again post-rename, so this uses the still-retired `app`).
1334
- // With dropRetiredModuleRows running before validateManifest, the stale
1335
- // retired row is GC'd and the duplicate-port gate doesn't trip downstream.
1360
+ // The motivating bug for hub#334. With dropRetiredModuleRows running
1361
+ // before validateManifest, the stale retired row is GC'd and the
1362
+ // duplicate-port gate doesn't trip downstream.
1336
1363
  const { path, cleanup } = makeTempPath();
1337
1364
  try {
1338
1365
  writeFileSync(
@@ -1340,10 +1367,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1340
1367
  JSON.stringify({
1341
1368
  services: [
1342
1369
  {
1343
- name: "app",
1370
+ name: RETIRED_FIXTURE_NAME,
1344
1371
  port: 1946,
1345
- paths: ["/app"],
1346
- health: "/app/health",
1372
+ paths: ["/legacy"],
1373
+ health: "/legacy/health",
1347
1374
  version: "0.1.4",
1348
1375
  },
1349
1376
  {
@@ -1366,12 +1393,11 @@ describe("retired-module row de-dupe (hub#334)", () => {
1366
1393
 
1367
1394
  test("interaction — retired row + legacy short-name pair both cleaned, correct order", () => {
1368
1395
  // Drop order matters: retired-module cleanup runs first, then
1369
- // legacy-short-name cleanup. This test ensures both passes
1370
- // compose correctly on a services.json that exercises both
1371
- // shapes simultaneously. The `app` row is unconditional retire
1372
- // (the original `agent` fixture is a live module again post-rename);
1373
- // the parachute-runner + runner pair triggers legacy-short-name
1374
- // dedup at port 1945.
1396
+ // legacy-short-name cleanup. This test ensures both passes compose
1397
+ // correctly on a services.json that exercises both shapes
1398
+ // simultaneously. The retired-fixture row is unconditional retire; the
1399
+ // parachute-runner + runner pair triggers legacy-short-name dedup at
1400
+ // port 1945.
1375
1401
  const { path, cleanup } = makeTempPath();
1376
1402
  try {
1377
1403
  writeFileSync(
@@ -1379,10 +1405,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1379
1405
  JSON.stringify({
1380
1406
  services: [
1381
1407
  {
1382
- name: "app",
1408
+ name: RETIRED_FIXTURE_NAME,
1383
1409
  port: 1946,
1384
- paths: ["/app"],
1385
- health: "/app/health",
1410
+ paths: ["/legacy"],
1411
+ health: "/legacy/health",
1386
1412
  version: "0.1.4",
1387
1413
  },
1388
1414
  {
@@ -1411,6 +1437,290 @@ describe("retired-module row de-dupe (hub#334)", () => {
1411
1437
  });
1412
1438
  });
1413
1439
 
1440
+ // Stale OLD-UI-host `app`/`parachute-app` row heal (hub-parity P5, B1). The
1441
+ // name `parachute-app` used to belong to the UI-host that renamed to
1442
+ // `parachute-surface` 2026-05-27; hub-parity P5 reuses the name for the NEW
1443
+ // super-surface app (mount /app, port 1944), so the RETIRED_MODULES entry that
1444
+ // used to GC the old row was removed. `healStaleUiHostAppRows` is its
1445
+ // shape-gated replacement for operators upgrading DIRECTLY from a pre-rename
1446
+ // release who still carry the stale `/surface`/1946 row. The load-bearing
1447
+ // assertion: a stale old-app row does NOT survive to spawn a crash-looping
1448
+ // unit, while the NEW app row (paths:["/app"], port 1944) is left intact.
1449
+ describe("stale OLD-UI-host app row heal (hub-parity P5, B1)", () => {
1450
+ test("heals (drops) a stale `parachute-app` row with the old /surface + 1946 shape", () => {
1451
+ const { path, cleanup } = makeTempPath();
1452
+ try {
1453
+ writeFileSync(
1454
+ path,
1455
+ JSON.stringify({
1456
+ services: [
1457
+ {
1458
+ name: "parachute-app",
1459
+ port: 1946,
1460
+ paths: ["/surface"],
1461
+ health: "/surface/healthz",
1462
+ version: "0.1.4",
1463
+ },
1464
+ ],
1465
+ }),
1466
+ );
1467
+ const m = readManifest(path);
1468
+ // The stale row is gone — it never survives to spawn a crash-looping
1469
+ // `--package @openparachute/parachute-app` unit the operator never
1470
+ // installed.
1471
+ expect(m.services).toHaveLength(0);
1472
+ // Rewritten clean on disk too.
1473
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
1474
+ expect(onDisk.services).toHaveLength(0);
1475
+ } finally {
1476
+ cleanup();
1477
+ }
1478
+ });
1479
+
1480
+ test("heals a bare-short `app` row with the old shape (the pre-rename self-register name)", () => {
1481
+ const { path, cleanup } = makeTempPath();
1482
+ try {
1483
+ writeFileSync(
1484
+ path,
1485
+ JSON.stringify({
1486
+ services: [
1487
+ {
1488
+ name: "app",
1489
+ port: 1946,
1490
+ paths: ["/surface"],
1491
+ health: "/surface/healthz",
1492
+ version: "0.1.4",
1493
+ },
1494
+ ],
1495
+ }),
1496
+ );
1497
+ const m = readManifest(path);
1498
+ expect(m.services).toHaveLength(0);
1499
+ } finally {
1500
+ cleanup();
1501
+ }
1502
+ });
1503
+
1504
+ test("heals on port 1946 even if the stale row's paths got mangled (port discriminant)", () => {
1505
+ // Corroborating OR-clause: a stale row squatting the old canonical 1946
1506
+ // is healed even if paths[0] isn't /surface-rooted anymore.
1507
+ const { path, cleanup } = makeTempPath();
1508
+ try {
1509
+ writeFileSync(
1510
+ path,
1511
+ JSON.stringify({
1512
+ services: [
1513
+ {
1514
+ name: "parachute-app",
1515
+ port: 1946,
1516
+ paths: ["/whatever"],
1517
+ health: "/whatever/health",
1518
+ version: "0.1.4",
1519
+ },
1520
+ ],
1521
+ }),
1522
+ );
1523
+ const m = readManifest(path);
1524
+ expect(m.services).toHaveLength(0);
1525
+ } finally {
1526
+ cleanup();
1527
+ }
1528
+ });
1529
+
1530
+ test("leaves the NEW app row (paths:['/app'], port 1944) intact — structurally disjoint", () => {
1531
+ const { path, cleanup } = makeTempPath();
1532
+ try {
1533
+ writeFileSync(
1534
+ path,
1535
+ JSON.stringify({
1536
+ services: [
1537
+ {
1538
+ name: "parachute-app",
1539
+ port: 1944,
1540
+ paths: ["/app"],
1541
+ health: "/app/health",
1542
+ version: "0.4.0",
1543
+ },
1544
+ ],
1545
+ }),
1546
+ );
1547
+ const m = readManifest(path);
1548
+ expect(m.services).toHaveLength(1);
1549
+ expect(m.services[0]?.name).toBe("parachute-app");
1550
+ expect(m.services[0]?.port).toBe(1944);
1551
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1552
+ } finally {
1553
+ cleanup();
1554
+ }
1555
+ });
1556
+
1557
+ test("never heals a /app-mounted app row, whatever its port (new-app protection)", () => {
1558
+ // Defensive: even if a new-app row somehow drifted onto a weird port, its
1559
+ // `/app` mount alone protects it from the heal.
1560
+ const { path, cleanup } = makeTempPath();
1561
+ try {
1562
+ writeFileSync(
1563
+ path,
1564
+ JSON.stringify({
1565
+ services: [
1566
+ {
1567
+ name: "app",
1568
+ port: 1946,
1569
+ paths: ["/app"],
1570
+ health: "/app/health",
1571
+ version: "0.4.0",
1572
+ },
1573
+ ],
1574
+ }),
1575
+ );
1576
+ const m = readManifest(path);
1577
+ expect(m.services).toHaveLength(1);
1578
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1579
+ } finally {
1580
+ cleanup();
1581
+ }
1582
+ });
1583
+
1584
+ test("coexistence — stale old-app + real parachute-surface both at 1946: stale healed, surface keeps /surface", () => {
1585
+ // The load-bearing coexistence case. Without the heal, the two rows collide
1586
+ // on port 1946 and trip the duplicate-port gate (or the stale row hijacks
1587
+ // /surface). With the heal running first, the stale row is dropped by shape
1588
+ // and surface survives clean.
1589
+ const { path, cleanup } = makeTempPath();
1590
+ try {
1591
+ writeFileSync(
1592
+ path,
1593
+ JSON.stringify({
1594
+ services: [
1595
+ {
1596
+ name: "parachute-app",
1597
+ port: 1946,
1598
+ paths: ["/surface"],
1599
+ health: "/surface/healthz",
1600
+ version: "0.1.4",
1601
+ },
1602
+ {
1603
+ name: "parachute-surface",
1604
+ port: 1946,
1605
+ paths: ["/surface"],
1606
+ health: "/surface/healthz",
1607
+ version: "0.2.0",
1608
+ },
1609
+ ],
1610
+ }),
1611
+ );
1612
+ const m = readManifest(path);
1613
+ expect(m.services).toHaveLength(1);
1614
+ expect(m.services[0]?.name).toBe("parachute-surface");
1615
+ expect(m.services[0]?.paths).toEqual(["/surface"]);
1616
+ } finally {
1617
+ cleanup();
1618
+ }
1619
+ });
1620
+
1621
+ test("double-write reproducer — stale `parachute-app` + `app` both at 1946/'/surface' → both healed", () => {
1622
+ // A pre-rename operator hit by the 2026-05-22 double-write carries BOTH
1623
+ // rows. The heal drops both by shape (running ahead of dropLegacyShortNameRows,
1624
+ // which would otherwise keep the `parachute-app` half — still stale).
1625
+ const { path, cleanup } = makeTempPath();
1626
+ try {
1627
+ writeFileSync(
1628
+ path,
1629
+ JSON.stringify({
1630
+ services: [
1631
+ {
1632
+ name: "parachute-app",
1633
+ port: 1946,
1634
+ paths: ["/surface"],
1635
+ health: "/surface/healthz",
1636
+ version: "0.1.4",
1637
+ },
1638
+ {
1639
+ name: "app",
1640
+ port: 1946,
1641
+ paths: ["/surface"],
1642
+ health: "/surface/healthz",
1643
+ version: "0.1.4",
1644
+ },
1645
+ ],
1646
+ }),
1647
+ );
1648
+ const m = readManifest(path);
1649
+ expect(m.services).toHaveLength(0);
1650
+ } finally {
1651
+ cleanup();
1652
+ }
1653
+ });
1654
+
1655
+ test("coexistence — NEW app (/app, 1944) + real surface (/surface, 1946): both survive", () => {
1656
+ // The steady-state post-P5 world: the new app and surface installed side by
1657
+ // side. Neither is healed; both route on their own mounts.
1658
+ const { path, cleanup } = makeTempPath();
1659
+ try {
1660
+ writeFileSync(
1661
+ path,
1662
+ JSON.stringify({
1663
+ services: [
1664
+ {
1665
+ name: "parachute-app",
1666
+ port: 1944,
1667
+ paths: ["/app"],
1668
+ health: "/app/health",
1669
+ version: "0.4.0",
1670
+ },
1671
+ {
1672
+ name: "parachute-surface",
1673
+ port: 1946,
1674
+ paths: ["/surface"],
1675
+ health: "/surface/healthz",
1676
+ version: "0.2.0",
1677
+ },
1678
+ ],
1679
+ }),
1680
+ );
1681
+ const m = readManifest(path);
1682
+ const names = m.services.map((s) => s.name).sort();
1683
+ expect(names).toEqual(["parachute-app", "parachute-surface"]);
1684
+ } finally {
1685
+ cleanup();
1686
+ }
1687
+ });
1688
+
1689
+ test("readManifestLenient heals the same stale old-app shape", () => {
1690
+ // The lenient reader shares the same raw-JSON heal pipeline — verify the
1691
+ // crash-loop row is dropped there too (the supervisor's boot path reads
1692
+ // lenient).
1693
+ const { path, cleanup } = makeTempPath();
1694
+ try {
1695
+ writeFileSync(
1696
+ path,
1697
+ JSON.stringify({
1698
+ services: [
1699
+ {
1700
+ name: "parachute-app",
1701
+ port: 1946,
1702
+ paths: ["/surface"],
1703
+ health: "/surface/healthz",
1704
+ version: "0.1.4",
1705
+ },
1706
+ {
1707
+ name: "parachute-vault",
1708
+ port: 1940,
1709
+ paths: ["/vault/default"],
1710
+ health: "/vault/default/health",
1711
+ version: "0.4.8",
1712
+ },
1713
+ ],
1714
+ }),
1715
+ );
1716
+ const m = readManifestLenient(path, { warn: () => {} });
1717
+ expect(m.services.map((s) => s.name)).toEqual(["parachute-vault"]);
1718
+ } finally {
1719
+ cleanup();
1720
+ }
1721
+ });
1722
+ });
1723
+
1414
1724
  describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
1415
1725
  test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
1416
1726
  // Reproduces what hub saw 2026-05-26: a fresh deploy installed
@@ -142,7 +142,10 @@ describe("setup", () => {
142
142
  // rejects duplicate ports between distinct services (hub#195).
143
143
  // parachute-runner rides along as a LEGACY row (runner left the
144
144
  // registries 2026-07-01): it must neither block the all-installed exit
145
- // nor crash the survey.
145
+ // nor crash the survey. parachute-app (hub-parity P5, 2026-07-11) is
146
+ // included so this fixture stays truly "everything installed" — without
147
+ // it, `app` is a genuinely offered-but-uninstalled service and the
148
+ // non-tty exit code flips from 0 to 1 (nothing left vs. work-to-offer).
146
149
  const seeds: Array<{ name: string; port: number }> = [
147
150
  { name: "parachute-vault", port: 1940 },
148
151
  { name: "parachute-notes", port: 1942 },
@@ -150,6 +153,7 @@ describe("setup", () => {
150
153
  { name: "parachute-agent", port: 1941 },
151
154
  { name: "parachute-runner", port: 1945 },
152
155
  { name: "parachute-surface", port: 1946 },
156
+ { name: "parachute-app", port: 1944 },
153
157
  ];
154
158
  for (const s of seeds) {
155
159
  upsertService(
@@ -82,7 +82,7 @@ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts"
82
82
 
83
83
  /**
84
84
  * Resolve a known module to the display + install bootstrap data the admin SPA
85
- * renders. Reads from FIRST_PARTY_FALLBACKS (notes) first,
85
+ * renders. Reads from FIRST_PARTY_FALLBACKS (notes / app) first,
86
86
  * KNOWN_MODULES (vault / scribe / agent / surface) second.
87
87
  *
88
88
  * Returns `undefined` if the short is in neither table — a genuinely
@@ -1,3 +1,4 @@
1
+ import type { Database } from "bun:sqlite";
1
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { createConnection } from "node:net";
3
4
  import { dirname, join } from "node:path";
@@ -15,6 +16,8 @@ import {
15
16
  defaultSleep,
16
17
  readHubPort,
17
18
  } from "../hub-control.ts";
19
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import { resolveRootRedirectDetailed, setRootRedirect } from "../hub-settings.ts";
18
21
  import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
19
22
  import {
20
23
  type ModuleManifest,
@@ -408,6 +411,16 @@ export interface InstallOpts {
408
411
  * origin for stable assertions.
409
412
  */
410
413
  wellKnownOrigin?: string;
414
+ /**
415
+ * Test seam for the `app`-only set-if-unset root-redirect write (hub-parity
416
+ * P5). Production opens `openHubDb(hubDbPath(configDir))` fresh and closes
417
+ * it after the write; passing this injects an already-open DB (e.g. a
418
+ * tempdir `hub.db`) so a test never touches the real `~/.parachute/hub.db`.
419
+ * Passing this ALSO opts a tempdir-manifestPath test INTO the write path —
420
+ * mirrors `guidanceCtx`'s discriminant: without it, the production gate
421
+ * (`manifestPath === SERVICES_MANIFEST_PATH`) skips the write entirely.
422
+ */
423
+ rootRedirectDb?: Database;
411
424
  }
412
425
 
413
426
  async function defaultRunner(cmd: readonly string[]): Promise<number> {
@@ -1351,6 +1364,37 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
1351
1364
  }
1352
1365
  }
1353
1366
 
1367
+ // App-only: default the hub's bare `/` redirect to the app's front door on
1368
+ // first install (hub-parity P5, 2026-07-11) — SET-IF-UNSET ONLY, never
1369
+ // clobbering an operator's existing choice (`parachute hub
1370
+ // set-root-redirect` or the admin SPA PUT both still win on any later run,
1371
+ // and either can change it back). "Unset" means the resolved redirect is
1372
+ // still the built-in `/admin` DEFAULT — i.e. NEITHER the DB row NOR the
1373
+ // `PARACHUTE_HUB_ROOT_REDIRECT` env var (container deploys pin their landing
1374
+ // page there) has set it. Gating on `getRootRedirect(db) === null` would
1375
+ // miss the env tier and silently override an env-configured operator, since
1376
+ // the DB row we'd write wins over env on read (`resolveRootRedirectDetailed`
1377
+ // is DB-first). Gated on the same production-vs-test discriminant the
1378
+ // guidance probe below uses: a test driving install against a tempdir
1379
+ // manifestPath never opens the real `~/.parachute/hub.db` unless it opts in
1380
+ // via `opts.rootRedirectDb`.
1381
+ if (short === "app") {
1382
+ const dbProbeAllowed =
1383
+ opts.rootRedirectDb !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1384
+ if (dbProbeAllowed) {
1385
+ const db = opts.rootRedirectDb ?? openHubDb(hubDbPath(configDir));
1386
+ try {
1387
+ if (resolveRootRedirectDetailed(db).source === "default") {
1388
+ setRootRedirect(db, "/app/");
1389
+ log("✓ The hub's front page (`/`) now opens the app at /app/.");
1390
+ log(" Change it any time: `parachute hub set-root-redirect <path>` or the admin SPA.");
1391
+ }
1392
+ } finally {
1393
+ if (!opts.rootRedirectDb) db.close();
1394
+ }
1395
+ }
1396
+ }
1397
+
1354
1398
  // Per-service install footer — canonical next-step URLs and configuration
1355
1399
  // hints. Vault prints its own (richer) footer from `parachute-vault init`
1356
1400
  // (PR #166), so the spec leaves vault out and we don't double up here.
@@ -110,7 +110,7 @@ function defaultAvailability(): InteractiveAvailability {
110
110
 
111
111
  /**
112
112
  * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
- * surface) regardless of tier — `installed` is true when the service
113
+ * surface / app) regardless of tier — `installed` is true when the service
114
114
  * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
115
  * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
116
  * notes); agent (`experimental`) is flagged exploratory in its blurb
@@ -121,7 +121,7 @@ function defaultAvailability(): InteractiveAvailability {
121
121
  * like any unknown row it neither blocks setup nor appears in the offer.
122
122
  *
123
123
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
124
- * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
124
+ * shorts (notes / app both carry a vendored manifest). KNOWN_MODULES shorts
125
125
  * (vault / scribe / agent / surface) ship `.parachute/module.json`
126
126
  * and self-register; pre-install we know manifestName + the urlForEntry quirk
127
127
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
@@ -175,8 +175,13 @@ export function isOfferable(choice: { short: string; installed: boolean }): bool
175
175
  const BLURBS: Record<string, string> = {
176
176
  vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
177
177
  surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
178
- // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
179
- app: "Parachute UI host auto-installs Notes on first boot (recommended over notes-daemon)",
178
+ // hub-parity P5 (2026-07-11): `app` is now the NEW super-surface front
179
+ // door (@openparachute/parachute-app)a real FIRST_PARTY_FALLBACKS
180
+ // entry, not a legacy survey row. It is UNRELATED to the pre-2026-05-27
181
+ // `app` package that renamed to `surface` (that blurb lives under the
182
+ // `surface` key above); the two just happen to share the short name
183
+ // across a rename.
184
+ app: "the super-surface front door — one app to sign in, browse your vault, and manage your hub/cloud",
180
185
  // notes is `deprecated` (not offered on a fresh setup) — this blurb only
181
186
  // renders if a legacy install surfaces it in the survey.
182
187
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
package/src/help.ts CHANGED
@@ -362,11 +362,11 @@ Exit codes:
362
362
 
363
363
  Example:
364
364
  $ parachute status
365
- SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
366
- parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
365
+ SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
366
+ parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
367
367
  → http://127.0.0.1:1940/vault/default/mcp
368
- parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
369
- → http://127.0.0.1:1946/surface/notes
368
+ parachute-surface 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
369
+ → http://127.0.0.1:1946/surface
370
370
  `;
371
371
  }
372
372
 
@@ -551,8 +551,9 @@ Examples:
551
551
  Module start commands (run by the supervisor under \`serve\`):
552
552
  vault parachute-vault serve
553
553
  scribe parachute-scribe serve
554
- app parachute-app serve
554
+ surface parachute-surface serve
555
555
  agent parachute-agent
556
+ app bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> --package @openparachute/parachute-app
556
557
  notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
557
558
  `;
558
559
  }