/** * webgpt onboarding — first-run setup wizard (verdict #17). * * Onboarding is a RECOVERABLE configuration state machine, never an automatic * side-effect script. First use only PROMPTS; browser launch, conversation * creation and git push keep going through the real execution boundaries. * Setup is a permanent, re-entrant /brb setup command; legacy users are * silently migrated to completed with zero interruption. * * Frozen pieces: * - onboardingVersion persisted in config; legacy classification from * established-use evidence (binding / conversationUrl / durable facts) — * git remote presence is NOT onboarding evidence. * - 7-step wizard (Welcome -> Environment -> Login -> Conversation -> Git -> * Review -> Done) with a SetupDraft that only commits at Review/Confirm * (real operations launch/bind/create are the documented exceptions). * - Git detection is READ-ONLY GitProjectFacts (local git, no network); * remote-exists != push-ready (needs upstream). * - pushPolicy manual|auto|never with an explicit consumer declaration: * this version declares NO auto-push consumer, so auto is a recorded * preference only and the UI says so (field liveness). */ /* ------------------------------------------------------------------ */ /* Onboarding version + legacy classification */ /* ------------------------------------------------------------------ */ export type OnboardingStatus = "new" | "in_progress" | "completed" | "dismissed"; export interface OnboardingState { /** Which onboarding generation (verdict #18: version answers "which flow"). */ version: 1; /** Where the user is (state answers "how far they got"). */ status: OnboardingStatus; /** Highest wizard step reached (for resume). */ step?: number; updatedAt?: number; } /** * Verdict #18: onboarding is part of BRB by default — no user-level switch. * The ONLY control is an operational kill switch that stops the AUTOMATIC * first-run prompt/entry while leaving /brb setup fully functional and * touching no durable state. */ export function onboardingAutoEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.BRB_ONBOARDING_AUTO !== "0"; } /** * The first-run hint, as durable command output (BUG-016). * * The hint used to be a `ctx.ui.notify()` fired *before* the command's own output. The code * path was correct -- the harness proved the text was produced -- but in the TUI the * transient notify was rendered and then replaced by the output the command sent next, so a * brand-new user never saw it, twice in a row, while staring at the screen. A hint that must * be read cannot live on a channel the next line of output erases; it belongs to that output. */ export const ONBOARDING_FIRST_RUN_HINT = "BRB 尚未完成首次设置。运行 /brb setup 可配置 ChatGPT 会话与项目 Git 推送策略;" + "期间 /brb status · doctor · help 不受影响。"; /** * Should THIS command carry the first-run hint? * * One function instead of a condition per call site, because the two rules that matter are * easy to get subtly wrong apart: the kill switch (verdict #18) is checked FIRST -- it stops * the automatic prompt while leaving /brb setup fully functional and touching no state -- * and only a genuinely new user qualifies. Legacy/migrated/completed/dismissed/in_progress * users are never prompted again. `env` is injectable so the kill switch is testable without * mutating the process environment. */ export function onboardingFirstRunHint( status: OnboardingStatus, env: NodeJS.ProcessEnv = process.env, ): string | null { if (!onboardingAutoEnabled(env)) return null; return status === "new" ? ONBOARDING_FIRST_RUN_HINT : null; } /** * Attach a hint to a command's own output: one extra trailing paragraph, never a replacement. * * The body is returned byte-for-byte when it was, so "the hint did not change the command's * result" is a property a test can assert rather than a promise in a comment. `null`/`""` * (no hint this time) also returns the body untouched, which is what keeps status, doctor and * help readable for everyone else. */ export function withOnboardingHint(body: string, hint: string | null | undefined): string { if (!hint) return body; return `${body}\n\n${hint}`; } export interface EstablishedUseEvidence { hasBinding: boolean; hasConversationUrl: boolean; hasRegistryUsage: boolean; } /** * Legacy migration classification. `git remote exists` is deliberately NOT in * the evidence list: a repository having a remote says nothing about whether * this bridge was set up. */ export function classifyOnboarding( stored: unknown, evidence: EstablishedUseEvidence, ): { status: OnboardingStatus; migratedLegacy: boolean } { if (stored && typeof stored === "object") { const s = stored as Partial; if (s.version === 1) { const status = s.status ?? "new"; return { status: status === "in_progress" ? "in_progress" : status, migratedLegacy: false }; } } const established = evidence.hasBinding || evidence.hasConversationUrl || evidence.hasRegistryUsage; if (established) { // Silent migration: this person already uses the bridge; never show the // "Welcome" flow to them. return { status: "completed", migratedLegacy: true }; } return { status: "new", migratedLegacy: false }; } /* ------------------------------------------------------------------ */ /* Git project facts (read-only, local git only) */ /* ------------------------------------------------------------------ */ export interface GitProjectFacts { isRepository: boolean; branch: string | null; detached: boolean; remotes: string[]; upstream: string | null; ahead: number | null; behind: number | null; dirty: boolean; unpushedCommits: number; error?: string; } export type GitRunner = (args: string[]) => { ok: boolean; stdout: string; stderr: string }; /** * Pure builder over an injected git runner (tests pass a fake or a real * mkdtemp repo). Uses only local git: rev-parse/status/remote/branch — no * network, no GitHub API. */ export function gitProjectFacts(run: GitRunner, cwd: string): GitProjectFacts { const rev = run(["rev-parse", "--is-inside-work-tree"]); if (!rev.ok || rev.stdout.trim() !== "true") { return { isRepository: false, branch: null, detached: false, remotes: [], upstream: null, ahead: null, behind: null, dirty: false, unpushedCommits: 0, error: "not a git repository" }; } const branchOut = run(["rev-parse", "--abbrev-ref", "HEAD"]); const branch = branchOut.ok && branchOut.stdout.trim() !== "HEAD" ? branchOut.stdout.trim() : null; const detached = !branch; const remotes = run(["remote"]) .ok ? run(["remote"]).stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []; // upstream for the current branch: rev-parse --abbrev-ref --symbolic-full-name @{upstream} const upOut = run(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); const upstream = upOut.ok && upOut.stdout.trim() !== "" && !/fatal/.test(upOut.stderr) ? upOut.stdout.trim() : null; // ahead/behind vs upstream let ahead: number | null = null; let behind: number | null = null; if (upstream) { const ab = run(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]); if (ab.ok) { const parts = ab.stdout.trim().split(/\s+/); ahead = Number(parts[0] ?? NaN); behind = Number(parts[1] ?? NaN); if (!Number.isFinite(ahead)) ahead = null; if (!Number.isFinite(behind)) behind = null; } } const dirty = run(["status", "--porcelain"]).ok && run(["status", "--porcelain"]).stdout.trim() !== ""; const unpushedCommits = ahead ?? 0; return { isRepository: true, branch, detached, remotes, upstream, ahead, behind, dirty, unpushedCommits }; } /* ------------------------------------------------------------------ */ /* Setup state machine (pure) */ /* ------------------------------------------------------------------ */ export type SetupStep = "welcome" | "environment" | "login" | "conversation" | "git" | "review" | "done"; export const SETUP_STEPS: readonly SetupStep[] = [ "welcome", "environment", "login", "conversation", "git", "review", "done", ]; export type PushPolicy = "manual" | "auto" | "never"; export interface SetupDraft { step: SetupStep; browserReady: boolean; loginReady: boolean; conversationMode: "bind" | "create" | "skip" | null; conversationUrl: string | null; gitFacts: GitProjectFacts | null; pushPolicy: PushPolicy | null; skippedGit: boolean; createdConversation: boolean; } export function emptyDraft(): SetupDraft { return { step: "welcome", browserReady: false, loginReady: false, conversationMode: null, conversationUrl: null, gitFacts: null, pushPolicy: null, skippedGit: false, createdConversation: false, }; } export function advance(draft: SetupDraft): SetupDraft { const idx = SETUP_STEPS.indexOf(draft.step); if (idx >= SETUP_STEPS.length - 1) return draft; return { ...draft, step: SETUP_STEPS[idx + 1] }; } export function back(draft: SetupDraft): SetupDraft { const idx = SETUP_STEPS.indexOf(draft.step); if (idx <= 0) return draft; return { ...draft, step: SETUP_STEPS[idx - 1] }; } /** Review-time completeness: what "done" means (conversation may be skipped). */ export function isSetupComplete(draft: SetupDraft): boolean { return draft.loginReady && draft.browserReady; } export function pushPolicyLabel(p: PushPolicy | null): string { switch (p) { case "auto": return "auto — 允许支持该策略的执行工作流在满足安全条件时自动 push(当前版本无 auto-push consumer,仅记录偏好)"; case "never": return "never — 桥自动化不得要求/触发 Git push"; case "manual": return "manual — 检测未推送提交但不自动 push,由用户/外部 agent 完成"; default: return "未设置"; } }