@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +19 -3
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +5 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +22 -17
- package/src/service-spec.ts +44 -60
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-5Mj6FqPg.css +1 -0
- package/web/ui/dist/assets/{index-D63mUkVX.js → index-BqjySZ_7.js} +12 -12
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 --
|
|
76
|
-
parachute install
|
|
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,
|
|
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,
|
|
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,
|
|
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-
|
|
159
|
-
→ http://127.0.0.1:
|
|
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
|
|
333
|
-
|
|
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>@<
|
|
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
|
|
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: ' +
|
package/src/module-manifest.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
}
|