@openparachute/hub 0.3.0-rc.1 → 0.5.0
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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +712 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +519 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +652 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +242 -37
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1206 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
package/src/commands/install.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { lstatSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { autoWireScribeAuth } from "../auto-wire.ts";
|
|
6
6
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
7
|
+
import {
|
|
8
|
+
type ModuleManifest,
|
|
9
|
+
ModuleManifestError,
|
|
10
|
+
readModuleManifest,
|
|
11
|
+
validateModuleManifest,
|
|
12
|
+
} from "../module-manifest.ts";
|
|
7
13
|
import { assignServicePort } from "../port-assign.ts";
|
|
8
14
|
import {
|
|
9
15
|
CANONICAL_PORT_MAX,
|
|
10
16
|
CANONICAL_PORT_MIN,
|
|
11
|
-
|
|
17
|
+
FIRST_PARTY_FALLBACKS,
|
|
18
|
+
type FirstPartyFallback,
|
|
19
|
+
type ServiceSpec,
|
|
20
|
+
composeServiceSpec,
|
|
12
21
|
isCanonicalPort,
|
|
13
|
-
knownServices,
|
|
14
22
|
} from "../service-spec.ts";
|
|
15
23
|
import { findService, readManifest, upsertService } from "../services-manifest.ts";
|
|
16
24
|
import { start as lifecycleStart } from "./lifecycle.ts";
|
|
@@ -49,6 +57,15 @@ export interface InstallOpts {
|
|
|
49
57
|
* Defaults to a symlink check against bun's global node_modules prefix.
|
|
50
58
|
*/
|
|
51
59
|
isLinked?: (pkg: string) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Returns the absolute path a global symlink points at, or null if no
|
|
62
|
+
* symlink exists. Used by local-path installs to skip a redundant
|
|
63
|
+
* `bun add -g <abspath>` when the path is already wired up — repeatedly
|
|
64
|
+
* `bun add -g <abspath>`'ing the same path appends duplicate entries to
|
|
65
|
+
* `~/.bun/install/global/package.json` until the lockfile parser breaks
|
|
66
|
+
* (hub#89). Defaults to a `readlink` against bun's global prefixes.
|
|
67
|
+
*/
|
|
68
|
+
linkedPath?: (pkg: string) => string | null;
|
|
52
69
|
/**
|
|
53
70
|
* Optional npm dist-tag or exact version to install. When set, the
|
|
54
71
|
* `bun add -g` call is composed as `<package>@<tag>` so RC testers can
|
|
@@ -82,6 +99,12 @@ export interface InstallOpts {
|
|
|
82
99
|
* the call without spawning a real child.
|
|
83
100
|
*/
|
|
84
101
|
startService?: (short: string) => Promise<number>;
|
|
102
|
+
/**
|
|
103
|
+
* `parachute install vault` only: skip the vault-name prompt by forwarding
|
|
104
|
+
* `--vault-name <name>` to `parachute-vault init`. Used by `parachute setup`
|
|
105
|
+
* (#45) to pre-collect the answer up front. Ignored for non-vault installs.
|
|
106
|
+
*/
|
|
107
|
+
vaultName?: string;
|
|
85
108
|
/**
|
|
86
109
|
* `parachute install scribe` only: pre-pick the transcription provider so
|
|
87
110
|
* the prompt doesn't fire. Validated against scribe's known providers — an
|
|
@@ -106,6 +129,19 @@ export interface InstallOpts {
|
|
|
106
129
|
* unless the test populates services.json directly.
|
|
107
130
|
*/
|
|
108
131
|
portProbe?: (port: number) => Promise<boolean>;
|
|
132
|
+
/**
|
|
133
|
+
* Test seam for reading `<packageDir>/.parachute/module.json`. Production
|
|
134
|
+
* uses the real file reader; tests inject a map from package-dir → manifest
|
|
135
|
+
* (or throw to simulate malformed JSON). Returns null when the package
|
|
136
|
+
* doesn't ship a manifest.
|
|
137
|
+
*/
|
|
138
|
+
readManifest?: (packageDir: string) => Promise<ModuleManifest | null>;
|
|
139
|
+
/**
|
|
140
|
+
* Test seam for reading `<absPath>/package.json` during local-path install.
|
|
141
|
+
* Production reads + parses the file; tests inject a stub. Returns the
|
|
142
|
+
* package's `name` (used to find the install dir post-bun-add).
|
|
143
|
+
*/
|
|
144
|
+
readPackageName?: (absPath: string) => string | null;
|
|
109
145
|
}
|
|
110
146
|
|
|
111
147
|
async function defaultRunner(cmd: readonly string[]): Promise<number> {
|
|
@@ -133,6 +169,24 @@ function defaultIsLinked(pkg: string): boolean {
|
|
|
133
169
|
return false;
|
|
134
170
|
}
|
|
135
171
|
|
|
172
|
+
function defaultLinkedPath(pkg: string): string | null {
|
|
173
|
+
// bun has two install shapes for "linked-style" globals:
|
|
174
|
+
// - `bun link`: <prefix>/node_modules/<pkg> is itself a symlink to source.
|
|
175
|
+
// - `bun add -g <abspath>`: <prefix>/node_modules/<pkg> is a real dir
|
|
176
|
+
// whose entries (package.json, etc.) are file-level symlinks to source.
|
|
177
|
+
// Resolving <prefix>/node_modules/<pkg>/package.json follows the link to
|
|
178
|
+
// the source package.json in either shape; dirname is the source dir.
|
|
179
|
+
for (const prefix of bunGlobalPrefixes()) {
|
|
180
|
+
const pkgJson = join(prefix, ...pkg.split("/"), "package.json");
|
|
181
|
+
try {
|
|
182
|
+
return dirname(realpathSync(pkgJson));
|
|
183
|
+
} catch {
|
|
184
|
+
// Not present at this prefix; try the next.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
136
190
|
/**
|
|
137
191
|
* Short-timeout TCP probe of `127.0.0.1:<port>`. Used by `parachute install`
|
|
138
192
|
* to detect canonical slots that something else is already on. Fail-open:
|
|
@@ -206,32 +260,192 @@ function defaultFindGlobalInstall(pkg: string): string | null {
|
|
|
206
260
|
return null;
|
|
207
261
|
}
|
|
208
262
|
|
|
209
|
-
|
|
263
|
+
function defaultReadPackageName(absPath: string): string | null {
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(readFileSync(join(absPath, "package.json"), "utf8"));
|
|
266
|
+
return typeof parsed?.name === "string" && parsed.name.length > 0 ? parsed.name : null;
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve the absolute path to the installed package directory. Local-path
|
|
274
|
+
* installs are their own source. Npm installs land under a bun globals
|
|
275
|
+
* prefix; we locate via `findGlobalInstall`. Returns null when the dir
|
|
276
|
+
* can't be located (first-party fallback path: not fatal; third-party:
|
|
277
|
+
* the manifest read downstream surfaces the error).
|
|
278
|
+
*/
|
|
279
|
+
function resolveInstallDir(
|
|
280
|
+
target: ResolvedTarget,
|
|
281
|
+
findGlobalInstall: (pkg: string) => string | null,
|
|
282
|
+
): string | null {
|
|
283
|
+
if (target.kind === "local-path") {
|
|
284
|
+
// The local checkout itself is the source. We could also re-read from
|
|
285
|
+
// bun's globals after install, but reading the original avoids any
|
|
286
|
+
// weirdness with bun symlinking the dir vs. copying it.
|
|
287
|
+
return target.absPath;
|
|
288
|
+
}
|
|
289
|
+
const pkgJsonPath = findGlobalInstall(target.packageName);
|
|
290
|
+
return pkgJsonPath ? dirname(pkgJsonPath) : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Read the installed package's `.parachute/module.json`.
|
|
295
|
+
*
|
|
296
|
+
* Returns `null` when the package doesn't ship one (first-party falls back to
|
|
297
|
+
* the vendored manifest; third-party hard-errors at the call site). Returns
|
|
298
|
+
* `"error"` when the manifest exists but is malformed (or the install
|
|
299
|
+
* directory itself can't be located post-install) — caller treats both as
|
|
300
|
+
* an install-aborting error and the helper has already logged.
|
|
301
|
+
*/
|
|
302
|
+
async function readInstalledManifest(
|
|
303
|
+
target: ResolvedTarget,
|
|
304
|
+
packageDir: string | null,
|
|
305
|
+
deps: {
|
|
306
|
+
readManifest: (packageDir: string) => Promise<ModuleManifest | null>;
|
|
307
|
+
log: (line: string) => void;
|
|
308
|
+
},
|
|
309
|
+
): Promise<ModuleManifest | null | "error"> {
|
|
310
|
+
if (!packageDir) {
|
|
311
|
+
// First-party fallback path (typical in tests): we don't actually need
|
|
312
|
+
// a real install dir — the vendored manifest covers us.
|
|
313
|
+
// Third-party: bun-add succeeded but we couldn't locate the install dir;
|
|
314
|
+
// caller already logged a probe-list — just say nothing's there.
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
return await deps.readManifest(packageDir);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err instanceof ModuleManifestError) {
|
|
321
|
+
deps.log(`✗ ${target.packageName}: invalid .parachute/module.json — ${err.message}`);
|
|
322
|
+
} else {
|
|
323
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
324
|
+
deps.log(`✗ ${target.packageName}: failed to read .parachute/module.json — ${msg}`);
|
|
325
|
+
}
|
|
326
|
+
return "error";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* What `parachute install <input>` resolved to. The CLI accepts three forms,
|
|
332
|
+
* and the resolution decides everything downstream — package name to bun-add,
|
|
333
|
+
* whether a vendored fallback applies, whether a missing
|
|
334
|
+
* `.parachute/module.json` is a hard error.
|
|
335
|
+
*/
|
|
336
|
+
type ResolvedTarget =
|
|
337
|
+
| {
|
|
338
|
+
readonly kind: "first-party";
|
|
339
|
+
readonly short: string;
|
|
340
|
+
readonly packageName: string;
|
|
341
|
+
readonly fallback: FirstPartyFallback;
|
|
342
|
+
}
|
|
343
|
+
| {
|
|
344
|
+
readonly kind: "npm";
|
|
345
|
+
readonly packageName: string;
|
|
346
|
+
}
|
|
347
|
+
| {
|
|
348
|
+
readonly kind: "local-path";
|
|
349
|
+
readonly absPath: string;
|
|
350
|
+
readonly packageName: string;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Map an `<input>` (shortname / npm package / absolute path) to a target.
|
|
355
|
+
* Returns null on resolution failure (with logs already written).
|
|
356
|
+
*
|
|
357
|
+
* Order matters: first-party shortnames win over a hypothetical npm package
|
|
358
|
+
* literally named "vault", and absolute-path detection has to come before the
|
|
359
|
+
* "anything else is npm" fallback.
|
|
360
|
+
*/
|
|
361
|
+
function resolveInstallTarget(
|
|
362
|
+
input: string,
|
|
363
|
+
opts: InstallOpts,
|
|
364
|
+
log: (line: string) => void,
|
|
365
|
+
): ResolvedTarget | null {
|
|
366
|
+
// Aliases (lens → notes) apply only to shortnames — npm packages and
|
|
367
|
+
// absolute paths pass through unaltered.
|
|
368
|
+
const aliased = SERVICE_ALIASES[input];
|
|
369
|
+
const candidate = aliased ?? input;
|
|
370
|
+
|
|
371
|
+
const fb = FIRST_PARTY_FALLBACKS[candidate];
|
|
372
|
+
if (fb) {
|
|
373
|
+
if (aliased !== undefined) {
|
|
374
|
+
log(`"${input}" has been renamed to "${aliased}"; installing ${aliased}.`);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
kind: "first-party",
|
|
378
|
+
short: candidate,
|
|
379
|
+
packageName: fb.package,
|
|
380
|
+
fallback: fb,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (input.startsWith("/")) {
|
|
385
|
+
if (!existsSync(input)) {
|
|
386
|
+
log(`unknown service: "${input}" (path does not exist)`);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const readName = opts.readPackageName ?? defaultReadPackageName;
|
|
390
|
+
const packageName = readName(input);
|
|
391
|
+
if (!packageName) {
|
|
392
|
+
log(`✗ ${input} has no readable package.json — can't install as a Parachute module.`);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return { kind: "local-path", absPath: input, packageName };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Anything else is treated as an npm package (bare or @scope/name). The
|
|
399
|
+
// module.json contract gates this — third-party packages without a
|
|
400
|
+
// manifest fail post-install with a clear error, not silently.
|
|
401
|
+
return { kind: "npm", packageName: input };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function install(input: string, opts: InstallOpts = {}): Promise<number> {
|
|
210
405
|
const runner = opts.runner ?? defaultRunner;
|
|
211
406
|
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
212
407
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
213
408
|
const now = opts.now ?? (() => new Date());
|
|
214
409
|
const log = opts.log ?? ((line) => console.log(line));
|
|
215
410
|
const isLinked = opts.isLinked ?? defaultIsLinked;
|
|
411
|
+
const linkedPath = opts.linkedPath ?? defaultLinkedPath;
|
|
216
412
|
const findGlobalInstall = opts.findGlobalInstall ?? defaultFindGlobalInstall;
|
|
413
|
+
const readManifest = opts.readManifest ?? readModuleManifest;
|
|
217
414
|
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
log(`"${service}" has been renamed to "${aliased}"; installing ${aliased}.`);
|
|
221
|
-
}
|
|
222
|
-
const resolvedService = aliased ?? service;
|
|
415
|
+
const target = resolveInstallTarget(input, opts, log);
|
|
416
|
+
if (!target) return 1;
|
|
223
417
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
418
|
+
// bun-add gate: skip when the package is already wired up.
|
|
419
|
+
// - first-party + isLinked: scribe-style `bun link` against an unpublished
|
|
420
|
+
// local checkout. `bun add -g` would 404.
|
|
421
|
+
// - local-path + symlink already points at this path: re-installing the
|
|
422
|
+
// same checkout. `bun add -g <abspath>` accumulates duplicate entries
|
|
423
|
+
// in `~/.bun/install/global/package.json` until bun's lockfile parser
|
|
424
|
+
// gives up (hub#89 — caught during paraclaw smoke testing 2026-04-27).
|
|
425
|
+
// Otherwise run `bun add -g <spec>` so bun's link plumbing produces a
|
|
426
|
+
// binary on PATH.
|
|
427
|
+
const localAlreadyLinkedTo = target.kind === "local-path" ? linkedPath(target.packageName) : null;
|
|
428
|
+
// Compare via realpath on the input side too, so symlinks in the path
|
|
429
|
+
// the user typed don't make us miss an existing match.
|
|
430
|
+
let targetReal: string | undefined;
|
|
431
|
+
if (target.kind === "local-path") {
|
|
432
|
+
try {
|
|
433
|
+
targetReal = realpathSync(target.absPath);
|
|
434
|
+
} catch {
|
|
435
|
+
targetReal = target.absPath;
|
|
436
|
+
}
|
|
229
437
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
438
|
+
if (target.kind === "first-party" && isLinked(target.packageName)) {
|
|
439
|
+
log(`${target.packageName} is already linked globally (bun link) — skipping bun add.`);
|
|
440
|
+
} else if (target.kind === "local-path" && localAlreadyLinkedTo === targetReal) {
|
|
441
|
+
log(`${target.packageName} is already linked at ${target.absPath} — skipping bun add.`);
|
|
233
442
|
} else {
|
|
234
|
-
const addSpec =
|
|
443
|
+
const addSpec =
|
|
444
|
+
target.kind === "local-path"
|
|
445
|
+
? target.absPath
|
|
446
|
+
: opts.tag
|
|
447
|
+
? `${target.packageName}@${opts.tag}`
|
|
448
|
+
: target.packageName;
|
|
235
449
|
log(`Installing ${addSpec}…`);
|
|
236
450
|
const addCode = await runner(["bun", "add", "-g", addSpec]);
|
|
237
451
|
if (addCode !== 0) {
|
|
@@ -242,9 +456,11 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
242
456
|
// Bailing here on exit code alone means the caller-visible install
|
|
243
457
|
// fails and downstream init/seed never runs — so probe the global
|
|
244
458
|
// prefix before treating non-zero as fatal.
|
|
245
|
-
const foundAt = findGlobalInstall(
|
|
459
|
+
const foundAt = findGlobalInstall(target.packageName);
|
|
246
460
|
if (foundAt) {
|
|
247
|
-
log(
|
|
461
|
+
log(
|
|
462
|
+
`bun add reported exit ${addCode} but ${target.packageName} is installed at ${foundAt}.`,
|
|
463
|
+
);
|
|
248
464
|
log(
|
|
249
465
|
"Known bun 1.2.x lockfile quirk — the package landed despite the warning. Proceeding. `bun upgrade` to 1.3.x avoids it.",
|
|
250
466
|
);
|
|
@@ -262,11 +478,69 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
262
478
|
}
|
|
263
479
|
}
|
|
264
480
|
|
|
481
|
+
// Read the installed `.parachute/module.json` (target convention). For
|
|
482
|
+
// first-party we fall back to the vendored manifest when absent; for
|
|
483
|
+
// third-party (npm / local-path) the manifest is the contract — its
|
|
484
|
+
// absence hard-errors here. See
|
|
485
|
+
// `parachute-patterns/patterns/module-json-extensibility.md`.
|
|
486
|
+
const installDir = resolveInstallDir(target, findGlobalInstall);
|
|
487
|
+
const installedManifest = await readInstalledManifest(target, installDir, {
|
|
488
|
+
readManifest,
|
|
489
|
+
log,
|
|
490
|
+
});
|
|
491
|
+
if (installedManifest === "error") return 1;
|
|
492
|
+
|
|
493
|
+
let manifest: ModuleManifest;
|
|
494
|
+
let extras = undefined as FirstPartyFallback["extras"] | undefined;
|
|
495
|
+
if (target.kind === "first-party") {
|
|
496
|
+
manifest = installedManifest ?? target.fallback.manifest;
|
|
497
|
+
extras = target.fallback.extras;
|
|
498
|
+
} else {
|
|
499
|
+
if (!installedManifest) {
|
|
500
|
+
log(`✗ ${target.packageName} does not ship .parachute/module.json — not a Parachute module.`);
|
|
501
|
+
log(
|
|
502
|
+
" Authors: see parachute-patterns/patterns/module-json-extensibility.md for the contract.",
|
|
503
|
+
);
|
|
504
|
+
return 1;
|
|
505
|
+
}
|
|
506
|
+
// Third-party `name` collides with a first-party shortname → reject
|
|
507
|
+
// before we mint a services.json row that would hide a real first-party
|
|
508
|
+
// install. (Scope namespace is also `name`; collision == squatting.)
|
|
509
|
+
if (FIRST_PARTY_FALLBACKS[installedManifest.name] !== undefined) {
|
|
510
|
+
log(
|
|
511
|
+
`✗ ${target.packageName}: module name "${installedManifest.name}" collides with a first-party Parachute module.`,
|
|
512
|
+
);
|
|
513
|
+
return 1;
|
|
514
|
+
}
|
|
515
|
+
manifest = installedManifest;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const short = target.kind === "first-party" ? target.short : manifest.name;
|
|
519
|
+
const spec: ServiceSpec = composeServiceSpec({
|
|
520
|
+
packageName: target.packageName,
|
|
521
|
+
manifest,
|
|
522
|
+
extras,
|
|
523
|
+
});
|
|
524
|
+
// services.json key. Third-party modules key by `manifest.name` (canonical
|
|
525
|
+
// short — what `parachute start <svc>` accepts). First-party services keep
|
|
526
|
+
// keying by `manifestName` ("parachute-vault" etc.) because the upstream
|
|
527
|
+
// services write themselves to services.json under that name; switching the
|
|
528
|
+
// CLI seed alone would create dueling rows. The first-party migration to
|
|
529
|
+
// name-keyed rows happens when each upstream ships its own module.json
|
|
530
|
+
// (parachute-hub#56 follow-ups). See parachute-hub#85.
|
|
531
|
+
const entryName = target.kind === "first-party" ? spec.manifestName : manifest.name;
|
|
532
|
+
|
|
265
533
|
if (spec.init) {
|
|
266
|
-
|
|
267
|
-
|
|
534
|
+
// Forward --vault-name from the InstallOpts when set so `parachute setup`
|
|
535
|
+
// (and any future programmatic caller) can pre-answer the name prompt.
|
|
536
|
+
const initCmd =
|
|
537
|
+
short === "vault" && opts.vaultName
|
|
538
|
+
? [...spec.init, "--vault-name", opts.vaultName]
|
|
539
|
+
: spec.init;
|
|
540
|
+
log(`Running ${initCmd.join(" ")}…`);
|
|
541
|
+
const initCode = await runner(initCmd);
|
|
268
542
|
if (initCode !== 0) {
|
|
269
|
-
log(`${
|
|
543
|
+
log(`${initCmd.join(" ")} exited ${initCode}`);
|
|
270
544
|
return initCode;
|
|
271
545
|
}
|
|
272
546
|
}
|
|
@@ -278,15 +552,10 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
278
552
|
// user-edited ports survive across upgrades. Compiled-in service-side
|
|
279
553
|
// fallbacks (vault → 1940 etc.) stay; this just adds a CLI-managed
|
|
280
554
|
// override.
|
|
281
|
-
const preInitEntry = findService(
|
|
555
|
+
const preInitEntry = findService(entryName, manifestPath);
|
|
282
556
|
const probe = opts.portProbe ?? defaultPortProbe;
|
|
283
|
-
const occupied = await collectOccupiedPorts(
|
|
284
|
-
|
|
285
|
-
spec.manifestName,
|
|
286
|
-
preInitEntry?.port,
|
|
287
|
-
probe,
|
|
288
|
-
);
|
|
289
|
-
const envPath = join(configDir, resolvedService, ".env");
|
|
557
|
+
const occupied = await collectOccupiedPorts(manifestPath, entryName, preInitEntry?.port, probe);
|
|
558
|
+
const envPath = join(configDir, short, ".env");
|
|
290
559
|
const canonicalPort = spec.seedEntry?.().port ?? preInitEntry?.port;
|
|
291
560
|
const portResult = assignServicePort({
|
|
292
561
|
envPath,
|
|
@@ -306,20 +575,24 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
306
575
|
// parachute-hub#44 reported notes not appearing in services.json on a fresh
|
|
307
576
|
// bun 1.2.x install; the gate logic was already correct, but a verify-step
|
|
308
577
|
// turns silent loss into something an operator can spot.
|
|
309
|
-
let entry = findService(
|
|
578
|
+
let entry = findService(entryName, manifestPath);
|
|
310
579
|
if (!entry && spec.seedEntry) {
|
|
311
580
|
const seedBase = spec.seedEntry();
|
|
581
|
+
// seedEntryFromManifest sets `name = manifest.manifestName`; for
|
|
582
|
+
// third-party we override to `entryName` (= manifest.name) so the row
|
|
583
|
+
// matches the lifecycle lookup key. First-party leaves it alone.
|
|
584
|
+
const withName = seedBase.name === entryName ? seedBase : { ...seedBase, name: entryName };
|
|
312
585
|
const seed =
|
|
313
|
-
|
|
586
|
+
withName.port === portResult.port ? withName : { ...withName, port: portResult.port };
|
|
314
587
|
upsertService(seed, manifestPath);
|
|
315
|
-
entry = findService(
|
|
588
|
+
entry = findService(entryName, manifestPath);
|
|
316
589
|
if (entry) {
|
|
317
590
|
log(
|
|
318
|
-
`Seeded services.json entry for ${
|
|
591
|
+
`Seeded services.json entry for ${entryName} (placeholder; service's own boot will overwrite).`,
|
|
319
592
|
);
|
|
320
593
|
} else {
|
|
321
594
|
log(
|
|
322
|
-
`⚠ tried to seed services.json entry for ${
|
|
595
|
+
`⚠ tried to seed services.json entry for ${entryName}, but the readback came back empty.`,
|
|
323
596
|
);
|
|
324
597
|
log(` manifest path: ${manifestPath}`);
|
|
325
598
|
log(" Re-run `parachute install` once the underlying issue is resolved.");
|
|
@@ -329,18 +602,29 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
329
602
|
// different one (collision). Reflect the CLI's choice so the hub and
|
|
330
603
|
// status views stay consistent with the .env we just wrote.
|
|
331
604
|
upsertService({ ...entry, port: portResult.port }, manifestPath);
|
|
332
|
-
entry = findService(
|
|
605
|
+
entry = findService(entryName, manifestPath);
|
|
333
606
|
log(
|
|
334
|
-
`Updated services.json port to ${portResult.port} for ${
|
|
607
|
+
`Updated services.json port to ${portResult.port} for ${entryName} (was ${preInitEntry?.port ?? "—"}).`,
|
|
335
608
|
);
|
|
336
609
|
}
|
|
337
610
|
|
|
611
|
+
// Stamp installDir on the row. Lifecycle reads it back to find the
|
|
612
|
+
// module's `.parachute/module.json` (third-party startCmd) and to spawn
|
|
613
|
+
// with cwd. Done after seed/port-update so we cover all paths uniformly:
|
|
614
|
+
// the service's own init may have written the row without installDir, and
|
|
615
|
+
// the seed itself doesn't carry it (composeServiceSpec → seedEntry uses
|
|
616
|
+
// the manifest, which doesn't know its own install location).
|
|
617
|
+
if (entry && installDir && entry.installDir !== installDir) {
|
|
618
|
+
upsertService({ ...entry, installDir }, manifestPath);
|
|
619
|
+
entry = findService(entryName, manifestPath);
|
|
620
|
+
}
|
|
621
|
+
|
|
338
622
|
if (!entry) {
|
|
339
623
|
log(
|
|
340
|
-
`Installed, but no services.json entry for "${
|
|
624
|
+
`Installed, but no services.json entry for "${entryName}" yet. Run \`parachute status\` after the service has started.`,
|
|
341
625
|
);
|
|
342
626
|
} else {
|
|
343
|
-
log(`✓ ${
|
|
627
|
+
log(`✓ ${entryName} registered on port ${entry.port}`);
|
|
344
628
|
if (!isCanonicalPort(entry.port)) {
|
|
345
629
|
log(
|
|
346
630
|
`⚠ port ${entry.port} is outside the canonical Parachute range (${CANONICAL_PORT_MIN}–${CANONICAL_PORT_MAX}); may conflict with other software.`,
|
|
@@ -368,7 +652,7 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
368
652
|
// ourselves if it was already running, mirroring the auto-wire pattern.
|
|
369
653
|
// Failure here doesn't fail the install: a flaky restart shouldn't undo a
|
|
370
654
|
// successful `bun add`.
|
|
371
|
-
if (
|
|
655
|
+
if (short === "scribe") {
|
|
372
656
|
const setupOpts: SetupScribeProviderOpts = { configDir, log };
|
|
373
657
|
if (opts.scribeProvider) setupOpts.preselectProvider = opts.scribeProvider;
|
|
374
658
|
if (opts.scribeKey) setupOpts.preselectKey = opts.scribeKey;
|
|
@@ -389,11 +673,9 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
389
673
|
const startService =
|
|
390
674
|
opts.startService ??
|
|
391
675
|
((short: string) => lifecycleStart(short, { manifestPath, configDir, log }));
|
|
392
|
-
const startCode = await startService(
|
|
676
|
+
const startCode = await startService(short);
|
|
393
677
|
if (startCode !== 0) {
|
|
394
|
-
log(
|
|
395
|
-
`⚠ ${resolvedService} didn't start cleanly. Run manually: parachute start ${resolvedService}`,
|
|
396
|
-
);
|
|
678
|
+
log(`⚠ ${short} didn't start cleanly. Run manually: parachute start ${short}`);
|
|
397
679
|
}
|
|
398
680
|
}
|
|
399
681
|
|
|
@@ -411,10 +693,17 @@ export async function install(service: string, opts: InstallOpts = {}): Promise<
|
|
|
411
693
|
// last line of the install always reflects ground truth, not an early
|
|
412
694
|
// snapshot. Surfaced by parachute-hub#44 — defensive logging that turns a
|
|
413
695
|
// missing entry into a visible failure rather than a silent one.
|
|
414
|
-
|
|
696
|
+
let finalEntry = findService(entryName, manifestPath);
|
|
697
|
+
// Re-stamp installDir if the service's first boot rewrote the row without
|
|
698
|
+
// it. Lifecycle commands beyond install (start/stop/restart/logs) need it
|
|
699
|
+
// present; we own this field, services don't have to know it exists.
|
|
700
|
+
if (finalEntry && installDir && finalEntry.installDir !== installDir) {
|
|
701
|
+
upsertService({ ...finalEntry, installDir }, manifestPath);
|
|
702
|
+
finalEntry = findService(entryName, manifestPath);
|
|
703
|
+
}
|
|
415
704
|
if (!finalEntry) {
|
|
416
705
|
log(
|
|
417
|
-
`⚠ ${
|
|
706
|
+
`⚠ ${entryName} is not in services.json after install. \`parachute status\` won't see it. Re-run install or file a bug.`,
|
|
418
707
|
);
|
|
419
708
|
}
|
|
420
709
|
|