@openparachute/hub 0.7.3-rc.6 → 0.7.3-rc.7
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__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup.test.ts +129 -15
- package/src/api-modules.ts +47 -13
- package/src/commands/setup.ts +31 -6
- package/src/module-manifest.ts +23 -9
- package/src/service-spec.ts +31 -12
- 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/);
|
|
@@ -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", () => {
|
|
@@ -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/);
|
package/src/api-modules.ts
CHANGED
|
@@ -15,11 +15,21 @@
|
|
|
15
15
|
* / `crashed` / `starting` / `restarting`) + pid. Absent when the
|
|
16
16
|
* hub is in CLI mode (no supervisor injected through HubFetchDeps).
|
|
17
17
|
*
|
|
18
|
-
* `focus` ("core" | "experimental") comes from each module's
|
|
19
|
-
* when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* `focus` ("core" | "experimental" | "deprecated") comes from each module's
|
|
19
|
+
* `module.json` when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
+
* groups core first, de-emphasizes experimental, and de-emphasizes
|
|
21
|
+
* `deprecated` (notes-daemon / runner) further — it NEVER hides an installed
|
|
22
|
+
* module (so an existing operator can still manage / uninstall a deprecated
|
|
23
|
+
* one). This is what makes a running, self-registered module (channel) visible
|
|
24
|
+
* + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
|
|
25
|
+
* it invisible.
|
|
26
|
+
*
|
|
27
|
+
* `available_to_install` is the fresh-install OFFER (2026-06-25): a module the
|
|
28
|
+
* hub will push as a new install. It's `available && focus !== "deprecated"`
|
|
29
|
+
* — so a `deprecated` module that is ALREADY installed still surfaces (via the
|
|
30
|
+
* `modules` union + `installed: true`) and is manageable, but a deprecated
|
|
31
|
+
* module is never offered as a fresh install. `agent` (`experimental`) stays
|
|
32
|
+
* offered.
|
|
23
33
|
*
|
|
24
34
|
* Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
|
|
25
35
|
* and `/api/grants` — the admin SPA mints this scope via
|
|
@@ -199,14 +209,33 @@ interface ModuleWireShape {
|
|
|
199
209
|
display_name: string;
|
|
200
210
|
tagline: string;
|
|
201
211
|
/**
|
|
202
|
-
* Discovery tier (2026-06-09 modular-UI architecture
|
|
203
|
-
* in the headline group; `experimental`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
212
|
+
* Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
|
|
213
|
+
* 2026-06-25). `core` modules render in the headline group; `experimental`
|
|
214
|
+
* modules render in a de-emphasized "Experimental" group; `deprecated`
|
|
215
|
+
* modules (notes-daemon / runner) render in a further-de-emphasized
|
|
216
|
+
* "Deprecated" group — never hidden when installed. Resolved from the
|
|
217
|
+
* module's `module.json` `focus` when declared, else `focusForShort`'s
|
|
218
|
+
* default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
|
|
219
|
+
* others → experimental).
|
|
207
220
|
*/
|
|
208
221
|
focus: ModuleFocus;
|
|
222
|
+
/**
|
|
223
|
+
* True iff the hub knows how to install this module (`package`/`manifest`
|
|
224
|
+
* resolvable). Historically "in the curated install catalog". Still set for
|
|
225
|
+
* every known module; a purely third-party services.json row is false.
|
|
226
|
+
* NOTE: this is NOT the fresh-install OFFER — a `deprecated` module is still
|
|
227
|
+
* `available: true` (it's installable for back-compat / re-install) but is
|
|
228
|
+
* excluded from `available_to_install`.
|
|
229
|
+
*/
|
|
209
230
|
available: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* The fresh-install OFFER (2026-06-25): whether the hub presents this module
|
|
233
|
+
* in the "available to install fresh" set. `available && focus !==
|
|
234
|
+
* "deprecated"`. The SPA's "Install a module" catalog filters on this so
|
|
235
|
+
* notes-daemon / runner aren't pushed on a fresh box; an already-installed
|
|
236
|
+
* deprecated module still surfaces (in the Installed section) for management.
|
|
237
|
+
*/
|
|
238
|
+
available_to_install: boolean;
|
|
210
239
|
installed: boolean;
|
|
211
240
|
installed_version: string | null;
|
|
212
241
|
latest_version: string | null;
|
|
@@ -562,8 +591,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
562
591
|
// module's manifest-declared `focus` (when installed + declared) wins; else
|
|
563
592
|
// the `focusForShort` default map. Sort: `core` group first (with the
|
|
564
593
|
// CURATED_MODULES recommended-install order floated to the top of that
|
|
565
|
-
// group), then `experimental
|
|
566
|
-
// never hides
|
|
594
|
+
// group), then `experimental`, then `deprecated` (notes / runner) last — the
|
|
595
|
+
// SPA renders the groups; `focus` never hides an installed module.
|
|
567
596
|
const recommendedOrder = new Map<string, number>(
|
|
568
597
|
(CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
|
|
569
598
|
);
|
|
@@ -585,6 +614,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
585
614
|
// known modules; a purely third-party services.json row (no install
|
|
586
615
|
// package) is not hub-installable → false.
|
|
587
616
|
available: m !== undefined,
|
|
617
|
+
// Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
|
|
618
|
+
// deprecated short (notes / runner) stays `available` (re-installable
|
|
619
|
+
// for back-compat) but is dropped from the offer set so the SPA's
|
|
620
|
+
// "Install a module" catalog doesn't push it on a fresh box.
|
|
621
|
+
available_to_install: m !== undefined && focus !== "deprecated",
|
|
588
622
|
installed: installed !== undefined,
|
|
589
623
|
installed_version: installed?.version ?? null,
|
|
590
624
|
latest_version: latestByShort.get(short) ?? null,
|
|
@@ -598,7 +632,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
598
632
|
};
|
|
599
633
|
return row;
|
|
600
634
|
});
|
|
601
|
-
const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
|
|
635
|
+
const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : f === "experimental" ? 1 : 2);
|
|
602
636
|
rows.sort((a, b) => {
|
|
603
637
|
if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
|
|
604
638
|
const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
|
package/src/commands/setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
KNOWN_MODULES,
|
|
12
12
|
type ServiceSpec,
|
|
13
13
|
composeServiceSpec,
|
|
14
|
+
focusForShort,
|
|
14
15
|
knownServices,
|
|
15
16
|
} from "../service-spec.ts";
|
|
16
17
|
import type { ServiceEntry } from "../services-manifest.ts";
|
|
@@ -108,10 +109,14 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
|
-
* Survey
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
112
|
+
* Survey ALL known first-party shortnames (vault / notes / scribe / agent /
|
|
113
|
+
* runner / surface) regardless of tier — `installed` is true when the service
|
|
114
|
+
* has a row in services.json. The fresh-install OFFER is narrowed downstream
|
|
115
|
+
* by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
|
|
116
|
+
* notes / runner); agent (`experimental`) is flagged exploratory in its blurb
|
|
117
|
+
* but stays offered. Surveying everything keeps `installed` detection complete
|
|
118
|
+
* (the "already installed" banner still lists a deprecated module an operator
|
|
119
|
+
* has on disk).
|
|
115
120
|
*
|
|
116
121
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
117
122
|
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
@@ -149,10 +154,30 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
|
|
|
149
154
|
});
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A surveyed service is OFFERED on a fresh setup iff it is not already
|
|
159
|
+
* installed AND its discovery tier is not `deprecated` (2026-06-25). The
|
|
160
|
+
* deprecated tier (notes-daemon, runner) stays resolvable + manageable for an
|
|
161
|
+
* existing install — it just isn't pushed on a fresh box. `agent`
|
|
162
|
+
* (`experimental`) is still offered. Exported so the setup tests can pin the
|
|
163
|
+
* exclusion directly.
|
|
164
|
+
*/
|
|
165
|
+
export function isOfferable(choice: { short: string; installed: boolean }): boolean {
|
|
166
|
+
if (choice.installed) return false;
|
|
167
|
+
// No `declared` arg: the survey fires PRE-install, before any module.json is
|
|
168
|
+
// on disk to read a self-declared `focus` from — so the static default map is
|
|
169
|
+
// the only signal available + the right one here.
|
|
170
|
+
return focusForShort(choice.short) !== "deprecated";
|
|
171
|
+
}
|
|
172
|
+
|
|
152
173
|
const BLURBS: Record<string, string> = {
|
|
153
174
|
vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
|
|
175
|
+
surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
|
|
176
|
+
// `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
|
|
154
177
|
app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
|
|
155
|
-
notes
|
|
178
|
+
// notes / runner are `deprecated` (not offered on a fresh setup) — these
|
|
179
|
+
// blurbs only render if a legacy install surfaces them in the survey.
|
|
180
|
+
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
|
|
156
181
|
scribe: "audio transcription for dictation + recordings",
|
|
157
182
|
runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
|
|
158
183
|
agent:
|
|
@@ -306,7 +331,7 @@ export async function setup(opts: SetupOpts = {}): Promise<number> {
|
|
|
306
331
|
|
|
307
332
|
const survey = surveyServices(manifestPath);
|
|
308
333
|
const installed = survey.filter((s) => s.installed);
|
|
309
|
-
const offered = survey.filter(
|
|
334
|
+
const offered = survey.filter(isOfferable);
|
|
310
335
|
|
|
311
336
|
if (installed.length > 0) {
|
|
312
337
|
log("Already installed:");
|
package/src/module-manifest.ts
CHANGED
|
@@ -66,16 +66,26 @@ export interface ConfigSchema {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Discovery tier (2026-06-09 modular-UI architecture).
|
|
70
|
-
* product surface (vault / scribe / hub / surface)
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* Discovery tier (2026-06-09 modular-UI architecture). Three tiers today:
|
|
70
|
+
* - `core` — the product surface (vault / scribe / hub / surface).
|
|
71
|
+
* - `experimental` — legit previews (agent) that render in a de-emphasized
|
|
72
|
+
* group but ARE offered on a fresh install.
|
|
73
|
+
* - `deprecated` — modules retained for back-compat (notes-daemon, runner)
|
|
74
|
+
* that stay resolvable + shown-if-installed (so an existing operator can
|
|
75
|
+
* still manage / uninstall them) but are NOT offered on a fresh setup
|
|
76
|
+
* (2026-06-25). Distinct from a fully retired module (`RETIRED_MODULES`),
|
|
77
|
+
* whose services.json row is GC'd on load.
|
|
78
|
+
*
|
|
79
|
+
* **Show all installed; never hide** — `focus` only sorts + labels for an
|
|
80
|
+
* installed/known module. The one behavioral lever it pulls is the fresh-install
|
|
81
|
+
* OFFER: `deprecated` shorts are dropped from the setup wizard + the admin SPA's
|
|
82
|
+
* "available to install" set.
|
|
73
83
|
*
|
|
74
84
|
* Absent in a `module.json` ⇒ the hub falls back to its default map (see
|
|
75
85
|
* `service-spec.focusForShort`), which defaults unlisted modules to
|
|
76
86
|
* `experimental`.
|
|
77
87
|
*/
|
|
78
|
-
export type ModuleFocus = "core" | "experimental";
|
|
88
|
+
export type ModuleFocus = "core" | "experimental" | "deprecated";
|
|
79
89
|
|
|
80
90
|
/**
|
|
81
91
|
* An event a module EMITS — the left-hand side of a Connection (2026-06-09
|
|
@@ -324,8 +334,10 @@ export interface ModuleManifest {
|
|
|
324
334
|
* Discovery tier (2026-06-09 modular-UI architecture). When a module
|
|
325
335
|
* declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
|
|
326
336
|
* falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
|
|
327
|
-
* `core`, everything else → `experimental`).
|
|
328
|
-
* `focus`
|
|
337
|
+
* `core`, notes/runner → `deprecated`, everything else → `experimental`).
|
|
338
|
+
* **Show all installed; never hide** — `focus` groups + de-emphasizes; the
|
|
339
|
+
* one behavioral lever is the fresh-install OFFER, which excludes
|
|
340
|
+
* `deprecated`. Additive + back-compatible.
|
|
329
341
|
*/
|
|
330
342
|
readonly focus?: ModuleFocus;
|
|
331
343
|
/**
|
|
@@ -711,12 +723,14 @@ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] |
|
|
|
711
723
|
});
|
|
712
724
|
}
|
|
713
725
|
|
|
714
|
-
const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
|
|
726
|
+
const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental", "deprecated"]);
|
|
715
727
|
|
|
716
728
|
function asFocus(v: unknown, where: string): ModuleFocus | undefined {
|
|
717
729
|
if (v === undefined) return undefined;
|
|
718
730
|
if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
|
|
719
|
-
throw new ModuleManifestError(
|
|
731
|
+
throw new ModuleManifestError(
|
|
732
|
+
`${where}: "focus" must be "core" | "experimental" | "deprecated" if present`,
|
|
733
|
+
);
|
|
720
734
|
}
|
|
721
735
|
return v as ModuleFocus;
|
|
722
736
|
}
|