diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 863bb9f..2294cf8 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -12,8 +12,10 @@ import { dirname, join } from 'node:path' import { appendDirective, + applySchemaLabels, createGiteaClient, discoverRepos, + ensurePmStateRepo, GiteaApiError, type DirectiveEntry, type GiteaClient, @@ -371,6 +373,27 @@ export function registerGiteaIpc(): void { } }) + // First-run bootstrap: apply the label schema to the work repo and ensure the + // pm-state sidecar exists. Idempotent — safe to re-run. `underOrg` tells us + // whether `owner` is an org (vs the token's personal namespace). + ipcMain.handle( + 'config:bootstrap', + async (_event, req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }) => { + try { + const conn = { baseUrl: req.baseUrl.replace(/\/+$/, ''), token: req.token } + const owner = req.owner.trim() + const repo = req.repo.trim() + const client = createGiteaClient({ ...conn, owner, repo }, fetch) + const labels = await applySchemaLabels(client) + const pmStateRepo = `${repo}-pm-state` + const pmState = await ensurePmStateRepo(conn, { owner, repo: pmStateRepo, underOrg: req.underOrg }, fetch) + return { ok: true as const, labels, pmState, pmStateRepo } + } catch (e) { + return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) } + } + }, + ) + // Validate a token + repo before saving: any authed read on the repo proves access. ipcMain.handle('config:test', async (_event, cfg: AppConfig) => { try { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d507def..f3344e3 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -29,6 +29,8 @@ const api = { get: () => ipcRenderer.invoke('config:get'), /** Discover the owners + repos a token can reach (for the onboarding pickers). */ discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn), + /** Apply the label schema + ensure the pm-state repo (first-run bootstrap). */ + bootstrap: (req: unknown) => ipcRenderer.invoke('config:bootstrap', req), /** Validate a token + repo before saving. */ test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg), /** Save config (token encrypted in main); takes effect without restart. */ diff --git a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx index 6e20ad9..71048ae 100644 --- a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx @@ -1,51 +1,121 @@ import React from 'react' +import type { BootstrapResult, DiscoverResult } from '../../global.js' import logoIcon from '../../design/assets/logo-icon.png' -import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js' +import { Badge, Button, Icon, Input, Radio, Select, Tag } from '../ui/index.js' -// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap -export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) { - const [step, setStep] = React.useState(0) - const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle') - const [repo, setRepo] = React.useState('stephen/commitea') - const [boot, setBoot] = React.useState(-1) // -1 idle, 0..2 running, 3 done +/** + * First-run onboarding — a real, live wizard. Welcome → Connect (live token + * discovery) → Repo (pick from the repos that token can actually reach) → + * Bootstrap (apply the label schema + create the pm-state sidecar, for real), + * then into the app. Every step talks to the main-process bridge; nothing here + * is faked. The token is saved encrypted in main only after a successful bootstrap. + */ +export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus' | 'capture') => void }) { + const [step, setStep] = React.useState(0) - React.useEffect(() => { - if (conn !== 'testing') return - const t = setTimeout(() => setConn('ok'), 1100) - return () => clearTimeout(t) - }, [conn]) + // connect step + const [baseUrl, setBaseUrl] = React.useState('https://gitea.stephenmann.io') + const [token, setToken] = React.useState('') + const [discovery, setDiscovery] = React.useState<'idle' | 'testing' | 'ok'>('idle') + const [found, setFound] = React.useState<{ user: string; owners: string[]; reposByOwner: Record } | null>(null) + const [error, setError] = React.useState(null) - React.useEffect(() => { - if (boot < 0 || boot >= 3) return - const t = setTimeout(() => setBoot(boot + 1), 700) - return () => clearTimeout(t) - }, [boot]) + // repo step + const [owner, setOwner] = React.useState('') + const [repo, setRepo] = React.useState('') + + // bootstrap step + const [boot, setBoot] = React.useState<'idle' | 'running' | 'done'>('idle') + const [bootResult, setBootResult] = React.useState | null>(null) + + const repoCount = found ? found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0) : 0 + + const test = async () => { + if (!baseUrl.trim() || !token.trim() || discovery === 'testing') return + setError(null) + setDiscovery('testing') + const res: DiscoverResult = await window.commitea.config + .discover({ baseUrl: baseUrl.trim(), token: token.trim() }) + .catch(() => ({ ok: false as const, error: 'unreachable' })) + if (!res.ok) { + setDiscovery('idle') + setFound(null) + setError( + res.error === '401' || res.error === '403' + ? 'The token was rejected — check it has repo + issue scopes.' + : res.error === '404' + ? "Couldn't reach that Gitea — check the URL." + : `Connection failed${res.error ? ` (${res.error})` : ''}.`, + ) + return + } + setFound({ user: res.user, owners: res.owners, reposByOwner: res.reposByOwner }) + setDiscovery('ok') + const o = res.owners.includes(res.user) ? res.user : res.owners[0] ?? '' + setOwner(o) + setRepo(res.reposByOwner[o]?.[0] ?? '') + } + + const runBootstrap = async () => { + if (!found || boot === 'running') return + setError(null) + setBoot('running') + const res = await window.commitea.config + .bootstrap({ baseUrl: baseUrl.trim(), token: token.trim(), owner, repo, underOrg: owner !== found.user }) + .catch(() => ({ ok: false as const, error: 'unreachable' })) + if (!res.ok) { + setBoot('idle') + setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`) + return + } + // persist the connection now that the repo is prepared + await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim() }) + setBootResult(res) + setBoot('done') + } const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap'] - const BOOT_TASKS = [ - 'Create stephen/pm-state (the sidecar)', - 'Apply the label schema to stephen/commitea', - 'Install a webhook · endpoint :48731', - ] + const repoOptions = found?.reposByOwner[owner] ?? [] const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => ( -
+
{children} - {footer ?
{footer}
: null} + {error ?

{error}

: null} + {footer ? ( +
+ {footer} +
+ ) : null}
) return ( -
- {/* brand */} +
@@ -58,15 +128,24 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture {STEPS.map((s, i) => (
- {i < step ? '✓' : i + 1} - {s} + + {i < step ? '✓' : i + 1} + + + {s} + - {i < STEPS.length - 1 ? : null} + {i < STEPS.length - 1 ? : null}
))}
@@ -76,8 +155,8 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture setStep(1)}>Begin}>

Good morning.

- I'm Reginald, your project manager. I interview you instead of making you fill in forms, - I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that. + I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in + honest ranges, and I never do the arithmetic myself — there's a scheduler for that.

Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost. @@ -86,79 +165,196 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture ) : null} {step === 1 ? ( - - - - }> + + + + + } + >

Your Gitea

- - + { + setBaseUrl(e.target.value) + setDiscovery('idle') + setFound(null) + }} + /> + { + setToken(e.target.value) + setDiscovery('idle') + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void test() + } + }} + />
- - {conn === 'ok' ? connected · 3 repos visible : null} + {discovery === 'ok' ? ( + + connected · {repoCount} repo{repoCount === 1 ? '' : 's'} visible + + ) : null}
) : null} {step === 2 ? ( - - - - }> + + + + + } + >

Which repo shall I manage?

-
- {['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => ( - - ))} + {found && found.owners.length > 1 ? ( +