@openparachute/hub 0.3.0-rc.1 → 0.5.1

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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -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
- getSpec,
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
- export async function install(service: string, opts: InstallOpts = {}): Promise<number> {
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 aliased = SERVICE_ALIASES[service];
219
- if (aliased !== undefined) {
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
- const spec = getSpec(resolvedService);
225
- if (!spec) {
226
- log(`unknown service: "${resolvedService}"`);
227
- log(`known services: ${knownServices().join(", ")}`);
228
- return 1;
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
- if (isLinked(spec.package)) {
232
- log(`${spec.package} is already linked globally (bun link) skipping bun add.`);
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 = opts.tag ? `${spec.package}@${opts.tag}` : spec.package;
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(spec.package);
459
+ const foundAt = findGlobalInstall(target.packageName);
246
460
  if (foundAt) {
247
- log(`bun add reported exit ${addCode} but ${spec.package} is installed at ${foundAt}.`);
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
- log(`Running ${spec.init.join(" ")}…`);
267
- const initCode = await runner(spec.init);
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(`${spec.init.join(" ")} exited ${initCode}`);
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(spec.manifestName, manifestPath);
555
+ const preInitEntry = findService(entryName, manifestPath);
282
556
  const probe = opts.portProbe ?? defaultPortProbe;
283
- const occupied = await collectOccupiedPorts(
284
- manifestPath,
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(spec.manifestName, manifestPath);
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
- seedBase.port === portResult.port ? seedBase : { ...seedBase, port: portResult.port };
586
+ withName.port === portResult.port ? withName : { ...withName, port: portResult.port };
314
587
  upsertService(seed, manifestPath);
315
- entry = findService(spec.manifestName, manifestPath);
588
+ entry = findService(entryName, manifestPath);
316
589
  if (entry) {
317
590
  log(
318
- `Seeded services.json entry for ${spec.manifestName} (placeholder; service's own boot will overwrite).`,
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 ${spec.manifestName}, but the readback came back empty.`,
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(spec.manifestName, manifestPath);
605
+ entry = findService(entryName, manifestPath);
333
606
  log(
334
- `Updated services.json port to ${portResult.port} for ${spec.manifestName} (was ${preInitEntry?.port ?? "—"}).`,
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 "${spec.manifestName}" yet. Run \`parachute status\` after the service has started.`,
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(`✓ ${spec.manifestName} registered on port ${entry.port}`);
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 (resolvedService === "scribe") {
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(resolvedService);
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
- const finalEntry = findService(spec.manifestName, manifestPath);
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
- `⚠ ${spec.manifestName} is not in services.json after install. \`parachute status\` won't see it. Re-run install or file a bug.`,
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