@openparachute/hub 0.3.0-rc.1 → 0.5.1
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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -13,10 +13,10 @@ describe("renderHub", () => {
|
|
|
13
13
|
expect(html).toContain("<script>");
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
test("fetches /.well-known/parachute.json and
|
|
16
|
+
test("fetches /.well-known/parachute.json and reads services[] + vaults[]", () => {
|
|
17
17
|
expect(html).toContain("/.well-known/parachute.json");
|
|
18
18
|
expect(html).toContain("doc.services");
|
|
19
|
-
expect(html).toContain("
|
|
19
|
+
expect(html).toContain("doc.vaults");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test("uses parachute.computer sage palette and serif/sans fonts", () => {
|
|
@@ -30,72 +30,79 @@ describe("renderHub", () => {
|
|
|
30
30
|
expect(html).toContain("prefers-color-scheme: dark");
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test("falls back to a generic icon
|
|
33
|
+
test("falls back to a generic icon for module tiles", () => {
|
|
34
34
|
expect(html).toContain("fallbackIcon");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("
|
|
38
|
-
|
|
39
|
-
expect(html).toContain("
|
|
40
|
-
expect(html).toContain("
|
|
41
|
-
expect(html).toContain("'tool'");
|
|
42
|
-
expect(html).toContain("'frontend'");
|
|
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");
|
|
43
41
|
});
|
|
44
42
|
|
|
45
|
-
test("
|
|
46
|
-
expect(html).toContain("
|
|
47
|
-
expect(html).toContain("tabindex");
|
|
48
|
-
expect(html).toContain("aria-expanded");
|
|
49
|
-
expect(html).toContain("Enter");
|
|
43
|
+
test("known module display order is vault → scribe → notes → agent", () => {
|
|
44
|
+
expect(html).toContain("['vault', 'scribe', 'notes', 'agent']");
|
|
50
45
|
});
|
|
51
46
|
|
|
52
|
-
test("
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
test("vault tile counts vaults[] (per instance) and links to /vault", () => {
|
|
48
|
+
// Vault count is the length of doc.vaults — one 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'");
|
|
58
55
|
});
|
|
59
56
|
|
|
60
|
-
test("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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");
|
|
64
62
|
});
|
|
65
63
|
|
|
66
|
-
test("
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
expect(html).toContain("
|
|
70
|
-
|
|
71
|
-
expect(html).toContain("configLoaded");
|
|
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'");
|
|
72
69
|
});
|
|
73
70
|
|
|
74
|
-
test("
|
|
75
|
-
expect(html).toContain("
|
|
76
|
-
expect(html).toContain("
|
|
77
|
-
expect(html).toContain("
|
|
78
|
-
|
|
79
|
-
expect(html).toContain("read-only in this launch");
|
|
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'");
|
|
80
76
|
});
|
|
81
77
|
|
|
82
|
-
test("
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
expect(html).toContain("
|
|
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-'");
|
|
87
85
|
});
|
|
88
86
|
|
|
89
|
-
test("
|
|
90
|
-
expect(html).toContain("
|
|
91
|
-
|
|
92
|
-
expect(html).toContain("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
87
|
+
test("empty state when no modules are registered", () => {
|
|
88
|
+
expect(html).toContain("No modules installed yet");
|
|
89
|
+
expect(html).toContain("parachute install vault");
|
|
93
90
|
});
|
|
94
91
|
|
|
95
|
-
test("
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
101
|
+
expect(html).not.toContain("renderConfigField");
|
|
102
|
+
expect(html).not.toContain("fetchConfig");
|
|
103
|
+
expect(html).not.toContain("kind-badge");
|
|
104
|
+
expect(html).not.toContain("info.mcpUrl");
|
|
105
|
+
expect(html).not.toContain("info.openInNotesUrl");
|
|
99
106
|
});
|
|
100
107
|
});
|
|
101
108
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { install } from "../commands/install.ts";
|
|
@@ -15,7 +15,7 @@ function makeTempPath(): { path: string; configDir: string; cleanup: () => void
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
describe("install", () => {
|
|
18
|
-
test("rejects
|
|
18
|
+
test("rejects third-party package with no module.json (hard error)", async () => {
|
|
19
19
|
const { path, cleanup } = makeTempPath();
|
|
20
20
|
try {
|
|
21
21
|
const logs: string[] = [];
|
|
@@ -28,7 +28,7 @@ describe("install", () => {
|
|
|
28
28
|
log: (l) => logs.push(l),
|
|
29
29
|
});
|
|
30
30
|
expect(code).toBe(1);
|
|
31
|
-
expect(logs.join("\n")).toMatch(/
|
|
31
|
+
expect(logs.join("\n")).toMatch(/does not ship \.parachute\/module\.json/);
|
|
32
32
|
} finally {
|
|
33
33
|
cleanup();
|
|
34
34
|
}
|
|
@@ -1142,4 +1142,328 @@ describe("install", () => {
|
|
|
1142
1142
|
cleanup();
|
|
1143
1143
|
}
|
|
1144
1144
|
});
|
|
1145
|
+
|
|
1146
|
+
test("third-party npm package with valid module.json installs", async () => {
|
|
1147
|
+
const { path, cleanup } = makeTempPath();
|
|
1148
|
+
try {
|
|
1149
|
+
const calls: string[][] = [];
|
|
1150
|
+
const logs: string[] = [];
|
|
1151
|
+
const code = await install("@acme/widget", {
|
|
1152
|
+
runner: async (cmd) => {
|
|
1153
|
+
calls.push([...cmd]);
|
|
1154
|
+
return 0;
|
|
1155
|
+
},
|
|
1156
|
+
manifestPath: path,
|
|
1157
|
+
startService: async () => 0,
|
|
1158
|
+
isLinked: () => false,
|
|
1159
|
+
portProbe: async () => false,
|
|
1160
|
+
log: (l) => logs.push(l),
|
|
1161
|
+
readManifest: async () => ({
|
|
1162
|
+
name: "widget",
|
|
1163
|
+
manifestName: "@acme/widget",
|
|
1164
|
+
kind: "api",
|
|
1165
|
+
port: 1950,
|
|
1166
|
+
paths: ["/widget"],
|
|
1167
|
+
health: "/healthz",
|
|
1168
|
+
}),
|
|
1169
|
+
findGlobalInstall: () => "/fake/prefix/@acme/widget/package.json",
|
|
1170
|
+
});
|
|
1171
|
+
expect(code).toBe(0);
|
|
1172
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", "@acme/widget"]);
|
|
1173
|
+
// hub#85: third-party rows are keyed by `manifest.name` (canonical
|
|
1174
|
+
// short — what `parachute start <svc>` accepts), not `manifestName`.
|
|
1175
|
+
const seeded = findService("widget", path);
|
|
1176
|
+
expect(seeded?.name).toBe("widget");
|
|
1177
|
+
expect(seeded?.port).toBe(1950);
|
|
1178
|
+
expect(findService("@acme/widget", path)).toBeUndefined();
|
|
1179
|
+
expect(logs.join("\n")).toMatch(/Seeded services\.json entry for widget/);
|
|
1180
|
+
expect(logs.join("\n")).toMatch(/widget registered on port 1950/);
|
|
1181
|
+
} finally {
|
|
1182
|
+
cleanup();
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test("third-party npm package without module.json hard-errors", async () => {
|
|
1187
|
+
const { path, cleanup } = makeTempPath();
|
|
1188
|
+
try {
|
|
1189
|
+
const logs: string[] = [];
|
|
1190
|
+
const code = await install("@acme/widget", {
|
|
1191
|
+
runner: async () => 0,
|
|
1192
|
+
manifestPath: path,
|
|
1193
|
+
startService: async () => 0,
|
|
1194
|
+
isLinked: () => false,
|
|
1195
|
+
portProbe: async () => false,
|
|
1196
|
+
log: (l) => logs.push(l),
|
|
1197
|
+
readManifest: async () => null,
|
|
1198
|
+
findGlobalInstall: () => "/fake/prefix/@acme/widget/package.json",
|
|
1199
|
+
});
|
|
1200
|
+
expect(code).toBe(1);
|
|
1201
|
+
expect(logs.join("\n")).toMatch(/does not ship \.parachute\/module\.json/);
|
|
1202
|
+
expect(logs.join("\n")).toMatch(/module-json-extensibility\.md/);
|
|
1203
|
+
} finally {
|
|
1204
|
+
cleanup();
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
test("third-party module name colliding with first-party shortname is rejected", async () => {
|
|
1209
|
+
const { path, cleanup } = makeTempPath();
|
|
1210
|
+
try {
|
|
1211
|
+
const logs: string[] = [];
|
|
1212
|
+
const code = await install("@evil/squatter", {
|
|
1213
|
+
runner: async () => 0,
|
|
1214
|
+
manifestPath: path,
|
|
1215
|
+
startService: async () => 0,
|
|
1216
|
+
isLinked: () => false,
|
|
1217
|
+
portProbe: async () => false,
|
|
1218
|
+
log: (l) => logs.push(l),
|
|
1219
|
+
readManifest: async () => ({
|
|
1220
|
+
name: "vault",
|
|
1221
|
+
manifestName: "@evil/squatter",
|
|
1222
|
+
kind: "api",
|
|
1223
|
+
port: 1950,
|
|
1224
|
+
paths: ["/vault"],
|
|
1225
|
+
health: "/healthz",
|
|
1226
|
+
}),
|
|
1227
|
+
findGlobalInstall: () => "/fake/prefix/@evil/squatter/package.json",
|
|
1228
|
+
});
|
|
1229
|
+
expect(code).toBe(1);
|
|
1230
|
+
expect(logs.join("\n")).toMatch(/collides with a first-party/);
|
|
1231
|
+
} finally {
|
|
1232
|
+
cleanup();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("local absolute path resolves package name + module.json", async () => {
|
|
1237
|
+
const { path, cleanup } = makeTempPath();
|
|
1238
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
|
|
1239
|
+
try {
|
|
1240
|
+
const calls: string[][] = [];
|
|
1241
|
+
const logs: string[] = [];
|
|
1242
|
+
const code = await install(pkgDir, {
|
|
1243
|
+
runner: async (cmd) => {
|
|
1244
|
+
calls.push([...cmd]);
|
|
1245
|
+
return 0;
|
|
1246
|
+
},
|
|
1247
|
+
manifestPath: path,
|
|
1248
|
+
startService: async () => 0,
|
|
1249
|
+
isLinked: () => false,
|
|
1250
|
+
portProbe: async () => false,
|
|
1251
|
+
log: (l) => logs.push(l),
|
|
1252
|
+
readManifest: async () => ({
|
|
1253
|
+
name: "demo",
|
|
1254
|
+
manifestName: "@local/demo",
|
|
1255
|
+
kind: "api",
|
|
1256
|
+
port: 1951,
|
|
1257
|
+
paths: ["/demo"],
|
|
1258
|
+
health: "/healthz",
|
|
1259
|
+
}),
|
|
1260
|
+
readPackageName: () => "@local/demo",
|
|
1261
|
+
});
|
|
1262
|
+
expect(code).toBe(0);
|
|
1263
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", pkgDir]);
|
|
1264
|
+
// hub#85: third-party row keys by `manifest.name`, not `manifestName`.
|
|
1265
|
+
const seeded = findService("demo", path);
|
|
1266
|
+
expect(seeded?.name).toBe("demo");
|
|
1267
|
+
expect(findService("@local/demo", path)).toBeUndefined();
|
|
1268
|
+
// hub#83: lifecycle needs installDir to find module.json + spawn cwd.
|
|
1269
|
+
expect(seeded?.installDir).toBe(pkgDir);
|
|
1270
|
+
} finally {
|
|
1271
|
+
cleanup();
|
|
1272
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test("local-path re-install skips bun add when symlink already points at it (hub#89)", async () => {
|
|
1277
|
+
// Reproduces the lockfile-pollution loop from hub#89: every
|
|
1278
|
+
// `parachute install /path/to/checkout` shells out `bun add -g
|
|
1279
|
+
// /path/to/checkout`, which appends a duplicate dependency to
|
|
1280
|
+
// ~/.bun/install/global/package.json. After ~5 re-installs bun's
|
|
1281
|
+
// lockfile parser gives up. Fix: if the global symlink already
|
|
1282
|
+
// resolves to this exact path, the install is a no-op for bun-add.
|
|
1283
|
+
const { path, cleanup } = makeTempPath();
|
|
1284
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
|
|
1285
|
+
// macOS tmpdir is symlinked (/var → /private/var); install resolves
|
|
1286
|
+
// both sides via realpath, so the stub must too.
|
|
1287
|
+
const pkgDirReal = realpathSync(pkgDir);
|
|
1288
|
+
try {
|
|
1289
|
+
const calls: string[][] = [];
|
|
1290
|
+
const logs: string[] = [];
|
|
1291
|
+
const code = await install(pkgDir, {
|
|
1292
|
+
runner: async (cmd) => {
|
|
1293
|
+
calls.push([...cmd]);
|
|
1294
|
+
return 0;
|
|
1295
|
+
},
|
|
1296
|
+
manifestPath: path,
|
|
1297
|
+
startService: async () => 0,
|
|
1298
|
+
isLinked: () => false,
|
|
1299
|
+
// The symlink at <bun-globals>/node_modules/@local/demo already
|
|
1300
|
+
// points at the same checkout we're installing — second-+ run.
|
|
1301
|
+
linkedPath: (pkg) => (pkg === "@local/demo" ? pkgDirReal : null),
|
|
1302
|
+
portProbe: async () => false,
|
|
1303
|
+
log: (l) => logs.push(l),
|
|
1304
|
+
readManifest: async () => ({
|
|
1305
|
+
name: "demo",
|
|
1306
|
+
manifestName: "@local/demo",
|
|
1307
|
+
kind: "api",
|
|
1308
|
+
port: 1951,
|
|
1309
|
+
paths: ["/demo"],
|
|
1310
|
+
health: "/healthz",
|
|
1311
|
+
}),
|
|
1312
|
+
readPackageName: () => "@local/demo",
|
|
1313
|
+
});
|
|
1314
|
+
expect(code).toBe(0);
|
|
1315
|
+
// No `bun add -g <pkgDir>` invocation — that's the whole point.
|
|
1316
|
+
const bunAddCalls = calls.filter((c) => c[0] === "bun" && c[1] === "add");
|
|
1317
|
+
expect(bunAddCalls).toEqual([]);
|
|
1318
|
+
// Downstream init/seed/installDir wiring still ran.
|
|
1319
|
+
const seeded = findService("demo", path);
|
|
1320
|
+
expect(seeded?.installDir).toBe(pkgDir);
|
|
1321
|
+
// Operator-visible breadcrumb so they understand why `bun add` was skipped.
|
|
1322
|
+
expect(logs.join("\n")).toMatch(/already linked at .* — skipping bun add/);
|
|
1323
|
+
} finally {
|
|
1324
|
+
cleanup();
|
|
1325
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("local-path install still bun-adds when symlink points elsewhere (hub#89)", async () => {
|
|
1330
|
+
// Operator moved their checkout: the global symlink is stale, pointing at
|
|
1331
|
+
// a different abspath. Re-run bun add against the new path so the link
|
|
1332
|
+
// gets refreshed (don't silently keep using the old target).
|
|
1333
|
+
const { path, cleanup } = makeTempPath();
|
|
1334
|
+
const pkgDir = mkdtempSync(join(tmpdir(), "pcli-localpkg-"));
|
|
1335
|
+
try {
|
|
1336
|
+
const calls: string[][] = [];
|
|
1337
|
+
const code = await install(pkgDir, {
|
|
1338
|
+
runner: async (cmd) => {
|
|
1339
|
+
calls.push([...cmd]);
|
|
1340
|
+
return 0;
|
|
1341
|
+
},
|
|
1342
|
+
manifestPath: path,
|
|
1343
|
+
startService: async () => 0,
|
|
1344
|
+
isLinked: () => false,
|
|
1345
|
+
linkedPath: () => "/Users/someone/old/checkout",
|
|
1346
|
+
portProbe: async () => false,
|
|
1347
|
+
log: () => {},
|
|
1348
|
+
readManifest: async () => ({
|
|
1349
|
+
name: "demo",
|
|
1350
|
+
manifestName: "@local/demo",
|
|
1351
|
+
kind: "api",
|
|
1352
|
+
port: 1951,
|
|
1353
|
+
paths: ["/demo"],
|
|
1354
|
+
health: "/healthz",
|
|
1355
|
+
}),
|
|
1356
|
+
readPackageName: () => "@local/demo",
|
|
1357
|
+
});
|
|
1358
|
+
expect(code).toBe(0);
|
|
1359
|
+
expect(calls[0]).toEqual(["bun", "add", "-g", pkgDir]);
|
|
1360
|
+
} finally {
|
|
1361
|
+
cleanup();
|
|
1362
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("npm-installed third-party module persists installDir from bun globals", async () => {
|
|
1367
|
+
// hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
|
|
1368
|
+
// the package.json that findGlobalInstall returns. Without this,
|
|
1369
|
+
// lifecycle has no way to locate the module's `.parachute/module.json`
|
|
1370
|
+
// and third-party `parachute start <name>` fails post-install.
|
|
1371
|
+
const { path, cleanup } = makeTempPath();
|
|
1372
|
+
try {
|
|
1373
|
+
const fakePrefix = "/fake/bun-globals/node_modules/@vendor/widget";
|
|
1374
|
+
const code = await install("@vendor/widget", {
|
|
1375
|
+
runner: async () => 0,
|
|
1376
|
+
manifestPath: path,
|
|
1377
|
+
startService: async () => 0,
|
|
1378
|
+
isLinked: () => false,
|
|
1379
|
+
portProbe: async () => false,
|
|
1380
|
+
log: () => {},
|
|
1381
|
+
readManifest: async () => ({
|
|
1382
|
+
name: "widget",
|
|
1383
|
+
manifestName: "@vendor/widget",
|
|
1384
|
+
kind: "api",
|
|
1385
|
+
port: 1952,
|
|
1386
|
+
paths: ["/widget"],
|
|
1387
|
+
health: "/widget/health",
|
|
1388
|
+
}),
|
|
1389
|
+
findGlobalInstall: () => `${fakePrefix}/package.json`,
|
|
1390
|
+
});
|
|
1391
|
+
expect(code).toBe(0);
|
|
1392
|
+
// hub#85: third-party row keys by `manifest.name`, not `manifestName`.
|
|
1393
|
+
const seeded = findService("widget", path);
|
|
1394
|
+
expect(seeded?.installDir).toBe(fakePrefix);
|
|
1395
|
+
expect(findService("@vendor/widget", path)).toBeUndefined();
|
|
1396
|
+
} finally {
|
|
1397
|
+
cleanup();
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("third-party with diverging name/manifestName keys services.json by name (hub#85)", async () => {
|
|
1402
|
+
// Repro for parachute-hub#85: parachute-agent ships `name: "agent",
|
|
1403
|
+
// manifestName: "parachute-agent"`. Install used to seed services.json
|
|
1404
|
+
// under `parachute-agent` (the npm label) while lifecycle looks up by
|
|
1405
|
+
// `agent` (the canonical short) → "unknown service". Fix: services.json
|
|
1406
|
+
// key is always `manifest.name` for third-party.
|
|
1407
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
1408
|
+
try {
|
|
1409
|
+
const startCalls: string[] = [];
|
|
1410
|
+
const logs: string[] = [];
|
|
1411
|
+
const code = await install("parachute-agent", {
|
|
1412
|
+
runner: async () => 0,
|
|
1413
|
+
manifestPath: path,
|
|
1414
|
+
configDir,
|
|
1415
|
+
startService: async (short) => {
|
|
1416
|
+
startCalls.push(short);
|
|
1417
|
+
return 0;
|
|
1418
|
+
},
|
|
1419
|
+
isLinked: () => false,
|
|
1420
|
+
portProbe: async () => false,
|
|
1421
|
+
log: (l) => logs.push(l),
|
|
1422
|
+
readManifest: async () => ({
|
|
1423
|
+
name: "agent",
|
|
1424
|
+
manifestName: "parachute-agent",
|
|
1425
|
+
kind: "api",
|
|
1426
|
+
port: 1945,
|
|
1427
|
+
paths: ["/agent"],
|
|
1428
|
+
health: "/agent/health",
|
|
1429
|
+
startCmd: ["bun", "server.ts"],
|
|
1430
|
+
}),
|
|
1431
|
+
findGlobalInstall: () => "/fake/prefix/parachute-agent/package.json",
|
|
1432
|
+
});
|
|
1433
|
+
expect(code).toBe(0);
|
|
1434
|
+
// services.json is keyed by `name`, not `manifestName`.
|
|
1435
|
+
expect(findService("agent", path)?.name).toBe("agent");
|
|
1436
|
+
expect(findService("parachute-agent", path)).toBeUndefined();
|
|
1437
|
+
// Auto-start receives the canonical short name (= manifest.name).
|
|
1438
|
+
expect(startCalls).toEqual(["agent"]);
|
|
1439
|
+
// Log lines speak in the canonical short name too. Port comes from
|
|
1440
|
+
// assignServicePort (third-party gets the first unassigned canonical
|
|
1441
|
+
// slot, currently 1944), not the manifest's port hint.
|
|
1442
|
+
const joined = logs.join("\n");
|
|
1443
|
+
expect(joined).toMatch(/Seeded services\.json entry for agent/);
|
|
1444
|
+
expect(joined).toMatch(/agent registered on port \d+/);
|
|
1445
|
+
expect(joined).not.toMatch(/Seeded services\.json entry for parachute-agent/);
|
|
1446
|
+
} finally {
|
|
1447
|
+
cleanup();
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("local absolute path that doesn't exist fails fast", async () => {
|
|
1452
|
+
const { path, cleanup } = makeTempPath();
|
|
1453
|
+
try {
|
|
1454
|
+
const logs: string[] = [];
|
|
1455
|
+
const code = await install("/nonexistent/path/xyz", {
|
|
1456
|
+
runner: async () => 0,
|
|
1457
|
+
manifestPath: path,
|
|
1458
|
+
startService: async () => 0,
|
|
1459
|
+
isLinked: () => false,
|
|
1460
|
+
portProbe: async () => false,
|
|
1461
|
+
log: (l) => logs.push(l),
|
|
1462
|
+
});
|
|
1463
|
+
expect(code).toBe(1);
|
|
1464
|
+
expect(logs.join("\n")).toMatch(/path does not exist/);
|
|
1465
|
+
} finally {
|
|
1466
|
+
cleanup();
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1145
1469
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
3
|
+
import { pemToJwk } from "../jwks.ts";
|
|
4
|
+
|
|
5
|
+
function freshRsaPem(): string {
|
|
6
|
+
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
7
|
+
return publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("pemToJwk", () => {
|
|
11
|
+
test("returns a JWK with kty=RSA, alg=RS256, use=sig, and the supplied kid", () => {
|
|
12
|
+
const pem = freshRsaPem();
|
|
13
|
+
const jwk = pemToJwk(pem, "test-kid");
|
|
14
|
+
expect(jwk.kty).toBe("RSA");
|
|
15
|
+
expect(jwk.alg).toBe("RS256");
|
|
16
|
+
expect(jwk.use).toBe("sig");
|
|
17
|
+
expect(jwk.kid).toBe("test-kid");
|
|
18
|
+
// n + e are non-empty base64url strings.
|
|
19
|
+
expect(jwk.n.length).toBeGreaterThan(0);
|
|
20
|
+
expect(jwk.e.length).toBeGreaterThan(0);
|
|
21
|
+
expect(jwk.n).not.toMatch(/[+/=]/);
|
|
22
|
+
expect(jwk.e).not.toMatch(/[+/=]/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("rejects a non-RSA key", () => {
|
|
26
|
+
const { publicKey } = generateKeyPairSync("ed25519");
|
|
27
|
+
const pem = publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
28
|
+
expect(() => pemToJwk(pem, "bad")).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("is deterministic for the same PEM + kid", () => {
|
|
32
|
+
const pem = freshRsaPem();
|
|
33
|
+
const a = pemToJwk(pem, "k");
|
|
34
|
+
const b = pemToJwk(pem, "k");
|
|
35
|
+
expect(b).toEqual(a);
|
|
36
|
+
});
|
|
37
|
+
});
|