@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21

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.
@@ -7,7 +7,6 @@
7
7
  *
8
8
  * bun-linked: git -C <checkout> pull --ff-only;
9
9
  * bun install --frozen-lockfile (if package.json/bun.lock changed);
10
- * bun run build (frontend kind, if `build` script exists);
11
10
  * parachute restart <svc>.
12
11
  *
13
12
  * npm-installed: bun add -g <pkg>@<tag>; parachute restart <svc>.
@@ -32,6 +31,19 @@
32
31
  * `bun add -g` to detect "already at latest" (the dist-tag didn't move).
33
32
  * Doing this avoids an unnecessary restart on a stable channel — a lot
34
33
  * cheaper than re-spawning a daemon that's already running the right code.
34
+ *
35
+ * Channel preservation (hub#332). The npm branch infers which dist-tag to
36
+ * use from the currently-installed version string: a `-rc(\.\d+)?$` suffix
37
+ * means the operator is on the rc chain → upgrade via `@rc`; otherwise
38
+ * `@latest`. This is load-bearing under pre-1.0 RC governance (parachute-
39
+ * patterns/patterns/governance.md rule 2): rc operators must stay on rc
40
+ * unless they explicitly promote. Before #332 the upgrade unconditionally
41
+ * pulled `@latest`, which silently downgraded an rc operator the moment
42
+ * `@latest` pointed at a prior stable. Operators can override the
43
+ * detection with `--channel rc|latest`. We also gate against silent
44
+ * downgrades: if `npm view <pkg>@<channel> version` resolves to something
45
+ * lower than what's installed, we abort with an actionable message
46
+ * (override with `--allow-downgrade`).
35
47
  */
36
48
 
37
49
  import { existsSync, readFileSync, realpathSync } from "node:fs";
@@ -101,8 +113,45 @@ export interface UpgradeOpts {
101
113
  * upgrade flow can be exercised without spawning real children.
102
114
  */
103
115
  restartFn?: (svc: string, opts: LifecycleOpts) => Promise<number>;
104
- /** npm dist-tag for the npm-installed branch. Defaults to `latest`. Ignored when bun-linked. */
116
+ /**
117
+ * Explicit npm dist-tag for the npm-installed branch. When set, this
118
+ * overrides channel auto-detection AND the operator's `--channel` flag
119
+ * (it's a programmatic pin used by callers that already know what they
120
+ * want — e.g. tests). Operators don't pass `tag` directly; they pass
121
+ * `--channel rc|latest` which flows into `channel` below.
122
+ *
123
+ * Ignored when bun-linked.
124
+ */
105
125
  tag?: string;
126
+ /**
127
+ * Operator-facing channel override (`--channel rc|latest`). Bypasses the
128
+ * auto-detection that infers the channel from the currently-installed
129
+ * version string. When unset, `parachute upgrade` reads the installed
130
+ * package.json `version` and picks `@rc` if it matches `/-rc(\.\d+)?$/`,
131
+ * `@latest` otherwise.
132
+ *
133
+ * Per governance rule 2 (pre-1.0 RC versioning,
134
+ * `parachute-patterns/patterns/governance.md`), operators on the dev
135
+ * chain run `@rc`; `@latest` is the explicit-stable channel. The default
136
+ * `parachute upgrade` (pre hub#332) hard-coded `@latest`, which silently
137
+ * downgraded rc operators when `@latest` pointed at a prior stable.
138
+ */
139
+ channel?: "rc" | "latest";
140
+ /**
141
+ * Bypass the "refuses-to-downgrade" guard. The npm-install branch
142
+ * compares the target version (what `npm view <pkg>@<channel> version`
143
+ * resolves to) against the installed version and aborts if it would go
144
+ * backward. Set true to opt in to a real downgrade.
145
+ */
146
+ allowDowngrade?: boolean;
147
+ /**
148
+ * Test seam for resolving a dist-tag to a concrete version. Defaults to
149
+ * `npm view <pkg>@<channel> version` via the injected runner. Returning
150
+ * null is treated as "unknown — skip the downgrade guard" (network down,
151
+ * registry unreachable, package not yet published on that channel) so a
152
+ * flaky probe never blocks a legitimate upgrade.
153
+ */
154
+ resolveChannelVersion?: (pkg: string, channel: string) => Promise<string | null>;
106
155
  }
107
156
 
108
157
  interface ResolvedTarget {
@@ -119,7 +168,15 @@ interface Resolved {
119
168
  log: (line: string) => void;
120
169
  findGlobalInstall: (pkg: string) => string | null;
121
170
  restartFn: (svc: string, opts: LifecycleOpts) => Promise<number>;
122
- tag: string;
171
+ /**
172
+ * Explicit pin (programmatic). When set, overrides both auto-detection
173
+ * and `channelOverride`. Undefined in the operator-facing default path.
174
+ */
175
+ tag: string | undefined;
176
+ /** Operator override (`--channel rc|latest`). Undefined → auto-detect. */
177
+ channelOverride: "rc" | "latest" | undefined;
178
+ allowDowngrade: boolean;
179
+ resolveChannelVersion: (pkg: string, channel: string) => Promise<string | null>;
123
180
  }
124
181
 
125
182
  function bunGlobalPrefixes(): string[] {
@@ -139,17 +196,119 @@ function defaultFindGlobalInstall(pkg: string): string | null {
139
196
  }
140
197
 
141
198
  function resolve(opts: UpgradeOpts): Resolved {
199
+ const runner = opts.runner ?? defaultRunner;
142
200
  return {
143
- runner: opts.runner ?? defaultRunner,
201
+ runner,
144
202
  manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
145
203
  configDir: opts.configDir ?? CONFIG_DIR,
146
204
  log: opts.log ?? ((line) => console.log(line)),
147
205
  findGlobalInstall: opts.findGlobalInstall ?? defaultFindGlobalInstall,
148
206
  restartFn: opts.restartFn ?? ((svc, lifecycleOpts) => lifecycleRestart(svc, lifecycleOpts)),
149
- tag: opts.tag ?? "latest",
207
+ tag: opts.tag,
208
+ channelOverride: opts.channel,
209
+ allowDowngrade: opts.allowDowngrade ?? false,
210
+ resolveChannelVersion:
211
+ opts.resolveChannelVersion ?? ((pkg, channel) => npmViewVersion(pkg, channel, runner)),
150
212
  };
151
213
  }
152
214
 
215
+ /**
216
+ * Channel detection from a semver-ish version string. Pre-1.0 governance
217
+ * (parachute-patterns/patterns/governance.md rule 2) ships rc chains as
218
+ * `<x>.<y>.<z>-rc.<N>` (or sometimes `-rc` with no N). Anything matching that
219
+ * trailing suffix is the rc channel; everything else is the stable channel.
220
+ */
221
+ export function detectChannel(installedVersion: string): "rc" | "latest" {
222
+ return /-rc(\.\d+)?$/.test(installedVersion) ? "rc" : "latest";
223
+ }
224
+
225
+ /**
226
+ * Inline semver-ish comparator. Returns < 0 / 0 / > 0 like `Array.prototype.sort`.
227
+ *
228
+ * Hub doesn't depend on `semver` and adding it for one call is overkill —
229
+ * npm dist-tags resolve to fully-qualified versions like `0.5.13-rc.13` or
230
+ * `0.5.10`, and we only need an ordering predicate ("would this be a
231
+ * downgrade?"). Spec compliance: split into [major, minor, patch] + an
232
+ * optional prerelease tail; numeric-compare the triple, then break ties by
233
+ * "no prerelease > has prerelease" (semver §11.4.3) and lex/numeric compare
234
+ * the prerelease dot-segments (§11.4.1–11.4.2). Returns null on malformed
235
+ * inputs so the caller can fail-open (skip the downgrade guard rather than
236
+ * block on a parser disagreement).
237
+ */
238
+ export function compareVersions(a: string, b: string): number | null {
239
+ const pa = parseSemver(a);
240
+ const pb = parseSemver(b);
241
+ if (!pa || !pb) return null;
242
+ for (let i = 0; i < 3; i++) {
243
+ const av = pa.parts[i] ?? 0;
244
+ const bv = pb.parts[i] ?? 0;
245
+ if (av !== bv) return av - bv;
246
+ }
247
+ // Equal triple: pre-release loses to no-pre-release.
248
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
249
+ if (pa.pre.length === 0) return 1;
250
+ if (pb.pre.length === 0) return -1;
251
+ // Both have pre-releases: dot-segment compare.
252
+ const len = Math.max(pa.pre.length, pb.pre.length);
253
+ for (let i = 0; i < len; i++) {
254
+ const av = pa.pre[i];
255
+ const bv = pb.pre[i];
256
+ if (av === undefined) return -1;
257
+ if (bv === undefined) return 1;
258
+ const an = /^\d+$/.test(av) ? Number(av) : null;
259
+ const bn = /^\d+$/.test(bv) ? Number(bv) : null;
260
+ if (an !== null && bn !== null) {
261
+ if (an !== bn) return an - bn;
262
+ continue;
263
+ }
264
+ if (an !== null) return -1; // numeric < non-numeric
265
+ if (bn !== null) return 1;
266
+ if (av < bv) return -1;
267
+ if (av > bv) return 1;
268
+ }
269
+ return 0;
270
+ }
271
+
272
+ function parseSemver(v: string): { parts: number[]; pre: string[] } | null {
273
+ // Tolerate a leading `v` and ignore build metadata after `+`.
274
+ const stripped = v.replace(/^v/, "").split("+")[0];
275
+ if (!stripped) return null;
276
+ const [core, ...preTail] = stripped.split("-");
277
+ if (!core) return null;
278
+ const partStrs = core.split(".");
279
+ if (partStrs.length < 1 || partStrs.length > 3) return null;
280
+ const parts: number[] = [];
281
+ for (const p of partStrs) {
282
+ if (!/^\d+$/.test(p)) return null;
283
+ parts.push(Number(p));
284
+ }
285
+ const pre = preTail.length === 0 ? [] : preTail.join("-").split(".").filter(Boolean);
286
+ return { parts, pre };
287
+ }
288
+
289
+ /**
290
+ * Resolve `<pkg>@<channel>` to a concrete version via `npm view`. Returns
291
+ * null when the probe fails (network down, registry unreachable, package
292
+ * not yet published on that channel) so callers can fail-open on the
293
+ * downgrade guard rather than block on a parser disagreement.
294
+ */
295
+ async function npmViewVersion(
296
+ pkg: string,
297
+ channel: string,
298
+ runner: UpgradeRunner,
299
+ ): Promise<string | null> {
300
+ const { code, stdout } = await runner.capture(["npm", "view", `${pkg}@${channel}`, "version"]);
301
+ if (code !== 0) return null;
302
+ const trimmed = stdout.trim();
303
+ if (!trimmed) return null;
304
+ // `npm view <pkg>@<tag> version` prints a single line ("0.5.10\n").
305
+ // Tag points to no version → empty stdout → return null above.
306
+ // Belt-and-braces: take the last non-empty line in case npm decided to be
307
+ // chatty.
308
+ const lines = trimmed.split("\n").filter(Boolean);
309
+ return lines[lines.length - 1] ?? null;
310
+ }
311
+
153
312
  /**
154
313
  * Synthetic services.json row for the hub. The hub isn't in services.json
155
314
  * (it's an implementation detail of `parachute expose`, not a user-facing
@@ -298,15 +457,6 @@ async function listChangedFiles(
298
457
  return stdout.trim().split("\n").filter(Boolean);
299
458
  }
300
459
 
301
- function packageHasScript(pkgJsonPath: string, name: string): boolean {
302
- try {
303
- const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
304
- return Boolean(json?.scripts && typeof json.scripts[name] === "string");
305
- } catch {
306
- return false;
307
- }
308
- }
309
-
310
460
  function readPackageVersion(pkgJsonPath: string): string | null {
311
461
  try {
312
462
  const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
@@ -373,26 +523,62 @@ async function upgradeLinked(
373
523
  }
374
524
  }
375
525
 
376
- if (target.spec?.kind === "frontend") {
377
- const pkgJsonPath = join(sourceDir, "package.json");
378
- if (packageHasScript(pkgJsonPath, "build")) {
379
- r.log(`${target.short}: bun run build`);
380
- const build = await r.runner.run(["bun", "run", "build"], { cwd: sourceDir });
381
- if (build !== 0) {
382
- r.log(`✗ ${target.short}: bun run build failed (exit ${build})`);
383
- return build;
384
- }
385
- }
386
- }
387
-
388
526
  r.log(`${target.short}: ${before.sha.slice(0, 7)} → ${after.sha.slice(0, 7)}; restarting…`);
389
527
  return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
390
528
  }
391
529
 
530
+ /**
531
+ * Pick which dist-tag we're going to ship at. Precedence:
532
+ *
533
+ * 1. explicit `tag` (programmatic — caller passed it directly)
534
+ * 2. operator `--channel rc|latest` flag
535
+ * 3. auto-detected from the installed version string (`-rc` suffix → rc)
536
+ * 4. `latest` fallback (no installed version to read — fresh-install case)
537
+ */
538
+ function pickChannel(installedVersion: string | null, r: Resolved): string {
539
+ if (r.tag) return r.tag;
540
+ if (r.channelOverride) return r.channelOverride;
541
+ if (installedVersion) return detectChannel(installedVersion);
542
+ return "latest";
543
+ }
544
+
392
545
  async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved): Promise<number> {
393
546
  r.log(`${target.short}: npm-installed (${sourceDir})`);
394
547
  const beforeVersion = readPackageVersion(join(sourceDir, "package.json"));
395
- const spec = `${target.packageName}@${r.tag}`;
548
+ const channel = pickChannel(beforeVersion, r);
549
+
550
+ // Downgrade guard: refuse to silently move backward. Only applies when
551
+ // we can read both sides — beforeVersion from disk, targetVersion via
552
+ // `npm view`. A null on either side means we fail-open (legacy
553
+ // behavior: just run `bun add -g`). This is the load-bearing fix for
554
+ // hub#332 — Aaron got `0.5.13-rc.13` → `0.5.10` because the implicit
555
+ // `@latest` resolved to a prior stable while he was on the rc chain.
556
+ if (beforeVersion && !r.allowDowngrade) {
557
+ const targetVersion = await r.resolveChannelVersion(target.packageName, channel);
558
+ if (targetVersion) {
559
+ const cmp = compareVersions(targetVersion, beforeVersion);
560
+ if (cmp !== null && cmp < 0) {
561
+ const channelHint =
562
+ channel === "rc" ? "" : " or rerun with `--channel rc` to stay on the rc chain";
563
+ const rcNote =
564
+ channel === "rc"
565
+ ? " (Unusual but possible: the @rc dist-tag may have been re-pointed at an older release.)"
566
+ : null;
567
+ r.log(
568
+ `✗ ${target.short}: refusing to downgrade — installed ${beforeVersion}, ` +
569
+ `target @${channel} resolves to ${targetVersion}.`,
570
+ );
571
+ if (rcNote) r.log(rcNote);
572
+ r.log(
573
+ ` To force this downgrade, run: bun add -g ${target.packageName}@${targetVersion}${channelHint}`,
574
+ );
575
+ r.log(" Or re-run with --allow-downgrade to bypass this check.");
576
+ return 1;
577
+ }
578
+ }
579
+ }
580
+
581
+ const spec = `${target.packageName}@${channel}`;
396
582
  r.log(`${target.short}: bun add -g ${spec}`);
397
583
  const code = await r.runner.run(["bun", "add", "-g", spec]);
398
584
  if (code !== 0) {
package/src/help.ts CHANGED
@@ -34,8 +34,8 @@ export function installHelp(): string {
34
34
  return `parachute install — install and register a Parachute service
35
35
 
36
36
  Usage:
37
- parachute install <service> [--tag <name>] [--no-start]
38
- parachute install all [--tag <name>] [--no-start]
37
+ parachute install <service> [--channel rc|latest] [--tag <name>] [--no-start]
38
+ parachute install all [--channel rc|latest] [--tag <name>] [--no-start]
39
39
  parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]
40
40
 
41
41
  Services:
@@ -54,8 +54,14 @@ What it does:
54
54
  6. start the service in the background (idempotent — no-op if already up)
55
55
 
56
56
  Flags:
57
+ --channel rc|latest npm dist-tag channel for the install. Defaults to
58
+ \`latest\` unless \`PARACHUTE_INSTALL_CHANNEL\` is set
59
+ (see Environment below). Loses to \`--tag\` (which
60
+ pins an exact version / tag). Garbage env values
61
+ fall back to \`latest\` with a warning.
57
62
  --tag <name> npm dist-tag or exact version to install
58
- (e.g. \`--tag rc\` → \`bun add -g @openparachute/vault@rc\`)
63
+ (e.g. \`--tag rc\` → \`bun add -g @openparachute/vault@rc\`).
64
+ Wins over \`--channel\` and the env var.
59
65
  Skipped if the package is already \`bun link\`-ed locally.
60
66
  --no-start skip the post-install daemon start. For piped / CI
61
67
  installs that own their own process model.
@@ -66,14 +72,25 @@ Flags:
66
72
  Stored in ~/.parachute/scribe/.env. Only meaningful for
67
73
  cloud providers (groq → GROQ_API_KEY, openai → OPENAI_API_KEY).
68
74
 
75
+ Environment:
76
+ PARACHUTE_INSTALL_CHANNEL=rc|latest
77
+ cluster-wide default channel. Lets a Render deploy
78
+ running the hub at \`@rc\` cascade rc to vault / app /
79
+ scribe / runner installed via the admin SPA — without
80
+ an explicit \`--channel\` per call. Loses to \`--channel\`
81
+ and \`--tag\`. Defaults to \`latest\` when unset.
82
+
69
83
  Examples:
70
84
  parachute install vault # installs, runs init, starts vault
71
- parachute install notes # installs and starts notes
85
+ parachute install app # installs app (auto-bootstraps Notes)
86
+ parachute install notes # back-compat: legacy notes-daemon (Phase 2 deprecating)
72
87
  parachute install scribe # installs, prompts for provider, starts scribe
73
88
  parachute install scribe --scribe-provider groq --scribe-key gsk_…
74
89
  # non-interactive scribe setup
75
- parachute install vault --tag rc # pin to rc dist-tag
76
- parachute install all --tag rc # bootstrap whole ecosystem to rc
90
+ parachute install vault --channel rc # pin to rc dist-tag
91
+ PARACHUTE_INSTALL_CHANNEL=rc parachute install vault # same, env-driven
92
+ parachute install vault --tag 0.3.0-rc.1 # pin to an exact version (wins over --channel)
93
+ parachute install all --channel rc # bootstrap whole ecosystem to rc
77
94
  parachute install vault --no-start # install without auto-starting (CI)
78
95
 
79
96
  Aliases:
@@ -93,14 +110,14 @@ What it does:
93
110
  1. surveys ~/.parachute/services.json — already-installed services are
94
111
  reported, then skipped from the picker
95
112
  2. shows a numbered multi-select for the remaining first-party services
96
- (vault, notes, scribe; channel is exploratory and only offered by name)
113
+ (vault, app, scribe; channel is exploratory and only offered by name)
97
114
  3. pre-collects all interactive answers up front so the installs can run
98
115
  without further prompting:
99
116
  - vault: vault name (default \`default\`)
100
117
  - scribe: transcription provider + API key for cloud providers
101
118
  4. iterates \`parachute install <svc>\` per pick, threading the collected
102
119
  answers and the shared --tag / --no-start flags
103
- 5. prints a summary banner with the running URLs (hub, vault, notes, scribe)
120
+ 5. prints a summary banner with the running URLs (hub, vault, app, scribe)
104
121
  and a hint for connecting Claude Code
105
122
 
106
123
  Behavior:
@@ -109,7 +126,7 @@ Behavior:
109
126
  (root cause), so subsequent fallout doesn't mask the original problem.
110
127
  - Non-TTY / piped invocations should use \`parachute install <svc>\` per
111
128
  service instead — \`setup\` assumes a terminal for the prompts.
112
- - Selection accepts numbers (\`1,3\`), names (\`vault, notes\`), or \`all\`.
129
+ - Selection accepts numbers (\`1,3\`), names (\`vault, app\`), or \`all\`.
113
130
 
114
131
  Flags:
115
132
  --tag <name> npm dist-tag or exact version, applied to every install
@@ -155,8 +172,8 @@ Example:
155
172
  SERVICE PORT VERSION PROCESS PID UPTIME HEALTH LATENCY SOURCE
156
173
  parachute-vault 1940 0.2.4 running 12345 2h 13m ok 2ms bun-linked → parachute-vault @ 8aa167b
157
174
  → http://127.0.0.1:1940/vault/default/mcp
158
- parachute-notes 1942 0.0.1 stopped - - - - npm (0.3.15-rc.1)
159
- → http://127.0.0.1:1942/notes
175
+ parachute-app 1946 0.2.0 running 12346 2h 12m ok 3ms npm (0.2.0-rc.4)
176
+ → http://127.0.0.1:1946/app/notes
160
177
  `;
161
178
  }
162
179
 
@@ -280,8 +297,9 @@ Start commands by service:
280
297
  hub bun <cli>/hub-server.ts --port <picked> ...
281
298
  vault parachute-vault serve
282
299
  scribe parachute-scribe serve
300
+ app parachute-app serve
283
301
  channel parachute-channel daemon
284
- notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]>
302
+ notes bun <cli>/notes-serve.ts --port <configured> --mount <paths[0]> # back-compat: legacy notes-daemon
285
303
  `;
286
304
  }
287
305
 
@@ -328,9 +346,17 @@ export function upgradeHelp(): string {
328
346
  Usage:
329
347
  parachute upgrade upgrade every installed service
330
348
  parachute upgrade <service> upgrade just that one
349
+ parachute upgrade [svc] --channel rc|latest
350
+ pin the dist-tag channel explicitly. Default:
351
+ auto-detect from the installed version (a
352
+ \`-rc\` suffix → rc; otherwise latest).
353
+ parachute upgrade [svc] --allow-downgrade
354
+ bypass the "refuses to downgrade" guard.
331
355
  parachute upgrade [svc] --tag <name>
332
- npm-installed services only — pin a dist-tag
333
- (default: latest). Ignored when bun-linked.
356
+ npm-installed services only — pin to an
357
+ explicit dist-tag or exact version. Overrides
358
+ --channel auto-detection. Ignored when
359
+ bun-linked.
334
360
 
335
361
  What it does:
336
362
  Detects whether the target service is bun-linked from a local checkout
@@ -341,18 +367,29 @@ What it does:
341
367
  modules with a build script, then \`parachute restart\`.
342
368
  Refuses on a dirty working tree — commit or stash first.
343
369
 
344
- npm bun add -g <pkg>@<tag>, then \`parachute restart\` if the
345
- installed version actually moved.
370
+ npm bun add -g <pkg>@<channel>, then \`parachute restart\` if the
371
+ installed version actually moved. Refuses silent downgrades:
372
+ if the resolved channel version is lower than what's installed,
373
+ aborts with an actionable message.
346
374
 
347
375
  Idempotent: if the source didn't change (HEAD unchanged after pull, or
348
376
  package.json version unchanged after bun add -g), the restart is skipped.
349
377
  Re-running on an up-to-date install is a fast no-op.
350
378
 
379
+ Channel detection (hub#332):
380
+ Pre-1.0 governance ships two channels — \`@rc\` (the development chain) and
381
+ \`@latest\` (explicitly-promoted stable). \`parachute upgrade\` reads the
382
+ installed package.json \`version\` and keeps you on the same channel: a
383
+ \`-rc\` suffix (e.g. \`0.5.13-rc.13\`) means you're on rc and the upgrade
384
+ pulls \`@rc\`; otherwise it pulls \`@latest\`. Override with \`--channel\`.
385
+
351
386
  Examples:
352
387
  parachute upgrade sweep hub + every installed service
353
388
  parachute upgrade vault just vault
354
389
  parachute upgrade hub upgrade the dispatcher itself (closes #251)
355
- parachute upgrade vault --tag rc pin the rc dist-tag (npm path only)
390
+ parachute upgrade hub --channel rc pin the rc channel
391
+ parachute upgrade hub --channel latest pin the stable channel
392
+ parachute upgrade vault --tag 0.4.1 pin to an exact version
356
393
  `;
357
394
  }
358
395
 
package/src/hub-server.ts CHANGED
@@ -1534,6 +1534,11 @@ export function hubFetch(
1534
1534
  manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
1535
1535
  };
1536
1536
  if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
1537
+ // hub#342: thread the test-injectable module-manifest reader
1538
+ // through so `management_url` resolution can be exercised in
1539
+ // unit tests without writing real install dirs.
1540
+ if (deps?.readModuleManifest !== undefined)
1541
+ modulesDeps.readModuleManifest = deps.readModuleManifest;
1537
1542
  return handleApiModules(req, modulesDeps);
1538
1543
  }
1539
1544
 
package/src/hub.ts CHANGED
@@ -357,6 +357,12 @@ const HTML_TEMPLATE = `<!doctype html>
357
357
  <p class="tagline">Your personal-computing modules.</p>
358
358
  </header>
359
359
 
360
+ <section class="section" id="get-started-section" hidden>
361
+ <h2>Get started</h2>
362
+ <p class="section-sub">Jump straight into what you came here for.</p>
363
+ <div class="grid" id="get-started-grid"></div>
364
+ </section>
365
+
360
366
  <section class="section" id="services-section">
361
367
  <h2>Services</h2>
362
368
  <p class="section-sub">Surfaces provided by services running on this hub.</p>
@@ -379,6 +385,8 @@ const HTML_TEMPLATE = `<!doctype html>
379
385
  (async () => {
380
386
  const servicesGrid = document.getElementById('services-grid');
381
387
  const adminGrid = document.getElementById('admin-grid');
388
+ const getStartedSection = document.getElementById('get-started-section');
389
+ const getStartedGrid = document.getElementById('get-started-grid');
382
390
 
383
391
  // Services entries are now data-driven from /.well-known/parachute.json.
384
392
  // Each services[] row carries (since hub#... — Phase D consumer side):
@@ -444,6 +452,68 @@ const HTML_TEMPLATE = `<!doctype html>
444
452
  }
445
453
  }
446
454
 
455
+ /**
456
+ * Render the "Get started" section (hub#342) above the Services grid.
457
+ *
458
+ * Two hardcoded targets, each conditional on its prerequisite being
459
+ * installed:
460
+ * - "Open Notes" → /app/notes/ (requires parachute-app installed;
461
+ * App auto-bootstraps Notes-as-UI per parachute-app §17, so the
462
+ * mere presence of App means /app/notes/ is live)
463
+ * - "Browse Vault" → /vault/<first-vault-name>/admin/ (requires
464
+ * parachute-vault installed; uses the first vault's name from
465
+ * its services.json mount path tail)
466
+ *
467
+ * If neither prerequisite is met (fresh install pre-wizard) the
468
+ * section stays hidden. The hardcoded shape mirrors the wizard's
469
+ * own done-screen "Start using your vault" tile — same architectural
470
+ * shape (single obvious entry point) at a different surface.
471
+ *
472
+ * Not driven by /api/modules because discovery is unauth — the
473
+ * services list from /.well-known/parachute.json is sufficient
474
+ * (it carries the same install-detection signal we need, no
475
+ * Bearer required).
476
+ */
477
+ function renderGetStarted(services) {
478
+ if (!getStartedGrid || !getStartedSection) return;
479
+ const tiles = [];
480
+ const hasApp = services.some((s) => s && s.name === 'parachute-app');
481
+ // services[] is fanned out per-vault for parachute-vault rows (see
482
+ // well-known.ts emitVaultRows) — "path" is the per-instance mount
483
+ // "/vault/<name>". Pick the first vault entry; the order matches
484
+ // services.json's paths[] order, so this is deterministic.
485
+ const vault = services.find((s) => s && s.name === 'parachute-vault');
486
+ if (hasApp) {
487
+ tiles.push({
488
+ title: 'Open Notes',
489
+ desc: 'Browse + capture in the Notes app — reads from your vault.',
490
+ href: '/app/notes/',
491
+ });
492
+ }
493
+ if (vault) {
494
+ // vault.path is the per-instance mount "/vault/<name>". Extract
495
+ // the tail as the display name; mirror the wizard's own
496
+ // firstVaultName() shape.
497
+ let vaultName = 'default';
498
+ if (typeof vault.path === 'string' && vault.path.startsWith('/vault/')) {
499
+ const tail = vault.path.slice('/vault/'.length).replace(/\/+$/, '');
500
+ if (tail.length > 0) vaultName = tail;
501
+ }
502
+ tiles.push({
503
+ title: 'Browse Vault',
504
+ desc: "Open your vault's admin UI — content, settings, MCP.",
505
+ href: '/vault/' + encodeURIComponent(vaultName) + '/admin/',
506
+ });
507
+ }
508
+ if (tiles.length === 0) {
509
+ getStartedSection.setAttribute('hidden', '');
510
+ return;
511
+ }
512
+ getStartedSection.removeAttribute('hidden');
513
+ getStartedGrid.innerHTML = '';
514
+ for (const tile of tiles) getStartedGrid.appendChild(renderTile(tile));
515
+ }
516
+
447
517
  function renderServices(services) {
448
518
  // Render one tile per service that declares a uiUrl. Entries without
449
519
  // uiUrl are intentionally omitted — vault is the canonical example
@@ -503,6 +573,7 @@ const HTML_TEMPLATE = `<!doctype html>
503
573
  if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
504
574
  const doc = await wk.json();
505
575
  const services = Array.isArray(doc.services) ? doc.services : [];
576
+ renderGetStarted(services);
506
577
  renderServices(services);
507
578
  } catch (err) {
508
579
  servicesGrid.innerHTML = '<div class="error">Could not load services: ' +
@@ -24,8 +24,6 @@
24
24
  import { promises as fs } from "node:fs";
25
25
  import { join } from "node:path";
26
26
 
27
- export type ModuleKind = "api" | "frontend" | "tool";
28
-
29
27
  export interface ModuleScopeBlock {
30
28
  /** OAuth scopes this module owns. Namespaced by `name` per oauth-scopes.md. */
31
29
  readonly defines?: readonly string[];
@@ -76,8 +74,6 @@ export interface ModuleManifest {
76
74
  readonly displayName?: string;
77
75
  /** One-line subtitle rendered under displayName. */
78
76
  readonly tagline?: string;
79
- /** Drives card vs. iframe vs. launcher in the hub. */
80
- readonly kind: ModuleKind;
81
77
  /** Default loopback port. CLI warns on conflict, doesn't block. */
82
78
  readonly port: number;
83
79
  /** URL paths the module serves under the hub origin. */
@@ -167,13 +163,6 @@ function asOptionalString(v: unknown, where: string, field: string): string | un
167
163
  return v;
168
164
  }
169
165
 
170
- function asKind(v: unknown, where: string): ModuleKind {
171
- if (v !== "api" && v !== "frontend" && v !== "tool") {
172
- throw new ModuleManifestError(`${where}: "kind" must be "api" | "frontend" | "tool"`);
173
- }
174
- return v;
175
- }
176
-
177
166
  function asPort(v: unknown, where: string): number {
178
167
  if (typeof v !== "number" || !Number.isInteger(v) || v <= 0 || v > 65535) {
179
168
  throw new ModuleManifestError(`${where}: "port" must be an integer 1..65535`);
@@ -341,9 +330,20 @@ function asDependencies(v: unknown, where: string): Record<string, ModuleDepende
341
330
  /**
342
331
  * Strict validator. Throws `ModuleManifestError` with the source path so
343
332
  * malformed third-party modules get a clear-enough error to fix. Required
344
- * fields are name, manifestName, kind, port, paths, health.
333
+ * fields are name, manifestName, port, paths, health. The historical `kind`
334
+ * field is fully retired as of hub#301 Phase C/D (#330) — any value (or none)
335
+ * is silently ignored; modules and third parties may continue to ship it in
336
+ * `module.json` without error but hub no longer reads it.
337
+ *
338
+ * The optional `logger` parameter is retained for forward-compatibility
339
+ * with future validator soft-warnings, even though the kind soft-warning
340
+ * it was originally added for has been removed.
345
341
  */
346
- export function validateModuleManifest(raw: unknown, where: string): ModuleManifest {
342
+ export function validateModuleManifest(
343
+ raw: unknown,
344
+ where: string,
345
+ _logger: Pick<Console, "warn"> = console,
346
+ ): ModuleManifest {
347
347
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
348
348
  throw new ModuleManifestError(`${where}: root must be an object`);
349
349
  }
@@ -356,7 +356,6 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
356
356
  );
357
357
  }
358
358
  const manifestName = asString(m.manifestName, where, "manifestName");
359
- const kind = asKind(m.kind, where);
360
359
  const port = asPort(m.port, where);
361
360
  const paths = asStringArray(m.paths, where, "paths");
362
361
  const health = asHealthPath(m.health, where);
@@ -404,7 +403,7 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
404
403
  stripPrefix = m.stripPrefix;
405
404
  }
406
405
 
407
- const out: ModuleManifest = { name, manifestName, kind, port, paths, health };
406
+ const out: ModuleManifest = { name, manifestName, port, paths, health };
408
407
  if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
409
408
  if (tagline !== undefined) (out as { tagline?: string }).tagline = tagline;
410
409
  if (startCmd !== undefined) (out as { startCmd?: readonly string[] }).startCmd = startCmd;
@@ -470,8 +469,14 @@ function asPathOrUrl(v: unknown, where: string, field: string): string | undefin
470
469
  * absent (caller decides whether that's an error — first-party modules fall
471
470
  * back to the vendored manifest; third-party hard-errors). Throws
472
471
  * `ModuleManifestError` on parse / validation failure.
472
+ *
473
+ * The optional `logger` parameter is retained for forward-compatibility
474
+ * with future validator soft-warnings. Defaults to `console`.
473
475
  */
474
- export async function readModuleManifest(packageDir: string): Promise<ModuleManifest | null> {
476
+ export async function readModuleManifest(
477
+ packageDir: string,
478
+ logger: Pick<Console, "warn"> = console,
479
+ ): Promise<ModuleManifest | null> {
475
480
  const path = join(packageDir, ".parachute", "module.json");
476
481
  let buf: string;
477
482
  try {
@@ -488,5 +493,5 @@ export async function readModuleManifest(packageDir: string): Promise<ModuleMani
488
493
  `${path}: failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`,
489
494
  );
490
495
  }
491
- return validateModuleManifest(parsed, path);
496
+ return validateModuleManifest(parsed, path, logger);
492
497
  }