@openparachute/hub 0.7.3-rc.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.5",
3
+ "version": "0.7.3-rc.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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, the rest
223
- // (agent/runner/notes) as `experimental` — so the agent-not-installed
224
- // class (running but invisible) can't recur.
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
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
- // Every known module is discoverable — vault/scribe/surface (core) +
250
- // agent/runner/notes (experimental).
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("experimental");
261
- expect(byShort.get("notes")?.focus).toBe("experimental");
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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Tests for `/api/vault-caps*` (B5 admin visibility / D-slice).
3
+ * Covers:
4
+ *
5
+ * - Auth boundary: GET + PUT require a bearer carrying `parachute:host:admin`.
6
+ * - GET joins services.json vault names with persisted caps (uncapped =
7
+ * null cap_bytes), ordered by name.
8
+ * - PUT sets/updates a cap (upsert), validates positive cap, refuses a
9
+ * vault not registered in services.json (400 vault_not_found).
10
+ * - 405 on wrong methods.
11
+ */
12
+ import type { Database } from "bun:sqlite";
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { handleListVaultCaps, handleSetVaultCap } from "../api-vault-caps.ts";
18
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
19
+ import { signAccessToken } from "../jwt-sign.ts";
20
+ import { createUser } from "../users.ts";
21
+ import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
22
+
23
+ const ISSUER = "https://hub.test";
24
+ const HOST_ADMIN_SCOPE = "parachute:host:admin";
25
+
26
+ interface Harness {
27
+ db: Database;
28
+ manifestPath: string;
29
+ cleanup: () => void;
30
+ }
31
+
32
+ function manifestWithVaults(...names: string[]): string {
33
+ const paths = names.map((n) => `/vault/${n}`);
34
+ return JSON.stringify({
35
+ services: [
36
+ { name: "parachute-vault", port: 4101, paths, health: "/health", version: "0.0.0-test" },
37
+ ],
38
+ });
39
+ }
40
+
41
+ function makeHarness(servicesJson?: string): Harness {
42
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-vault-caps-"));
43
+ const db = openHubDb(hubDbPath(dir));
44
+ const manifestPath = join(dir, "services.json");
45
+ writeFileSync(manifestPath, servicesJson ?? manifestWithVaults("beta", "personal"));
46
+ return {
47
+ db,
48
+ manifestPath,
49
+ cleanup: () => {
50
+ db.close();
51
+ rmSync(dir, { recursive: true, force: true });
52
+ },
53
+ };
54
+ }
55
+
56
+ let harness: Harness;
57
+ beforeEach(() => {
58
+ harness = makeHarness();
59
+ });
60
+ afterEach(() => {
61
+ harness.cleanup();
62
+ });
63
+
64
+ async function makeAdminBearer(scopes = [HOST_ADMIN_SCOPE]): Promise<string> {
65
+ const user = await createUser(harness.db, "operator", "any-password", {
66
+ allowMulti: true,
67
+ passwordChanged: true,
68
+ });
69
+ const minted = await signAccessToken(harness.db, {
70
+ sub: user.id,
71
+ scopes,
72
+ audience: "hub",
73
+ clientId: "parachute-hub-spa",
74
+ issuer: ISSUER,
75
+ ttlSeconds: 600,
76
+ });
77
+ return minted.token;
78
+ }
79
+
80
+ function req(path: string, init: RequestInit = {}): Request {
81
+ return new Request(`${ISSUER}${path}`, init);
82
+ }
83
+
84
+ function withBearer(path: string, bearer: string, init: RequestInit = {}): Request {
85
+ const headers = new Headers(init.headers ?? {});
86
+ headers.set("authorization", `Bearer ${bearer}`);
87
+ return new Request(`${ISSUER}${path}`, { ...init, headers });
88
+ }
89
+
90
+ function deps() {
91
+ return { db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath };
92
+ }
93
+
94
+ describe("handleListVaultCaps", () => {
95
+ test("401 with no Authorization header", async () => {
96
+ const res = await handleListVaultCaps(req("/api/vault-caps"), deps());
97
+ expect(res.status).toBe(401);
98
+ });
99
+
100
+ test("403 when bearer lacks parachute:host:admin", async () => {
101
+ const bearer = await makeAdminBearer(["other:scope"]);
102
+ const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
103
+ expect(res.status).toBe(403);
104
+ });
105
+
106
+ test("405 on POST", async () => {
107
+ const bearer = await makeAdminBearer();
108
+ const res = await handleListVaultCaps(
109
+ withBearer("/api/vault-caps", bearer, { method: "POST" }),
110
+ deps(),
111
+ );
112
+ expect(res.status).toBe(405);
113
+ });
114
+
115
+ test("joins services.json vaults with caps, uncapped = null cap_bytes", async () => {
116
+ const bearer = await makeAdminBearer();
117
+ // Only "beta" has a persisted cap; "personal" stays uncapped.
118
+ setVaultCap(harness.db, "beta", 1024 * 1024 * 1024);
119
+ const res = await handleListVaultCaps(withBearer("/api/vault-caps", bearer), deps());
120
+ expect(res.status).toBe(200);
121
+ expect(res.headers.get("cache-control")).toBe("no-store");
122
+ const body = (await res.json()) as {
123
+ vault_caps: Array<{ vault_name: string; cap_bytes: number | null }>;
124
+ };
125
+ // Both vaults present, ordered by name (beta, personal).
126
+ expect(body.vault_caps.map((c) => c.vault_name)).toEqual(["beta", "personal"]);
127
+ const beta = body.vault_caps.find((c) => c.vault_name === "beta");
128
+ const personal = body.vault_caps.find((c) => c.vault_name === "personal");
129
+ expect(beta?.cap_bytes).toBe(1024 * 1024 * 1024);
130
+ expect(personal?.cap_bytes).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("handleSetVaultCap", () => {
135
+ test("401 with no Authorization header", async () => {
136
+ const res = await handleSetVaultCap(
137
+ req("/api/vault-caps/beta", {
138
+ method: "PUT",
139
+ headers: { "content-type": "application/json" },
140
+ body: JSON.stringify({ cap_bytes: 1000 }),
141
+ }),
142
+ "beta",
143
+ deps(),
144
+ );
145
+ expect(res.status).toBe(401);
146
+ });
147
+
148
+ test("403 when bearer lacks parachute:host:admin", async () => {
149
+ const bearer = await makeAdminBearer(["other:scope"]);
150
+ const res = await handleSetVaultCap(
151
+ withBearer("/api/vault-caps/beta", bearer, {
152
+ method: "PUT",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({ cap_bytes: 1000 }),
155
+ }),
156
+ "beta",
157
+ deps(),
158
+ );
159
+ expect(res.status).toBe(403);
160
+ });
161
+
162
+ test("400 on a fractional (non-integer) cap", async () => {
163
+ const bearer = await makeAdminBearer();
164
+ const res = await handleSetVaultCap(
165
+ withBearer("/api/vault-caps/beta", bearer, {
166
+ method: "PUT",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({ cap_bytes: 1.5 }),
169
+ }),
170
+ "beta",
171
+ deps(),
172
+ );
173
+ expect(res.status).toBe(400);
174
+ });
175
+
176
+ test("405 on GET", async () => {
177
+ const bearer = await makeAdminBearer();
178
+ const res = await handleSetVaultCap(withBearer("/api/vault-caps/beta", bearer), "beta", deps());
179
+ expect(res.status).toBe(405);
180
+ });
181
+
182
+ test("sets a cap on a registered vault and persists it (upsert)", async () => {
183
+ const bearer = await makeAdminBearer();
184
+ const res = await handleSetVaultCap(
185
+ withBearer("/api/vault-caps/beta", bearer, {
186
+ method: "PUT",
187
+ headers: { "content-type": "application/json" },
188
+ body: JSON.stringify({ cap_bytes: 2 * 1024 * 1024 * 1024 }),
189
+ }),
190
+ "beta",
191
+ deps(),
192
+ );
193
+ expect(res.status).toBe(200);
194
+ const body = (await res.json()) as { vault_cap: { cap_bytes: number } };
195
+ expect(body.vault_cap.cap_bytes).toBe(2 * 1024 * 1024 * 1024);
196
+ expect(getVaultCapBytes(harness.db, "beta")).toBe(2 * 1024 * 1024 * 1024);
197
+ });
198
+
199
+ test("400 vault_not_found for a vault not in services.json", async () => {
200
+ const bearer = await makeAdminBearer();
201
+ const res = await handleSetVaultCap(
202
+ withBearer("/api/vault-caps/ghost", bearer, {
203
+ method: "PUT",
204
+ headers: { "content-type": "application/json" },
205
+ body: JSON.stringify({ cap_bytes: 1000 }),
206
+ }),
207
+ "ghost",
208
+ deps(),
209
+ );
210
+ expect(res.status).toBe(400);
211
+ const body = (await res.json()) as { error: string };
212
+ expect(body.error).toBe("vault_not_found");
213
+ // Nothing persisted for the rejected name.
214
+ expect(getVaultCapBytes(harness.db, "ghost")).toBeNull();
215
+ });
216
+
217
+ test("400 on non-positive cap", async () => {
218
+ const bearer = await makeAdminBearer();
219
+ const res = await handleSetVaultCap(
220
+ withBearer("/api/vault-caps/beta", bearer, {
221
+ method: "PUT",
222
+ headers: { "content-type": "application/json" },
223
+ body: JSON.stringify({ cap_bytes: 0 }),
224
+ }),
225
+ "beta",
226
+ deps(),
227
+ );
228
+ expect(res.status).toBe(400);
229
+ const body = (await res.json()) as { error: string };
230
+ expect(body.error).toBe("invalid_request");
231
+ });
232
+ });
@@ -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
- expect(focusForShort("runner")).toBe("experimental");
58
- expect(focusForShort("notes")).toBe("experimental");
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
- "notes", // single pick — no follow-up prompts
333
+ // surfacea 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-notes",
346
+ name: "parachute-surface",
235
347
  version: "0.1.0",
236
- port: 1942,
237
- paths: ["/notes"],
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
- "vault, notes", // multi-select
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-notes",
386
+ name: "parachute-surface",
274
387
  version: "0.1.0",
275
- port: 1942,
276
- paths: ["/notes"],
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", "notes"]);
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
- "notes", // single pick, no follow-up prompts
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-notes",
423
+ name: "parachute-surface",
310
424
  version: "0.1.0",
311
- port: 1942,
312
- paths: ["/notes"],
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(["notes"]);
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/);