@openparachute/hub 0.5.14-rc.9 → 0.6.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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -55,12 +55,15 @@ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
55
55
  import { changePasswordRateLimiter } from "./rate-limit.ts";
56
56
  import { isHttpsRequest } from "./request-protocol.ts";
57
57
  import { findActiveSession } from "./sessions.ts";
58
+ import { isTotpEnrolled } from "./two-factor-store.ts";
58
59
  import {
59
60
  PASSWORD_MAX_LEN,
60
61
  UserNotFoundError,
62
+ type VaultVerb,
61
63
  getUserById,
62
64
  isFirstAdmin,
63
65
  validatePassword,
66
+ vaultVerbsForUserVault,
64
67
  verifyPassword,
65
68
  } from "./users.ts";
66
69
 
@@ -489,6 +492,15 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
489
492
  const adminFlag = isFirstAdmin(deps.db, user.id);
490
493
  const csrf = ensureCsrfToken(req);
491
494
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
495
+ // Per-vault mintable verbs for the "mint an access token" affordance on each
496
+ // tile. Reads the assignment role (today always write → ["read", "write"])
497
+ // so the UI only ever offers a verb the POST handler would accept. Empty for
498
+ // the admin / no-vault branches (no assigned vaults to iterate).
499
+ const mintableVerbs: Record<string, VaultVerb[]> = {};
500
+ for (const v of user.assignedVaults) {
501
+ const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
502
+ if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
503
+ }
492
504
  return htmlResponse(
493
505
  renderAccountHome({
494
506
  username: user.username,
@@ -497,6 +509,8 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
497
509
  hubOrigin: deps.hubOrigin,
498
510
  isFirstAdmin: adminFlag,
499
511
  csrfToken: csrf.token,
512
+ twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
513
+ mintableVerbs,
500
514
  }),
501
515
  200,
502
516
  extra,
@@ -35,6 +35,7 @@
35
35
  import type { Database } from "bun:sqlite";
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { dirname } from "node:path";
38
+ import { MissingDependencyError, type MissingDependencyWire } from "@openparachute/depcheck";
38
39
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
39
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
40
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
@@ -49,11 +50,7 @@ import {
49
50
  getSpec,
50
51
  synthesizeManifestForKnownModule,
51
52
  } from "./service-spec.ts";
52
- import {
53
- findService,
54
- readManifestLenient,
55
- removeService,
56
- } from "./services-manifest.ts";
53
+ import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
57
54
  import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
58
55
  import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
59
56
 
@@ -81,6 +78,15 @@ export interface Operation {
81
78
  log: string[];
82
79
  /** Error message when status is `failed`. Mirrored from the underlying throw. */
83
80
  error?: string;
81
+ /**
82
+ * Structured error detail when the failure is a known typed error — today
83
+ * only `MissingDependencyError.toWire()` (a missing external binary like
84
+ * `bun` / `git` during install). The operations-polling SPA switches on
85
+ * `error_detail.error_type === "missing_dependency"` to render a dedicated
86
+ * install card; the plain `error` string is the fallback for everything
87
+ * else. Wire shape matches `@openparachute/depcheck`'s `MissingDependencyWire`.
88
+ */
89
+ error_detail?: MissingDependencyWire;
84
90
  startedAt: string;
85
91
  finishedAt?: string;
86
92
  }
@@ -89,7 +95,11 @@ export interface OperationsRegistry {
89
95
  create(kind: OperationKind, short: string): Operation;
90
96
  get(id: string): Operation | undefined;
91
97
  /** Append a log line + (optionally) advance status. */
92
- update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void;
98
+ update(
99
+ id: string,
100
+ patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
101
+ logLine?: string,
102
+ ): void;
93
103
  }
94
104
 
95
105
  /**
@@ -122,11 +132,16 @@ class InMemoryOperationsRegistry implements OperationsRegistry {
122
132
  return this.ops.get(id);
123
133
  }
124
134
 
125
- update(id: string, patch: Partial<Pick<Operation, "status" | "error">>, logLine?: string): void {
135
+ update(
136
+ id: string,
137
+ patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
138
+ logLine?: string,
139
+ ): void {
126
140
  const op = this.ops.get(id);
127
141
  if (!op) return;
128
142
  if (patch.status) op.status = patch.status;
129
143
  if (patch.error !== undefined) op.error = patch.error;
144
+ if (patch.error_detail !== undefined) op.error_detail = patch.error_detail;
130
145
  if (logLine) op.log.push(logLine);
131
146
  if (patch.status === "succeeded" || patch.status === "failed") {
132
147
  op.finishedAt = this.clock().toISOString();
@@ -520,13 +535,37 @@ export async function handleInstall(
520
535
  // immediately + the work runs in the background. Errors get logged
521
536
  // to the operation; nothing throws back to the request handler.
522
537
  void runInstall(op.id, short, spec, deps, bodyChannel).catch((err) => {
523
- const msg = err instanceof Error ? err.message : String(err);
524
- registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
538
+ failOperation(registry, op.id, "install", err);
525
539
  });
526
540
 
527
541
  return acceptedOp(op.id);
528
542
  }
529
543
 
544
+ /**
545
+ * Mark an async op failed, attaching the structured `error_detail` wire when
546
+ * the underlying throw is a `MissingDependencyError` (a missing external
547
+ * binary like `bun` / `git` during install). The operations-polling SPA reads
548
+ * `error_detail` to render the dedicated install card; the plain `error`
549
+ * string is the fallback for every other failure.
550
+ */
551
+ function failOperation(
552
+ registry: OperationsRegistry,
553
+ opId: string,
554
+ verb: string,
555
+ err: unknown,
556
+ ): void {
557
+ const msg = err instanceof Error ? err.message : String(err);
558
+ if (err instanceof MissingDependencyError) {
559
+ registry.update(
560
+ opId,
561
+ { status: "failed", error: msg, error_detail: err.toWire() },
562
+ `${verb} failed: ${err.binary} not installed`,
563
+ );
564
+ return;
565
+ }
566
+ registry.update(opId, { status: "failed", error: msg }, `${verb} failed: ${msg}`);
567
+ }
568
+
530
569
  /**
531
570
  * Internal install runner. Exported so non-API callers (the first-boot
532
571
  * wizard at `/admin/setup`, hub#259) can drive the same install →
@@ -722,8 +761,7 @@ export async function handleUpgrade(
722
761
  const spec = specFor(short);
723
762
 
724
763
  void runUpgrade(op.id, short, spec, deps).catch((err) => {
725
- const msg = err instanceof Error ? err.message : String(err);
726
- registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
764
+ failOperation(registry, op.id, "upgrade", err);
727
765
  });
728
766
  return acceptedOp(op.id);
729
767
  }
package/src/api-users.ts CHANGED
@@ -336,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
336
336
  }
337
337
  }
338
338
 
339
- /** DELETE /api/users/:id — hard-delete + token revocation + session/grant cleanup. */
339
+ /**
340
+ * DELETE /api/users/:id — hard-delete + token revocation + session/grant
341
+ * cleanup.
342
+ *
343
+ * Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
344
+ * 204 pre-consistency-fix) so the SPA can warn that the deleted user's
345
+ * tokens linger ~60s on resource-server revocation caches — same surface
346
+ * the reset-password path carries. The race-tolerant "row already gone"
347
+ * path stays a bodyless 204 (nothing was revoked here, no lag to report).
348
+ */
340
349
  export async function handleDeleteUser(
341
350
  req: Request,
342
351
  userId: string,
@@ -390,11 +399,28 @@ export async function handleDeleteUser(
390
399
  if (!removed) {
391
400
  // Race: row deleted by a concurrent request. Operator's intent
392
401
  // (no such user) is already satisfied — same shape as the grant-
393
- // revoke race in `admin-grants.ts`.
402
+ // revoke race in `admin-grants.ts`. No tokens were revoked by THIS
403
+ // call, so there's no revocation lag to warn about; keep the bodyless
404
+ // 204 for the race path.
394
405
  return new Response(null, { status: 204 });
395
406
  }
396
407
  console.log(`user deleted: id=${userId} username=${target.username}`);
397
- return new Response(null, { status: 204 });
408
+ // `revocation_lag_seconds`: same consistency fix the reset-password path
409
+ // got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
410
+ // in hub's DB immediately, but resource servers (vault, scribe, …) cache
411
+ // the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
412
+ // — a deleted user's tokens linger for up to ~60s on those caches. Surface
413
+ // that so the admin isn't surprised when a just-deleted user's client can
414
+ // still read for a minute (relevant in the stolen-device / compromise
415
+ // threat model). 200 + body instead of the old bare 204 so the SPA can
416
+ // render the warning banner.
417
+ return new Response(
418
+ JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
419
+ {
420
+ status: 200,
421
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
422
+ },
423
+ );
398
424
  }
399
425
 
400
426
  /**
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * Run `parachute --help` or `parachute <subcommand> --help` for usage.
7
7
  */
8
8
 
9
+ import { MissingDependencyError } from "@openparachute/depcheck";
9
10
  import pkg from "../package.json" with { type: "json" };
10
11
  import { CloudflaredStateError } from "./cloudflare/state.ts";
11
12
  import { auth } from "./commands/auth.ts";
@@ -472,12 +473,21 @@ async function main(argv: string[]): Promise<number> {
472
473
  return 1;
473
474
  }
474
475
  const exposeArgs = flagExtract.rest;
475
- const layer = exposeArgs[0];
476
+ let layer = exposeArgs[0];
476
477
  const mode = exposeArgs[1];
477
478
  if (isHelpFlag(layer)) {
478
479
  console.log(exposeHelp());
479
480
  return 0;
480
481
  }
482
+ // Alias: `parachute expose cloudflare [--domain X] [off]` is shorthand for
483
+ // `parachute expose public --cloudflare …`. Cloudflare is a public-internet
484
+ // provider, so we rewrite the layer to `public` and force the cloudflare
485
+ // flag — the rest of the dispatch (domain prompt, off-path, etc.) is
486
+ // identical to the canonical form.
487
+ if (layer === "cloudflare") {
488
+ layer = "public";
489
+ flagExtract.cloudflare = true;
490
+ }
481
491
  if (layer !== "tailnet" && layer !== "public") {
482
492
  console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
483
493
  console.error("usage: parachute expose tailnet [off]");
@@ -749,26 +759,13 @@ async function main(argv: string[]): Promise<number> {
749
759
  // after `vault` (including --help) is passed through verbatim.
750
760
  if (rest.length === 0) return await dispatchVault(["--help"]);
751
761
 
752
- // `parachute vault tokens create` in a TTY with no scope-narrowing flag
753
- // guided flow. Any of --scope / --read / --permission means the user
754
- // has already decided, so we stay out of the way. Non-TTY always
755
- // bypasses (no way to answer a prompt). Label is orthogonal the
756
- // guided flow prompts for it only if --label wasn't supplied.
757
- const wantsGuidedTokenCreate =
758
- rest[0] === "tokens" &&
759
- rest[1] === "create" &&
760
- isTtyInteractive() &&
761
- !rest.includes("--scope") &&
762
- !rest.includes("--read") &&
763
- !rest.includes("--permission") &&
764
- !isHelpFlag(rest[2]);
765
- if (wantsGuidedTokenCreate) {
766
- const { runVaultTokensCreateInteractive } = await import(
767
- "./commands/vault-tokens-create-interactive.ts"
768
- );
769
- return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
770
- }
771
-
762
+ // Everything under `vault` forwards transparently to `parachute-vault`.
763
+ // `vault tokens create` used to route through a guided interactive
764
+ // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
765
+ // subcommand it now exits 1 with migration guidance. Access tokens are
766
+ // hub-issued JWTs; mint them with `parachute auth mint-token` or the
767
+ // admin SPA Connect card. We forward verbatim so the operator sees
768
+ // vault's own migration error rather than a hub-side stub.
772
769
  return await dispatchVault(rest);
773
770
  }
774
771
 
@@ -788,6 +785,14 @@ async function run(argv: string[]): Promise<number> {
788
785
  try {
789
786
  return await main(argv);
790
787
  } catch (err) {
788
+ if (err instanceof MissingDependencyError) {
789
+ // A required external binary wasn't on PATH (git / tailscale / tail /
790
+ // …). Print the friendly install block to stderr. interactive:true so
791
+ // the operator at a terminal sees the "ask your sysadmin" trailer; the
792
+ // message was already formatted at construction, so we just emit it.
793
+ console.error(err.message);
794
+ return 1;
795
+ }
791
796
  if (err instanceof ServicesManifestError) {
792
797
  console.error(`services.json is malformed: ${err.message}`);
793
798
  console.error("Fix or remove the file, then re-run.");
package/src/clients.ts CHANGED
@@ -12,12 +12,24 @@
12
12
  * plaintext exactly once. The token endpoint enforces client_secret per
13
13
  * RFC 6749 §3.2.1 (closes #72).
14
14
  *
15
- * Approval gate (closes #74): every row carries a `status` of `pending` or
16
- * `approved`. New self-registrations default to `pending`; only registrations
17
- * that authenticate with an operator token bearing `hub:admin` (the install-
18
- * time path for first-party modules) land as `approved`. The OAuth flow
19
- * rejects `pending` clients at `/oauth/authorize` and `/oauth/token`. An
20
- * operator promotes a pending client via `parachute auth approve-client`.
15
+ * Status column (`pending` | `approved`): every row carries one. New
16
+ * self-registrations default to `pending`; registrations that authenticate
17
+ * with an operator token bearing `hub:admin` (the install-time path for
18
+ * first-party modules) land as `approved`.
19
+ *
20
+ * Single-consent change (2026-05-29): the separate operator "approve this
21
+ * client" gate was retired. The user's OAuth consent IS the authorization —
22
+ * `handleAuthorizeGet` now session-gates a `pending` client: a request
23
+ * carrying a valid session auto-approves the client (status → `approved`,
24
+ * audit-logged) and FALLS THROUGH to the normal consent screen; a session-
25
+ * less request still renders the unauth "App not yet approved" page whose
26
+ * sign-in CTA round-trips back to authorize (after login the user re-enters
27
+ * with a session → auto-approve → consent). The `status` column, the DCR
28
+ * `pending` default, the `/oauth/token` pending rejection, and the
29
+ * `parachute auth approve-client` / SPA approve surfaces all persist but are
30
+ * near-vestigial — kept for defense-in-depth and back-compat. Motivation:
31
+ * Notes/Claude DCR a fresh `client_id` per instance, so a per-client_id
32
+ * approval gate re-prompted the operator constantly.
21
33
  */
22
34
  import type { Database } from "bun:sqlite";
23
35
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -2,8 +2,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
- export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
6
-
7
5
  export const DEFAULT_TUNNEL_NAME = "parachute";
8
6
 
9
7
  /**
@@ -16,12 +14,20 @@ export const DEFAULT_TUNNEL_NAME = "parachute";
16
14
  * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
17
15
  * Re-running `parachute expose public --cloudflare` regenerates the file
18
16
  * at the new path; the legacy file is left in place but unused.
17
+ *
18
+ * `configDir` overrides the base (`~/.parachute` by default). Tests pass a
19
+ * tmp dir so per-tunnel-derived paths never resolve against the operator's
20
+ * real `CONFIG_DIR` — otherwise running the suite scribbles fixture
21
+ * config.yml + log files into `~/.parachute/cloudflared/<name>/`.
19
22
  */
20
- export function cloudflaredPathsFor(tunnelName: string): {
23
+ export function cloudflaredPathsFor(
24
+ tunnelName: string,
25
+ configDir: string = CONFIG_DIR,
26
+ ): {
21
27
  configPath: string;
22
28
  logPath: string;
23
29
  } {
24
- const dir = join(CLOUDFLARED_DIR, tunnelName);
30
+ const dir = join(configDir, "cloudflared", tunnelName);
25
31
  return {
26
32
  configPath: join(dir, "config.yml"),
27
33
  logPath: join(dir, "cloudflared.log"),
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { isBinaryNotFoundError, lookupDep } from "@openparachute/depcheck";
4
5
  import type { Runner } from "../tailscale/run.ts";
5
6
 
6
7
  export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
@@ -10,30 +11,22 @@ export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
10
11
  * "binary not on PATH" errors — anything else (EACCES from a non-executable
11
12
  * file, corrupted binary, etc.) propagates so we don't silently report
12
13
  * "not installed" when something more specific is wrong.
14
+ *
15
+ * The not-found matcher is `@openparachute/depcheck`'s `isBinaryNotFoundError`
16
+ * — the single source of truth across the ecosystem (this used to be a local
17
+ * copy that drifted from vault's `git-preflight.ts`). Pass the binary name so
18
+ * a not-found message about an unrelated file isn't mis-attributed.
13
19
  */
14
20
  export async function isCloudflaredInstalled(runner: Runner): Promise<boolean> {
15
21
  try {
16
22
  const { code } = await runner(["cloudflared", "--version"]);
17
23
  return code === 0;
18
24
  } catch (err) {
19
- if (isBinaryNotFoundError(err)) return false;
25
+ if (isBinaryNotFoundError(err, "cloudflared")) return false;
20
26
  throw err;
21
27
  }
22
28
  }
23
29
 
24
- function isBinaryNotFoundError(err: unknown): boolean {
25
- if (!err || typeof err !== "object") return false;
26
- const e = err as { code?: unknown; message?: unknown };
27
- if (e.code === "ENOENT") return true;
28
- // Bun.spawn's error shape varies across versions; fall back to message
29
- // string matching so we catch "Executable not found in $PATH" and
30
- // "ENOENT" variants without pinning to one runtime detail.
31
- if (typeof e.message === "string") {
32
- return /ENOENT|not found|No such file/i.test(e.message);
33
- }
34
- return false;
35
- }
36
-
37
30
  /**
38
31
  * `cloudflared tunnel login` drops a cert at `~/.cloudflared/cert.pem` — its
39
32
  * presence is cloudflared's own login marker. Every `cloudflared tunnel
@@ -61,30 +54,37 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
61
54
  * other → the binary-download path is still the best generic answer
62
55
  *
63
56
  * The `arch` parameter is the architecture string in `process.arch`
64
- * shape (`x64`, `arm64`, `arm`). Mapped to the suffix cloudflared uses
65
- * in its release artifacts (`amd64`, `arm64`, `arm`). Unknown arches
66
- * fall through to a generic pointer at the releases page.
57
+ * shape (`x64`, `arm64`, `arm`). The static-binary curl recipe + the arch
58
+ * mapping now live in `@openparachute/depcheck`'s `cloudflared` registry
59
+ * entry (`install.linuxBinaryUrl`) the single source of truth shared with
60
+ * the structured `MissingDependencyError` UX. This function keeps its own
61
+ * prose (the surrounding "works across distros" framing the expose flow
62
+ * prints) but derives the URL + arch support from the registry so the two
63
+ * can't drift. A `undefined` recipe (arch with no published artifact) is the
64
+ * signal to fall through to the generic releases pointer rather than
65
+ * fabricating a 404-bound URL.
67
66
  */
68
67
  export function cloudflaredInstallHint(
69
68
  platform: NodeJS.Platform = process.platform,
70
69
  arch: NodeJS.Architecture = process.arch,
71
70
  ): string {
71
+ const releasesUrl = "https://github.com/cloudflare/cloudflared/releases/latest";
72
72
  if (platform === "darwin") {
73
73
  return [
74
74
  "Install cloudflared:",
75
75
  " brew install cloudflared",
76
76
  "",
77
77
  "(or download a static binary from",
78
- " https://github.com/cloudflare/cloudflared/releases/latest)",
78
+ ` ${releasesUrl})`,
79
79
  ].join("\n");
80
80
  }
81
81
  if (platform === "linux") {
82
- const suffix = linuxArtifactSuffix(arch);
83
- if (suffix) {
82
+ const downloadUrl = cloudflaredLinuxDownloadUrl(arch);
83
+ if (downloadUrl) {
84
84
  return [
85
85
  "Install cloudflared (static binary — works across distros):",
86
- ` curl -L -o /usr/local/bin/cloudflared \\`,
87
- ` https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${suffix}`,
86
+ " curl -L -o /usr/local/bin/cloudflared \\",
87
+ ` ${downloadUrl}`,
88
88
  " sudo chmod +x /usr/local/bin/cloudflared",
89
89
  " cloudflared --version",
90
90
  "",
@@ -93,33 +93,28 @@ export function cloudflaredInstallHint(
93
93
  }
94
94
  return [
95
95
  "Install cloudflared from the official binary release:",
96
- " https://github.com/cloudflare/cloudflared/releases/latest",
96
+ ` ${releasesUrl}`,
97
97
  `(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
98
98
  ].join("\n");
99
99
  }
100
- return [
101
- "Install cloudflared from the official binary release:",
102
- " https://github.com/cloudflare/cloudflared/releases/latest",
103
- ].join("\n");
100
+ return ["Install cloudflared from the official binary release:", ` ${releasesUrl}`].join("\n");
104
101
  }
105
102
 
106
103
  /**
107
- * Map a Node `process.arch` to the suffix Cloudflare uses for its
108
- * cloudflared-linux-* release artifacts. Returns undefined for arches
109
- * that don't have a published artifact (we surface a generic pointer
110
- * in that case instead of fabricating a download URL that 404s).
104
+ * Pull the cloudflared-linux-<suffix> download URL for an arch out of the
105
+ * depcheck registry's static-binary recipe. The registry recipe is a
106
+ * multi-line `curl / chmod / version` block; we extract the single
107
+ * `https://…/cloudflared-linux-<suffix>` line so this function's own prose
108
+ * wraps the canonical URL. Returns undefined when the arch has no published
109
+ * artifact (registry recipe is undefined) — the caller then uses the generic
110
+ * pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
111
111
  */
112
- function linuxArtifactSuffix(arch: NodeJS.Architecture): string | undefined {
113
- switch (arch) {
114
- case "x64":
115
- return "amd64";
116
- case "arm64":
117
- return "arm64";
118
- case "arm":
119
- return "arm";
120
- case "ia32":
121
- return "386";
122
- default:
123
- return undefined;
124
- }
112
+ function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
113
+ const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
114
+ if (!recipe) return undefined;
115
+ const urlLine = recipe
116
+ .split("\n")
117
+ .map((l) => l.trim())
118
+ .find((l) => l.startsWith("https://"));
119
+ return urlLine;
125
120
  }