@openparachute/hub 0.5.2 → 0.5.9-rc.6

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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -13,10 +13,9 @@ describe("renderHub", () => {
13
13
  expect(html).toContain("<script>");
14
14
  });
15
15
 
16
- test("fetches /.well-known/parachute.json and reads services[] + vaults[]", () => {
16
+ test("fetches /.well-known/parachute.json for the Use section", () => {
17
17
  expect(html).toContain("/.well-known/parachute.json");
18
18
  expect(html).toContain("doc.services");
19
- expect(html).toContain("doc.vaults");
20
19
  });
21
20
 
22
21
  test("uses parachute.computer sage palette and serif/sans fonts", () => {
@@ -30,79 +29,133 @@ describe("renderHub", () => {
30
29
  expect(html).toContain("prefers-color-scheme: dark");
31
30
  });
32
31
 
33
- test("falls back to a generic icon for module tiles", () => {
34
- expect(html).toContain("fallbackIcon");
32
+ test("renders two sections: Services and Admin, each with its own heading + grid", () => {
33
+ expect(html).toContain('id="services-section"');
34
+ expect(html).toContain('id="admin-section"');
35
+ expect(html).toContain('id="services-grid"');
36
+ expect(html).toContain('id="admin-grid"');
37
+ expect(html).toContain("<h2>Services</h2>");
38
+ expect(html).toContain("<h2>Admin</h2>");
35
39
  });
36
40
 
37
- test("renders one tile per module type, not per service instance", () => {
38
- expect(html).toContain("aggregate(services, vaults)");
39
- expect(html).toContain("renderTile");
40
- expect(html).toContain("MODULE_ORDER");
41
+ test("Services section sub-text frames the section as service-owned surfaces", () => {
42
+ // Services-vs-Admin axis is OWNERSHIP (services own their UIs vs
43
+ // hub owns host admin), not function. The sub-text reflects that —
44
+ // earlier "Browse, transcribe, and run" framing put it on the
45
+ // function axis, which broke down once you noticed services have
46
+ // UIs that mix use / config / admin.
47
+ expect(html).toContain("Surfaces provided by services");
41
48
  });
42
49
 
43
- test("known module display order is vault scribe notes → agent", () => {
44
- expect(html).toContain("['vault', 'scribe', 'notes', 'agent']");
50
+ test("Services tiles are data-driven from each service's uiUrl + displayName", () => {
51
+ // Phase D consumer-side: SERVICE_LABELS / SERVICE_ORDER hardcoding
52
+ // retired. Each well-known services[] row carries displayName + uiUrl
53
+ // (sourced from module.json via hub-server's loadServiceUiMetadata);
54
+ // tiles render directly from those.
55
+ expect(html).toContain("svc.uiUrl");
56
+ expect(html).toContain("svc.displayName");
57
+ // No more hardcoded short→label map.
58
+ expect(html).not.toContain("'Notes', desc:");
59
+ expect(html).not.toContain("['notes', 'scribe', 'agent']");
45
60
  });
46
61
 
47
- test("vault tile counts vaults[] (per instance) and links to /vault", () => {
48
- // Vault count is the length of doc.vaultsone entry per /vault/<name>
49
- // mount, so a single ServiceEntry with paths=[a,b,c] still shows "3
50
- // registered". The manage link is the hub's vault SPA at /vault
51
- // (renamed from /hub/vaults in the realignment), never an individual
52
- // vault backend.
53
- expect(html).toContain("vaults.length");
54
- expect(html).toContain("'/vault'");
62
+ test("Services skip rule emerges from data, not name-checks (vault has no uiUrl)", () => {
63
+ // The previous `isVaultName` hardcoded skip is gonevault doesn't
64
+ // declare uiUrl, so it naturally doesn't render. Other API-only
65
+ // modules (current or future) get the same treatment for free.
66
+ expect(html).toContain("if (!svc || !svc.uiUrl) continue;");
67
+ // The function definition is gone (the comment may still mention the
68
+ // name as historical context — we only care about the active code).
69
+ expect(html).not.toContain("function isVaultName");
55
70
  });
56
71
 
57
- test("non-vault tiles take their manageUrl from the service's path", () => {
58
- // shortName('parachute-scribe') = 'scribe' tile links to svc.path,
59
- // which is whatever the module declared (e.g. /scribe, /notes, /agent).
60
- // Hardcoding the link would silently break on a custom mount.
61
- expect(html).toContain("manageUrl: svc.path");
72
+ test("Services tiles sort alphabetically by displayName", () => {
73
+ // Per the module-json-extensibility pattern doc default ordering
74
+ // until a `displayOrder` field surfaces. localeCompare keeps the
75
+ // ordering stable across locales for ASCII labels.
76
+ expect(html).toContain("a.title.localeCompare(b.title)");
62
77
  });
63
78
 
64
- test("tiles for module types with zero instances are hidden", () => {
65
- // Aggregate only inserts a group when the type has at least one entry;
66
- // tilesInOrder iterates the map. No "0 registered" surface.
67
- expect(html).not.toContain("0 registered");
68
- expect(html).toContain("count === 1 ? '1 registered'");
79
+ test("Admin section is hardcoded (always visible) with three entries", () => {
80
+ expect(html).toContain("ADMIN_ENTRIES");
81
+ expect(html).toContain("/admin/vaults");
82
+ expect(html).toContain("/admin/permissions");
83
+ expect(html).toContain("/admin/tokens");
69
84
  });
70
85
 
71
- test("module labels are humanized (Vault / Scribe / Notes / Agent)", () => {
72
- expect(html).toContain("vault: 'Vault'");
73
- expect(html).toContain("scribe: 'Scribe'");
74
- expect(html).toContain("notes: 'Notes'");
75
- expect(html).toContain("agent: 'Agent'");
86
+ test("Admin section renders synchronously (does not depend on the well-known fetch)", () => {
87
+ // Even if the fetch is slow or fails, the operator should see Admin
88
+ // surfaces — they may be the reason the operator landed on /.
89
+ expect(html).toContain("renderAdmin();");
90
+ expect(html).toContain("Admin section is static");
76
91
  });
77
92
 
78
- test("vault-name detection covers parachute-vault and parachute-vault-<name>", () => {
79
- // Phase-1 multi-vault keeps a single ServiceEntry with multiple paths
80
- // (parachute-vault), but the door is open for per-instance entries
81
- // (parachute-vault-techne). isVaultName has to accept both.
82
- expect(html).toContain("isVaultName");
83
- expect(html).toContain("'parachute-vault'");
84
- expect(html).toContain("'parachute-vault-'");
93
+ test("Services section empty state guides operators to declare uiUrl", () => {
94
+ // Empty state under Phase D means "no installed service has declared
95
+ // uiUrl yet" different shape from pre-D ("none installed at all").
96
+ // Hint points at the pattern doc since the fix is in module.json,
97
+ // not at install time.
98
+ expect(html).toContain("No services with a UI declared yet");
99
+ expect(html).toContain("module-json-extensibility");
85
100
  });
86
101
 
87
- test("empty state when no modules are registered", () => {
88
- expect(html).toContain("No modules installed yet");
89
- expect(html).toContain("parachute install vault");
102
+ test("Use section error state surfaces the underlying message", () => {
103
+ expect(html).toContain("Could not load services");
90
104
  });
91
105
 
92
- test("error state surfaces the underlying message", () => {
93
- expect(html).toContain("Could not load modules");
94
- });
95
-
96
- test("does not retain the per-service interactive-card / config-form code", () => {
97
- // The home page is now a directory of modules — per-instance config
98
- // forms and detail panels live behind the Manage links (vault SPA at
99
- // /vault, the running module's own UI elsewhere). Keeping the
100
- // dead code around is a maintenance trap.
106
+ test("does not retain the old aggregate-by-module-type code", () => {
107
+ // The Vault collapse + per-module aggregation pattern is gone — Use
108
+ // entries are direct service-path → label lookups; Admin is hardcoded.
109
+ expect(html).not.toContain("aggregate(services, vaults)");
110
+ expect(html).not.toContain("MODULE_LABELS");
101
111
  expect(html).not.toContain("renderConfigField");
102
- expect(html).not.toContain("fetchConfig");
103
112
  expect(html).not.toContain("kind-badge");
104
- expect(html).not.toContain("info.mcpUrl");
105
- expect(html).not.toContain("info.openInNotesUrl");
113
+ });
114
+
115
+ test("default render (no session) emits the 'Sign in' affordance", () => {
116
+ expect(html).toContain('class="auth-indicator"');
117
+ expect(html).toContain("Sign in");
118
+ expect(html).toContain('href="/login?next=/"');
119
+ // No POST form, no CSRF input — those only appear when signed in.
120
+ expect(html).not.toContain('action="/logout"');
121
+ expect(html).not.toContain("__csrf");
122
+ });
123
+ });
124
+
125
+ describe("renderHub — signed-in indicator (rc.13)", () => {
126
+ test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
127
+ const html = renderHub({
128
+ session: { displayName: "aaron", csrfToken: "csrf-token-xyz" },
129
+ });
130
+ expect(html).toContain('class="auth-indicator"');
131
+ expect(html).toContain("Signed in as");
132
+ expect(html).toContain("aaron");
133
+ // Inline POST form for sign-out — CSRF token embedded as the
134
+ // existing `__csrf` field name (matches /logout's expectations).
135
+ expect(html).toContain('method="POST" action="/logout"');
136
+ expect(html).toContain('name="__csrf"');
137
+ expect(html).toContain('value="csrf-token-xyz"');
138
+ expect(html).toContain("Sign out");
139
+ // No "Sign in" affordance when signed in.
140
+ expect(html).not.toContain('href="/login?next=/"');
141
+ });
142
+
143
+ test("displayName with HTML special chars is escaped", () => {
144
+ // Username field allows alphanumerics historically, but the
145
+ // displayName field on the wire is forward-compatible with profile
146
+ // names that may contain &, <, >. Escape at render time.
147
+ const html = renderHub({
148
+ session: { displayName: "<aaron>&friends", csrfToken: "tok" },
149
+ });
150
+ expect(html).toContain("&lt;aaron&gt;&amp;friends");
151
+ expect(html).not.toContain("<aaron>&friends");
152
+ });
153
+
154
+ test("CSRF token with HTML special chars is escaped in the value attribute", () => {
155
+ const html = renderHub({
156
+ session: { displayName: "aaron", csrfToken: 'token"with"quotes' },
157
+ });
158
+ expect(html).toContain('value="token&quot;with&quot;quotes"');
106
159
  });
107
160
  });
108
161
 
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type DetectInstallSourceDeps,
4
+ detectHubInstallSource,
5
+ detectInstallSource,
6
+ formatInstallSourceLabel,
7
+ isStale,
8
+ } from "../install-source.ts";
9
+
10
+ /**
11
+ * Stub helpers for the detect path. Production reads the operator's bun
12
+ * globals + real package.jsons; here we wire everything from a virtual
13
+ * filesystem so each kind (npm / bun-linked / unknown / stale) has a
14
+ * deterministic shape.
15
+ */
16
+ function makeDeps(opts: {
17
+ prefixes?: readonly string[];
18
+ packageVersions?: Record<string, string>;
19
+ bunGlobalLinks?: Record<string, string>;
20
+ gitHeads?: Record<string, string>;
21
+ }): DetectInstallSourceDeps {
22
+ const prefixes = opts.prefixes ?? ["/home/test/.bun/install/global/node_modules"];
23
+ return {
24
+ bunGlobalPrefixes: () => prefixes,
25
+ resolveBunGlobal: (pkg) => opts.bunGlobalLinks?.[pkg] ?? null,
26
+ readJson: (path) => {
27
+ // Path looks like `<pkgDir>/package.json` — strip suffix.
28
+ const pkgDirRaw = path.replace(/\/package\.json$/, "");
29
+ const v = opts.packageVersions?.[pkgDirRaw];
30
+ if (v === undefined) throw new Error(`no package.json at ${pkgDirRaw}`);
31
+ return { name: "@stub/pkg", version: v };
32
+ },
33
+ readGitHead: (path) => opts.gitHeads?.[path],
34
+ };
35
+ }
36
+
37
+ describe("detectInstallSource", () => {
38
+ test("classifies a bun-linked checkout (installDir outside bun globals)", () => {
39
+ const deps = makeDeps({
40
+ packageVersions: { "/Users/me/code/parachute-notes": "0.3.15-rc.1" },
41
+ gitHeads: { "/Users/me/code/parachute-notes": "051c404" },
42
+ });
43
+ const source = detectInstallSource(
44
+ { entryName: "parachute-notes", installDir: "/Users/me/code/parachute-notes" },
45
+ deps,
46
+ );
47
+ expect(source.kind).toBe("bun-linked");
48
+ expect(source.path).toBe("/Users/me/code/parachute-notes");
49
+ expect(source.gitHead).toBe("051c404");
50
+ expect(source.livePackageVersion).toBe("0.3.15-rc.1");
51
+ });
52
+
53
+ test("classifies an npm install (installDir under bun globals)", () => {
54
+ const deps = makeDeps({
55
+ prefixes: ["/home/test/.bun/install/global/node_modules"],
56
+ packageVersions: {
57
+ "/home/test/.bun/install/global/node_modules/@openparachute/scribe": "0.4.2-rc.1",
58
+ },
59
+ });
60
+ const source = detectInstallSource(
61
+ {
62
+ entryName: "parachute-scribe",
63
+ installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
64
+ },
65
+ deps,
66
+ );
67
+ expect(source.kind).toBe("npm");
68
+ expect(source.livePackageVersion).toBe("0.4.2-rc.1");
69
+ expect(source.gitHead).toBeUndefined();
70
+ });
71
+
72
+ test("falls back to bun-global symlink lookup when installDir is absent", () => {
73
+ const deps = makeDeps({
74
+ bunGlobalLinks: { "@openparachute/vault": "/Users/me/code/parachute-vault" },
75
+ packageVersions: { "/Users/me/code/parachute-vault": "0.4.4-rc.3" },
76
+ gitHeads: { "/Users/me/code/parachute-vault": "8aa167b" },
77
+ });
78
+ const source = detectInstallSource({ entryName: "parachute-vault" }, deps);
79
+ expect(source.kind).toBe("bun-linked");
80
+ expect(source.path).toBe("/Users/me/code/parachute-vault");
81
+ expect(source.gitHead).toBe("8aa167b");
82
+ });
83
+
84
+ test("returns unknown when nothing resolves (no installDir, no first-party mapping)", () => {
85
+ const deps = makeDeps({});
86
+ const source = detectInstallSource({ entryName: "agent" }, deps);
87
+ expect(source.kind).toBe("unknown");
88
+ expect(source.path).toBeUndefined();
89
+ expect(source.gitHead).toBeUndefined();
90
+ });
91
+
92
+ test("omits gitHead when the bun-linked path isn't a git repo", () => {
93
+ const deps = makeDeps({
94
+ packageVersions: { "/tmp/no-git/pkg": "1.0.0" },
95
+ // gitHeads intentionally missing → readGitHead returns undefined.
96
+ });
97
+ const source = detectInstallSource(
98
+ { entryName: "third-party", installDir: "/tmp/no-git/pkg" },
99
+ deps,
100
+ );
101
+ expect(source.kind).toBe("bun-linked");
102
+ expect(source.gitHead).toBeUndefined();
103
+ expect(source.livePackageVersion).toBe("1.0.0");
104
+ });
105
+
106
+ test("omits livePackageVersion when package.json is unreadable", () => {
107
+ const deps = makeDeps({
108
+ packageVersions: {}, // every read throws
109
+ });
110
+ const source = detectInstallSource(
111
+ { entryName: "third-party", installDir: "/tmp/no-pkg" },
112
+ deps,
113
+ );
114
+ expect(source.kind).toBe("bun-linked");
115
+ expect(source.livePackageVersion).toBeUndefined();
116
+ });
117
+
118
+ test("trailing-slash prefix doesn't false-match a sibling directory", () => {
119
+ // Subtle: `/home/test/.bun/install/global/node_modules-other` shouldn't
120
+ // be classified as "under" `/home/test/.bun/install/global/node_modules`.
121
+ // The prefix join in `isUnderBunGlobals` adds a trailing slash precisely
122
+ // to avoid this — pin the behavior.
123
+ const deps = makeDeps({
124
+ prefixes: ["/home/test/.bun/install/global/node_modules"],
125
+ packageVersions: {
126
+ "/home/test/.bun/install/global/node_modules-other/pkg": "1.0.0",
127
+ },
128
+ });
129
+ const source = detectInstallSource(
130
+ {
131
+ entryName: "third-party",
132
+ installDir: "/home/test/.bun/install/global/node_modules-other/pkg",
133
+ },
134
+ deps,
135
+ );
136
+ expect(source.kind).toBe("bun-linked");
137
+ });
138
+ });
139
+
140
+ describe("isStale", () => {
141
+ test("flags drift between cached entry version and live package.json", () => {
142
+ expect(
143
+ isStale("0.3.11-rc.1", {
144
+ kind: "bun-linked",
145
+ path: "/Users/me/code/parachute-notes",
146
+ livePackageVersion: "0.3.15-rc.1",
147
+ }),
148
+ ).toBe(true);
149
+ });
150
+
151
+ test("does not flag a matching version", () => {
152
+ expect(
153
+ isStale("0.3.15-rc.1", {
154
+ kind: "bun-linked",
155
+ path: "/Users/me/code/parachute-notes",
156
+ livePackageVersion: "0.3.15-rc.1",
157
+ }),
158
+ ).toBe(false);
159
+ });
160
+
161
+ test("does not flag npm-installed services (cached version IS the source)", () => {
162
+ expect(
163
+ isStale("0.4.2-rc.1", {
164
+ kind: "npm",
165
+ path: "/path/to/global",
166
+ livePackageVersion: "0.4.2-rc.1",
167
+ }),
168
+ ).toBe(false);
169
+ });
170
+
171
+ test("does not flag when live version is unavailable", () => {
172
+ expect(
173
+ isStale("0.3.11-rc.1", {
174
+ kind: "bun-linked",
175
+ path: "/Users/me/code/parachute-notes",
176
+ // livePackageVersion absent — can't compute drift, don't false-flag.
177
+ }),
178
+ ).toBe(false);
179
+ });
180
+
181
+ test("does not flag unknown sources", () => {
182
+ expect(isStale("1.0.0", { kind: "unknown" })).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe("formatInstallSourceLabel", () => {
187
+ test("bun-linked → basename + short SHA", () => {
188
+ expect(
189
+ formatInstallSourceLabel({
190
+ kind: "bun-linked",
191
+ path: "/Users/me/code/parachute-notes",
192
+ gitHead: "051c404",
193
+ }),
194
+ ).toBe("bun-linked → parachute-notes @ 051c404");
195
+ });
196
+
197
+ test("bun-linked without gitHead drops the @ <sha> suffix", () => {
198
+ expect(
199
+ formatInstallSourceLabel({
200
+ kind: "bun-linked",
201
+ path: "/Users/me/code/parachute-notes",
202
+ }),
203
+ ).toBe("bun-linked → parachute-notes");
204
+ });
205
+
206
+ test("npm with version", () => {
207
+ expect(
208
+ formatInstallSourceLabel({
209
+ kind: "npm",
210
+ path: "/some/global/dir",
211
+ livePackageVersion: "0.4.2-rc.1",
212
+ }),
213
+ ).toBe("npm (0.4.2-rc.1)");
214
+ });
215
+
216
+ test("npm without version", () => {
217
+ expect(formatInstallSourceLabel({ kind: "npm" })).toBe("npm");
218
+ });
219
+
220
+ test("unknown sources render as 'unknown'", () => {
221
+ expect(formatInstallSourceLabel({ kind: "unknown" })).toBe("unknown");
222
+ });
223
+ });
224
+
225
+ describe("detectHubInstallSource", () => {
226
+ test("classifies the hub based on its source location", () => {
227
+ // Exercise the happy path via the real hub's `src/` dir. The result
228
+ // depends on the test environment (CI vs. bun-linked checkout), so we
229
+ // only assert the kind is one of the known classifications — not the
230
+ // exact value. `readGitHead` is stubbed so the test never forks a real
231
+ // git process; the contract under test is "climb to package.json,
232
+ // classify by location against bun globals" — git is incidental.
233
+ const source = detectHubInstallSource(import.meta.dir, {
234
+ readGitHead: () => "deadbeef",
235
+ });
236
+ expect(["bun-linked", "npm", "unknown"]).toContain(source.kind);
237
+ });
238
+
239
+ test("returns unknown when no package.json exists above srcDir", () => {
240
+ // `/private` exists on macOS but has no package.json up the chain;
241
+ // injected readJson always throws so the walk hits the climb-cap.
242
+ const source = detectHubInstallSource("/private/var/empty", {
243
+ readJson: () => {
244
+ throw new Error("no package.json");
245
+ },
246
+ });
247
+ expect(source.kind).toBe("unknown");
248
+ });
249
+ });
@@ -264,9 +264,11 @@ describe("install", () => {
264
264
 
265
265
  test("CLI overrides a non-canonical port written by init when canonical is free", async () => {
266
266
  // Pre-#53 the CLI deferred to whatever port the service's init wrote
267
- // (e.g. 5173, Vite's dev default for notes). With CLI-as-port-authority
268
- // the canonical slot wins when free: the manifest is updated and the
269
- // .env carries PORT=<canonical> so the next daemon boot binds it.
267
+ // (e.g. 5173, Vite's dev default for notes). With hub-as-port-authority
268
+ // the canonical slot wins when free: services.json is updated to the
269
+ // canonical port (post-hub#206 the install path no longer touches .env;
270
+ // services.json is the single source of truth at boot per the 4-tier
271
+ // resolvePort ladder in scribe/agent).
270
272
  const { path, configDir, cleanup } = makeTempPath();
271
273
  try {
272
274
  const logs: string[] = [];
@@ -1047,11 +1049,14 @@ describe("install", () => {
1047
1049
  }
1048
1050
  });
1049
1051
 
1050
- // CLI-as-port-authority (#53). Install assigns the service's port up front
1051
- // and writes `PORT=<port>` into `<configDir>/<svc>/.env`. lifecycle.start
1052
- // merges that .env into spawn env, so the next daemon boot binds the port
1053
- // the CLI picked.
1054
- test("install writes PORT=<canonical> to .env when the slot is free", async () => {
1052
+ // Hub-as-port-authority (#53), services.json-is-authoritative (#206).
1053
+ // Install picks the service's port up front and reflects it in
1054
+ // services.json. Pre-#206 it also wrote `PORT=<port>` into the service's
1055
+ // `.env`; post-#206 it doesn't — services.json is the single source of
1056
+ // truth at boot per the 4-tier resolvePort ladder in scribe#41 / agent#146
1057
+ // / agent#148, so the duplicate `.env` PORT was at best dead weight and
1058
+ // at worst a source of drift on re-install.
1059
+ test("install reflects canonical port in services.json without writing PORT to .env (hub#206)", async () => {
1055
1060
  const { path, configDir, cleanup } = makeTempPath();
1056
1061
  try {
1057
1062
  const code = await install("vault", {
@@ -1064,34 +1069,38 @@ describe("install", () => {
1064
1069
  log: () => {},
1065
1070
  });
1066
1071
  expect(code).toBe(0);
1067
- const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
1068
- expect(envText).toContain("PORT=1940");
1072
+ // services.json is authoritative that's where the port lives.
1073
+ const entry = findService("parachute-vault", path);
1074
+ expect(entry?.port).toBe(1940);
1075
+ // .env should NOT have a PORT line. The directory may not even
1076
+ // exist (nothing in this test path writes to the service's config
1077
+ // dir); if it does, the file shouldn't carry PORT.
1078
+ const envPath = join(configDir, "vault", ".env");
1079
+ if (existsSync(envPath)) {
1080
+ expect(readFileSync(envPath, "utf8")).not.toMatch(/^PORT=/m);
1081
+ }
1069
1082
  } finally {
1070
1083
  cleanup();
1071
1084
  }
1072
1085
  });
1073
1086
 
1074
- test("install preserves a pre-existing PORT in .env across re-installs", async () => {
1087
+ test("install does NOT preserve a pre-existing PORT in .env across re-installs (hub#206)", async () => {
1088
+ // Pre-#206 a stale `.env` PORT survived a re-install: an operator
1089
+ // who edited services.json to fix a duplicate would get re-stamped
1090
+ // by the .env on the next `parachute install`. Post-#206 services.json
1091
+ // is authoritative; the install path leaves `.env` alone but
1092
+ // services.json reflects the freshly-assigned port. The stale `.env`
1093
+ // PORT is harmless because the boot-time resolvePort ladder reads
1094
+ // services.json before falling through to the bare PORT env tier.
1075
1095
  const { path, configDir, cleanup } = makeTempPath();
1076
1096
  try {
1077
- // First install assigns canonical 1940.
1078
- await install("vault", {
1079
- runner: async () => 0,
1080
- manifestPath: path,
1081
- configDir,
1082
- startService: async () => 0,
1083
- isLinked: () => false,
1084
- portProbe: async () => false,
1085
- log: () => {},
1086
- });
1087
- // Hand-edit .env to use a custom port (operator override).
1088
1097
  const envPath = join(configDir, "vault", ".env");
1089
- const original = readFileSync(envPath, "utf8");
1090
- const edited = original.replace("PORT=1940", "PORT=1947");
1091
- const { writeFileSync } = await import("node:fs");
1092
- writeFileSync(envPath, edited);
1098
+ const { mkdirSync, writeFileSync } = await import("node:fs");
1099
+ mkdirSync(join(configDir, "vault"), { recursive: true });
1100
+ // Pre-existing .env with an operator-edited (now-stale) PORT.
1101
+ const before = "PORT=1947\nOTHER=keepme\n";
1102
+ writeFileSync(envPath, before);
1093
1103
 
1094
- // Second install must preserve the operator's choice, not stomp it.
1095
1104
  await install("vault", {
1096
1105
  runner: async () => 0,
1097
1106
  manifestPath: path,
@@ -1101,13 +1110,20 @@ describe("install", () => {
1101
1110
  portProbe: async () => false,
1102
1111
  log: () => {},
1103
1112
  });
1104
- expect(readFileSync(envPath, "utf8")).toContain("PORT=1947");
1113
+
1114
+ // services.json gets the freshly-assigned canonical port (1940),
1115
+ // NOT the stale 1947 from .env.
1116
+ const entry = findService("parachute-vault", path);
1117
+ expect(entry?.port).toBe(1940);
1118
+ // .env is bit-for-bit untouched: the stale PORT stays, OTHER stays,
1119
+ // and we did NOT rewrite the file with a new PORT line.
1120
+ expect(readFileSync(envPath, "utf8")).toBe(before);
1105
1121
  } finally {
1106
1122
  cleanup();
1107
1123
  }
1108
1124
  });
1109
1125
 
1110
- test("install falls back inside the canonical range when the slot is occupied", async () => {
1126
+ test("install falls back inside the canonical range when the slot is occupied (hub#206 — no .env write)", async () => {
1111
1127
  const { path, configDir, cleanup } = makeTempPath();
1112
1128
  try {
1113
1129
  // Pretend something else is on 1940.
@@ -1133,11 +1149,14 @@ describe("install", () => {
1133
1149
  });
1134
1150
  expect(code).toBe(0);
1135
1151
  // First reservation slot is 1944.
1136
- const envText = readFileSync(join(configDir, "vault", ".env"), "utf8");
1137
- expect(envText).toContain("PORT=1944");
1138
1152
  const entry = findService("parachute-vault", path);
1139
1153
  expect(entry?.port).toBe(1944);
1140
1154
  expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1155
+ // .env is not touched.
1156
+ const envPath = join(configDir, "vault", ".env");
1157
+ if (existsSync(envPath)) {
1158
+ expect(readFileSync(envPath, "utf8")).not.toMatch(/^PORT=/m);
1159
+ }
1141
1160
  } finally {
1142
1161
  cleanup();
1143
1162
  }