@openparachute/hub 0.6.3-rc.3 → 0.6.3

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.
@@ -174,6 +174,15 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
174
174
  )}</div>`
175
175
  : "";
176
176
 
177
+ // Suppress the "Get started with your AI" card on the no-vault branch:
178
+ // that branch tells the user "You don't have a vault yet" + "ask the operator
179
+ // to assign you one," so a do-the-thing card alongside reads as contradictory
180
+ // (do-this vs you-lack-the-prerequisite). The admin (isFirstAdmin) and
181
+ // assigned-vault branches both have a vault to act against, so the card
182
+ // belongs there.
183
+ const hasNoVault = !isFirstAdmin && assignedVaults.length === 0;
184
+ const startedCard = hasNoVault ? "" : renderGetStartedCard();
185
+
177
186
  const vaultCard = renderVaultCard({
178
187
  assignedVaults,
179
188
  trimmedOrigin,
@@ -197,12 +206,51 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
197
206
  </div>
198
207
  ${mintedBanner}
199
208
  ${mintErrorBanner}
209
+ ${startedCard}
200
210
  ${vaultCard}
201
211
  ${accountCard}
202
212
  </div>${COPY_SCRIPT}`;
203
213
  return baseDocument(`${username} — Parachute`, body);
204
214
  }
205
215
 
216
+ /**
217
+ * The "Get started with your AI" card — the real first stop for a friend
218
+ * landing on `/account/`. Mirrors the operator setup-wizard's
219
+ * `renderStarterPromptsSection` (same two parachute.computer/onboarding/*
220
+ * links + copy) so friends and operators get the same on-ramp. The prompts
221
+ * live on parachute.computer rather than embedded here so they iterate
222
+ * without a hub release; this card just links.
223
+ *
224
+ * Placed near the top of the page (after any banners, before the vault card)
225
+ * because "what do I actually do with this?" is the friend's first question —
226
+ * the connect details below answer "how", this answers "what next".
227
+ */
228
+ function renderGetStartedCard(): string {
229
+ return `
230
+ <section class="section get-started" data-testid="get-started-card">
231
+ <h2>Get started with your AI</h2>
232
+ <p>Two ready-made prompts to paste into Claude (or another AI assistant)
233
+ once your vault is connected — they walk you through it, no setup
234
+ knowledge needed.</p>
235
+ <div class="starter-grid">
236
+ <a class="starter-tile" href="https://parachute.computer/onboarding/vault-setup/"
237
+ target="_blank" rel="noopener" data-testid="starter-vault-setup">
238
+ <h3>Set up your vault</h3>
239
+ <p>Your AI interviews you about where your notes live now and suggests
240
+ a structure that fits how you think.</p>
241
+ <span class="starter-cta">Open prompt ↗</span>
242
+ </a>
243
+ <a class="starter-tile" href="https://parachute.computer/onboarding/surface-build/"
244
+ target="_blank" rel="noopener" data-testid="starter-surface-build">
245
+ <h3>Build a custom UI</h3>
246
+ <p>Your AI builds you a little web app for your vault — your own way to
247
+ see and add to it.</p>
248
+ <span class="starter-cta">Open prompt ↗</span>
249
+ </a>
250
+ </div>
251
+ </section>`;
252
+ }
253
+
206
254
  interface VaultCardOpts {
207
255
  assignedVaults: string[];
208
256
  trimmedOrigin: string;
@@ -303,9 +351,9 @@ function renderVaultCard(opts: VaultCardOpts): string {
303
351
  approve. (Your hub must be reachable from the web for this.)</p>
304
352
  </div>
305
353
 
306
- <p class="mcp-connect-hint" data-testid="connect-any-client-hint">Any other MCP
307
- client (Codex, Goose, Cursor, your own agent): point it at the same endpoint
308
- above over HTTP.</p>
354
+ <p class="mcp-connect-hint" data-testid="connect-any-client-hint">Using something
355
+ else? Point any MCP client at the same endpoint above. (ChatGPT and some other
356
+ web UIs call these "connectors.")</p>
309
357
  </div>
310
358
  <p class="vault-notes-cta">
311
359
  <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
@@ -478,19 +526,24 @@ function renderAccountCard(opts: AccountCardOpts): string {
478
526
  <dl class="kv">
479
527
  <dt>Username</dt>
480
528
  <dd><code>${username}</code></dd>
481
- <dt>Two-factor authentication</dt>
482
- ${twoFactorStatus}
483
529
  </dl>
484
- <p>
485
- <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
486
- </p>
487
- <p>
488
- ${twoFactorLink}
489
- </p>
490
530
  <form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
491
531
  ${renderCsrfHiddenInput(csrfToken)}
492
532
  <button type="submit" class="btn btn-secondary">Sign out</button>
493
533
  </form>
534
+ <details class="account-security" data-testid="account-security">
535
+ <summary>Security &amp; password</summary>
536
+ <dl class="kv">
537
+ <dt>Two-factor authentication</dt>
538
+ ${twoFactorStatus}
539
+ </dl>
540
+ <p>
541
+ <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
542
+ </p>
543
+ <p>
544
+ ${twoFactorLink}
545
+ </p>
546
+ </details>
494
547
  </section>`;
495
548
  }
496
549
 
@@ -604,6 +657,60 @@ const STYLES = `
604
657
  margin-top: 1.25rem;
605
658
  }
606
659
  .section p { margin: 0.4rem 0; }
660
+
661
+ .get-started h3 {
662
+ font-family: ${FONT_SERIF};
663
+ font-weight: 400;
664
+ font-size: 1rem;
665
+ margin: 0 0 0.3rem;
666
+ color: ${PALETTE.fg};
667
+ }
668
+ .starter-grid {
669
+ display: grid;
670
+ grid-template-columns: 1fr 1fr;
671
+ gap: 0.75rem;
672
+ margin: 0.75rem 0 0.2rem;
673
+ }
674
+ .starter-tile {
675
+ display: block;
676
+ border: 1px solid ${PALETTE.borderLight};
677
+ border-radius: 8px;
678
+ padding: 0.8rem 0.9rem;
679
+ background: ${PALETTE.bgSoft};
680
+ text-decoration: none;
681
+ color: inherit;
682
+ transition: border-color 0.15s ease, background 0.15s ease;
683
+ }
684
+ .starter-tile:hover { border-color: ${PALETTE.accent}; background: ${PALETTE.accentSoft}; }
685
+ .starter-tile p {
686
+ font-size: 0.84rem;
687
+ color: ${PALETTE.fgMuted};
688
+ margin: 0 0 0.5rem;
689
+ }
690
+ .starter-cta {
691
+ font-size: 0.85rem;
692
+ font-weight: 500;
693
+ color: ${PALETTE.accent};
694
+ }
695
+ @media (max-width: 480px) {
696
+ .starter-grid { grid-template-columns: 1fr; }
697
+ }
698
+
699
+ .account-security {
700
+ margin: 0.9rem 0 0;
701
+ padding-top: 0.6rem;
702
+ border-top: 1px solid ${PALETTE.borderLight};
703
+ }
704
+ .account-security > summary {
705
+ cursor: pointer;
706
+ font-size: 0.88rem;
707
+ font-weight: 600;
708
+ color: ${PALETTE.fgMuted};
709
+ list-style: revert;
710
+ }
711
+ .account-security > summary:hover { color: ${PALETTE.fg}; }
712
+ .account-security .kv { margin-top: 0.6rem; }
713
+
607
714
  .vault-name {
608
715
  font-family: ${FONT_MONO};
609
716
  font-size: 1rem;
@@ -879,7 +986,14 @@ const STYLES = `
879
986
  code { background: #1f1c18; color: #e8e4dc; }
880
987
  .copy-row code { background: transparent; }
881
988
  .section { border-top-color: #3a362f; }
882
- .mcp-method, .vault-notes-cta, .token-mint { border-top-color: #3a362f; }
989
+ .mcp-method, .vault-notes-cta, .token-mint,
990
+ .account-security { border-top-color: #3a362f; }
991
+ .get-started h3 { color: #f0ece4; }
992
+ .starter-tile { border-color: #3a362f; background: #1f1c18; }
993
+ .starter-tile:hover { border-color: ${PALETTE.accent}; }
994
+ .starter-tile p { color: #a8a29a; }
995
+ .account-security > summary { color: #a8a29a; }
996
+ .account-security > summary:hover { color: #f0ece4; }
883
997
  .brand-tag { border-color: #3a362f; color: #a8a29a; }
884
998
  .copy-row { background: #1f1c18; border-color: #3a362f; }
885
999
  .btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
@@ -69,10 +69,21 @@ export type ConnectorServiceDeps = ManagedUnitDeps;
69
69
 
70
70
  export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
71
71
 
72
+ /**
73
+ * Reverse-DNS prefix for the launchd label + plist filename. Exported so the
74
+ * migrate/teardown stale-per-module-autostart sweep (`src/stale-module-units.ts`,
75
+ * hub#522) can reuse it as a SKIP-list anchor — the connector unit is owned by
76
+ * the supervised model (`expose off --cloudflare` tears it down), and the sweep
77
+ * must never touch it. Reusing the constant keeps the skip-list from drifting if
78
+ * this prefix ever changes.
79
+ */
80
+ export const CLOUDFLARED_LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
81
+ /** systemd unit name prefix. Exported for the same skip-list reason as above. */
82
+ export const CLOUDFLARED_SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
72
83
  /** Reverse-DNS prefix for the launchd label + plist filename. */
73
- const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
84
+ const LAUNCHD_LABEL_PREFIX = CLOUDFLARED_LAUNCHD_LABEL_PREFIX;
74
85
  /** systemd unit name prefix. */
75
- const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
86
+ const SYSTEMD_UNIT_PREFIX = CLOUDFLARED_SYSTEMD_UNIT_PREFIX;
76
87
  /** Provenance comment baked into every rendered connector unit file. */
77
88
  const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
78
89
 
@@ -86,6 +86,11 @@ import { type PortListeningFn, defaultPortListening } from "../port-probe.ts";
86
86
  import { type AliveFn, clearPid, readPid } from "../process-state.ts";
87
87
  import { shortNameForManifest } from "../service-spec.ts";
88
88
  import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
89
+ import {
90
+ type DisableStaleModuleUnitsOpts,
91
+ type DisableStaleModuleUnitsResult,
92
+ disableStaleModuleUnits,
93
+ } from "../stale-module-units.ts";
89
94
 
90
95
  /**
91
96
  * Absolute path to this hub checkout's `src/cli.ts` — the entry the hub unit's
@@ -180,6 +185,19 @@ export interface CutoverDeps {
180
185
  sleep: (ms: number) => Promise<void>;
181
186
  /** The hub-unit deps for install / detect / manager calls. */
182
187
  hubUnitDeps: HubUnitDeps;
188
+ /**
189
+ * Detect + DISABLE any stale per-module autostart unit (#522 — the load-bearing
190
+ * fix). A leftover standalone `parachute-<short>.service` (systemd KeepAlive) /
191
+ * `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
192
+ * keeps RESPAWNING an unsupervised module that binds the module's port — the
193
+ * supervised child then EADDRINUSE-crash-loops. Killing the process is
194
+ * whack-a-mole (the unit resurrects it); we must disable the UNIT. Run in the
195
+ * STOP phase (after the per-module detached stop, before the port-free verify)
196
+ * so the freed port lets the supervised module bind. Ownership-safe (known
197
+ * module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
198
+ * Injectable so tests never touch real systemctl/launchctl.
199
+ */
200
+ disableStaleModuleUnits: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
183
201
  }
184
202
 
185
203
  export interface WriteUnitOpts {
@@ -329,6 +347,7 @@ export const defaultCutoverDeps: CutoverDeps = {
329
347
  probeHealth: defaultHubUnitDeps.probeHealth,
330
348
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
331
349
  hubUnitDeps: defaultHubUnitDeps,
350
+ disableStaleModuleUnits,
332
351
  };
333
352
 
334
353
  export interface CutoverOpts {
@@ -673,6 +692,22 @@ export async function cutoverToSupervised(opts: CutoverOpts = {}): Promise<Cutov
673
692
  await stopDetachedModule(target, configDir, deps, timeoutMs, pollMs, log);
674
693
  }
675
694
 
695
+ // --- Step 3b (#522): DISABLE stale per-module autostart UNITS. ---
696
+ // The load-bearing fix for the recurring "port 1940 taken" crash-loop: a
697
+ // leftover standalone `parachute-<short>.service` (systemd KeepAlive) or
698
+ // `computer.parachute.<short>` (launchd KeepAlive) from the pre-supervisor era
699
+ // keeps RESPAWNING an unsupervised module that binds the port — so the
700
+ // per-module stop above (and the orphan sweep below) is whack-a-mole: the unit
701
+ // resurrects the process within seconds, serving OLD code. We must DISABLE the
702
+ // UNIT so the port stays free for the supervised child. MUST run HERE — after
703
+ // the detached stop, BEFORE the verify-ports-free + unit start — so the freed
704
+ // port lets the supervised module bind. Ownership-safe (known module shorts
705
+ // only; hub + cloudflared skipped), idempotent, non-fatal (a failed disable
706
+ // warns + continues; a system-level unit it can't disable → warn with the
707
+ // manual sudo command). Every disabled unit is reported.
708
+ log("Checking for stale per-module autostart units to disable…");
709
+ deps.disableStaleModuleUnits({ deps: deps.hubUnitDeps, log: (l) => log(l) });
710
+
676
711
  // --- Step 4: §7.2 ORPHAN SWEEP — per services.json port + the hub port. ---
677
712
  // The HUB port keeps the pre-existing blind-adopt (mirrors stopHub's 1939
678
713
  // orphan-adoption — out of scope for MUST-FIX 2). The MODULE ports get the
@@ -797,6 +832,13 @@ export interface TeardownOpts {
797
832
  removedLaunchdMessage: (label: string) => string;
798
833
  removedSystemdMessage: (unitName: string) => string;
799
834
  }) => ManagedUnitRemoveResult;
835
+ /**
836
+ * Test seam: the stale-per-module-autostart disable (#522). Teardown also
837
+ * disables any leftover standalone module autostart unit so a rollback to
838
+ * foreground `serve` doesn't leave a competing module respawning at boot.
839
+ * Injectable so tests never touch real systemctl/launchctl.
840
+ */
841
+ disableStaleModuleUnits?: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
800
842
  }
801
843
 
802
844
  /**
@@ -815,6 +857,7 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
815
857
  const log = opts.log ?? ((line) => console.log(line));
816
858
  const deps = opts.deps ?? defaultHubUnitDeps;
817
859
  const remove = opts.remove ?? removeManagedUnit;
860
+ const disableStale = opts.disableStaleModuleUnits ?? disableStaleModuleUnits;
818
861
  const res = remove({
819
862
  launchdLabel: HUB_LAUNCHD_LABEL,
820
863
  systemdUnitName: HUB_SYSTEMD_UNIT_NAME,
@@ -824,6 +867,11 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
824
867
  removedSystemdMessage: (unitName) =>
825
868
  `Removed systemd unit ${unitName} — the hub no longer starts on boot.`,
826
869
  });
870
+ // #522: also disable any leftover standalone per-module autostart unit so a
871
+ // rollback to foreground `serve` doesn't leave a competing module respawning at
872
+ // boot to race whatever the operator brings up next. Ownership-safe (known
873
+ // module shorts only; hub + cloudflared skipped), idempotent, non-fatal.
874
+ disableStale({ deps, log });
827
875
  if (res.removed) {
828
876
  for (const m of res.messages) log(m);
829
877
  log("");
@@ -648,10 +648,25 @@ export interface BuildHubManagedUnitOpts {
648
648
  *
649
649
  * Resolves the absolute `bun` path via the `which` seam (launchd/systemd don't
650
650
  * search `$PATH` — mirrors how the connector resolves cloudflared). The env
651
- * carries `PARACHUTE_HOME` / `PORT` / `PATH` / `BUN_INSTALL` — and INTENTIONALLY
652
- * OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale origin here would re-create the
653
- * iss-mismatch class; `resolveStartupIssuer` derives it and start-hub self-heals
654
- * the operator token + vault `.env` to the current origin (design §4.1 comment).
651
+ * carries `PARACHUTE_BIND_HOST` / `PARACHUTE_HOME` / `PORT` / `PATH` /
652
+ * `BUN_INSTALL` — and INTENTIONALLY OMITS `PARACHUTE_HUB_ORIGIN`: baking a stale
653
+ * origin here would re-create the iss-mismatch class; `resolveStartupIssuer`
654
+ * derives it and start-hub self-heals the operator token + vault `.env` to the
655
+ * current origin (design §4.1 comment).
656
+ *
657
+ * BIND HOST — `PARACHUTE_BIND_HOST=127.0.0.1` is forced here so every
658
+ * self-hosted supervised hub binds loopback. `parachute serve` itself defaults
659
+ * the bind host to `0.0.0.0` (serve.ts), which is correct for the container
660
+ * shape (the platform's HTTP forwarder must reach the hub) but WRONG for a
661
+ * self-hosted box — bare `serve` would expose the admin/OAuth surfaces on every
662
+ * interface, contradicting the pre-supervisor detached behavior and the trust
663
+ * model `layerOf` (hub-server.ts) assumes (header-absent ⇒ "loopback"). The
664
+ * container path never calls this builder (the Dockerfile pins
665
+ * `ENV PARACHUTE_BIND_HOST=0.0.0.0` + runs `serve` directly), so it stays
666
+ * 0.0.0.0. The canonical expose path is unaffected: cloudflared/tailscale dial
667
+ * `127.0.0.1:<port>` from the same host, and the hub's own proxy targets
668
+ * `http://127.0.0.1:<port>` (hub-server.ts). An operator who genuinely wants
669
+ * all-interfaces can override the generated unit; the default is loopback.
655
670
  *
656
671
  * NOT called by any command in this PR (additive — Phase 3 wires it into `init`).
657
672
  */
@@ -676,6 +691,11 @@ export function buildHubManagedUnit(opts: BuildHubManagedUnitOpts): ManagedUnit
676
691
  systemdDescription: "Parachute hub (serve + supervisor)",
677
692
  execStart: [bunPath, opts.cliPath, "serve"],
678
693
  env: {
694
+ // Force loopback on every self-hosted supervised hub. serve.ts defaults
695
+ // to 0.0.0.0 (container-first); a self-hosted box must NOT bare-serve
696
+ // all-interfaces. Container path bypasses this builder (Dockerfile pins
697
+ // its own 0.0.0.0). See the docstring for the full trust-model rationale.
698
+ PARACHUTE_BIND_HOST: "127.0.0.1",
679
699
  // PARACHUTE_HOME captured at install time (design §4.2) — NOT the default.
680
700
  PARACHUTE_HOME: opts.parachuteHome,
681
701
  PORT: String(port),