nexus/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md
Nexus Dev 214df47bf5 docs(31): create phase plan for Puter.js Zero-Config Cloud
4 plans across 3 waves covering all 5 CLOUD requirements:
- Plan 01 (W1): Puter proxy service, routes, tests (CLOUD-01, CLOUD-02)
- Plan 02 (W1): Google OAuth PKCE + API key storage (CLOUD-03, CLOUD-05)
- Plan 03 (W2): Provider Selection UI, 4-step wizard (CLOUD-01/03/04/05)
- Plan 04 (W3): OAuth claim endpoint + human verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:49 +00:00

23 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
31-puter.js-zero-config-cloud 03 execute 2
31-01
31-02
ui/src/components/onboarding/ProviderSelectionStep.tsx
ui/src/components/onboarding/PuterAuthButton.tsx
ui/src/components/onboarding/GoogleOAuthButton.tsx
ui/src/components/onboarding/ApiKeyEntryForm.tsx
ui/src/components/NexusOnboardingWizard.tsx
ui/src/api/puter-proxy.ts
true
CLOUD-01
CLOUD-03
CLOUD-04
CLOUD-05
truths artifacts key_links
User sees a Provider Selection step (Step 3 of 4) in the onboarding wizard with Puter, Google, and API Key options
Clicking 'Continue with Puter' triggers Puter auth popup, stores token server-side, and advances wizard
Google option shows policy-risk warning before enabling the Sign in button (3-second gate)
API key option shows inline form with provider dropdown and key input
Skip for now button is always visible and advances to root directory step
Pre-installed tools (Hermes, Claude Code, OpenClaw) show detected badges via adapter probe
Step indicator shows 'Step N of 4' (updated from 3)
path provides exports
ui/src/components/onboarding/ProviderSelectionStep.tsx Provider selection UI with three option cards and skip button
ProviderSelectionStep
path provides exports
ui/src/components/onboarding/PuterAuthButton.tsx Puter auth button that loads CDN script, calls signIn, posts token to server
PuterAuthButton
path provides exports
ui/src/components/onboarding/GoogleOAuthButton.tsx Google OAuth button with 3-second risk warning gate
GoogleOAuthButton
path provides exports
ui/src/components/onboarding/ApiKeyEntryForm.tsx API key entry form with provider dropdown
ApiKeyEntryForm
path provides exports
ui/src/api/puter-proxy.ts API client for puter-proxy and oauth endpoints
puterProxyApi
path provides
ui/src/components/NexusOnboardingWizard.tsx 4-step wizard with provider selection step inserted
from to via pattern
ui/src/components/onboarding/PuterAuthButton.tsx POST /api/puter-proxy/token fetch call to store Puter token server-side puter-proxy/token
from to via pattern
ui/src/components/onboarding/GoogleOAuthButton.tsx POST /api/oauth/google/authorize fetch call to get auth URL, then window.open oauth/google/authorize
from to via pattern
ui/src/components/onboarding/ApiKeyEntryForm.tsx POST /api/api-keys/store fetch call to store API key api-keys/store
from to via pattern
ui/src/components/NexusOnboardingWizard.tsx ui/src/components/onboarding/ProviderSelectionStep.tsx import and render in step 3 ProviderSelectionStep
Build the Provider Selection step UI components and wire them into the existing 3-step onboarding wizard (making it 4 steps). This includes the Puter auth button, Google OAuth button with risk warning, API key entry form, and adapter auto-detection badges.

Purpose: CLOUD-01 (Puter zero-config UI), CLOUD-03 (Google OAuth UI), CLOUD-04 (auto-detect tools), CLOUD-05 (API key entry UI). This is the user-facing surface for all cloud provider paths. Output: ProviderSelectionStep, PuterAuthButton, GoogleOAuthButton, ApiKeyEntryForm, updated wizard

<execution_context> @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md @.planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md @.planning/phases/30-hardware-detection-mode-selection/30-02-SUMMARY.md From ui/src/components/NexusOnboardingWizard.tsx (current state): ```typescript // 3-step wizard: step 1 = hardware, step 2 = mode, step 3 = root directory // Step indicator: "Step {step} of 3" // defaultAdapter state: "claude_local" | "hermes_local" // probing state for adapter detection // handleSubmit creates company + agents on step 3 form submit ```

From ui/src/components/onboarding/ModeSelector.tsx (selected state pattern):

// Cards use: border-primary bg-primary/5 when selected
// Vertical stack with gap-3
// Each card is a <button type="button"> element

From ui/src/api/agents.ts:

probeAdapter: (type: string) =>
  api.get<{ available: boolean; status: string }>(`/adapters/${encodeURIComponent(type)}/probe`),

From 31-UI-SPEC.md (copywriting):

Step heading: "Choose a provider"
Step subheading: "No API keys needed for the zero-config path."
Puter card label: "Puter -- free, zero-config"
Puter card description: "Free AI powered by your Puter.com account. No API key needed."
Google card label: "Google -- Gemini free tier"
Google risk warning body: "Google has suspended accounts that used third-party apps with Gemini credentials. This may affect your Gmail and Workspace access. Use a Google AI Studio API key instead if you want to avoid this risk."
API key card label: "API key -- subscription provider"
Task 1: API client + PuterAuthButton + GoogleOAuthButton + ApiKeyEntryForm ui/src/api/puter-proxy.ts, ui/src/components/onboarding/PuterAuthButton.tsx, ui/src/components/onboarding/GoogleOAuthButton.tsx, ui/src/components/onboarding/ApiKeyEntryForm.tsx ui/src/api/agents.ts, ui/src/api/client.ts, ui/src/components/onboarding/ModeSelector.tsx, ui/src/components/NexusOnboardingWizard.tsx, .planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md **Create ui/src/api/puter-proxy.ts:**
Import `api` from `./client`. Export `puterProxyApi` with:
- `storeToken: (companyId: string, token: string) => api.post("/puter-proxy/token", { companyId, token })`
- `getAuthUrl: (companyId: string) => api.post<{ url: string }>("/oauth/google/authorize", { companyId })`
- `storeApiKey: (companyId: string, provider: string, apiKey: string) => api.post("/api-keys/store", { companyId, provider, apiKey })`

**Create ui/src/components/onboarding/PuterAuthButton.tsx:**

Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`.

State: `loading` boolean.

`loadScript` helper: check if `window.puter` exists; if not, create a `<script>` tag with `src="https://js.puter.com/v2/"`, append to `<head>`, wait for `load` event. Return a promise.

`handleClick` (the onClick handler — MUST be synchronous entry point for popup to work):
1. Set `loading(true)`.
2. Call `loadScript()` to ensure Puter SDK is loaded.
3. Call `(window as any).puter.auth.signIn()` — this opens the popup. Must be in the same synchronous-ish call chain from the click event.
4. After signIn resolves, extract token: try `(window as any).puter.authToken` first, then `(window as any).puter.auth?.token`, then call `(window as any).puter.auth?.getUser?.()` and check for token on the returned object. Log a warning if token is undefined (per Pitfall 1 in RESEARCH.md).
5. If `companyId` is available, POST token to `/api/puter-proxy/token` via `puterProxyApi.storeToken(companyId, token)`. If `companyId` is not yet available (pre-company-creation), store token in a ref/state for later submission.
6. Call `onSuccess()`. Set `loading(false)`.
7. On any error: `onError("Puter sign-in failed. Check your Puter.com account and try again.")`.

Render a `<Button>` (from @/components/ui/button). When `loading`, show spinner (h-4 w-4 animate-spin SVG, same pattern as NexusOnboardingWizard submit button) and text "Connecting to Puter...". When connected, show CheckCircle icon (from lucide-react) and text "Puter connected -- Continue". Otherwise show LogIn icon and text "Continue with Puter". Use `aria-busy={loading}`.

**IMPORTANT NOTE on companyId timing:** The company is not created until step 4 (root directory submit). The provider selection step (step 3) happens BEFORE company creation. The PuterAuthButton should store the token in component state and pass it up to the wizard. The wizard will POST it to the server after company creation in handleSubmit. Adjust the component accordingly: `onSuccess` receives the token string, parent stores it.

**Create ui/src/components/onboarding/GoogleOAuthButton.tsx:**

Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`.

State: `loading`, `warningShown` (boolean, starts false), `warningTimer` (boolean, starts false — becomes true after 3 seconds).

On mount (or when component becomes visible), start a 3-second timeout that sets `warningTimer = true`. The "Sign in with Google" button is disabled until `warningTimer === true`.

Render:
1. Policy-risk warning (always visible when this option is selected):
   ```
   <div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
     <p className="font-medium">Policy risk -- read before continuing</p>
     <p className="mt-1">Google has suspended accounts that used third-party apps with Gemini credentials. This may affect your Gmail and Workspace access. Use a Google AI Studio API key instead if you want to avoid this risk.</p>
   </div>
   ```
2. Button: "Sign in with Google" (disabled for 3 seconds, then enabled). On click: call `puterProxyApi.getAuthUrl(companyId)` to get the auth URL, then `window.open(url, "_blank", "popup,width=600,height=700")`. Listen for the popup close or for `window.addEventListener("message", ...)` for a success signal. Alternatively, poll `window.location` for `?google_oauth=success` query param. Simpler approach: after opening popup, show "Signing in..." state. The callback redirects to `/?google_oauth=success`, and the parent page detects this.

**IMPORTANT NOTE on companyId timing (same as Puter):** Google OAuth flow also happens before company creation. The `POST /oauth/google/authorize` endpoint needs companyId to store the token. Solution: defer the OAuth flow to require companyId, OR have the authorize endpoint accept a temporary session identifier that gets linked to companyId later. Simpler: make the provider selection step collect the user's INTENT only (which provider they chose), and execute the actual auth flow during handleSubmit after company creation. This avoids the companyId timing issue entirely.

**Revised approach for all auth buttons:** Instead of executing auth immediately, the provider selection step records the user's CHOICE. Then:
- For Puter: the popup runs immediately (before company creation) because it's a user gesture. Store the token in React state. Post it to server during handleSubmit after company creation.
- For Google: similarly, open popup immediately, but the callback URL includes a temporary state ID. The server stores tokens keyed by state. After company creation, a second call links the tokens to the companyId.
- For API key: store in React state, post during handleSubmit.

Actually the SIMPLEST approach: just let all three auth paths run immediately and store tokens in React state. After company creation in handleSubmit, POST all collected tokens/keys to the server. This avoids needing companyId during the auth step.

The GoogleOAuthButton should: open popup to Google auth URL (using a temp state), wait for popup to redirect back to `/api/oauth/google/callback?code=...&state=...` which exchanges the code and stores temporarily. After the popup completes, signal success. During handleSubmit, the stored tokens get linked to the real companyId.

For simplicity in this phase: The Puter and Google auth buttons collect credentials client-side (Puter token in state, Google opens popup that returns to callback). The wizard handleSubmit posts credentials to server after company creation. The GoogleOAuthButton stores the OAuth code exchange result temporarily on the server keyed by state UUID, and handleSubmit calls a "claim" endpoint.

**Simplest viable approach:** Puter token is captured client-side in React state. Google OAuth and API key are also captured client-side. All are posted to server after company creation. The Google OAuth flow captures an auth code client-side via redirect, but since it's a server-side exchange, we need the callback to be server-side.

**Final approach:**
- Puter: signIn() popup -> capture token in React state -> post after company creation. Clean.
- Google: POST /oauth/google/authorize -> server returns { url, stateId }. Open popup to url. Server callback exchanges code, stores tokens keyed by stateId in a temp Map. After company creation, POST /oauth/google/claim { stateId, companyId } -> moves tokens from temp to secretService.
- API key: capture in React state -> post after company creation. Clean.

**Create ui/src/components/onboarding/ApiKeyEntryForm.tsx:**

Props: `{ onSave: (provider: string, apiKey: string) => void; onError: (msg: string) => void }`.

State: `provider` (default "openai"), `apiKey` (string), `saving` (boolean).

Render:
1. `<Label>` + select dropdown with options: OpenAI, Anthropic, Groq.
2. `<Label>` + `<Input>` for API key, type="password".
3. `<Button>` "Save API key". On click: validate apiKey not empty, call `onSave(provider, apiKey)`.
4. Use shadcn `Label`, `Input`, `Button` components. Inline form (no page navigation).
cd /opt/nexus && ls ui/src/api/puter-proxy.ts ui/src/components/onboarding/PuterAuthButton.tsx ui/src/components/onboarding/GoogleOAuthButton.tsx ui/src/components/onboarding/ApiKeyEntryForm.tsx - grep -q "puterProxyApi" ui/src/api/puter-proxy.ts - grep -q "puter-proxy/token" ui/src/api/puter-proxy.ts - grep -q "js.puter.com/v2" ui/src/components/onboarding/PuterAuthButton.tsx - grep -q "puter.auth.signIn" ui/src/components/onboarding/PuterAuthButton.tsx - grep -q "Continue with Puter" ui/src/components/onboarding/PuterAuthButton.tsx - grep -q "Connecting to Puter" ui/src/components/onboarding/PuterAuthButton.tsx - grep -q "Policy risk" ui/src/components/onboarding/GoogleOAuthButton.tsx - grep -q "suspended accounts" ui/src/components/onboarding/GoogleOAuthButton.tsx - grep -q "Sign in with Google" ui/src/components/onboarding/GoogleOAuthButton.tsx - grep -q "3000\|3 .* second\|setTimeout" ui/src/components/onboarding/GoogleOAuthButton.tsx - grep -q "openai\|anthropic\|groq" ui/src/components/onboarding/ApiKeyEntryForm.tsx - grep -q "Save API key" ui/src/components/onboarding/ApiKeyEntryForm.tsx - grep -q 'type="password"' ui/src/components/onboarding/ApiKeyEntryForm.tsx PuterAuthButton loads CDN and triggers signIn popup, GoogleOAuthButton shows risk warning with 3s gate, ApiKeyEntryForm captures provider/key pairs, all pass credentials back to parent via callbacks Task 2: ProviderSelectionStep + wire 4-step wizard ui/src/components/onboarding/ProviderSelectionStep.tsx, ui/src/components/NexusOnboardingWizard.tsx ui/src/components/NexusOnboardingWizard.tsx, ui/src/components/onboarding/ModeSelector.tsx, ui/src/components/onboarding/PuterAuthButton.tsx, ui/src/components/onboarding/GoogleOAuthButton.tsx, ui/src/components/onboarding/ApiKeyEntryForm.tsx, ui/src/api/agents.ts, .planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md **Create ui/src/components/onboarding/ProviderSelectionStep.tsx:**
Props:
```typescript
interface ProviderSelectionStepProps {
  onPuterToken: (token: string) => void;
  onGoogleOAuthState: (stateId: string) => void;
  onApiKey: (provider: string, apiKey: string) => void;
  onSkip: () => void;
  onContinue: () => void;
  detectedAdapters: Record<string, boolean>; // e.g., { hermes_local: true, claude_local: false }
}
```

State: `selectedProvider` ("puter" | "google" | "apikey" | null), `providerReady` boolean (true when auth is complete for selected provider), `error` string | null.

Layout (following ModeSelector card pattern from 30-02):
1. Three vertical cards in a stack (`flex flex-col gap-3`):
   - **Puter card**: `<button type="button">` with `border-primary bg-primary/5` when selected. Label: "Puter -- free, zero-config". Description: "Free AI powered by your Puter.com account. No API key needed." If any adapters detected, show badges inside card: `<span className="text-xs text-primary">{Name} detected</span>`.
   - **Google card**: Same pattern. Label: "Google -- Gemini free tier". Description: "Sign in with Google to access Gemini via your Google account."
   - **API key card**: Same pattern. Label: "API key -- subscription provider". Description: "Use your own OpenAI, Anthropic, or Groq API key."

2. Below cards, conditionally render the action component for the selected provider:
   - Puter selected: `<PuterAuthButton onSuccess={(token) => { onPuterToken(token); setProviderReady(true); }} onError={setError} />`
   - Google selected: `<GoogleOAuthButton onSuccess={(stateId) => { onGoogleOAuthState(stateId); setProviderReady(true); }} onError={setError} />`
   - API key selected: `<ApiKeyEntryForm onSave={(prov, key) => { onApiKey(prov, key); setProviderReady(true); }} onError={setError} />`

3. If `providerReady`: show a "Continue" button calling `onContinue()`.

4. Always show: `<Button variant="ghost" onClick={onSkip}>Skip for now</Button>` with `aria-label="Skip provider setup for now"`.

5. Show detected adapter badges on each card (run probes on mount):
   For each adapter in `detectedAdapters` where value is true, show `<span className="text-xs text-primary">Hermes detected</span>` (or "Claude Code detected", "OpenClaw detected") inside the relevant card area.

6. Error display: if `error` is set, show `<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">{error}</p>`.

**Update ui/src/components/NexusOnboardingWizard.tsx:**

Changes:
1. Import `ProviderSelectionStep` from `./onboarding/ProviderSelectionStep`.
2. Import `puterProxyApi` from `../api/puter-proxy`.
3. Change step count from 3 to 4. Step indicator: `"Step {step} of 4"`.
4. Add state: `puterToken` (string | null), `googleOAuthStateId` (string | null), `apiKeyData` ({ provider: string; apiKey: string } | null).
5. Add state: `detectedAdapters` (Record<string, boolean>, default `{}`).
6. Update the adapter probe useEffect to also probe `claude_local` and `openclaw_gateway` (in parallel with existing `hermes_local` probe). Store results in `detectedAdapters`.
7. Renumber steps:
   - Step 1: Hardware Detection (unchanged)
   - Step 2: Mode Selection (unchanged)
   - Step 3: Provider Selection (NEW)
   - Step 4: Root Directory (was step 3)
8. Step 2 "Continue" now goes to step 3 (not step 3/root dir).
9. Step 3 renders `<ProviderSelectionStep>`. "Skip" and "Continue" both advance to step 4. Back goes to step 2.
10. Step 4 is the existing root directory form. Back goes to step 3.
11. In `handleSubmit` (after company creation, before navigate), add credential storage:
   ```typescript
   // Store collected provider credentials after company creation
   if (puterToken) {
     await puterProxyApi.storeToken(company.id, puterToken).catch(() => {});
   }
   if (googleOAuthStateId) {
     // Claim Google OAuth tokens for this company
     await fetch("/api/oauth/google/claim", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ stateId: googleOAuthStateId, companyId: company.id }),
     }).catch(() => {});
   }
   if (apiKeyData) {
     await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {});
   }
   ```
   All credential storage is non-blocking (catch(() => {})) — same pattern as mode save.
12. Reset new state variables in the reset effect (when wizard closes): `setPuterToken(null)`, `setGoogleOAuthStateId(null)`, `setApiKeyData(null)`.
13. Update heading for step 3: "Choose a provider" with subheading "No API keys needed for the zero-config path."
cd /opt/nexus && pnpm --filter @paperclipai/ui exec tsc --noEmit 2>&1 | head -20 - grep -q "ProviderSelectionStep" ui/src/components/onboarding/ProviderSelectionStep.tsx - grep -q "Choose a provider" ui/src/components/onboarding/ProviderSelectionStep.tsx - grep -q "Skip for now" ui/src/components/onboarding/ProviderSelectionStep.tsx - grep -q "border-primary bg-primary/5" ui/src/components/onboarding/ProviderSelectionStep.tsx - grep -q "detected" ui/src/components/onboarding/ProviderSelectionStep.tsx - grep -q "Step 3.*Provider\|ProviderSelectionStep" ui/src/components/NexusOnboardingWizard.tsx - grep -q "Step {step} of 4\|of 4" ui/src/components/NexusOnboardingWizard.tsx - grep -q "puterToken" ui/src/components/NexusOnboardingWizard.tsx - grep -q "puter-proxy/token\|storeToken" ui/src/components/NexusOnboardingWizard.tsx - grep -q "openclaw_gateway\|claude_local" ui/src/components/NexusOnboardingWizard.tsx - TypeScript compilation passes (tsc --noEmit exits 0) 4-step wizard with ProviderSelectionStep at step 3, adapter auto-detection badges, Puter/Google/APIKey credentials collected and stored after company creation, Skip always available - `cd /opt/nexus && pnpm --filter @paperclipai/ui exec tsc --noEmit` — no TypeScript errors - All new components exist with correct exports - Wizard shows 4 steps with provider selection at step 3 - Provider cards match ModeSelector visual pattern - All UI copy matches 31-UI-SPEC.md copywriting contract

<success_criteria>

  • Provider Selection step renders three provider cards with correct copy
  • Puter auth triggers popup and captures token
  • Google OAuth shows risk warning with 3-second gate
  • API key form captures provider/key pairs
  • Auto-detected adapters show badges on cards
  • Skip for now always works
  • Wizard is 4 steps, credentials stored after company creation </success_criteria>
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-03-SUMMARY.md`