Skip to content

Hand off to a daemon after app setup - #63

Open
boudra wants to merge 2 commits into
mainfrom
feat/hub-daemon-onboarding
Open

Hand off to a daemon after app setup#63
boudra wants to merge 2 commits into
mainfrom
feat/hub-daemon-onboarding

Conversation

@boudra

@boudra boudra commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What changed

App setup used to end on the dashboard, leaving a new operator with a Hub that cannot run anything and no idea that a daemon is what runs it. This is the browser handoff phase of zero-friction Hub onboarding: after app setup, the operator gets one exact command to paste into a terminal, Hub watches for the daemon that command connects, and both ways out land them directly in the Default project their instance was provisioned with.

The intended journey is npx @getpaseo/hub → operator setup → app setup → daemon handoffpaseo hub login → connect → initialize and deploy.

Goals

  • Add a post-app onboarding step that asks the operator to link a daemon.
  • Complete app onboarding server-side before that step renders, so a CLI authorization page opened in another tab is not blocked by the onboarding guard.
  • Show one exact, copyable command: paseo hub login <current-origin> self-hosted, paseo hub login on the official hosted Hub.
  • Poll existing organization daemon state with explicit loading, waiting, failure-with-retry, connected, and "Do this later" states.
  • Land both success and skip directly in the already-created Default project.
  • Add no durable onboarding state, no migration, and no project creation.

Non-goals

Why

App onboarding completes before the handoff renders

The onboarding guard is server-side: an instance operator whose app onboarding is incomplete gets appSetupRequired for every authenticated route, including /cli-login. The terminal command on this screen opens a browser tab to approve a CLI login, so if the handoff were a gate, the flow would deadlock — the screen telling you to run the command would be the same screen blocking the command from finishing.

So leaving app setup is a server fact. completeAppSetup runs first; the moment it succeeds, /cli-login is reachable in any tab. The handoff itself is a client-only phase held in AccountApp, not a route and not a database row. It lives in the tab that finished app setup. A reload lands on the dashboard exactly as before, which is also why no migration or onboarding column is needed.

The command names the address the operator is already looking at

paseo hub login with no argument resolves to the CLI's own default origin. Any other Hub has to be named, and the only address certainly reachable from the operator's machine is the one their browser is on. So the rule is one comparison against that single origin — no deployment flag, no per-instance configuration, nothing new to set.

  • Self-hosted: paseo hub login http://localhost:3000 (the live browser origin, exactly).
  • Official hosted Hub: paseo hub login.

Polling states

The handoff subscribes to the existing organization daemon query and maps it to four named states outside React, so each one has a test:

State What the operator sees
checking "Checking for daemons…" before the first answer
waiting "Waiting for a daemon to connect…" with the command still on screen
failed An alert with the server's own message and a Check again retry; the command stays visible
linked "Daemon connected", naming the daemon, with Continue

A daemon counts as linked when it is active and connected. A connection is never taken back because a later poll failed — linked outranks failed.

Both exits land in the Default project

Instance setup already provisions a Default project, so ending onboarding on a project list with one entry asks the operator to pick the only choice. Continue and Do this later both navigate to /o/<organization-slug>/projects/default/overview, reusing the organization slug already passed into the handoff. Nothing is queried and no project is created.

The route is committed before the client phase is dropped. Dropping it first renders the dashboard at whatever URL onboarding happens to be standing on, which flashes the project list on the way past.

Related work

🚫 Rollout is blocked

Do not merge this PR until getpaseo/paseo#3651 is merged and released.

This screen tells the operator that their terminal "signs you in, connects this daemon, and offers to set up a starter workflow". Today's released CLI authenticates only — connect, initialize, and deploy are added by #3651. Shipping the browser instructions first would promise a flow the CLI does not yet offer.

Test harness correction

Enrolling a daemon in the E2E harness used to reach the database directly with pg.Client, which only works against PostgreSQL and forced the journey to pay for a container. Issuing the enrollment token now goes through the application-owned runtime boundary — the child process that owns the database answers a daemon-enrollment-token command — so it works on either runtime.

That removed the only runtime asymmetry in the journey, and the core handoff spec now runs fully on embedded PGlite with no PostgreSQL container at all. The same journey was also run forced onto PostgreSQL to confirm equivalence, and the affected shared suites still run against PostgreSQL as before.

Verification

Check Result
npx vitest run src/daemons/handoff.test.tsx 11 passed — exact origin in command, hosted omits the argument, look-alike origin still named, canonical Default-project route, all four polling states, success survives a failed poll
npx playwright test e2e/daemon-handoff.spec.ts --project=desktop-chromium (embedded PGlite) 2 passed, 21.3s
Same journey forced onto PostgreSQL (temporary variant, not committed) 2 passed, 21.4s
npx vitest run src/instance-setup/browser-claim.integration.test.ts (PostgreSQL, testcontainers) 6 passed
npx playwright test e2e/apps.spec.ts e2e/first-run.spec.ts --project=desktop-chromium 23 passed
npx playwright test e2e/apps-mobile.spec.ts --project=mobile-chromium 6 passed
npm run release:check exit 0 (test:release, typecheck ×3, lint, format:check, build, npm pack --dry-run)

The E2E journey proves, in one causal run: app-setup completion reaches the handoff; the self-hosted command contains the current origin exactly and copies to the clipboard; /cli-login renders its verification-code form in a second tab while the handoff is displayed; a newly enrolled and connected daemon advances to the success state; and both exits assert the canonical Default-project URL and its Overview immediately after the click, rather than clicking a project link. The screen is axe-clean.

Risk surface

  • Onboarding entry ordering. AccountApp gained one client-side phase check ahead of the existing status branches. Worth confirming the handoff cannot capture a session that is signed out or has no organization — it renders only for appSetupRequired and active.
  • Account state contract. appSetupRequired now declares organization.slug required instead of optional. The server has always sent the same resolved membership as the active state; the integration test asserts both states carry the same slug.
  • Shared E2E helpers. AppSetupSurface.leave() and skipAppSetup() now walk through the handoff and end on the project Overview, so every journey that passes through app setup changed destination. Assertions were updated only where the new true destination required it.
  • No database change. No migration, no new column, no durable onboarding row.

boudra added 2 commits August 21, 2026 19:28
App setup used to end on the dashboard, leaving the operator with no idea
that Hub needs a daemon to run anything. A new step now names the exact
command to run in a terminal and waits for the daemon it connects.

App onboarding is marked complete on the server before this step renders,
so the browser tab the CLI opens for authorization reaches CLI login
instead of being sent back into setup. The step itself is a client-side
phase, not a gate: it lives in the tab that finished app setup, adds no
durable state, and a reload lands on the dashboard exactly as before.

The command is derived from the address the operator is already looking
at. Only a Hub served from the origin `paseo hub login` already defaults
to may omit the argument, so no deployment flag is introduced.

Success and "Do this later" both continue to the dashboard and the default
project that instance setup already provisioned.
Both ways out of the handoff hid the phase and revealed the project list,
leaving the operator to pick the only entry on it. The accepted journey
ends inside the project instance setup already provisioned, so "Continue"
and "Do this later" now navigate there using the organization slug the
handoff was already given. Nothing is queried and no project is created.

The route is committed before the phase is dropped. Dropping it first
renders the dashboard at whatever URL onboarding is standing on, which is
what flashed the project list on the way past.

The E2E now asserts the canonical Default-project URL and its Overview
immediately after the exit, instead of clicking a project link and
performing the navigation the product owes the operator. Shared app-setup
helpers follow the true destination; the handoff journey runs embedded,
so it needs no PostgreSQL container of its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant