@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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
|
|
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("
|
|
34
|
-
expect(html).toContain("
|
|
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("
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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("
|
|
44
|
-
|
|
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("
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
expect(html).toContain("
|
|
62
|
+
test("Services skip rule emerges from data, not name-checks (vault has no uiUrl)", () => {
|
|
63
|
+
// The previous `isVaultName` hardcoded skip is gone — vault 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("
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
expect(html).toContain("
|
|
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("
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
expect(html).
|
|
68
|
-
expect(html).toContain("
|
|
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("
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
expect(html).toContain("
|
|
75
|
-
expect(html).toContain("
|
|
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("
|
|
79
|
-
// Phase
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
expect(html).toContain("
|
|
84
|
-
expect(html).toContain("
|
|
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("
|
|
88
|
-
expect(html).toContain("
|
|
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("
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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("<aaron>&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"with"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
|
|
268
|
-
// the canonical slot wins when free:
|
|
269
|
-
//
|
|
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
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// the
|
|
1054
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
}
|