@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 +1 -1
- 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/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/notes-serve.ts +73 -31
- package/src/service-spec.ts +113 -29
- package/src/services-manifest.ts +104 -11
|
@@ -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: "
|
|
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: "
|
|
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.
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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:
|
|
1285
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1258
1286
|
port: 1946,
|
|
1259
|
-
paths: ["/
|
|
1260
|
-
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).
|
|
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:
|
|
1315
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1288
1316
|
port: 9999,
|
|
1289
|
-
paths: ["/
|
|
1290
|
-
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
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
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:
|
|
1370
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1344
1371
|
port: 1946,
|
|
1345
|
-
paths: ["/
|
|
1346
|
-
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
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
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:
|
|
1408
|
+
name: RETIRED_FIXTURE_NAME,
|
|
1383
1409
|
port: 1946,
|
|
1384
|
-
paths: ["/
|
|
1385
|
-
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(
|
package/src/api-modules.ts
CHANGED
|
@@ -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
|
package/src/commands/install.ts
CHANGED
|
@@ -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.
|
package/src/commands/setup.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
//
|
|
179
|
-
|
|
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
|
|
366
|
-
parachute-vault
|
|
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-
|
|
369
|
-
→ http://127.0.0.1:1946/surface
|
|
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
|
-
|
|
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
|
}
|