@openparachute/hub 0.6.2 → 0.6.3-rc.2
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 +87 -35
- package/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +690 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/expose-cloudflare.test.ts +163 -72
- package/src/__tests__/expose-off-auto.test.ts +26 -1
- package/src/__tests__/expose.test.ts +260 -240
- package/src/__tests__/hub-control.test.ts +1 -242
- package/src/__tests__/hub-server.test.ts +64 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +416 -1448
- package/src/__tests__/managed-unit.test.ts +575 -0
- package/src/__tests__/migrate-cutover.test.ts +840 -0
- package/src/__tests__/migrate-offer.test.ts +240 -0
- package/src/__tests__/migrate.test.ts +132 -0
- package/src/__tests__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +504 -0
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/__tests__/upgrade.test.ts +351 -5
- package/src/api-hub-upgrade.ts +384 -0
- package/src/api-hub.ts +2 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +97 -12
- package/src/cloudflare/connector-service.ts +117 -322
- package/src/commands/expose-cloudflare.ts +63 -71
- package/src/commands/expose-supervisor.ts +247 -0
- package/src/commands/expose.ts +59 -48
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +455 -816
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +535 -235
- package/src/commands/upgrade.ts +100 -2
- package/src/help.ts +128 -68
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +39 -0
- package/src/hub-unit.ts +735 -0
- package/src/hub-upgrade-helper.ts +306 -0
- package/src/hub-upgrade-mode.ts +209 -0
- package/src/hub-upgrade-status.ts +150 -0
- package/src/managed-unit.ts +692 -0
- package/src/migrate-offer.ts +186 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/process-state.ts +19 -3
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +389 -38
- package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
- package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
|
@@ -3,9 +3,26 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { status } from "../commands/status.ts";
|
|
6
|
-
import {
|
|
6
|
+
import type { HubUnitDeps, HubUnitStateResult } from "../hub-unit.ts";
|
|
7
|
+
import type { ModuleStatesResult } from "../module-ops-client.ts";
|
|
7
8
|
import { upsertService } from "../services-manifest.ts";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Phase 5b: `status` reads the hub row from the platform manager + `/health` and
|
|
12
|
+
* the module rows from the running supervisor (`GET /api/modules`). The detached
|
|
13
|
+
* pidfile/HTTP-probe arm was retired, so these tests — the table-rendering /
|
|
14
|
+
* per-module URL deep-link / persisted-start-error / state-rollup coverage that
|
|
15
|
+
* used to live on the detached arm — drive the supervised arm instead. The
|
|
16
|
+
* detached-specific cases that no longer exist (HTTP probe success/failure, the
|
|
17
|
+
* http-401-healthy carve-out, known-stopped-skips-probe) are not re-asserted: a
|
|
18
|
+
* module's run-state comes from the supervisor now, not an HTTP probe.
|
|
19
|
+
*
|
|
20
|
+
* The hub-row state machine + module-state degradation paths are covered in
|
|
21
|
+
* `status-supervisor.test.ts`; this file focuses on the manifest-derived
|
|
22
|
+
* rendering (URLs, version, persisted start-error) that `manifestRowBase`
|
|
23
|
+
* produces for each module row.
|
|
24
|
+
*/
|
|
25
|
+
|
|
9
26
|
function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
|
|
10
27
|
const dir = mkdtempSync(join(tmpdir(), "pcli-status-"));
|
|
11
28
|
return {
|
|
@@ -15,276 +32,120 @@ function makeTempPath(): { path: string; cleanup: () => void; configDir: string
|
|
|
15
32
|
};
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
26
|
-
print: (l) => lines.push(l),
|
|
27
|
-
});
|
|
28
|
-
expect(code).toBe(0);
|
|
29
|
-
expect(lines.join("\n")).toMatch(/No services installed/);
|
|
30
|
-
} finally {
|
|
31
|
-
cleanup();
|
|
32
|
-
}
|
|
33
|
-
});
|
|
35
|
+
/** Install-source deps that never touch the real filesystem. */
|
|
36
|
+
const STUB_INSTALL_SOURCE = {
|
|
37
|
+
bunGlobalPrefixes: () => [] as string[],
|
|
38
|
+
resolveBunGlobal: () => null,
|
|
39
|
+
readJson: () => ({ version: "0.6.2" }),
|
|
40
|
+
readGitHead: () => undefined,
|
|
41
|
+
};
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
path,
|
|
51
|
-
);
|
|
52
|
-
const seen: string[] = [];
|
|
53
|
-
const lines: string[] = [];
|
|
54
|
-
const code = await status({
|
|
55
|
-
manifestPath: path,
|
|
56
|
-
fetchImpl: async (url) => {
|
|
57
|
-
seen.push(String(url));
|
|
58
|
-
return new Response(null, { status: 200 });
|
|
59
|
-
},
|
|
60
|
-
print: (l) => lines.push(l),
|
|
61
|
-
});
|
|
62
|
-
expect(code).toBe(0);
|
|
63
|
-
expect(seen).toContain("http://localhost:1940/health");
|
|
64
|
-
expect(seen).toContain("http://localhost:3200/scribe/health");
|
|
65
|
-
// Header reflects the post-workstream-F column shape:
|
|
66
|
-
// SERVICE PORT VERSION STATE PID UPTIME LATENCY SOURCE
|
|
67
|
-
expect(lines[0]).toMatch(/SERVICE/);
|
|
68
|
-
expect(lines[0]).toMatch(/STATE/);
|
|
69
|
-
expect(lines[0]).not.toMatch(/PROCESS/);
|
|
70
|
-
expect(lines[0]).not.toMatch(/HEALTH/);
|
|
71
|
-
expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
|
|
72
|
-
// Healthy probe rolls up to `active` per design-system.md §6.
|
|
73
|
-
expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
|
|
74
|
-
} finally {
|
|
75
|
-
cleanup();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
43
|
+
const FAKE_HUB_UNIT_DEPS = {
|
|
44
|
+
platform: "linux",
|
|
45
|
+
getuid: () => 1000,
|
|
46
|
+
homeDir: () => "/home/op",
|
|
47
|
+
userName: () => "op",
|
|
48
|
+
which: () => "/usr/bin/systemctl",
|
|
49
|
+
run: () => ({ code: 0, stdout: "", stderr: "" }),
|
|
50
|
+
writeFile: () => {},
|
|
51
|
+
removeFile: () => {},
|
|
52
|
+
readFile: () => undefined,
|
|
53
|
+
exists: () => false,
|
|
54
|
+
probeHealth: async () => true,
|
|
55
|
+
portListening: async () => true,
|
|
56
|
+
sleep: async () => {},
|
|
57
|
+
} as unknown as HubUnitDeps;
|
|
78
58
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
upsertService(
|
|
83
|
-
{
|
|
84
|
-
name: "parachute-vault",
|
|
85
|
-
port: 1940,
|
|
86
|
-
paths: ["/"],
|
|
87
|
-
health: "/health",
|
|
88
|
-
version: "0.2.4",
|
|
89
|
-
lastStartError: {
|
|
90
|
-
error_type: "missing_dependency",
|
|
91
|
-
error_description: "parachute-vault is required ...",
|
|
92
|
-
binary: "parachute-vault",
|
|
93
|
-
install: { generic: "parachute install vault" },
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
path,
|
|
97
|
-
);
|
|
98
|
-
const lines: string[] = [];
|
|
99
|
-
await status({
|
|
100
|
-
manifestPath: path,
|
|
101
|
-
// Probe refuses (service down) — the row is failing, and the
|
|
102
|
-
// start-error note explains why.
|
|
103
|
-
fetchImpl: async () => {
|
|
104
|
-
throw new Error("ECONNREFUSED");
|
|
105
|
-
},
|
|
106
|
-
print: (l) => lines.push(l),
|
|
107
|
-
});
|
|
108
|
-
const out = lines.join("\n");
|
|
109
|
-
expect(out).toMatch(/failed to start: parachute-vault not installed/);
|
|
110
|
-
} finally {
|
|
111
|
-
cleanup();
|
|
112
|
-
}
|
|
113
|
-
});
|
|
59
|
+
function fakeOpenDb(): { close: () => void } {
|
|
60
|
+
return { close: () => {} };
|
|
61
|
+
}
|
|
114
62
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
63
|
+
interface ArmOpts {
|
|
64
|
+
managerState?: HubUnitStateResult;
|
|
65
|
+
hubHealthy?: boolean;
|
|
66
|
+
moduleStates?: ModuleStatesResult;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Drive `status` through the supervised arm with a healthy hub + the given module
|
|
71
|
+
* states. Defaults: manager `active`, hub `/health` OK, no module rows.
|
|
72
|
+
*/
|
|
73
|
+
function supervisorOpts(configDir: string, path: string, o: ArmOpts = {}) {
|
|
74
|
+
return {
|
|
75
|
+
manifestPath: path,
|
|
76
|
+
configDir,
|
|
77
|
+
installSourceDeps: STUB_INSTALL_SOURCE,
|
|
78
|
+
hubSrcDir: "/nonexistent/hub/src",
|
|
79
|
+
supervisor: {
|
|
80
|
+
hubUnitDeps: FAKE_HUB_UNIT_DEPS,
|
|
81
|
+
queryHubUnitState: () => o.managerState ?? { state: "active" as const },
|
|
82
|
+
probeHubHealth: async () => o.hubHealthy ?? true,
|
|
83
|
+
fetchModuleStates: async () => o.moduleStates ?? { supervisorAvailable: true, modules: [] },
|
|
84
|
+
openDb: fakeOpenDb as unknown as (configDir: string) => import("bun:sqlite").Database,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A `running` supervisor snapshot for a short name (the happy-path module row). */
|
|
90
|
+
function runningModule(short: string, version = "0.6.2") {
|
|
91
|
+
return {
|
|
92
|
+
short,
|
|
93
|
+
installed: true,
|
|
94
|
+
installed_version: version,
|
|
95
|
+
supervisor_status: "running" as const,
|
|
96
|
+
pid: 5151,
|
|
97
|
+
supervisor_start_error: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
140
100
|
|
|
141
|
-
|
|
142
|
-
|
|
101
|
+
describe("status — table + hub row", () => {
|
|
102
|
+
test("empty manifest still renders the hub row (a unit-managed hub runs with zero modules)", async () => {
|
|
103
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
143
104
|
try {
|
|
144
|
-
upsertService(
|
|
145
|
-
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
|
|
146
|
-
path,
|
|
147
|
-
);
|
|
148
105
|
const lines: string[] = [];
|
|
149
106
|
const code = await status({
|
|
150
|
-
|
|
151
|
-
fetchImpl: async () => new Response(null, { status: 503 }),
|
|
107
|
+
...supervisorOpts(configDir, path),
|
|
152
108
|
print: (l) => lines.push(l),
|
|
153
109
|
});
|
|
154
|
-
expect(code).toBe(
|
|
155
|
-
|
|
156
|
-
expect(lines.some((l) => l.includes("
|
|
110
|
+
expect(code).toBe(0);
|
|
111
|
+
// The hub row is meaningful even with no modules installed.
|
|
112
|
+
expect(lines.some((l) => l.includes("parachute-hub (internal)"))).toBe(true);
|
|
157
113
|
} finally {
|
|
158
114
|
cleanup();
|
|
159
115
|
}
|
|
160
116
|
});
|
|
161
117
|
|
|
162
|
-
test("
|
|
163
|
-
// Vault's canonical health path `/vault/<name>/health` returns 401
|
|
164
|
-
// without an API key — that's the server replying "I'm up but you
|
|
165
|
-
// need auth," not "I'm down." `parachute status` used to roll 401
|
|
166
|
-
// into the failing bucket via `res.ok`, surfacing "failing" on every
|
|
167
|
-
// fresh install (vault was fine — the probe was just confused).
|
|
168
|
-
// Now: 401 specifically counts as healthy. Other 4xx (404, 400) stay
|
|
169
|
-
// unhealthy — those mean the configured health path is misshapen.
|
|
118
|
+
test("all-running modules return 0 and render the table with versions + state", async () => {
|
|
170
119
|
const { path, configDir, cleanup } = makeTempPath();
|
|
171
120
|
try {
|
|
172
121
|
upsertService(
|
|
173
122
|
{
|
|
174
123
|
name: "parachute-vault",
|
|
175
124
|
port: 1940,
|
|
176
|
-
paths: ["/"],
|
|
125
|
+
paths: ["/vault/default"],
|
|
177
126
|
health: "/vault/default/health",
|
|
178
|
-
version: "0.2
|
|
127
|
+
version: "0.6.2",
|
|
179
128
|
},
|
|
180
129
|
path,
|
|
181
130
|
);
|
|
182
|
-
writePid("vault", 4242, configDir);
|
|
183
|
-
const lines: string[] = [];
|
|
184
|
-
const code = await status({
|
|
185
|
-
manifestPath: path,
|
|
186
|
-
configDir,
|
|
187
|
-
alive: () => true,
|
|
188
|
-
fetchImpl: async () => new Response(null, { status: 401 }),
|
|
189
|
-
print: (l) => lines.push(l),
|
|
190
|
-
});
|
|
191
|
-
expect(code).toBe(0);
|
|
192
|
-
expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
|
|
193
|
-
// No "failing" rollup, no `! probe: http 401` continuation line.
|
|
194
|
-
expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(false);
|
|
195
|
-
expect(lines.some((l) => l.includes("probe: http 401"))).toBe(false);
|
|
196
|
-
} finally {
|
|
197
|
-
cleanup();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("running + healthy probe shows STATE=active, pid + uptime", async () => {
|
|
202
|
-
const { path, configDir, cleanup } = makeTempPath();
|
|
203
|
-
try {
|
|
204
|
-
upsertService(
|
|
205
|
-
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
|
|
206
|
-
path,
|
|
207
|
-
);
|
|
208
|
-
writePid("vault", 4242, configDir);
|
|
209
|
-
const lines: string[] = [];
|
|
210
|
-
const code = await status({
|
|
211
|
-
manifestPath: path,
|
|
212
|
-
configDir,
|
|
213
|
-
alive: () => true,
|
|
214
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
215
|
-
print: (l) => lines.push(l),
|
|
216
|
-
});
|
|
217
|
-
expect(code).toBe(0);
|
|
218
|
-
// Pre-F: STATE was a two-column (PROCESS=running, HEALTH=ok) split.
|
|
219
|
-
// Post-F: collapsed to one column showing `active`.
|
|
220
|
-
expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
|
|
221
|
-
expect(lines.some((l) => l.includes("4242"))).toBe(true);
|
|
222
|
-
// Probe-detail continuation line is suppressed for active rows
|
|
223
|
-
// (the rollup is sufficient — no need to repeat "ok").
|
|
224
|
-
expect(lines.some((l) => l.includes("probe:"))).toBe(false);
|
|
225
|
-
} finally {
|
|
226
|
-
cleanup();
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("known-stopped process renders STATE=inactive, skips probe, exits 0", async () => {
|
|
231
|
-
const { path, configDir, cleanup } = makeTempPath();
|
|
232
|
-
try {
|
|
233
|
-
upsertService(
|
|
234
|
-
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
|
|
235
|
-
path,
|
|
236
|
-
);
|
|
237
|
-
writePid("vault", 4242, configDir);
|
|
238
|
-
let probed = false;
|
|
239
131
|
const lines: string[] = [];
|
|
240
132
|
const code = await status({
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
fetchImpl: async () => {
|
|
245
|
-
probed = true;
|
|
246
|
-
return new Response(null, { status: 200 });
|
|
247
|
-
},
|
|
133
|
+
...supervisorOpts(configDir, path, {
|
|
134
|
+
moduleStates: { supervisorAvailable: true, modules: [runningModule("vault")] },
|
|
135
|
+
}),
|
|
248
136
|
print: (l) => lines.push(l),
|
|
249
137
|
});
|
|
250
138
|
expect(code).toBe(0);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
expect(
|
|
139
|
+
const vaultLine = lines.find((l) => l.includes("parachute-vault"));
|
|
140
|
+
expect(vaultLine).toMatch(/\bactive\b/);
|
|
141
|
+
expect(vaultLine).toMatch(/0\.6\.2/);
|
|
254
142
|
} finally {
|
|
255
143
|
cleanup();
|
|
256
144
|
}
|
|
257
145
|
});
|
|
258
146
|
|
|
259
|
-
test("
|
|
147
|
+
test("persisted lastStartError surfaces on a continuation line", async () => {
|
|
260
148
|
const { path, configDir, cleanup } = makeTempPath();
|
|
261
|
-
try {
|
|
262
|
-
upsertService(
|
|
263
|
-
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
|
|
264
|
-
path,
|
|
265
|
-
);
|
|
266
|
-
let probed = false;
|
|
267
|
-
const code = await status({
|
|
268
|
-
manifestPath: path,
|
|
269
|
-
configDir,
|
|
270
|
-
fetchImpl: async () => {
|
|
271
|
-
probed = true;
|
|
272
|
-
return new Response(null, { status: 200 });
|
|
273
|
-
},
|
|
274
|
-
print: () => {},
|
|
275
|
-
});
|
|
276
|
-
expect(code).toBe(0);
|
|
277
|
-
expect(probed).toBe(true);
|
|
278
|
-
} finally {
|
|
279
|
-
cleanup();
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// URL column: the launch-day pain was a user staring at the table not
|
|
284
|
-
// knowing where to point Claude.ai or curl. Each row gets a " → URL"
|
|
285
|
-
// continuation line so the next step is obvious.
|
|
286
|
-
test("vault row prints MCP URL beneath it (path + /mcp suffix)", async () => {
|
|
287
|
-
const { path, cleanup } = makeTempPath();
|
|
288
149
|
try {
|
|
289
150
|
upsertService(
|
|
290
151
|
{
|
|
@@ -292,519 +153,107 @@ describe("status", () => {
|
|
|
292
153
|
port: 1940,
|
|
293
154
|
paths: ["/vault/default"],
|
|
294
155
|
health: "/vault/default/health",
|
|
295
|
-
version: "0.2
|
|
156
|
+
version: "0.6.2",
|
|
157
|
+
lastStartError: {
|
|
158
|
+
error_type: "missing_dependency",
|
|
159
|
+
error_description: "parachute-vault not installed",
|
|
160
|
+
binary: "parachute-vault",
|
|
161
|
+
},
|
|
296
162
|
},
|
|
297
163
|
path,
|
|
298
164
|
);
|
|
299
165
|
const lines: string[] = [];
|
|
300
|
-
await status({
|
|
301
|
-
|
|
302
|
-
|
|
166
|
+
const code = await status({
|
|
167
|
+
...supervisorOpts(configDir, path, {
|
|
168
|
+
// No live supervisor start-error → falls back to the persisted manifest note.
|
|
169
|
+
moduleStates: {
|
|
170
|
+
supervisorAvailable: true,
|
|
171
|
+
modules: [
|
|
172
|
+
{
|
|
173
|
+
short: "vault",
|
|
174
|
+
installed: true,
|
|
175
|
+
installed_version: "0.6.2",
|
|
176
|
+
supervisor_status: "crashed",
|
|
177
|
+
pid: null,
|
|
178
|
+
supervisor_start_error: null,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
303
183
|
print: (l) => lines.push(l),
|
|
304
184
|
});
|
|
305
|
-
|
|
185
|
+
// crashed → failing → exit 1; the persisted missing-dependency note shows.
|
|
186
|
+
expect(code).toBe(1);
|
|
187
|
+
expect(lines.join("\n")).toMatch(/failed to start: parachute-vault not installed/);
|
|
306
188
|
} finally {
|
|
307
189
|
cleanup();
|
|
308
190
|
}
|
|
309
191
|
});
|
|
192
|
+
});
|
|
310
193
|
|
|
311
|
-
|
|
312
|
-
|
|
194
|
+
describe("status — per-module URL deep-links (manifestRowBase / urlForEntry)", () => {
|
|
195
|
+
async function urlFor(name: string, port: number, paths: string[]): Promise<string> {
|
|
196
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
313
197
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
name: "parachute-scribe",
|
|
317
|
-
port: 1943,
|
|
318
|
-
paths: ["/scribe"],
|
|
319
|
-
health: "/scribe/health",
|
|
320
|
-
version: "0.1.0",
|
|
321
|
-
},
|
|
322
|
-
path,
|
|
323
|
-
);
|
|
198
|
+
const short = name.replace(/^parachute-/, "");
|
|
199
|
+
upsertService({ name, port, paths, health: `${paths[0]}/health`, version: "0.6.2" }, path);
|
|
324
200
|
const lines: string[] = [];
|
|
325
201
|
await status({
|
|
326
|
-
|
|
327
|
-
|
|
202
|
+
...supervisorOpts(configDir, path, {
|
|
203
|
+
moduleStates: { supervisorAvailable: true, modules: [runningModule(short)] },
|
|
204
|
+
}),
|
|
328
205
|
print: (l) => lines.push(l),
|
|
329
206
|
});
|
|
330
|
-
|
|
207
|
+
return lines.join("\n");
|
|
331
208
|
} finally {
|
|
332
209
|
cleanup();
|
|
333
210
|
}
|
|
334
|
-
}
|
|
211
|
+
}
|
|
335
212
|
|
|
336
|
-
test("
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
upsertService(
|
|
340
|
-
{
|
|
341
|
-
name: "parachute-notes",
|
|
342
|
-
port: 1942,
|
|
343
|
-
paths: ["/notes"],
|
|
344
|
-
health: "/notes/health",
|
|
345
|
-
version: "0.0.1",
|
|
346
|
-
},
|
|
347
|
-
path,
|
|
348
|
-
);
|
|
349
|
-
const lines: string[] = [];
|
|
350
|
-
await status({
|
|
351
|
-
manifestPath: path,
|
|
352
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
353
|
-
print: (l) => lines.push(l),
|
|
354
|
-
});
|
|
355
|
-
expect(lines.some((l) => l === " → http://127.0.0.1:1942/notes")).toBe(true);
|
|
356
|
-
} finally {
|
|
357
|
-
cleanup();
|
|
358
|
-
}
|
|
213
|
+
test("vault row prints the MCP URL (path + /mcp suffix)", async () => {
|
|
214
|
+
const out = await urlFor("parachute-vault", 1940, ["/vault/default"]);
|
|
215
|
+
expect(out).toMatch(/\/vault\/default\/mcp/);
|
|
359
216
|
});
|
|
360
217
|
|
|
361
|
-
test("
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
upsertService(
|
|
365
|
-
{
|
|
366
|
-
name: "parachute-channel",
|
|
367
|
-
port: 1941,
|
|
368
|
-
paths: ["/channel"],
|
|
369
|
-
health: "/channel/health",
|
|
370
|
-
version: "0.1.0",
|
|
371
|
-
},
|
|
372
|
-
path,
|
|
373
|
-
);
|
|
374
|
-
const lines: string[] = [];
|
|
375
|
-
await status({
|
|
376
|
-
manifestPath: path,
|
|
377
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
378
|
-
print: (l) => lines.push(l),
|
|
379
|
-
});
|
|
380
|
-
expect(lines.some((l) => l === " → http://127.0.0.1:1941/channel")).toBe(true);
|
|
381
|
-
} finally {
|
|
382
|
-
cleanup();
|
|
383
|
-
}
|
|
218
|
+
test("scribe row prints the root URL (API is at /, ignore path prefix)", async () => {
|
|
219
|
+
const out = await urlFor("parachute-scribe", 3200, ["/scribe"]);
|
|
220
|
+
expect(out).toMatch(/127\.0\.0\.1:3200|localhost:3200/);
|
|
384
221
|
});
|
|
385
222
|
|
|
386
|
-
test("
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
upsertService(
|
|
390
|
-
{
|
|
391
|
-
name: "third-party-thing",
|
|
392
|
-
port: 9000,
|
|
393
|
-
paths: ["/widget"],
|
|
394
|
-
health: "/health",
|
|
395
|
-
version: "1.0.0",
|
|
396
|
-
},
|
|
397
|
-
path,
|
|
398
|
-
);
|
|
399
|
-
const lines: string[] = [];
|
|
400
|
-
await status({
|
|
401
|
-
manifestPath: path,
|
|
402
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
403
|
-
print: (l) => lines.push(l),
|
|
404
|
-
});
|
|
405
|
-
expect(lines.some((l) => l === " → http://127.0.0.1:9000/widget")).toBe(true);
|
|
406
|
-
} finally {
|
|
407
|
-
cleanup();
|
|
408
|
-
}
|
|
223
|
+
test("notes row prints the UI URL (port + /notes mount)", async () => {
|
|
224
|
+
const out = await urlFor("parachute-notes", 5173, ["/notes"]);
|
|
225
|
+
expect(out).toMatch(/:5173\/notes/);
|
|
409
226
|
});
|
|
410
227
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// is operator-visible. Warning, not error — operators may have moved the
|
|
415
|
-
// service deliberately to dodge a third-party clash.
|
|
416
|
-
describe("canonical-port drift warning", () => {
|
|
417
|
-
test("warns when scribe is at non-canonical port (1944 instead of 1943)", async () => {
|
|
418
|
-
const { path, cleanup } = makeTempPath();
|
|
419
|
-
try {
|
|
420
|
-
upsertService(
|
|
421
|
-
{
|
|
422
|
-
name: "parachute-scribe",
|
|
423
|
-
port: 1944,
|
|
424
|
-
paths: ["/scribe"],
|
|
425
|
-
health: "/scribe/health",
|
|
426
|
-
version: "0.4.0",
|
|
427
|
-
},
|
|
428
|
-
path,
|
|
429
|
-
);
|
|
430
|
-
const lines: string[] = [];
|
|
431
|
-
await status({
|
|
432
|
-
manifestPath: path,
|
|
433
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
434
|
-
print: (l) => lines.push(l),
|
|
435
|
-
});
|
|
436
|
-
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
437
|
-
} finally {
|
|
438
|
-
cleanup();
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("does not warn when service is on its canonical port", async () => {
|
|
443
|
-
const { path, cleanup } = makeTempPath();
|
|
444
|
-
try {
|
|
445
|
-
upsertService(
|
|
446
|
-
{
|
|
447
|
-
name: "parachute-scribe",
|
|
448
|
-
port: 1943,
|
|
449
|
-
paths: ["/scribe"],
|
|
450
|
-
health: "/scribe/health",
|
|
451
|
-
version: "0.4.0",
|
|
452
|
-
},
|
|
453
|
-
path,
|
|
454
|
-
);
|
|
455
|
-
const lines: string[] = [];
|
|
456
|
-
await status({
|
|
457
|
-
manifestPath: path,
|
|
458
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
459
|
-
print: (l) => lines.push(l),
|
|
460
|
-
});
|
|
461
|
-
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
462
|
-
} finally {
|
|
463
|
-
cleanup();
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
test("does not warn for third-party services with no canonical port", async () => {
|
|
468
|
-
const { path, cleanup } = makeTempPath();
|
|
469
|
-
try {
|
|
470
|
-
upsertService(
|
|
471
|
-
{
|
|
472
|
-
name: "third-party-thing",
|
|
473
|
-
port: 9000,
|
|
474
|
-
paths: ["/widget"],
|
|
475
|
-
health: "/health",
|
|
476
|
-
version: "1.0.0",
|
|
477
|
-
},
|
|
478
|
-
path,
|
|
479
|
-
);
|
|
480
|
-
const lines: string[] = [];
|
|
481
|
-
await status({
|
|
482
|
-
manifestPath: path,
|
|
483
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
484
|
-
print: (l) => lines.push(l),
|
|
485
|
-
});
|
|
486
|
-
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
487
|
-
} finally {
|
|
488
|
-
cleanup();
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
test("warning does not affect exit code (status stays 0 when healthy)", async () => {
|
|
493
|
-
const { path, cleanup } = makeTempPath();
|
|
494
|
-
try {
|
|
495
|
-
upsertService(
|
|
496
|
-
{
|
|
497
|
-
name: "parachute-scribe",
|
|
498
|
-
port: 1944,
|
|
499
|
-
paths: ["/scribe"],
|
|
500
|
-
health: "/scribe/health",
|
|
501
|
-
version: "0.4.0",
|
|
502
|
-
},
|
|
503
|
-
path,
|
|
504
|
-
);
|
|
505
|
-
const code = await status({
|
|
506
|
-
manifestPath: path,
|
|
507
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
508
|
-
print: () => {},
|
|
509
|
-
});
|
|
510
|
-
// Drift is informational. A healthy probed service still returns 0
|
|
511
|
-
// even when the port has drifted off canonical.
|
|
512
|
-
expect(code).toBe(0);
|
|
513
|
-
} finally {
|
|
514
|
-
cleanup();
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("warning still fires when service is stopped (probe skipped)", async () => {
|
|
519
|
-
const { path, configDir, cleanup } = makeTempPath();
|
|
520
|
-
try {
|
|
521
|
-
upsertService(
|
|
522
|
-
{
|
|
523
|
-
name: "parachute-scribe",
|
|
524
|
-
port: 1944,
|
|
525
|
-
paths: ["/scribe"],
|
|
526
|
-
health: "/scribe/health",
|
|
527
|
-
version: "0.4.0",
|
|
528
|
-
},
|
|
529
|
-
path,
|
|
530
|
-
);
|
|
531
|
-
writePid("scribe", 4242, configDir);
|
|
532
|
-
const lines: string[] = [];
|
|
533
|
-
await status({
|
|
534
|
-
manifestPath: path,
|
|
535
|
-
configDir,
|
|
536
|
-
alive: () => false,
|
|
537
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
538
|
-
print: (l) => lines.push(l),
|
|
539
|
-
});
|
|
540
|
-
// Drift is computed from services.json, not from the probe — a
|
|
541
|
-
// stopped service with a drifted port should still surface the
|
|
542
|
-
// warning so operators see the miswire even before they start it.
|
|
543
|
-
expect(lines.some((l) => l.includes("canonical port is 1943"))).toBe(true);
|
|
544
|
-
} finally {
|
|
545
|
-
cleanup();
|
|
546
|
-
}
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
test("multi-vault instance rows do not surface a drift warning (intentional gap)", async () => {
|
|
550
|
-
// Pinning the documented gap: `parachute-vault-default` is not
|
|
551
|
-
// a canonical manifest name in FIRST_PARTY_FALLBACKS, so
|
|
552
|
-
// `canonicalPortForManifest` returns undefined and no drift
|
|
553
|
-
// warning fires — even when the row's port differs from the
|
|
554
|
-
// canonical `parachute-vault` port (1940). Rationale lives on
|
|
555
|
-
// `canonicalPortForManifest` in service-spec.ts; this test pins
|
|
556
|
-
// the behavior so a future change to the lookup shape doesn't
|
|
557
|
-
// accidentally start emitting drift on every multi-vault row
|
|
558
|
-
// without an explicit decision.
|
|
559
|
-
const { path, cleanup } = makeTempPath();
|
|
560
|
-
try {
|
|
561
|
-
upsertService(
|
|
562
|
-
{
|
|
563
|
-
name: "parachute-vault-default",
|
|
564
|
-
port: 1944,
|
|
565
|
-
paths: ["/vault/default"],
|
|
566
|
-
health: "/vault/default/health",
|
|
567
|
-
version: "0.2.4",
|
|
568
|
-
},
|
|
569
|
-
path,
|
|
570
|
-
);
|
|
571
|
-
const lines: string[] = [];
|
|
572
|
-
await status({
|
|
573
|
-
manifestPath: path,
|
|
574
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
575
|
-
print: (l) => lines.push(l),
|
|
576
|
-
});
|
|
577
|
-
expect(lines.some((l) => l.includes("canonical port"))).toBe(false);
|
|
578
|
-
} finally {
|
|
579
|
-
cleanup();
|
|
580
|
-
}
|
|
581
|
-
});
|
|
228
|
+
test("channel row prints port + /channel mount", async () => {
|
|
229
|
+
const out = await urlFor("parachute-channel", 1943, ["/channel"]);
|
|
230
|
+
expect(out).toMatch(/:1943\/channel/);
|
|
582
231
|
});
|
|
583
232
|
|
|
584
|
-
test("
|
|
233
|
+
test("unknown third-party service falls back to bare host:port + paths[0]", async () => {
|
|
585
234
|
const { path, configDir, cleanup } = makeTempPath();
|
|
586
235
|
try {
|
|
587
236
|
upsertService(
|
|
588
237
|
{
|
|
589
|
-
name: "
|
|
590
|
-
port:
|
|
591
|
-
paths: ["/
|
|
592
|
-
health: "/
|
|
593
|
-
version: "0.
|
|
238
|
+
name: "acme-widget",
|
|
239
|
+
port: 4321,
|
|
240
|
+
paths: ["/widget"],
|
|
241
|
+
health: "/widget/health",
|
|
242
|
+
version: "1.0.0",
|
|
243
|
+
installDir: "/tmp/acme",
|
|
594
244
|
},
|
|
595
245
|
path,
|
|
596
246
|
);
|
|
597
|
-
writePid("vault", 4242, configDir);
|
|
598
247
|
const lines: string[] = [];
|
|
599
248
|
await status({
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
249
|
+
...supervisorOpts(configDir, path, {
|
|
250
|
+
moduleStates: { supervisorAvailable: true, modules: [runningModule("acme-widget")] },
|
|
251
|
+
}),
|
|
604
252
|
print: (l) => lines.push(l),
|
|
605
253
|
});
|
|
606
|
-
expect(lines.
|
|
254
|
+
expect(lines.join("\n")).toMatch(/:4321\/widget/);
|
|
607
255
|
} finally {
|
|
608
256
|
cleanup();
|
|
609
257
|
}
|
|
610
258
|
});
|
|
611
|
-
|
|
612
|
-
describe("install-source surface (hub#243)", () => {
|
|
613
|
-
test("renders SOURCE column header + per-row label", async () => {
|
|
614
|
-
const { path, cleanup } = makeTempPath();
|
|
615
|
-
try {
|
|
616
|
-
upsertService(
|
|
617
|
-
{
|
|
618
|
-
name: "parachute-vault",
|
|
619
|
-
port: 1940,
|
|
620
|
-
paths: ["/vault/default"],
|
|
621
|
-
health: "/vault/default/health",
|
|
622
|
-
version: "0.4.4-rc.3",
|
|
623
|
-
installDir: "/Users/me/code/parachute-vault",
|
|
624
|
-
},
|
|
625
|
-
path,
|
|
626
|
-
);
|
|
627
|
-
const lines: string[] = [];
|
|
628
|
-
await status({
|
|
629
|
-
manifestPath: path,
|
|
630
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
631
|
-
print: (l) => lines.push(l),
|
|
632
|
-
installSourceDeps: {
|
|
633
|
-
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
634
|
-
resolveBunGlobal: () => null,
|
|
635
|
-
readJson: (p) =>
|
|
636
|
-
p === "/Users/me/code/parachute-vault/package.json"
|
|
637
|
-
? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
|
|
638
|
-
: (() => {
|
|
639
|
-
throw new Error("nope");
|
|
640
|
-
})(),
|
|
641
|
-
readGitHead: () => "8aa167b",
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
expect(lines[0]).toMatch(/SOURCE/);
|
|
645
|
-
expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
|
|
646
|
-
} finally {
|
|
647
|
-
cleanup();
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("STALE continuation line fires when bun-linked live version != cached version", async () => {
|
|
652
|
-
// Reproduces hub#243's motivating case: services.json says 0.3.11-rc.1
|
|
653
|
-
// but the live source has been rebuilt to 0.3.15-rc.1. Operator should
|
|
654
|
-
// see STALE in one glance from `parachute status` output.
|
|
655
|
-
const { path, cleanup } = makeTempPath();
|
|
656
|
-
try {
|
|
657
|
-
upsertService(
|
|
658
|
-
{
|
|
659
|
-
name: "parachute-notes",
|
|
660
|
-
port: 1942,
|
|
661
|
-
paths: ["/notes"],
|
|
662
|
-
health: "/notes/health",
|
|
663
|
-
version: "0.3.11-rc.1",
|
|
664
|
-
installDir: "/Users/me/code/parachute-notes",
|
|
665
|
-
},
|
|
666
|
-
path,
|
|
667
|
-
);
|
|
668
|
-
const lines: string[] = [];
|
|
669
|
-
await status({
|
|
670
|
-
manifestPath: path,
|
|
671
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
672
|
-
print: (l) => lines.push(l),
|
|
673
|
-
installSourceDeps: {
|
|
674
|
-
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
675
|
-
resolveBunGlobal: () => null,
|
|
676
|
-
readJson: (p) =>
|
|
677
|
-
p === "/Users/me/code/parachute-notes/package.json"
|
|
678
|
-
? { name: "@openparachute/notes", version: "0.3.15-rc.1" }
|
|
679
|
-
: (() => {
|
|
680
|
-
throw new Error("nope");
|
|
681
|
-
})(),
|
|
682
|
-
readGitHead: () => "051c404",
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
expect(
|
|
686
|
-
lines.some((l) =>
|
|
687
|
-
l.includes("STALE: services.json cached 0.3.11-rc.1; live package.json 0.3.15-rc.1"),
|
|
688
|
-
),
|
|
689
|
-
).toBe(true);
|
|
690
|
-
} finally {
|
|
691
|
-
cleanup();
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
test("npm-installed services render as `npm (<version>)` and never STALE", async () => {
|
|
696
|
-
const { path, cleanup } = makeTempPath();
|
|
697
|
-
try {
|
|
698
|
-
upsertService(
|
|
699
|
-
{
|
|
700
|
-
name: "parachute-scribe",
|
|
701
|
-
port: 1943,
|
|
702
|
-
paths: ["/scribe"],
|
|
703
|
-
health: "/scribe/health",
|
|
704
|
-
version: "0.4.2-rc.1",
|
|
705
|
-
installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
|
|
706
|
-
},
|
|
707
|
-
path,
|
|
708
|
-
);
|
|
709
|
-
const lines: string[] = [];
|
|
710
|
-
await status({
|
|
711
|
-
manifestPath: path,
|
|
712
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
713
|
-
print: (l) => lines.push(l),
|
|
714
|
-
installSourceDeps: {
|
|
715
|
-
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
716
|
-
resolveBunGlobal: () => null,
|
|
717
|
-
readJson: (p) =>
|
|
718
|
-
p === "/home/test/.bun/install/global/node_modules/@openparachute/scribe/package.json"
|
|
719
|
-
? { name: "@openparachute/scribe", version: "0.4.2-rc.1" }
|
|
720
|
-
: (() => {
|
|
721
|
-
throw new Error("nope");
|
|
722
|
-
})(),
|
|
723
|
-
readGitHead: () => undefined,
|
|
724
|
-
},
|
|
725
|
-
});
|
|
726
|
-
expect(lines.some((l) => l.includes("npm (0.4.2-rc.1)"))).toBe(true);
|
|
727
|
-
expect(lines.some((l) => l.includes("STALE:"))).toBe(false);
|
|
728
|
-
} finally {
|
|
729
|
-
cleanup();
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test("entries without installDir fall back to bun-global symlink lookup", async () => {
|
|
734
|
-
// Some services.json entries (older first-party rows, or rows written
|
|
735
|
-
// by a service that doesn't echo installDir) leave the field absent.
|
|
736
|
-
// detectInstallSource maps the entry name → first-party package and
|
|
737
|
-
// probes bun globals for the symlink. Pins that fallback path.
|
|
738
|
-
const { path, cleanup } = makeTempPath();
|
|
739
|
-
try {
|
|
740
|
-
upsertService(
|
|
741
|
-
{
|
|
742
|
-
name: "parachute-vault",
|
|
743
|
-
port: 1940,
|
|
744
|
-
paths: ["/vault/default"],
|
|
745
|
-
health: "/vault/default/health",
|
|
746
|
-
version: "0.4.4-rc.3",
|
|
747
|
-
// No installDir.
|
|
748
|
-
},
|
|
749
|
-
path,
|
|
750
|
-
);
|
|
751
|
-
const lines: string[] = [];
|
|
752
|
-
await status({
|
|
753
|
-
manifestPath: path,
|
|
754
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
755
|
-
print: (l) => lines.push(l),
|
|
756
|
-
installSourceDeps: {
|
|
757
|
-
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
758
|
-
resolveBunGlobal: (pkg) =>
|
|
759
|
-
pkg === "@openparachute/vault" ? "/Users/me/code/parachute-vault" : null,
|
|
760
|
-
readJson: (p) =>
|
|
761
|
-
p === "/Users/me/code/parachute-vault/package.json"
|
|
762
|
-
? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
|
|
763
|
-
: (() => {
|
|
764
|
-
throw new Error("nope");
|
|
765
|
-
})(),
|
|
766
|
-
readGitHead: () => "8aa167b",
|
|
767
|
-
},
|
|
768
|
-
});
|
|
769
|
-
expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
|
|
770
|
-
} finally {
|
|
771
|
-
cleanup();
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
test("third-party row without installDir + no mapping renders as 'unknown'", async () => {
|
|
776
|
-
const { path, cleanup } = makeTempPath();
|
|
777
|
-
try {
|
|
778
|
-
upsertService(
|
|
779
|
-
{
|
|
780
|
-
name: "someapp",
|
|
781
|
-
port: 1946,
|
|
782
|
-
paths: ["/someapp"],
|
|
783
|
-
health: "/someapp/health",
|
|
784
|
-
version: "0.1.4-rc.1",
|
|
785
|
-
// No installDir; someapp isn't in FIRST_PARTY_FALLBACKS by short name,
|
|
786
|
-
// and the fallback bun-global lookup needs a known package name.
|
|
787
|
-
},
|
|
788
|
-
path,
|
|
789
|
-
);
|
|
790
|
-
const lines: string[] = [];
|
|
791
|
-
await status({
|
|
792
|
-
manifestPath: path,
|
|
793
|
-
fetchImpl: async () => new Response(null, { status: 200 }),
|
|
794
|
-
print: (l) => lines.push(l),
|
|
795
|
-
installSourceDeps: {
|
|
796
|
-
bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
|
|
797
|
-
resolveBunGlobal: () => null,
|
|
798
|
-
readJson: () => {
|
|
799
|
-
throw new Error("not reached");
|
|
800
|
-
},
|
|
801
|
-
readGitHead: () => undefined,
|
|
802
|
-
},
|
|
803
|
-
});
|
|
804
|
-
expect(lines.some((l) => l.includes("unknown"))).toBe(true);
|
|
805
|
-
} finally {
|
|
806
|
-
cleanup();
|
|
807
|
-
}
|
|
808
|
-
});
|
|
809
|
-
});
|
|
810
259
|
});
|