@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -6,12 +6,34 @@ import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-to
|
|
|
6
6
|
import {
|
|
7
7
|
formatBootstrapTokenBanner,
|
|
8
8
|
formatListeningBanner,
|
|
9
|
+
hubPortConflictMessage,
|
|
9
10
|
resolveStartupIssuer,
|
|
10
11
|
seedInitialAdminIfNeeded,
|
|
11
12
|
} from "../commands/serve.ts";
|
|
12
13
|
import { openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { getUserByUsername, userCount } from "../users.ts";
|
|
14
15
|
|
|
16
|
+
describe("hubPortConflictMessage (hub#536)", () => {
|
|
17
|
+
test("maps a port-in-use error to a clear duplicate-supervisor message", () => {
|
|
18
|
+
// Bun surfaces a port conflict as "...Is port 1939 in use?"; node-style is
|
|
19
|
+
// "EADDRINUSE: address already in use". Both must map to the clear message.
|
|
20
|
+
for (const m of [
|
|
21
|
+
"EADDRINUSE: address already in use 127.0.0.1:1939",
|
|
22
|
+
"Failed to start server. Is port 1939 in use?",
|
|
23
|
+
]) {
|
|
24
|
+
const out = hubPortConflictMessage(new Error(m), 1939);
|
|
25
|
+
expect(out).toContain("already in use");
|
|
26
|
+
expect(out).toContain("duplicate supervisor");
|
|
27
|
+
expect(out).toContain("1939");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns null for an unrelated error (caller re-throws the original)", () => {
|
|
32
|
+
expect(hubPortConflictMessage(new Error("permission denied"), 1939)).toBeNull();
|
|
33
|
+
expect(hubPortConflictMessage("not even an Error", 1939)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
15
37
|
describe("seedInitialAdminIfNeeded", () => {
|
|
16
38
|
let dir: string;
|
|
17
39
|
let dbPath: string;
|
|
@@ -248,6 +270,13 @@ describe("bootstrap-token wiring under needs-setup", () => {
|
|
|
248
270
|
});
|
|
249
271
|
|
|
250
272
|
describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
273
|
+
// Stub "no exposure recorded" so the undefined-asserting tests are
|
|
274
|
+
// isolated from the host's real ~/.parachute/expose-state.json — the
|
|
275
|
+
// default reader picks up a live exposure on the dev box and the expose
|
|
276
|
+
// tier (#531) would otherwise shadow the "no source → undefined" cases.
|
|
277
|
+
// The expose tier itself is exercised in its own describe block below.
|
|
278
|
+
const noExpose = (): string | undefined => undefined;
|
|
279
|
+
|
|
251
280
|
test("explicit opts.issuer wins over everything", () => {
|
|
252
281
|
const got = resolveStartupIssuer(
|
|
253
282
|
{ issuer: "https://override.example" },
|
|
@@ -296,9 +325,9 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
296
325
|
});
|
|
297
326
|
|
|
298
327
|
test("returns undefined when no source has a value", () => {
|
|
299
|
-
expect(resolveStartupIssuer({}, {})).toBeUndefined();
|
|
300
|
-
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "" })).toBeUndefined();
|
|
301
|
-
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "" })).toBeUndefined();
|
|
328
|
+
expect(resolveStartupIssuer({}, {}, noExpose)).toBeUndefined();
|
|
329
|
+
expect(resolveStartupIssuer({}, { RENDER_EXTERNAL_URL: "" }, noExpose)).toBeUndefined();
|
|
330
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "" }, noExpose)).toBeUndefined();
|
|
302
331
|
});
|
|
303
332
|
|
|
304
333
|
test("empty string after slash-strip collapses to undefined (defensive)", () => {
|
|
@@ -306,7 +335,7 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
306
335
|
// Guards against a misconfigured env where someone sets the var to "/"
|
|
307
336
|
// expecting it to mean "root" (it doesn't — leaves hub without a usable
|
|
308
337
|
// origin, which is the same as not setting it at all).
|
|
309
|
-
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" })).toBeUndefined();
|
|
338
|
+
expect(resolveStartupIssuer({}, { PARACHUTE_HUB_ORIGIN: "/" }, noExpose)).toBeUndefined();
|
|
310
339
|
});
|
|
311
340
|
|
|
312
341
|
// Fly.io self-host path (patterns#100). resolveStartupIssuer is the
|
|
@@ -341,11 +370,96 @@ describe("resolveStartupIssuer — precedence chain (hub#365)", () => {
|
|
|
341
370
|
});
|
|
342
371
|
|
|
343
372
|
test("FLY_APP_NAME with slash rejected (defensive — Fly slugs don't contain /)", () => {
|
|
344
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "a/b" })).toBeUndefined();
|
|
345
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "../etc/passwd" })).toBeUndefined();
|
|
373
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "a/b" }, noExpose)).toBeUndefined();
|
|
374
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "../etc/passwd" }, noExpose)).toBeUndefined();
|
|
346
375
|
});
|
|
347
376
|
|
|
348
377
|
test("FLY_APP_NAME empty string → no fallback", () => {
|
|
349
|
-
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" })).toBeUndefined();
|
|
378
|
+
expect(resolveStartupIssuer({}, { FLY_APP_NAME: "" }, noExpose)).toBeUndefined();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
|
|
383
|
+
// The reboot-persistent bug: the launchd plist / systemd unit that keeps
|
|
384
|
+
// `parachute serve` alive carries no PARACHUTE_HUB_ORIGIN, so on every
|
|
385
|
+
// reboot the hub boots with no flag/env/platform origin and would stamp
|
|
386
|
+
// iss from the per-request origin — which exposed resource servers (vault)
|
|
387
|
+
// reject until they restart. Reading expose-state.json's hubOrigin makes
|
|
388
|
+
// iss deterministic across reboots. The readExpose seam lets us drive this
|
|
389
|
+
// without touching the real ~/.parachute.
|
|
390
|
+
const EXPOSED = "https://parachute.taildf9ce2.ts.net";
|
|
391
|
+
|
|
392
|
+
test("returns expose-state.hubOrigin when no flag/env/platform set", () => {
|
|
393
|
+
const got = resolveStartupIssuer({}, {}, () => EXPOSED);
|
|
394
|
+
expect(got).toBe(EXPOSED);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("explicit opts.issuer wins over expose-state", () => {
|
|
398
|
+
const got = resolveStartupIssuer({ issuer: "https://override.example" }, {}, () => EXPOSED);
|
|
399
|
+
expect(got).toBe("https://override.example");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("PARACHUTE_HUB_ORIGIN wins over expose-state", () => {
|
|
403
|
+
const got = resolveStartupIssuer(
|
|
404
|
+
{},
|
|
405
|
+
{ PARACHUTE_HUB_ORIGIN: "https://env.example" },
|
|
406
|
+
() => EXPOSED,
|
|
407
|
+
);
|
|
408
|
+
expect(got).toBe("https://env.example");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("RENDER_EXTERNAL_URL wins over expose-state", () => {
|
|
412
|
+
const got = resolveStartupIssuer(
|
|
413
|
+
{},
|
|
414
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
415
|
+
() => EXPOSED,
|
|
416
|
+
);
|
|
417
|
+
expect(got).toBe("https://app.onrender.com");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("FLY_APP_NAME wins over expose-state", () => {
|
|
421
|
+
const got = resolveStartupIssuer({}, { FLY_APP_NAME: "my-parachute" }, () => EXPOSED);
|
|
422
|
+
expect(got).toBe("https://my-parachute.fly.dev");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("returns undefined when expose-state is absent (no hubOrigin recorded)", () => {
|
|
426
|
+
expect(resolveStartupIssuer({}, {}, () => undefined)).toBeUndefined();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("strips trailing slashes from the expose origin", () => {
|
|
430
|
+
expect(resolveStartupIssuer({}, {}, () => `${EXPOSED}/`)).toBe(EXPOSED);
|
|
431
|
+
expect(resolveStartupIssuer({}, {}, () => `${EXPOSED}//`)).toBe(EXPOSED);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("ignores a loopback expose hubOrigin (never re-pin the degraded mode)", () => {
|
|
435
|
+
expect(resolveStartupIssuer({}, {}, () => "http://127.0.0.1:1939")).toBeUndefined();
|
|
436
|
+
expect(resolveStartupIssuer({}, {}, () => "http://localhost:1939")).toBeUndefined();
|
|
437
|
+
expect(resolveStartupIssuer({}, {}, () => "http://[::1]:1939")).toBeUndefined();
|
|
438
|
+
expect(resolveStartupIssuer({}, {}, () => "http://0.0.0.0:1939")).toBeUndefined();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("ignores a non-http(s) or malformed expose origin", () => {
|
|
442
|
+
expect(resolveStartupIssuer({}, {}, () => "ftp://parachute.example")).toBeUndefined();
|
|
443
|
+
expect(resolveStartupIssuer({}, {}, () => "not-a-url")).toBeUndefined();
|
|
444
|
+
expect(resolveStartupIssuer({}, {}, () => "")).toBeUndefined();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("default reader is swallowed-safe (no real ~/.parachute) — does not throw", () => {
|
|
448
|
+
// The default readExpose swallows a malformed-file throw; with no
|
|
449
|
+
// exposure recorded under the test PARACHUTE_HOME it just yields
|
|
450
|
+
// undefined → undefined issuer. The key assertion is "doesn't throw."
|
|
451
|
+
expect(() => resolveStartupIssuer({}, {})).not.toThrow();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a throwing injected reader can't crash startup (returns undefined)", () => {
|
|
455
|
+
// The readExpose() call is try/catch-wrapped so even a non-swallowing
|
|
456
|
+
// reader can't propagate into boot. With no flag/env/platform origin set
|
|
457
|
+
// and a throwing reader, the issuer resolves to undefined (the degraded
|
|
458
|
+
// request-origin mode) rather than crashing `parachute serve`.
|
|
459
|
+
const throwing = () => {
|
|
460
|
+
throw new Error("malformed expose-state.json");
|
|
461
|
+
};
|
|
462
|
+
expect(() => resolveStartupIssuer({}, {}, throwing)).not.toThrow();
|
|
463
|
+
expect(resolveStartupIssuer({}, {}, throwing)).toBeUndefined();
|
|
350
464
|
});
|
|
351
465
|
});
|
|
@@ -4051,6 +4051,116 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
|
|
|
4051
4051
|
}
|
|
4052
4052
|
});
|
|
4053
4053
|
|
|
4054
|
+
test("JSON probe hands the bootstrap token VALUE to a loopback caller (hub#576)", async () => {
|
|
4055
|
+
const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
|
|
4056
|
+
"../bootstrap-token.ts"
|
|
4057
|
+
);
|
|
4058
|
+
_resetBootstrapTokenForTests();
|
|
4059
|
+
const token = generateBootstrapToken();
|
|
4060
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4061
|
+
try {
|
|
4062
|
+
const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
|
|
4063
|
+
db,
|
|
4064
|
+
manifestPath: h.manifestPath,
|
|
4065
|
+
configDir: h.dir,
|
|
4066
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4067
|
+
issuer: "http://127.0.0.1:1939",
|
|
4068
|
+
registry: getDefaultOperationsRegistry(),
|
|
4069
|
+
requestIsLoopback: true,
|
|
4070
|
+
});
|
|
4071
|
+
const body = (await res.json()) as {
|
|
4072
|
+
requireBootstrapToken: boolean;
|
|
4073
|
+
bootstrapToken?: string;
|
|
4074
|
+
};
|
|
4075
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
4076
|
+
expect(body.bootstrapToken).toBe(token);
|
|
4077
|
+
} finally {
|
|
4078
|
+
_resetBootstrapTokenForTests();
|
|
4079
|
+
db.close();
|
|
4080
|
+
}
|
|
4081
|
+
});
|
|
4082
|
+
|
|
4083
|
+
test("JSON probe withholds the token VALUE from a non-loopback caller (hub#576)", async () => {
|
|
4084
|
+
const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
|
|
4085
|
+
"../bootstrap-token.ts"
|
|
4086
|
+
);
|
|
4087
|
+
_resetBootstrapTokenForTests();
|
|
4088
|
+
generateBootstrapToken();
|
|
4089
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4090
|
+
try {
|
|
4091
|
+
const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
|
|
4092
|
+
db,
|
|
4093
|
+
manifestPath: h.manifestPath,
|
|
4094
|
+
configDir: h.dir,
|
|
4095
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4096
|
+
issuer: "http://127.0.0.1:1939",
|
|
4097
|
+
registry: getDefaultOperationsRegistry(),
|
|
4098
|
+
requestIsLoopback: false,
|
|
4099
|
+
});
|
|
4100
|
+
const body = (await res.json()) as {
|
|
4101
|
+
requireBootstrapToken: boolean;
|
|
4102
|
+
bootstrapToken?: string;
|
|
4103
|
+
};
|
|
4104
|
+
// The boolean still tells a public browser a token is required...
|
|
4105
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
4106
|
+
// ...but the VALUE never leaks to it.
|
|
4107
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
4108
|
+
} finally {
|
|
4109
|
+
_resetBootstrapTokenForTests();
|
|
4110
|
+
db.close();
|
|
4111
|
+
}
|
|
4112
|
+
});
|
|
4113
|
+
|
|
4114
|
+
test("JSON probe fails CLOSED when loopback is unknown (hub#576)", async () => {
|
|
4115
|
+
const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
|
|
4116
|
+
"../bootstrap-token.ts"
|
|
4117
|
+
);
|
|
4118
|
+
_resetBootstrapTokenForTests();
|
|
4119
|
+
generateBootstrapToken();
|
|
4120
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4121
|
+
try {
|
|
4122
|
+
// `requestIsLoopback` omitted entirely — must be treated as non-loopback.
|
|
4123
|
+
const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
|
|
4124
|
+
db,
|
|
4125
|
+
manifestPath: h.manifestPath,
|
|
4126
|
+
configDir: h.dir,
|
|
4127
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4128
|
+
issuer: "http://127.0.0.1:1939",
|
|
4129
|
+
registry: getDefaultOperationsRegistry(),
|
|
4130
|
+
});
|
|
4131
|
+
const body = (await res.json()) as { bootstrapToken?: string };
|
|
4132
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
4133
|
+
} finally {
|
|
4134
|
+
_resetBootstrapTokenForTests();
|
|
4135
|
+
db.close();
|
|
4136
|
+
}
|
|
4137
|
+
});
|
|
4138
|
+
|
|
4139
|
+
test("JSON probe omits the token when no admin gate is active (hub#576)", async () => {
|
|
4140
|
+
const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
|
|
4141
|
+
_resetBootstrapTokenForTests(); // no token minted → not in wizard mode
|
|
4142
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4143
|
+
try {
|
|
4144
|
+
const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
|
|
4145
|
+
db,
|
|
4146
|
+
manifestPath: h.manifestPath,
|
|
4147
|
+
configDir: h.dir,
|
|
4148
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
4149
|
+
issuer: "http://127.0.0.1:1939",
|
|
4150
|
+
registry: getDefaultOperationsRegistry(),
|
|
4151
|
+
requestIsLoopback: true,
|
|
4152
|
+
});
|
|
4153
|
+
const body = (await res.json()) as {
|
|
4154
|
+
requireBootstrapToken: boolean;
|
|
4155
|
+
bootstrapToken?: string;
|
|
4156
|
+
};
|
|
4157
|
+
expect(body.requireBootstrapToken).toBe(false);
|
|
4158
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
4159
|
+
} finally {
|
|
4160
|
+
db.close();
|
|
4161
|
+
}
|
|
4162
|
+
});
|
|
4163
|
+
|
|
4054
4164
|
test("vault step skip mode short-circuits + persists setup_vault_skipped", async () => {
|
|
4055
4165
|
const db = openHubDb(hubDbPath(h.dir));
|
|
4056
4166
|
try {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type EnrichedPathDeps,
|
|
4
|
+
enrichedPath,
|
|
5
|
+
enrichedUnitPath,
|
|
6
|
+
operatorToolDirs,
|
|
7
|
+
} from "../spawn-path.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build EnrichedPathDeps with a fake fs (set of existing paths) + pinned
|
|
11
|
+
* platform/arch/home so tests never touch the real disk or host.
|
|
12
|
+
*/
|
|
13
|
+
function fakeDeps(opts: {
|
|
14
|
+
home?: string;
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
arch?: string;
|
|
17
|
+
existing?: string[];
|
|
18
|
+
}): EnrichedPathDeps {
|
|
19
|
+
const existing = new Set(opts.existing ?? []);
|
|
20
|
+
return {
|
|
21
|
+
homeDir: () => opts.home ?? "/home/op",
|
|
22
|
+
exists: (p) => existing.has(p),
|
|
23
|
+
platform: opts.platform ?? "darwin",
|
|
24
|
+
arch: opts.arch ?? "arm64",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("enrichedPath — operator-tool PATH enrichment", () => {
|
|
29
|
+
test("preserves the inherited PATH and keeps its order", () => {
|
|
30
|
+
const deps = fakeDeps({ existing: [] });
|
|
31
|
+
const result = enrichedPath({ PATH: "/usr/bin:/bin" }, deps);
|
|
32
|
+
expect(result).toBe("/usr/bin:/bin");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("appends operator-tool dirs only when they exist on disk", () => {
|
|
36
|
+
// .local/bin + brew exist; .bun/bin does NOT → only the two existing append.
|
|
37
|
+
const deps = fakeDeps({
|
|
38
|
+
home: "/home/op",
|
|
39
|
+
platform: "darwin",
|
|
40
|
+
arch: "arm64",
|
|
41
|
+
existing: ["/home/op/.local/bin", "/opt/homebrew/bin"],
|
|
42
|
+
});
|
|
43
|
+
const result = enrichedPath({ PATH: "/usr/bin" }, deps);
|
|
44
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin:/opt/homebrew/bin");
|
|
45
|
+
expect(result).not.toContain("/home/op/.bun/bin");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("inherited PATH wins over appended defaults (append, not prepend)", () => {
|
|
49
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
50
|
+
const result = enrichedPath({ PATH: "/first:/second" }, deps);
|
|
51
|
+
const parts = result.split(":");
|
|
52
|
+
expect(parts[0]).toBe("/first");
|
|
53
|
+
expect(parts[1]).toBe("/second");
|
|
54
|
+
expect(parts[2]).toBe("/home/op/.local/bin");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("dedupes — an appended dir already in the inherited PATH is not duplicated", () => {
|
|
58
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
59
|
+
const result = enrichedPath({ PATH: "/usr/bin:/home/op/.local/bin" }, deps);
|
|
60
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin");
|
|
61
|
+
expect(result.split(":").filter((p) => p === "/home/op/.local/bin")).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("PARACHUTE_EXTRA_PATH is PREPENDED so an operator can intentionally shadow", () => {
|
|
65
|
+
const deps = fakeDeps({ existing: ["/home/op/.local/bin"] });
|
|
66
|
+
const result = enrichedPath(
|
|
67
|
+
{ PATH: "/usr/bin", PARACHUTE_EXTRA_PATH: "/opt/custom/bin:/opt/more" },
|
|
68
|
+
deps,
|
|
69
|
+
);
|
|
70
|
+
const parts = result.split(":");
|
|
71
|
+
expect(parts[0]).toBe("/opt/custom/bin");
|
|
72
|
+
expect(parts[1]).toBe("/opt/more");
|
|
73
|
+
expect(parts[2]).toBe("/usr/bin");
|
|
74
|
+
expect(parts[3]).toBe("/home/op/.local/bin");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("PARACHUTE_EXTRA_PATH dedupes against inherited (extra-first wins position)", () => {
|
|
78
|
+
const deps = fakeDeps({ existing: [] });
|
|
79
|
+
const result = enrichedPath({ PATH: "/usr/bin:/dup", PARACHUTE_EXTRA_PATH: "/dup" }, deps);
|
|
80
|
+
expect(result).toBe("/dup:/usr/bin");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("empty inherited PATH yields just the existing appended dirs", () => {
|
|
84
|
+
const deps = fakeDeps({
|
|
85
|
+
home: "/home/op",
|
|
86
|
+
platform: "darwin",
|
|
87
|
+
arch: "arm64",
|
|
88
|
+
existing: ["/home/op/.local/bin", "/opt/homebrew/bin", "/home/op/.bun/bin"],
|
|
89
|
+
});
|
|
90
|
+
const result = enrichedPath({ PATH: "" }, deps);
|
|
91
|
+
expect(result).toBe("/home/op/.local/bin:/opt/homebrew/bin:/home/op/.bun/bin");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("platform / arch branches", () => {
|
|
95
|
+
test("darwin arm64 → /opt/homebrew/bin", () => {
|
|
96
|
+
const deps = fakeDeps({
|
|
97
|
+
platform: "darwin",
|
|
98
|
+
arch: "arm64",
|
|
99
|
+
existing: ["/opt/homebrew/bin"],
|
|
100
|
+
});
|
|
101
|
+
expect(enrichedPath({ PATH: "/usr/bin" }, deps)).toBe("/usr/bin:/opt/homebrew/bin");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("darwin x64 → /usr/local/bin (Intel brew)", () => {
|
|
105
|
+
const deps = fakeDeps({
|
|
106
|
+
platform: "darwin",
|
|
107
|
+
arch: "x64",
|
|
108
|
+
existing: ["/usr/local/bin"],
|
|
109
|
+
});
|
|
110
|
+
// /usr/local/bin is the brew bin on Intel macOS; here it's the only existing dir.
|
|
111
|
+
expect(enrichedPath({ PATH: "/usr/bin" }, deps)).toBe("/usr/bin:/usr/local/bin");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("linux → only $HOME/.local/bin + $HOME/.bun/bin, no brew bin", () => {
|
|
115
|
+
const deps = fakeDeps({
|
|
116
|
+
home: "/home/op",
|
|
117
|
+
platform: "linux",
|
|
118
|
+
arch: "x64",
|
|
119
|
+
existing: ["/home/op/.local/bin", "/home/op/.bun/bin", "/opt/homebrew/bin"],
|
|
120
|
+
});
|
|
121
|
+
const result = enrichedPath({ PATH: "/usr/bin" }, deps);
|
|
122
|
+
expect(result).toBe("/usr/bin:/home/op/.local/bin:/home/op/.bun/bin");
|
|
123
|
+
// /opt/homebrew/bin exists in the fake fs but is NOT a Linux candidate dir.
|
|
124
|
+
expect(result).not.toContain("/opt/homebrew/bin");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("operatorToolDirs — candidate dir list", () => {
|
|
130
|
+
test("darwin arm64: .local/bin, /opt/homebrew/bin, .bun/bin (in order)", () => {
|
|
131
|
+
expect(operatorToolDirs("/home/op", "darwin", "arm64")).toEqual([
|
|
132
|
+
"/home/op/.local/bin",
|
|
133
|
+
"/opt/homebrew/bin",
|
|
134
|
+
"/home/op/.bun/bin",
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("darwin x64: brew is /usr/local/bin", () => {
|
|
139
|
+
expect(operatorToolDirs("/home/op", "darwin", "x64")).toEqual([
|
|
140
|
+
"/home/op/.local/bin",
|
|
141
|
+
"/usr/local/bin",
|
|
142
|
+
"/home/op/.bun/bin",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("linux: no brew dir", () => {
|
|
147
|
+
expect(operatorToolDirs("/home/op", "linux", "x64")).toEqual([
|
|
148
|
+
"/home/op/.local/bin",
|
|
149
|
+
"/home/op/.bun/bin",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("enrichedUnitPath — launchd/systemd unit PATH", () => {
|
|
155
|
+
test("bun bin first, then system dirs, then operator-tool dirs (darwin arm64)", () => {
|
|
156
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "arm64", undefined);
|
|
157
|
+
expect(result).toBe(
|
|
158
|
+
"/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin:/opt/homebrew/bin",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("dedupes — Intel brew /usr/local/bin already present in the base is not duplicated", () => {
|
|
163
|
+
// darwin x64 brew dir == /usr/local/bin, which the base already carries.
|
|
164
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "x64", undefined);
|
|
165
|
+
expect(result.split(":").filter((p) => p === "/usr/local/bin")).toHaveLength(1);
|
|
166
|
+
expect(result).toBe("/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("linux: appends $HOME/.local/bin (no brew dir)", () => {
|
|
170
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "linux", "x64", undefined);
|
|
171
|
+
expect(result).toBe("/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin:/home/op/.local/bin");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("includes operator-tool dirs unconditionally (no existence check)", () => {
|
|
175
|
+
// No fs seam here — the unit-side dirs are baked in even if absent on disk.
|
|
176
|
+
const result = enrichedUnitPath("/home/op/.bun", "/home/op", "darwin", "arm64", undefined);
|
|
177
|
+
expect(result).toContain("/home/op/.local/bin");
|
|
178
|
+
expect(result).toContain("/opt/homebrew/bin");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("PARACHUTE_EXTRA_PATH is prepended", () => {
|
|
182
|
+
const result = enrichedUnitPath(
|
|
183
|
+
"/home/op/.bun",
|
|
184
|
+
"/home/op",
|
|
185
|
+
"darwin",
|
|
186
|
+
"arm64",
|
|
187
|
+
"/opt/custom/bin",
|
|
188
|
+
);
|
|
189
|
+
expect(result.split(":")[0]).toBe("/opt/custom/bin");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -256,4 +256,68 @@ describe("status — per-module URL deep-links (manifestRowBase / urlForEntry)",
|
|
|
256
256
|
cleanup();
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
test("non-curated supervised module reads `active` via the `supervised` fallback — hub#539", async () => {
|
|
261
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
262
|
+
try {
|
|
263
|
+
// surface is supervised but absent from the curated `modules` catalog
|
|
264
|
+
// (which only carries vault/scribe). Before hub#539 it mapped to
|
|
265
|
+
// `inactive` despite running; now `status` falls back to `supervised`.
|
|
266
|
+
upsertService(
|
|
267
|
+
{
|
|
268
|
+
name: "parachute-surface",
|
|
269
|
+
port: 1946,
|
|
270
|
+
paths: ["/surface"],
|
|
271
|
+
health: "/surface/healthz",
|
|
272
|
+
version: "0.2.2",
|
|
273
|
+
},
|
|
274
|
+
path,
|
|
275
|
+
);
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
await status({
|
|
278
|
+
...supervisorOpts(configDir, path, {
|
|
279
|
+
// `modules` empty (curated catalog omits surface); run-state ONLY in
|
|
280
|
+
// `supervised` — exactly the wire shape the live hub now returns.
|
|
281
|
+
moduleStates: {
|
|
282
|
+
supervisorAvailable: true,
|
|
283
|
+
modules: [],
|
|
284
|
+
supervised: [runningModule("surface")],
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
print: (l) => lines.push(l),
|
|
288
|
+
});
|
|
289
|
+
const surfaceLine = lines.find((l) => l.includes("parachute-surface")) ?? "";
|
|
290
|
+
expect(surfaceLine).toMatch(/\bactive\b/);
|
|
291
|
+
expect(surfaceLine).not.toMatch(/\binactive\b/);
|
|
292
|
+
} finally {
|
|
293
|
+
cleanup();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("module absent from BOTH modules and supervised stays `inactive` — hub#539 boundary", async () => {
|
|
298
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
299
|
+
try {
|
|
300
|
+
upsertService(
|
|
301
|
+
{
|
|
302
|
+
name: "parachute-surface",
|
|
303
|
+
port: 1946,
|
|
304
|
+
paths: ["/surface"],
|
|
305
|
+
health: "/surface/healthz",
|
|
306
|
+
version: "0.2.2",
|
|
307
|
+
},
|
|
308
|
+
path,
|
|
309
|
+
);
|
|
310
|
+
const lines: string[] = [];
|
|
311
|
+
await status({
|
|
312
|
+
...supervisorOpts(configDir, path, {
|
|
313
|
+
moduleStates: { supervisorAvailable: true, modules: [], supervised: [] },
|
|
314
|
+
}),
|
|
315
|
+
print: (l) => lines.push(l),
|
|
316
|
+
});
|
|
317
|
+
const surfaceLine = lines.find((l) => l.includes("parachute-surface")) ?? "";
|
|
318
|
+
expect(surfaceLine).toMatch(/\binactive\b/);
|
|
319
|
+
} finally {
|
|
320
|
+
cleanup();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
259
323
|
});
|