@openparachute/hub 0.7.3-rc.6 → 0.7.3-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/api-modules.ts +47 -13
- package/src/commands/init.ts +10 -2
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/module-manifest.ts +23 -9
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/{index-CKv8qCCQ.js → index-DZzX_Enf.js} +10 -10
- package/web/ui/dist/index.html +2 -2
package/package.json
CHANGED
|
@@ -219,9 +219,11 @@ describe("GET /api/modules", () => {
|
|
|
219
219
|
// yet. Post-2026-06-09 (modular-UI architecture, P2) discovery is driven
|
|
220
220
|
// by the UNION of the bootstrap registries (KNOWN_MODULES ∪
|
|
221
221
|
// FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
|
|
222
|
-
// surfaces — core (vault/scribe/surface) in the headline tier,
|
|
223
|
-
//
|
|
224
|
-
//
|
|
222
|
+
// surfaces — core (vault/scribe/surface) in the headline tier, agent as
|
|
223
|
+
// `experimental`, and notes/runner as `deprecated` (2026-06-25, still
|
|
224
|
+
// resolvable but not offered for fresh installs) — so the agent-not-installed
|
|
225
|
+
// class (running but invisible) can't recur while deprecated modules stop
|
|
226
|
+
// being pushed on a fresh box.
|
|
225
227
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
226
228
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
227
229
|
db: h.db,
|
|
@@ -233,8 +235,9 @@ describe("GET /api/modules", () => {
|
|
|
233
235
|
const body = (await res.json()) as {
|
|
234
236
|
modules: Array<{
|
|
235
237
|
short: string;
|
|
236
|
-
focus: "core" | "experimental";
|
|
238
|
+
focus: "core" | "experimental" | "deprecated";
|
|
237
239
|
available: boolean;
|
|
240
|
+
available_to_install: boolean;
|
|
238
241
|
installed: boolean;
|
|
239
242
|
latest_version: string | null;
|
|
240
243
|
}>;
|
|
@@ -242,12 +245,14 @@ describe("GET /api/modules", () => {
|
|
|
242
245
|
};
|
|
243
246
|
const shorts = body.modules.map((m) => m.short);
|
|
244
247
|
// The core tier leads, in the recommended install order (vault → scribe),
|
|
245
|
-
// ahead of every experimental module.
|
|
248
|
+
// ahead of every experimental module, which lead the deprecated ones.
|
|
246
249
|
expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
|
|
247
250
|
expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
// agent (experimental) sorts ahead of notes/runner (deprecated).
|
|
252
|
+
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("runner"));
|
|
253
|
+
expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
|
|
254
|
+
// Every known module is discoverable — vault/scribe/surface (core),
|
|
255
|
+
// agent (experimental), notes/runner (deprecated).
|
|
251
256
|
for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
|
|
252
257
|
expect(shorts).toContain(s);
|
|
253
258
|
}
|
|
@@ -257,15 +262,65 @@ describe("GET /api/modules", () => {
|
|
|
257
262
|
expect(byShort.get("scribe")?.focus).toBe("core");
|
|
258
263
|
expect(byShort.get("surface")?.focus).toBe("core");
|
|
259
264
|
expect(byShort.get("agent")?.focus).toBe("experimental");
|
|
260
|
-
expect(byShort.get("runner")?.focus).toBe("
|
|
261
|
-
expect(byShort.get("notes")?.focus).toBe("
|
|
265
|
+
expect(byShort.get("runner")?.focus).toBe("deprecated");
|
|
266
|
+
expect(byShort.get("notes")?.focus).toBe("deprecated");
|
|
267
|
+
// `available` stays true for every known module (re-installable), but the
|
|
268
|
+
// fresh-install OFFER (`available_to_install`) drops the deprecated tier —
|
|
269
|
+
// notes/runner aren't pushed on a fresh box; agent (experimental) still is.
|
|
262
270
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
271
|
+
expect(byShort.get("vault")?.available_to_install).toBe(true);
|
|
272
|
+
expect(byShort.get("scribe")?.available_to_install).toBe(true);
|
|
273
|
+
expect(byShort.get("surface")?.available_to_install).toBe(true);
|
|
274
|
+
expect(byShort.get("agent")?.available_to_install).toBe(true);
|
|
275
|
+
expect(byShort.get("runner")?.available_to_install).toBe(false);
|
|
276
|
+
expect(byShort.get("notes")?.available_to_install).toBe(false);
|
|
263
277
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
264
278
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
265
279
|
// Supervisor wasn't injected → flag reflects that.
|
|
266
280
|
expect(body.supervisor_available).toBe(false);
|
|
267
281
|
});
|
|
268
282
|
|
|
283
|
+
test("an installed deprecated module (runner) still surfaces for management but is not offered for fresh install (2026-06-25)", async () => {
|
|
284
|
+
// A legacy operator with runner on disk: the row must remain visible
|
|
285
|
+
// (installed: true) + manageable, in the `deprecated` tier — but
|
|
286
|
+
// `available_to_install` is false so the SPA's install catalog won't push
|
|
287
|
+
// it. Mirrors the notes-daemon back-compat posture.
|
|
288
|
+
writeManifest(h.manifestPath, [
|
|
289
|
+
{
|
|
290
|
+
name: "parachute-runner",
|
|
291
|
+
port: 1945,
|
|
292
|
+
paths: ["/runner", "/.parachute"],
|
|
293
|
+
health: "/runner/healthz",
|
|
294
|
+
version: "0.2.0",
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
298
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
299
|
+
db: h.db,
|
|
300
|
+
issuer: ISSUER,
|
|
301
|
+
manifestPath: h.manifestPath,
|
|
302
|
+
fetchLatestVersion: async () => null,
|
|
303
|
+
});
|
|
304
|
+
const body = (await res.json()) as {
|
|
305
|
+
modules: Array<{
|
|
306
|
+
short: string;
|
|
307
|
+
focus: "core" | "experimental" | "deprecated";
|
|
308
|
+
installed: boolean;
|
|
309
|
+
installed_version: string | null;
|
|
310
|
+
available: boolean;
|
|
311
|
+
available_to_install: boolean;
|
|
312
|
+
}>;
|
|
313
|
+
};
|
|
314
|
+
const runner = body.modules.find((m) => m.short === "runner");
|
|
315
|
+
expect(runner).toBeDefined();
|
|
316
|
+
expect(runner?.installed).toBe(true);
|
|
317
|
+
expect(runner?.installed_version).toBe("0.2.0");
|
|
318
|
+
expect(runner?.focus).toBe("deprecated");
|
|
319
|
+
// Still hub-installable (re-install path) but NOT offered fresh.
|
|
320
|
+
expect(runner?.available).toBe(true);
|
|
321
|
+
expect(runner?.available_to_install).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
269
324
|
test("scribe row carries package + display props from KNOWN_MODULES", async () => {
|
|
270
325
|
// Spot-check the wire shape resolves scribe-specific fields
|
|
271
326
|
// (package, displayName, tagline) from KNOWN_MODULES rather than a
|
|
@@ -277,11 +277,13 @@ describe("validateModuleManifest", () => {
|
|
|
277
277
|
// --- 2026-06-09 modular-UI architecture P1 fields: focus / configUiUrl /
|
|
278
278
|
// adminCapabilities / events / actions. All optional + additive. ---
|
|
279
279
|
|
|
280
|
-
test("focus accepts core / experimental and rejects anything else", () => {
|
|
280
|
+
test("focus accepts core / experimental / deprecated and rejects anything else", () => {
|
|
281
281
|
expect(validateModuleManifest({ ...VALID, focus: "core" }, "x").focus).toBe("core");
|
|
282
282
|
expect(validateModuleManifest({ ...VALID, focus: "experimental" }, "x").focus).toBe(
|
|
283
283
|
"experimental",
|
|
284
284
|
);
|
|
285
|
+
// `deprecated` tier (2026-06-25) — a module can self-declare it.
|
|
286
|
+
expect(validateModuleManifest({ ...VALID, focus: "deprecated" }, "x").focus).toBe("deprecated");
|
|
285
287
|
// Absent stays absent (hub falls back to its default map downstream).
|
|
286
288
|
expect(validateModuleManifest(VALID, "x").focus).toBeUndefined();
|
|
287
289
|
expect(() => validateModuleManifest({ ...VALID, focus: "headline" }, "x")).toThrow(/focus/);
|
|
@@ -3,10 +3,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
MIN_RAM_MIB,
|
|
6
7
|
SCRIBE_DEFAULT_PROVIDER,
|
|
7
8
|
SCRIBE_PROVIDERS,
|
|
8
9
|
apiKeyEnvFor,
|
|
10
|
+
clearScribeProvider,
|
|
11
|
+
decideLocalProvider,
|
|
9
12
|
isKnownScribeProvider,
|
|
13
|
+
platformLocalProvider,
|
|
14
|
+
readAvailableRamMib,
|
|
10
15
|
readScribeProviderState,
|
|
11
16
|
scribeConfigPath,
|
|
12
17
|
scribeEnvPath,
|
|
@@ -41,6 +46,118 @@ describe("provider catalog", () => {
|
|
|
41
46
|
});
|
|
42
47
|
});
|
|
43
48
|
|
|
49
|
+
describe("platformLocalProvider — the Linux 'local' trap fix", () => {
|
|
50
|
+
test("macOS → parakeet-mlx", () => {
|
|
51
|
+
expect(platformLocalProvider("darwin")).toBe("parakeet-mlx");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Linux → onnx-asr (NOT the macOS-only parakeet-mlx)", () => {
|
|
55
|
+
expect(platformLocalProvider("linux")).toBe("onnx-asr");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("unsupported platform → null (steer to cloud)", () => {
|
|
59
|
+
expect(platformLocalProvider("win32")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("readAvailableRamMib", () => {
|
|
64
|
+
test("non-Linux returns a positive number (totalmem fallback) or null", () => {
|
|
65
|
+
const ram = readAvailableRamMib("darwin");
|
|
66
|
+
// On any real CI/dev box totalmem is well-defined + positive.
|
|
67
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Linux path reads /proc/meminfo (or null when unreadable)", () => {
|
|
71
|
+
const ram = readAvailableRamMib("linux");
|
|
72
|
+
// On macOS CI there's no /proc/meminfo → null; on Linux CI a positive MiB.
|
|
73
|
+
expect(ram === null || (typeof ram === "number" && ram > 0)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("decideLocalProvider — the RAM/platform gate", () => {
|
|
78
|
+
test("Linux with ample RAM → ok, onnx-asr", () => {
|
|
79
|
+
const d = decideLocalProvider("linux", 4096);
|
|
80
|
+
expect(d.ok).toBe(true);
|
|
81
|
+
expect(d.provider).toBe("onnx-asr");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("macOS with ample RAM → ok, parakeet-mlx", () => {
|
|
85
|
+
const d = decideLocalProvider("darwin", 16384);
|
|
86
|
+
expect(d.ok).toBe(true);
|
|
87
|
+
expect(d.provider).toBe("parakeet-mlx");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("below the RAM floor → refused, steers to groq, carries a reason", () => {
|
|
91
|
+
const d = decideLocalProvider("linux", MIN_RAM_MIB - 1);
|
|
92
|
+
expect(d.ok).toBe(false);
|
|
93
|
+
expect(d.steerTo).toBe("groq");
|
|
94
|
+
expect(d.reason).toContain(String(MIN_RAM_MIB));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("exactly at the floor is OK (>= floor)", () => {
|
|
98
|
+
expect(decideLocalProvider("linux", MIN_RAM_MIB).ok).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("unknown RAM (null) does not refuse on a supported platform", () => {
|
|
102
|
+
expect(decideLocalProvider("linux", null).ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unsupported platform → refused regardless of RAM, steers to groq", () => {
|
|
106
|
+
const d = decideLocalProvider("win32", 99999);
|
|
107
|
+
expect(d.ok).toBe(false);
|
|
108
|
+
expect(d.steerTo).toBe("groq");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("clearScribeProvider", () => {
|
|
113
|
+
test("removes transcribe.provider, preserving other keys", () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
117
|
+
writeFileSync(
|
|
118
|
+
scribeConfigPath(h.dir),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
transcribe: { provider: "onnx-asr", language: "en" },
|
|
121
|
+
auth: { required_token: "keep" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
clearScribeProvider(h.dir);
|
|
125
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
126
|
+
expect(parsed.transcribe).toEqual({ language: "en" });
|
|
127
|
+
expect(parsed.auth).toEqual({ required_token: "keep" });
|
|
128
|
+
} finally {
|
|
129
|
+
h.cleanup();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("drops the transcribe block entirely when provider was its only key", () => {
|
|
134
|
+
const h = makeHarness();
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(join(h.dir, "scribe"), { recursive: true });
|
|
137
|
+
writeFileSync(
|
|
138
|
+
scribeConfigPath(h.dir),
|
|
139
|
+
JSON.stringify({ transcribe: { provider: "onnx-asr" }, auth: { required_token: "x" } }),
|
|
140
|
+
);
|
|
141
|
+
clearScribeProvider(h.dir);
|
|
142
|
+
const parsed = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
|
|
143
|
+
expect(parsed.transcribe).toBeUndefined();
|
|
144
|
+
expect(parsed.auth).toEqual({ required_token: "x" });
|
|
145
|
+
} finally {
|
|
146
|
+
h.cleanup();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no-op when the file is absent", () => {
|
|
151
|
+
const h = makeHarness();
|
|
152
|
+
try {
|
|
153
|
+
// No file written.
|
|
154
|
+
expect(() => clearScribeProvider(h.dir)).not.toThrow();
|
|
155
|
+
} finally {
|
|
156
|
+
h.cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
44
161
|
describe("readScribeProviderState", () => {
|
|
45
162
|
test("missing file: configExists false, no provider", () => {
|
|
46
163
|
const h = makeHarness();
|
|
@@ -53,14 +53,34 @@ describe("focusForShort", () => {
|
|
|
53
53
|
expect(focusForShort("vault")).toBe("core");
|
|
54
54
|
expect(focusForShort("scribe")).toBe("core");
|
|
55
55
|
expect(focusForShort("surface")).toBe("core");
|
|
56
|
+
// agent stays a legit experimental preview — still offered on a fresh install.
|
|
56
57
|
expect(focusForShort("agent")).toBe("experimental");
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// notes (notes-daemon, deprecated 2026-05-22) + runner (per Aaron
|
|
59
|
+
// 2026-06-25, not for new installs) are `deprecated`: still resolvable +
|
|
60
|
+
// shown-if-installed, but NOT offered on a fresh setup.
|
|
61
|
+
expect(focusForShort("runner")).toBe("deprecated");
|
|
62
|
+
expect(focusForShort("notes")).toBe("deprecated");
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
test("unlisted shorts default to experimental", () => {
|
|
62
66
|
expect(focusForShort("some-third-party-module")).toBe("experimental");
|
|
63
67
|
});
|
|
68
|
+
|
|
69
|
+
test("a declared deprecated focus is honored over the default map", () => {
|
|
70
|
+
// A module can self-declare `deprecated` in its module.json.
|
|
71
|
+
expect(focusForShort("agent", "deprecated")).toBe("deprecated");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("deprecated shorts stay resolvable (discoverable) — back-compat for existing installs", () => {
|
|
75
|
+
// The deprecated tier de-emphasizes + drops the fresh-install OFFER; it does
|
|
76
|
+
// NOT remove the short from the resolution surface, so an existing
|
|
77
|
+
// notes/runner install keeps routing + lifecycle.
|
|
78
|
+
const shorts = discoverableShorts();
|
|
79
|
+
expect(shorts).toContain("notes");
|
|
80
|
+
expect(shorts).toContain("runner");
|
|
81
|
+
expect(isKnownModuleShort("notes")).toBe(true);
|
|
82
|
+
expect(isKnownModuleShort("runner")).toBe(true);
|
|
83
|
+
});
|
|
64
84
|
});
|
|
65
85
|
|
|
66
86
|
describe("isKnownModuleShort", () => {
|
|
@@ -1825,6 +1825,24 @@ describe("handleSetupVaultPost", () => {
|
|
|
1825
1825
|
expect(cfg?.cleanup).toEqual({ provider: "anthropic", default: true });
|
|
1826
1826
|
expect(cfg?.cleanupProviders).toEqual({ anthropic: { apiKey: "sk-ant-cleanup-key" } });
|
|
1827
1827
|
});
|
|
1828
|
+
|
|
1829
|
+
test("scribe transcribe=local resolves to the host platform's backend (the Linux trap fix)", async () => {
|
|
1830
|
+
// The bug: `local` mapped UNCONDITIONALLY to parakeet-mlx (macOS-only),
|
|
1831
|
+
// silently broken on every Linux box. After the fix it resolves to the
|
|
1832
|
+
// platform backend. We assert against the actual host so it's correct on
|
|
1833
|
+
// both Mac (parakeet-mlx) and Linux CI (onnx-asr). The RAM gate only kicks
|
|
1834
|
+
// in below 2 GB — CI/dev boxes clear it, so the choice stays `local`.
|
|
1835
|
+
const { platformLocalProvider } = await import("../scribe-config.ts");
|
|
1836
|
+
const expected = platformLocalProvider(process.platform);
|
|
1837
|
+
const { response } = await postVaultWithFields(h, { scribe_provider: "local" });
|
|
1838
|
+
expect(response.status).toBe(303);
|
|
1839
|
+
const cfg = readScribeConfig(h.dir);
|
|
1840
|
+
if (expected !== null) {
|
|
1841
|
+
// On a supported platform with enough RAM, `local` resolves to the
|
|
1842
|
+
// platform backend — NOT a hardcoded parakeet-mlx on Linux.
|
|
1843
|
+
expect(cfg?.transcribe).toEqual({ provider: expected });
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1828
1846
|
});
|
|
1829
1847
|
|
|
1830
1848
|
// --- end-to-end through hubFetch -----------------------------------------
|
|
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { InstallOpts } from "../commands/install.ts";
|
|
6
|
-
import { parseServicePicks, setup } from "../commands/setup.ts";
|
|
6
|
+
import { isOfferable, parseServicePicks, setup } from "../commands/setup.ts";
|
|
7
7
|
import { upsertService } from "../services-manifest.ts";
|
|
8
8
|
|
|
9
9
|
interface InstallCall {
|
|
@@ -110,6 +110,26 @@ describe("parseServicePicks", () => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
describe("isOfferable (fresh-install OFFER, 2026-06-25)", () => {
|
|
114
|
+
test("offers an uninstalled core/experimental module", () => {
|
|
115
|
+
expect(isOfferable({ short: "vault", installed: false })).toBe(true);
|
|
116
|
+
expect(isOfferable({ short: "scribe", installed: false })).toBe(true);
|
|
117
|
+
expect(isOfferable({ short: "surface", installed: false })).toBe(true);
|
|
118
|
+
// agent stays a legit experimental preview — still offered.
|
|
119
|
+
expect(isOfferable({ short: "agent", installed: false })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("does NOT offer a deprecated module (notes / runner) on a fresh install", () => {
|
|
123
|
+
expect(isOfferable({ short: "notes", installed: false })).toBe(false);
|
|
124
|
+
expect(isOfferable({ short: "runner", installed: false })).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("never offers an already-installed module regardless of tier", () => {
|
|
128
|
+
expect(isOfferable({ short: "vault", installed: true })).toBe(false);
|
|
129
|
+
expect(isOfferable({ short: "notes", installed: true })).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
113
133
|
describe("setup", () => {
|
|
114
134
|
test("exits 0 with friendly note when every known service is installed", async () => {
|
|
115
135
|
const h = makeHarness();
|
|
@@ -155,6 +175,96 @@ describe("setup", () => {
|
|
|
155
175
|
}
|
|
156
176
|
});
|
|
157
177
|
|
|
178
|
+
test("fresh box: the offered 'Available to install' list excludes deprecated notes/runner (2026-06-25)", async () => {
|
|
179
|
+
const h = makeHarness();
|
|
180
|
+
try {
|
|
181
|
+
// 'all' picks every OFFERED service. With a clean services.json the survey
|
|
182
|
+
// sees every known short; the offered filter must drop notes + runner
|
|
183
|
+
// (deprecated) while keeping vault/scribe/surface/agent. Only vault +
|
|
184
|
+
// scribe have pre-install follow-up prompts (vault name, scribe provider);
|
|
185
|
+
// surface + agent have none — so the scripted answers below are complete.
|
|
186
|
+
const availability = scriptedAvailability([
|
|
187
|
+
"all", // pick everything offered
|
|
188
|
+
"default", // vault name (vault is in the offered set)
|
|
189
|
+
"1", // scribe provider
|
|
190
|
+
]);
|
|
191
|
+
const code = await setup({
|
|
192
|
+
manifestPath: h.manifestPath,
|
|
193
|
+
configDir: h.configDir,
|
|
194
|
+
log: (l) => h.logs.push(l),
|
|
195
|
+
availability,
|
|
196
|
+
installFn: async (short, opts) => {
|
|
197
|
+
h.calls.push({ short, opts });
|
|
198
|
+
return 0;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
expect(code).toBe(0);
|
|
202
|
+
// The "Available to install" banner must NOT list notes / runner.
|
|
203
|
+
const joined = h.logs.join("\n");
|
|
204
|
+
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
205
|
+
expect(availableBlock).not.toMatch(/\bnotes\b/);
|
|
206
|
+
expect(availableBlock).not.toMatch(/\brunner\b/);
|
|
207
|
+
// …and `install()` is never invoked for the deprecated shorts.
|
|
208
|
+
const installedShorts = h.calls.map((c) => c.short);
|
|
209
|
+
expect(installedShorts).not.toContain("notes");
|
|
210
|
+
expect(installedShorts).not.toContain("runner");
|
|
211
|
+
// The non-deprecated set is still offered + installed.
|
|
212
|
+
expect(installedShorts).toContain("vault");
|
|
213
|
+
expect(installedShorts).toContain("scribe");
|
|
214
|
+
expect(installedShorts).toContain("surface");
|
|
215
|
+
expect(installedShorts).toContain("agent");
|
|
216
|
+
} finally {
|
|
217
|
+
h.cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("an already-installed deprecated module still shows in 'Already installed' + isn't re-offered (back-compat)", async () => {
|
|
222
|
+
const h = makeHarness();
|
|
223
|
+
try {
|
|
224
|
+
// Legacy operator with runner (deprecated) on disk. It must surface in the
|
|
225
|
+
// "Already installed" banner (so they know it's there + can manage it via
|
|
226
|
+
// `parachute <verb> runner`), and must NOT reappear in the fresh-install
|
|
227
|
+
// OFFER list.
|
|
228
|
+
upsertService(
|
|
229
|
+
{
|
|
230
|
+
name: "parachute-runner",
|
|
231
|
+
version: "0.2.0",
|
|
232
|
+
port: 1945,
|
|
233
|
+
paths: ["/runner"],
|
|
234
|
+
health: "/runner/healthz",
|
|
235
|
+
},
|
|
236
|
+
h.manifestPath,
|
|
237
|
+
);
|
|
238
|
+
const availability = scriptedAvailability([
|
|
239
|
+
"surface", // pick a still-offered module
|
|
240
|
+
]);
|
|
241
|
+
const code = await setup({
|
|
242
|
+
manifestPath: h.manifestPath,
|
|
243
|
+
configDir: h.configDir,
|
|
244
|
+
log: (l) => h.logs.push(l),
|
|
245
|
+
availability,
|
|
246
|
+
installFn: async (short, opts) => {
|
|
247
|
+
h.calls.push({ short, opts });
|
|
248
|
+
return 0;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
expect(code).toBe(0);
|
|
252
|
+
const joined = h.logs.join("\n");
|
|
253
|
+
// Banner lists runner as already installed…
|
|
254
|
+
const installedBlock = joined.slice(
|
|
255
|
+
joined.indexOf("Already installed:"),
|
|
256
|
+
joined.indexOf("Available to install:"),
|
|
257
|
+
);
|
|
258
|
+
expect(installedBlock).toMatch(/\brunner\b/);
|
|
259
|
+
// …but runner is NOT in the fresh-install offer.
|
|
260
|
+
const availableBlock = joined.slice(joined.indexOf("Available to install:"));
|
|
261
|
+
expect(availableBlock).not.toMatch(/\brunner\b/);
|
|
262
|
+
expect(h.calls.map((c) => c.short)).not.toContain("runner");
|
|
263
|
+
} finally {
|
|
264
|
+
h.cleanup();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
158
268
|
test("rejects non-TTY when there's work to offer", async () => {
|
|
159
269
|
const h = makeHarness();
|
|
160
270
|
try {
|
|
@@ -220,7 +330,9 @@ describe("setup", () => {
|
|
|
220
330
|
const h = makeHarness();
|
|
221
331
|
try {
|
|
222
332
|
const availability = scriptedAvailability([
|
|
223
|
-
|
|
333
|
+
// surface — a non-deprecated module with no follow-up prompts. (notes,
|
|
334
|
+
// the prior pick, is now `deprecated` → not in the offered set.)
|
|
335
|
+
"surface",
|
|
224
336
|
]);
|
|
225
337
|
const code = await setup({
|
|
226
338
|
manifestPath: h.manifestPath,
|
|
@@ -231,10 +343,10 @@ describe("setup", () => {
|
|
|
231
343
|
h.calls.push({ short, opts });
|
|
232
344
|
upsertService(
|
|
233
345
|
{
|
|
234
|
-
name: "parachute-
|
|
346
|
+
name: "parachute-surface",
|
|
235
347
|
version: "0.1.0",
|
|
236
|
-
port:
|
|
237
|
-
paths: ["/
|
|
348
|
+
port: 1946,
|
|
349
|
+
paths: ["/surface"],
|
|
238
350
|
health: "/health",
|
|
239
351
|
},
|
|
240
352
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -257,7 +369,8 @@ describe("setup", () => {
|
|
|
257
369
|
const h = makeHarness();
|
|
258
370
|
try {
|
|
259
371
|
const availability = scriptedAvailability([
|
|
260
|
-
|
|
372
|
+
// surface replaces the prior `notes` pick (now deprecated → not offered).
|
|
373
|
+
"vault, surface", // multi-select
|
|
261
374
|
"default", // vault name
|
|
262
375
|
]);
|
|
263
376
|
const code = await setup({
|
|
@@ -270,10 +383,10 @@ describe("setup", () => {
|
|
|
270
383
|
if (short === "vault") return 7; // fail vault
|
|
271
384
|
upsertService(
|
|
272
385
|
{
|
|
273
|
-
name: "parachute-
|
|
386
|
+
name: "parachute-surface",
|
|
274
387
|
version: "0.1.0",
|
|
275
|
-
port:
|
|
276
|
-
paths: ["/
|
|
388
|
+
port: 1946,
|
|
389
|
+
paths: ["/surface"],
|
|
277
390
|
health: "/health",
|
|
278
391
|
},
|
|
279
392
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -282,7 +395,7 @@ describe("setup", () => {
|
|
|
282
395
|
},
|
|
283
396
|
});
|
|
284
397
|
expect(code).toBe(7);
|
|
285
|
-
expect(h.calls.map((c) => c.short)).toEqual(["vault", "
|
|
398
|
+
expect(h.calls.map((c) => c.short)).toEqual(["vault", "surface"]);
|
|
286
399
|
expect(h.logs.join("\n")).toMatch(/non-zero exit code/);
|
|
287
400
|
} finally {
|
|
288
401
|
h.cleanup();
|
|
@@ -295,7 +408,8 @@ describe("setup", () => {
|
|
|
295
408
|
const availability = scriptedAvailability([
|
|
296
409
|
"9", // out-of-range index — loop re-prompts
|
|
297
410
|
"nope", // unknown name — loop re-prompts again
|
|
298
|
-
|
|
411
|
+
// surface — a non-deprecated single pick with no follow-up prompts.
|
|
412
|
+
"surface",
|
|
299
413
|
]);
|
|
300
414
|
const code = await setup({
|
|
301
415
|
manifestPath: h.manifestPath,
|
|
@@ -306,10 +420,10 @@ describe("setup", () => {
|
|
|
306
420
|
h.calls.push({ short, opts });
|
|
307
421
|
upsertService(
|
|
308
422
|
{
|
|
309
|
-
name: "parachute-
|
|
423
|
+
name: "parachute-surface",
|
|
310
424
|
version: "0.1.0",
|
|
311
|
-
port:
|
|
312
|
-
paths: ["/
|
|
425
|
+
port: 1946,
|
|
426
|
+
paths: ["/surface"],
|
|
313
427
|
health: "/health",
|
|
314
428
|
},
|
|
315
429
|
opts.manifestPath ?? h.manifestPath,
|
|
@@ -318,7 +432,7 @@ describe("setup", () => {
|
|
|
318
432
|
},
|
|
319
433
|
});
|
|
320
434
|
expect(code).toBe(0);
|
|
321
|
-
expect(h.calls.map((c) => c.short)).toEqual(["
|
|
435
|
+
expect(h.calls.map((c) => c.short)).toEqual(["surface"]);
|
|
322
436
|
expect(availability.remaining()).toBe(0);
|
|
323
437
|
const joined = h.logs.join("\n");
|
|
324
438
|
expect(joined).toMatch(/out-of-range/);
|