diff --git a/.changeset/twelve-wings-like.md b/.changeset/twelve-wings-like.md new file mode 100644 index 000000000..6d3de94d1 --- /dev/null +++ b/.changeset/twelve-wings-like.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +chore: split credentials.tsx pure logic into credentials/ modules with tests diff --git a/src/setup/credentials.tsx b/src/setup/credentials.tsx index 899dd51cc..b143fb80b 100644 --- a/src/setup/credentials.tsx +++ b/src/setup/credentials.tsx @@ -1,5720 +1,30 @@ -import { existsSync } from "node:fs"; -import { spawn } from "node:child_process"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { homedir } from "node:os"; -import path from "node:path"; -import { Box, Text, useInput, useStdin, useStdout } from "ink"; -import { configureAuthProvider } from "../auth/configure.js"; -import { runOAuthAuth } from "../auth/oauth.js"; -import { - AWS_ACCESS_KEY_ID_ENV_KEY, - AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, - AWS_SECRET_ACCESS_KEY_ENV_KEY, - AWS_SESSION_TOKEN_ENV_KEY, - BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, - BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, - DEFAULT_PROVIDER, - DEFAULT_VERTEX_LOCATION, - getDefaultModelId, - getMissingProviderEnvKey, - getProviderApiKeyEnvKey, - getProviderBaseUrlEnvKey, - getProviderBaseUrlWarnings, - getProviderLabel, - getProviderLocationEnvKey, - getProviderModelOptions, - getProviderProjectEnvKey, - getProviderRegionEnvKey, - getProviderRegionEnvKeys, - getProviderSecretKeyEnvKey, - providerRequiresApiKey, - isValidModelId, - normalizeProvider, - normalizeModelId, - OPENAI_CHATGPT_EMAIL_ENV_KEY, - OPENAI_CHATGPT_PLAN_ENV_KEY, - OPENWIKI_GOOGLE_CLIENT_ID_ENV_KEY, - OPENWIKI_GOOGLE_CLIENT_SECRET_ENV_KEY, - OPENWIKI_MODEL_ID_ENV_KEY, - OPENWIKI_PROVIDER_ENV_KEY, - OPENWIKI_TAVILY_API_KEY_ENV_KEY, - OPENWIKI_X_CLIENT_ID_ENV_KEY, - type OpenWikiProvider, - providerRequiresBaseUrl, - providerRequiresRegion, - providerRequiresSecretKey, - providerUsesAwsSdkCredentials, - providerUsesExternalCliAuth, - providerUsesOAuth, - resolveConfiguredProvider, - resolveProviderRegion, - SELECTABLE_OPENWIKI_PROVIDERS, -} from "../config/constants.js"; -import { - type ChatGptLoginHandle, - type CodexTokens, - codexTokensToEnv, - formatChatGptAccount, - isChatGptTokenExpired, - loginWithChatGPT, - readCodexTokensFromEnv, -} from "../agent/openai-chatgpt-oauth.js"; -import type { AuthProviderId } from "../auth/types.js"; -import type { OpenWikiRunMode } from "../cli/commands.js"; -import { - loadLangSmithSetup, - nextLangSmithApiKeyEnv, - saveLangSmithSetup, -} from "../connectors/sources/langsmith/setup.js"; -import type { LangSmithRegion } from "../connectors/sources/langsmith/setup.js"; -import type { ConnectorId } from "../connectors/types.js"; -import { - detectExternalCliCredential, - getExternalCliAuthAdapter, - isExternalCliAvailable, - runExternalCliLogin, - type ExternalCliAuthState, -} from "../auth/external-cli-auth.js"; -import { getConnectorConfigPath } from "../config/openwiki-home.js"; -import { - getSavedEnvValue, - getShellEnvValue, - openWikiEnvPath, - saveOpenWikiEnv, -} from "../config/env.js"; -import { - createEmptyOnboardingConfig, - isOpenWikiOnboardingCompleteSync, - isOnboardingComplete, - isRepositoryCodeOnboardingCompleteSync, - readOpenWikiOnboardingConfig, - readRepositoryWikiInstructions, - saveRepositoryWikiInstructions, - saveOpenWikiOnboardingConfig, - type OpenWikiOnboardingConfig, -} from "./onboarding.js"; -import { - getSuggestedCronExpression, - installOpenWikiPowerSchedule, - installConnectorSchedule, - validateCronExpression, -} from "../scheduling/schedules.js"; - -export type InitSetupResult = { - mode: OpenWikiRunMode; - modelId: string | null; - onboardingCompleted: boolean; - provider: OpenWikiProvider | null; - repoRoot?: string; - runIngestionNow: boolean; - savedApiKey: boolean; - savedBaseUrl: boolean; - savedGcpLocation: boolean; - savedGcpProject: boolean; - savedLangSmithKey: boolean; - savedModelId: boolean; - savedProvider: boolean; - savedRegion: boolean; - savedSecretKey: boolean; - shouldContinueToRun: boolean; -}; - -type InitSetupProps = { - allowModeSelection?: boolean; - mode: OpenWikiRunMode; - modelIdOverride?: string | null; - onComplete: (result: InitSetupResult) => void; - onError: (message: string) => void; - /** - * When true (explicit `--init`), walk every applicable step even when it is - * already configured, so the run can review/change any of them. When false - * the wizard skips satisfied steps and collects only what is missing. - */ - walkAllSteps?: boolean; -}; - -type PromptStep = - | "api-key" - | "base-url" - | "code-repo-confirm" - | "code-repo-path" - | "external-cli-auth" - | "final" - | "gcp-location" - | "gcp-project" - | "langsmith" - | "model" - | "oauth-login" - | "provider" - | "region" - | "run-mode" - | "secret-key" - | "source-auth" - | "global-cron-custom" - | "global-cron-mode" - | "global-power-mode" - | "source-description" - | "source-description-custom" - | "source-langsmith-key" - | "source-langsmith-projects" - | "source-langsmith-region" - | "source-langsmith-workspaces" - | "source-menu" - | "source-path" - | "source-confirm-continue" - | "source-secret" - | "template" - | "wiki-goal"; - -type SourceSetupOption = { - authProvider?: AuthProviderId; - displayName: string; - examples: string[]; - id: ConnectorId; - instructions: string[]; - secretInputs: SourceSecretInput[]; -}; - -type SourceSecretInput = { - envKey: string; - label: string; - optional?: boolean; - secret?: boolean; -}; - -type SourceSetupState = { - authUrl?: string; - connectorConfig?: Record; - copiedAuthUrlToClipboard?: boolean; - savedScheduleWarning?: string; - secretValues: Record; -}; - -type PromptInputKey = { - backspace?: boolean; - ctrl?: boolean; - delete?: boolean; - downArrow?: boolean; - leftArrow?: boolean; - meta?: boolean; - return?: boolean; - rightArrow?: boolean; - tab?: boolean; - upArrow?: boolean; -}; - -type ModelSelectionOption = - | { - id: string; - kind: "preset"; - label: string; - } - | { - kind: "custom"; - }; - -type OnboardingMode = { - description: string; - id: string; - name: string; - sourceIds: ConnectorId[]; - suggestedSources: string[]; - suggestedGoal: string; -}; - -const ONBOARDING_TEMPLATES = [ - { - description: - "Maintain a structured project wiki from a local Git repository, with code-oriented pages for architecture, workflows, source maps, and operational guidance.", - id: "code", - name: "Code", - sourceIds: ["langsmith"], - suggestedSources: ["Local Git repository"], - suggestedGoal: "A code wiki for this repository.", - }, - { - description: - "A personal assistant wiki that builds memory from email, notes, social/research sources, and web search so you can ask about projects, priorities, people, and recurring context.", - id: "personal", - name: "Personal", - sourceIds: [ - "git-repo", - "google", - "notion", - "web-search", - "hackernews", - "x", - ], - suggestedSources: [ - "Gmail", - "Notion", - "Web Search (Tavily)", - "Hacker News", - "X/Twitter", - ], - suggestedGoal: - "Your personal brain. Track active projects, people, organizations, decisions, commitments, follow-ups, useful links, recurring themes, and fresh external signals. Organize the wiki so a personal assistant can answer what changed, what matters, what needs attention, and where supporting evidence came from. Be selective: summarize durable context and explicit action items, not every raw item.", - }, -] as const satisfies readonly OnboardingMode[]; - -const RUN_MODE_OPTIONS = [ - { - description: - "Build a local personal brain wiki in ~/.openwiki/wiki from configured sources.", - id: "personal", - name: "Personal", - }, - { - description: - "Build repository documentation in ./openwiki for this codebase.", - id: "code", - name: "Code", - }, -] as const satisfies readonly { - description: string; - id: OpenWikiRunMode; - name: string; -}[]; - -const LANGSMITH_REGION_OPTIONS = [ - { - description: "US workspaces. The default.", - host: "https://api.smith.langchain.com", - id: "us", - name: "US", - }, - { - description: "EU workspaces.", - host: "https://eu.api.smith.langchain.com", - id: "eu", - name: "EU", - }, -] as const satisfies readonly { - description: string; - host: string; - id: LangSmithRegion; - name: string; -}[]; - -/** - * One LangSmith workspace as the wizard edits it. `apiKey` holds a value entered - * this session (empty = keep the committed key); it is written to ~/.openwiki/.env - * under `apiKeyEnv` on completion, never committed. - */ -interface LangsmithWorkspaceDraft { - apiKeyEnv: string; - region: LangSmithRegion; - apiKey: string; - projects: string[]; -} - -const SOURCE_OPTIONS = [ - { - displayName: "Local Git repository", - examples: [ - "Track architecture notes from this repo.", - "Summarize recent commits and changed files.", - ], - id: "git-repo", - instructions: [ - "Choose the local repository directory OpenWiki should read.", - "The default is the current working directory, and you can replace it with another path.", - "You can add more repositories later in the connector config file.", - ], - secretInputs: [], - }, - { - displayName: "LangSmith traces", - examples: ["support-bot-prod", "chat-agent"], - id: "langsmith", - instructions: [ - "Document how your agent runs, grounded in its LangSmith traces.", - "List the projects to document; written to openwiki/.langsmith.json (committed).", - ], - // No secret input: the LangSmith key is captured by the earlier `langsmith` - // spine step (and provided as a CI secret), and used at pull time, not here. - secretInputs: [], - }, - { - authProvider: "notion", - displayName: "Notion", - examples: [ - "Ingest product specs, meeting notes, and research pages.", - "Prioritize pages related to Applied AI and customer feedback.", - ], - id: "notion", - instructions: [ - "OpenWiki uses Notion's hosted MCP OAuth flow.", - "No client ID, client secret, or pasted Notion token is required.", - "Approve access in the browser window when it opens.", - ], - secretInputs: [], - }, - { - authProvider: "gmail", - displayName: "Gmail", - examples: [ - "Capture important project email threads from the last 24 hours.", - "Look for vendor updates, customer feedback, and action items.", - ], - id: "google", - instructions: [ - "Create OAuth credentials in Google Cloud for a desktop or web app.", - "Enable the Gmail API for the Google Cloud project.", - "Add http://127.0.0.1:53682/callback as an authorized redirect URI.", - "Paste the client ID and client secret below.", - ], - secretInputs: [ - { - envKey: OPENWIKI_GOOGLE_CLIENT_ID_ENV_KEY, - label: "Google OAuth client ID", - }, - { - envKey: OPENWIKI_GOOGLE_CLIENT_SECRET_ENV_KEY, - label: "Google OAuth client secret", - secret: true, - }, - ], - }, - { - displayName: "Web Search (Tavily)", - examples: [ - "Track a company, product category, or technical topic.", - "Find launch posts, docs, pricing pages, and recent articles.", - ], - id: "web-search", - instructions: [ - "Create a Tavily account and API key.", - "Paste the Tavily API key below.", - "Describe the topics, companies, or pages OpenWiki should search for on the next screen.", - ], - secretInputs: [ - { - envKey: OPENWIKI_TAVILY_API_KEY_ENV_KEY, - label: "Tavily API key", - secret: true, - }, - ], - }, - { - displayName: "Hacker News", - examples: [ - "Monitor threads about AI agents, evals, infrastructure, and startups.", - "Capture notable discussions and links related to my research topics.", - ], - id: "hackernews", - instructions: [ - "No account setup is required for Hacker News.", - "OpenWiki uses public Hacker News feed and search APIs.", - "Describe the topics, keywords, users, or story types OpenWiki should watch on the next screen.", - ], - secretInputs: [], - }, - { - authProvider: "x", - displayName: "X / Twitter", - examples: [ - "Track my home timeline, bookmarks, and key lists.", - "Summarize tweets from AI researchers and product announcements.", - ], - id: "x", - instructions: [ - "Create an X OAuth 2.0 app.", - "Use a native app or public client when possible.", - "Add http://127.0.0.1:53682/callback as a callback URI.", - "Paste the OAuth client ID below.", - ], - secretInputs: [ - { - envKey: OPENWIKI_X_CLIENT_ID_ENV_KEY, - label: "X OAuth client ID", - }, - ], - }, -] as const satisfies readonly SourceSetupOption[]; - -const CRON_MODE_OPTIONS = [ - "Use suggested schedule", - "Enter custom cron", -] as const; -const POWER_MODE_OPTIONS = [ - "Set up Mac wake/sleep window", - "Skip power setup", -] as const; -const CRON_FIELD_LABELS = ["minute", "hour", "day", "month", "weekday"]; -const SOURCE_CONTINUE_OPTIONS = [ - "Go back to connections", - "Continue without all sources", -] as const; -const FINAL_OPTIONS = ["Run ingestion now", "Run later"] as const; -const CODE_REPO_OPTIONS = ["Confirm and continue", "Edit path"] as const; - -export function needsCredentialSetup( - modelIdOverride: string | null = null, - mode: OpenWikiRunMode = "personal", -): boolean { - const provider = resolveConfiguredProvider(); - - const needsCredentials = - !hasValidConfiguredProvider() || - needsAwsCredentialRepair(provider) || - needsCredentialStep(provider) || - needsSecretKeyStep(provider) || - needsBaseUrlStep(provider) || - needsRegionStep(provider) || - (modelIdOverride === null && - process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || - needsLangSmithStep(); - - if (needsCredentials) { - return true; - } - - return mode === "code" - ? !isRepositoryCodeOnboardingCompleteSync(getDefaultCodeRepoRootPath()) - : !isOpenWikiOnboardingCompleteSync(); -} - -function needsAwsCredentialRepair(provider: OpenWikiProvider): boolean { - return ( - providerUsesAwsSdkCredentials(provider) && - getMissingProviderEnvKey(provider) !== null - ); -} - -function getAwsCredentialRepairMessage( - provider: OpenWikiProvider, -): string | null { - if (!providerUsesAwsSdkCredentials(provider)) { - return null; - } - - const missingEnvKey = getMissingProviderEnvKey(provider); - - if (!missingEnvKey) { - return null; - } - - const pair = - missingEnvKey === BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY || - missingEnvKey === BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY - ? `${BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY} and ${BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY}` - : `${AWS_ACCESS_KEY_ID_ENV_KEY} and ${AWS_SECRET_ACCESS_KEY_ENV_KEY}`; - - return `${missingEnvKey} is missing or blank. Set both ${pair}, or unset both in your shell and ${openWikiEnvPath}, then restart OpenWiki.`; -} - -/** - * Whether the provider still needs its primary credential collected. For - * `oauth` providers this is a valid, non-expired stored token; for API-key - * providers it is a pasted key; for keyless providers (gemini-enterprise) it is - * the required GCP project id. - */ -function needsCredentialStep(provider: OpenWikiProvider): boolean { - if (providerUsesOAuth(provider)) { - return !hasValidStoredToken(); - } - - return ( - getMissingProviderEnvKey(provider) !== null && - credentialStep(provider) !== null - ); -} - -/** The step that collects the provider's primary credential. */ -function credentialStep(provider: OpenWikiProvider): PromptStep | null { - if (providerUsesOAuth(provider)) { - return "oauth-login"; - } - - if (providerUsesAwsSdkCredentials(provider)) { - return null; - } - - if (providerUsesExternalCliAuth(provider)) { - return "external-cli-auth"; - } - - if (providerRequiresApiKey(provider)) { - return "api-key"; - } - - return getProviderProjectEnvKey(provider) ? "gcp-project" : null; -} - -/** - * Every managed env key the wizard lets you set for a provider, in checklist - * order: the provider selection, its credential keys, the model, and the - * LangSmith tracing key. Used to detect which of them a shell export is - * currently shadowing (a shell var wins at runtime and would silently override - * the choice made here). Returns key names only, never values. - */ -function getWizardManagedEnvKeys(provider: OpenWikiProvider): string[] { - return [ - OPENWIKI_PROVIDER_ENV_KEY, - getProviderApiKeyEnvKey(provider), - getProviderSecretKeyEnvKey(provider), - getProviderProjectEnvKey(provider), - getProviderLocationEnvKey(provider), - getProviderBaseUrlEnvKey(provider), - getProviderRegionEnvKey(provider), - OPENWIKI_MODEL_ID_ENV_KEY, - "LANGSMITH_API_KEY", - ].filter((key): key is string => key !== undefined); -} - -/** - * The setup steps that apply to a provider and run mode, in the order the wizard - * walks them. Unlike the skip-based waterfall in {@link getInitialStep}, this - * includes steps already satisfied by the environment, so navigation can reach - * and re-edit an auto-skipped step. The provider's primary credential step - * ({@link credentialStep}) is emitted once; for keyless providers that step is - * the GCP project, so it is not appended again below. - */ -export function orderedSetupSteps( - provider: OpenWikiProvider, - mode: OpenWikiRunMode, - allowModeSelection: boolean, -): PromptStep[] { - const steps: PromptStep[] = []; - - if (allowModeSelection) { - steps.push("run-mode"); - } - - steps.push("provider"); - - const primary = credentialStep(provider); - if (primary) { - steps.push(primary); - } - - if (providerRequiresSecretKey(provider)) { - steps.push("secret-key"); - } - if (getProviderProjectEnvKey(provider) && primary !== "gcp-project") { - steps.push("gcp-project"); - } - if ( - getProviderProjectEnvKey(provider) && - getProviderLocationEnvKey(provider) - ) { - steps.push("gcp-location"); - } - if (providerRequiresBaseUrl(provider)) { - steps.push("base-url"); - } - if (providerRequiresRegion(provider)) { - steps.push("region"); - } - - steps.push("model"); - steps.push("langsmith"); - - // Personal mode's template is fixed by the run mode, so it skips the - // Code/Personal chooser and walks straight into the wiki brief. Only code - // mode needs a spine step after langsmith (repo confirmation). - if (mode === "code") { - steps.push("code-repo-confirm"); - } - - return steps; -} - -/** - * The step after `step` in the applicable spine, or null when `step` is the last - * spine step or outside it. Drives forward navigation: Enter advances to the - * next applicable step in order rather than skipping ones already satisfied by - * the environment, so setup reads as a sequential walk. - */ -export function nextSetupStep( - step: PromptStep | null, - provider: OpenWikiProvider, - mode: OpenWikiRunMode, - allowModeSelection: boolean, -): PromptStep | null { - if (step === null) { - return null; - } - const spine = orderedSetupSteps(provider, mode, allowModeSelection); - const index = spine.indexOf(step); - return index >= 0 && index + 1 < spine.length ? spine[index + 1] : null; -} - -function hasValidStoredToken(env: NodeJS.ProcessEnv = process.env): boolean { - const tokens = readCodexTokensFromEnv(env); - - return tokens !== null && !isChatGptTokenExpired(tokens.expiresAtMs); -} - -function needsGcpProjectStep(provider: OpenWikiProvider): boolean { - const projectEnvKey = getProviderProjectEnvKey(provider); - - return projectEnvKey ? !process.env[projectEnvKey] : false; -} - -function needsBaseUrlStep(provider: OpenWikiProvider): boolean { - if (!providerRequiresBaseUrl(provider)) { - return false; - } - - return !isBaseUrlConfigured(provider); -} - -function isBaseUrlConfigured(provider: OpenWikiProvider): boolean { - const baseUrlEnvKey = getProviderBaseUrlEnvKey(provider); - - return baseUrlEnvKey ? Boolean(process.env[baseUrlEnvKey]) : false; -} - -function needsSecretKeyStep(provider: OpenWikiProvider): boolean { - if (!providerRequiresSecretKey(provider)) { - return false; - } - - return !isSecretKeyConfigured(provider); -} - -function isSecretKeyConfigured(provider: OpenWikiProvider): boolean { - const secretKeyEnvKey = getProviderSecretKeyEnvKey(provider); - - return secretKeyEnvKey ? Boolean(process.env[secretKeyEnvKey]) : false; -} - -function needsRegionStep(provider: OpenWikiProvider): boolean { - if (!providerRequiresRegion(provider)) { - return false; - } - - return !isRegionConfigured(provider); -} - -/** - * Whether the optional LangSmith tracing step still needs to be shown. +import type { InitSetupProps } from "./credentials/types.js"; +import { useInitSetup } from "./credentials/use-init-setup.js"; +import { InitSetupView } from "./credentials/view.js"; + +export type { InitSetupResult } from "./credentials/types.js"; +export { + ensureRunModeConfig, + findNearestGitRepoRoot, + getInitialStep, + getNextStepAfterProvider, + hydrateRunModeConfig, + needsCredentialSetup, + needsLangSmithStep, + nextSetupStep, + orderedSetupSteps, + resolveStepStatus, +} from "./credentials/steps.js"; +export { getOAuthAuthorizationStatusText } from "./credentials/format.js"; + +/** + * First-run setup wizard. * - * The step is optional, so "answered" must include skipping it. Skipping does - * not persist `LANGSMITH_API_KEY` — `saveOpenWikiEnv` strips empty values, so - * the key is simply absent afterwards. What the step always records instead is - * `LANGCHAIN_TRACING_V2` (`"false"` on skip, `"true"` when a key is entered), - * which survives because it is non-empty. So the step is unanswered only when - * neither a key is present (e.g. from a shell export) nor a tracing decision - * has been recorded. - */ -export function needsLangSmithStep( - env: NodeJS.ProcessEnv = process.env, -): boolean { - return !env.LANGSMITH_API_KEY && env.LANGCHAIN_TRACING_V2 === undefined; -} - -function isRegionConfigured(provider: OpenWikiProvider): boolean { - return resolveProviderRegion(provider) !== undefined; -} - -function isCredentialConfigured(provider: OpenWikiProvider): boolean { - return providerUsesOAuth(provider) - ? hasValidStoredToken() - : getMissingProviderEnvKey(provider) === null; -} - -function getCredentialSetupDetail( - provider: OpenWikiProvider, - tokens: CodexTokens | null = null, -): string { - if (providerUsesOAuth(provider)) { - if (!isCredentialConfigured(provider) && !tokens) { - return "sign in with your ChatGPT account"; - } - - const account = formatChatGptAccount( - tokens?.email ?? process.env[OPENAI_CHATGPT_EMAIL_ENV_KEY] ?? null, - tokens?.planType ?? process.env[OPENAI_CHATGPT_PLAN_ENV_KEY] ?? null, - ); - - return account ? `signed in as ${account}` : "signed in with ChatGPT"; - } - - if (providerUsesAwsSdkCredentials(provider)) { - if (process.env[AWS_BEARER_TOKEN_BEDROCK_ENV_KEY]?.trim()) { - return "Bedrock bearer token (takes precedence)"; - } - - const missingEnvKey = getMissingProviderEnvKey(provider); - - if (missingEnvKey) { - if ( - missingEnvKey === BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY || - missingEnvKey === BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY - ) { - return "incomplete legacy Bedrock keys; set both or clear both"; - } - - if ( - missingEnvKey === AWS_ACCESS_KEY_ID_ENV_KEY || - missingEnvKey === AWS_SECRET_ACCESS_KEY_ENV_KEY - ) { - return "incomplete standard AWS credentials; set the full set or unset it"; - } - - return `incomplete AWS credential configuration (${missingEnvKey})`; - } - - const legacyApiKey = getProviderApiKeyEnvKey(provider); - const legacySecretKey = getProviderSecretKeyEnvKey(provider); - const usesLegacyKeys = Boolean( - legacyApiKey && - legacySecretKey && - process.env[legacyApiKey]?.trim() && - process.env[legacySecretKey]?.trim(), - ); - - const ignoresOrphanSessionToken = Boolean( - process.env[AWS_SESSION_TOKEN_ENV_KEY]?.trim() && - !process.env[AWS_ACCESS_KEY_ID_ENV_KEY]?.trim() && - !process.env[AWS_SECRET_ACCESS_KEY_ENV_KEY]?.trim(), - ); - - return usesLegacyKeys - ? "legacy Bedrock keys (take precedence)" - : ignoresOrphanSessionToken - ? "AWS SDK default credential chain (orphan AWS_SESSION_TOKEN ignored)" - : "AWS SDK default credential chain"; - } - - const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); - - return isCredentialConfigured(provider) - ? "available from environment" - : apiKeyEnvKey - ? `save ${apiKeyEnvKey} to ${openWikiEnvPath}` - : "configure Google Cloud credentials"; -} - -/** - * Copies text to the terminal's clipboard using the OSC 52 escape sequence. - * This targets the user's local terminal emulator even when OpenWiki runs over - * SSH, unlike shelling out to a host clipboard utility. + * A thin composition root: {@link useInitSetup} owns the state machine and + * returns the wired-up view props, and {@link InitSetupView} renders them. The + * controller and presentation live in their own modules so this entry point + * stays small and the two concerns can be reasoned about independently. */ -function copyToClipboard(text: string): void { - const encoded = Buffer.from(text, "utf8").toString("base64"); - - process.stdout.write(`\u001b]52;c;${encoded}\u0007`); -} - -function openLoginUrl(url: string): void { - try { - const child = - process.platform === "win32" - ? spawn("cmd", ["/c", "start", '""', `"${url}"`], { - detached: true, - stdio: "ignore", - windowsVerbatimArguments: true, - }) - : spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { - detached: true, - stdio: "ignore", - }); - - child.on("error", () => { - // The URL is also rendered for manual use on headless/SSH machines. - }); - child.unref(); - } catch { - // Ignore spawn failures; the URL is still rendered for manual use. - } -} - -export function InitSetup({ - allowModeSelection = false, - mode, - modelIdOverride = null, - onComplete, - onError, - walkAllSteps = false, -}: InitSetupProps) { - const { stdout } = useStdout(); - const initialProvider = resolveConfiguredProvider(); - const [step, setStepRaw] = useState(null); - const navHistory = useRef([]); - // Guards the mount effect so the initial step is seeded once per mount, not - // re-seeded when the effect re-fires on parent re-renders. - const didInitializeRef = useRef(false); - // Seed the LangSmith selection from the committed config only once, so - // navigating back and re-confirming the repo does not clobber in-progress edits - // (the file is not written until setup completes). - const langsmithPreloadedRef = useRef(false); - /** - * Advance to a step, recording the current step on the back-navigation - * history unless this is a back move. A ref-backed stack so Esc can retrace - * the actual path taken (including the branchy source sub-flow), which a - * linear spine cannot model. - */ - function setStep(next: PromptStep | null, opts?: { back?: boolean }): void { - if (!opts?.back && step !== null && next !== null && next !== step) { - navHistory.current.push(step); - } - setStepRaw(next); - } - const [selectedMode, setSelectedMode] = useState(mode); - const [provider, setProvider] = useState(initialProvider); - const [apiKey, setApiKey] = useState(null); - const [baseUrl, setBaseUrl] = useState(null); - const [secretKey, setSecretKey] = useState(null); - const [region, setRegion] = useState(null); - const [gcpProject, setGcpProject] = useState(null); - const [gcpLocation, setGcpLocation] = useState(null); - const [modelId, setModelId] = useState(null); - const [langSmithKey, setLangSmithKey] = useState(null); - // LangSmith workspaces as the wizard edits them (region + key + projects), - // seeded from the committed config; committed on completion. - const [langsmithWorkspaces, setLangsmithWorkspaces] = useState< - LangsmithWorkspaceDraft[] - >([]); - // The workspace currently being added or edited, folded into langsmithWorkspaces - // when its projects step is confirmed. - const [langsmithDraft, setLangsmithDraft] = - useState(null); - // Index of the workspace being edited; === langsmithWorkspaces.length for a new - // one. - const [langsmithEditingIndex, setLangsmithEditingIndex] = useState(0); - const [ - langsmithWorkspaceSelectionIndex, - setLangsmithWorkspaceSelectionIndex, - ] = useState(0); - const [langsmithRegionSelectionIndex, setLangsmithRegionSelectionIndex] = - useState(0); - // True once the LangSmith workspaces were opened this run; guards the WYSIWYG - // write so an untouched setup never rewrites openwiki/.langsmith.json. - const [langsmithSourcesTouched, setLangsmithSourcesTouched] = useState(false); - // True once the user confirms a provider this session. Provider always holds a - // default value, so a null-check cannot detect the in-session choice. - const [providerConfirmed, setProviderConfirmed] = useState(false); - const [input, setInput] = useState(""); - const [onboardingConfig, setOnboardingConfig] = - useState(() => createEmptyOnboardingConfig()); - const [sourceState, setSourceState] = useState({ - secretValues: {}, - }); - const [selectedSourceId, setSelectedSourceId] = - useState("git-repo"); - const [secretInputIndex, setSecretInputIndex] = useState(0); - const [providerSelectionIndex, setProviderSelectionIndex] = useState(() => - getProviderSelectionIndex(initialProvider), - ); - const [modelSelectionIndex, setModelSelectionIndex] = useState(() => - getModelSelectionIndex( - initialProvider, - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - getDefaultModelId(initialProvider), - ), - ); - const [runModeSelectionIndex, setRunModeSelectionIndex] = useState(() => - getRunModeSelectionIndex(mode), - ); - const [sourceSelectionIndex, setSourceSelectionIndex] = useState(0); - const [sourceDescriptionSelectionIndex, setSourceDescriptionSelectionIndex] = - useState(0); - const [templateSelectionIndex, setTemplateSelectionIndex] = useState(0); - const [cronModeSelectionIndex, setCronModeSelectionIndex] = useState(0); - const [powerModeSelectionIndex, setPowerModeSelectionIndex] = useState(0); - const [cronFieldSelectionIndex, setCronFieldSelectionIndex] = useState(0); - const [cronReplaceCurrentField, setCronReplaceCurrentField] = useState(true); - const [sourceContinueSelectionIndex, setSourceContinueSelectionIndex] = - useState(0); - const [finalSelectionIndex, setFinalSelectionIndex] = useState(0); - const [codeRepoSelectionIndex, setCodeRepoSelectionIndex] = useState(0); - const [codeRepoRoot, setCodeRepoRoot] = useState(() => - getDefaultCodeRepoRootPath(), - ); - // Dedicated buffer for the code-repo-path field, kept separate from the shared - // `input` (which seedInputForStep prefills with credentials on other steps) so - // a secret never shares the buffer that feeds the thread-id path hash. - const [codeRepoPathInput, setCodeRepoPathInput] = useState(""); - const [codeRepoConfirmed, setCodeRepoConfirmed] = useState(false); - const [isCustomModelInput, setIsCustomModelInput] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const [isSaving, setIsSaving] = useState(false); - const [externalCliAuth, setExternalCliAuth] = useState({ - kind: "idle", - }); - const externalCliProbeProvider = useRef(null); - const { setRawMode } = useStdin(); - const [isAuthRunning, setIsAuthRunning] = useState(false); - const [oauthTokens, setOauthTokens] = useState(null); - const [loginUrl, setLoginUrl] = useState(null); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [loginAttempt, setLoginAttempt] = useState(0); - const [copied, setCopied] = useState(false); - const [forceModelStep, setForceModelStep] = useState(false); - const loginHandleRef = useRef(null); - - const activeSourceOptions = useMemo( - () => getTemplateSourceOptions(getConfigModeId(onboardingConfig)), - [onboardingConfig.modeId, onboardingConfig.templateId], - ); - const selectedSource = getSourceOption(selectedSourceId); - const suggestedCronExpression = useMemo( - () => getSuggestedCronExpression(onboardingConfig), - [onboardingConfig], - ); - const suggestedCronDescription = useMemo(() => { - const validation = validateCronExpression(suggestedCronExpression); - return validation.valid ? validation.description : suggestedCronExpression; - }, [suggestedCronExpression]); - const inputDisplayWidth = getInputDisplayWidth(stdout.columns); - - useEffect(() => { - let cancelled = false; - - readOpenWikiOnboardingConfig() - .then(async (config) => { - // Seed the initial step exactly once per mount. onComplete/onError are - // inline parent closures in the deps, so this effect re-fires on parent - // re-renders; without this guard a re-fire would reset step back to the - // first step (getInitialStep with walkAll always returns it). - if (cancelled || didInitializeRef.current) { - return; - } - didInitializeRef.current = true; - - const defaultRepoRoot = getDefaultCodeRepoRootPath(); - const configForMode = allowModeSelection - ? config - : await hydrateRunModeConfig( - ensureRunModeConfig(config, mode), - mode, - defaultRepoRoot, - ); - if (configForMode !== config) { - await saveOpenWikiOnboardingConfig({ - ...configForMode, - wikiGoal: mode === "code" ? undefined : configForMode.wikiGoal, - }); - } - setOnboardingConfig(configForMode); - const initialStep = getInitialStep( - modelIdOverride, - initialProvider, - configForMode, - mode, - allowModeSelection, - walkAllSteps, - ); - - if (initialStep === null) { - onComplete({ - mode, - modelId: - modelIdOverride ?? process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? null, - onboardingCompleted: true, - provider: initialProvider, - runIngestionNow: false, - savedApiKey: false, - savedBaseUrl: false, - savedGcpLocation: false, - savedGcpProject: false, - savedLangSmithKey: false, - savedModelId: false, - savedProvider: false, - savedRegion: false, - savedSecretKey: false, - shouldContinueToRun: true, - }); - return; - } - - setProvider(initialProvider); - setProviderSelectionIndex(getProviderSelectionIndex(initialProvider)); - setModelSelectionIndex( - getModelSelectionIndex( - initialProvider, - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - getDefaultModelId(initialProvider), - ), - ); - setIsCustomModelInput( - initialStep === "model" && - shouldStartWithCustomModelInput(initialProvider), - ); - if (initialStep === "wiki-goal") { - setInput(getTemplateGoal(getConfigModeId(config))); - } - if (initialStep === "code-repo-confirm") { - setCodeRepoRoot(defaultRepoRoot); - setCodeRepoSelectionIndex(0); - } - setStep(initialStep); - }) - .catch((loadError: unknown) => { - if (!cancelled) { - onError(getErrorMessage(loadError)); - } - }); - - return () => { - cancelled = true; - }; - }, [ - allowModeSelection, - initialProvider, - modelIdOverride, - onComplete, - onError, - mode, - ]); - - // Drive the browser OAuth login whenever the wizard enters the oauth-login - // step or the user retries after a failure. - useEffect(() => { - if (step !== "oauth-login") { - return; - } - - let cancelled = false; - - setIsLoggingIn(true); - setLoginUrl(null); - setCopied(false); - setInput(""); - setError(null); - loginHandleRef.current = null; - - void (async () => { - try { - const tokens = await loginWithChatGPT( - (url) => { - if (cancelled) { - return; - } - - setLoginUrl(url); - openLoginUrl(url); - }, - (handle) => { - if (!cancelled) { - loginHandleRef.current = handle; - } - }, - ); - - if (cancelled) { - return; - } - - setOauthTokens(tokens); - setIsLoggingIn(false); - - const nextStep = - nextSetupStep( - "oauth-login", - provider, - selectedMode, - allowModeSelection, - ) ?? - getNextStepAfterApiKey( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: tokens, - nextProvider: provider, - runMode: selectedMode, - }); - } catch (loginError) { - if (cancelled) { - return; - } - - setIsLoggingIn(false); - setError(getErrorMessage(loginError)); - } - })(); - - return () => { - cancelled = true; - }; - }, [step, loginAttempt]); - - useEffect(() => { - if ( - step !== "external-cli-auth" || - !providerUsesExternalCliAuth(provider) || - externalCliProbeProvider.current === provider - ) { - return; - } - - externalCliProbeProvider.current = provider; - - let cancelled = false; - setExternalCliAuth({ kind: "checking" }); - - void (async () => { - const credential = await detectExternalCliCredential(provider); - - if (cancelled) { - return; - } - - if (credential) { - setExternalCliAuth({ kind: "detected" }); - return; - } - - const cliAvailable = await isExternalCliAvailable(provider); - - if (cancelled) { - return; - } - - setExternalCliAuth({ kind: "not-detected", cliAvailable }); - })(); - - return () => { - cancelled = true; - }; - }, [step, provider]); - - async function launchExternalCliLogin() { - setExternalCliAuth({ kind: "logging-in" }); - setRawMode?.(false); - - try { - const success = await runExternalCliLogin(provider); - - if (!success) { - setExternalCliAuth({ kind: "login-failed" }); - return; - } - - const credential = await detectExternalCliCredential(provider); - - setExternalCliAuth( - credential - ? { kind: "detected" } - : { kind: "not-detected", cliAvailable: true }, - ); - } finally { - setRawMode?.(true); - } - } - - /** - * Pre-fill the input or selection for a step reached via navigation, so a done - * step opens ready to edit. Secret steps are pre-filled with the stored key, - * which renders as dots (formatSecretInputDisplay), never raw. - */ - function seedInputForStep(target: PromptStep): void { - switch (target) { - case "provider": - setProviderSelectionIndex(getProviderSelectionIndex(provider)); - break; - case "run-mode": - setRunModeSelectionIndex(getRunModeSelectionIndex(selectedMode)); - break; - case "source-langsmith-region": - setLangsmithRegionSelectionIndex( - getLangsmithRegionSelectionIndex(langsmithDraft?.region ?? "us"), - ); - break; - case "source-langsmith-key": - // Prefill an edited workspace's key from ~/.openwiki/.env so it can be - // kept or replaced; a new workspace starts empty. - setInput( - langsmithDraft?.apiKey || - (langsmithDraft - ? (getSavedEnvValue(langsmithDraft.apiKeyEnv) ?? "") - : ""), - ); - break; - case "source-langsmith-projects": - setInput((langsmithDraft?.projects ?? []).join(", ")); - break; - case "model": { - // Point the cursor at the saved model (or the --modelId override), not - // the provider default, so it matches the checklist on a re-walk. - const seededModelId = - modelId ?? - modelIdOverride ?? - getSavedEnvValue(OPENWIKI_MODEL_ID_ENV_KEY) ?? - getDefaultModelId(provider); - setModelSelectionIndex(getModelSelectionIndex(provider, seededModelId)); - // Preset-less providers (e.g. Bedrock) take the model as free text, so - // restore the saved id into the field; selection-based providers drive - // off the index and keep the input empty. - setInput( - shouldStartWithCustomModelInput(provider) - ? (modelId ?? - modelIdOverride ?? - getSavedEnvValue(OPENWIKI_MODEL_ID_ENV_KEY) ?? - "") - : "", - ); - break; - } - case "api-key": { - const envKey = getProviderApiKeyEnvKey(provider); - setInput(apiKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); - break; - } - case "external-cli-auth": { - const envKey = getProviderApiKeyEnvKey(provider); - setInput(apiKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); - break; - } - case "secret-key": { - const envKey = getProviderSecretKeyEnvKey(provider); - setInput(secretKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); - break; - } - case "base-url": { - const envKey = getProviderBaseUrlEnvKey(provider); - setInput(baseUrl ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); - break; - } - case "region": { - const envKey = getProviderRegionEnvKey(provider); - setInput(region ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); - break; - } - case "gcp-project": { - const envKey = getProviderProjectEnvKey(provider); - setInput( - gcpProject ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : ""), - ); - break; - } - case "gcp-location": { - const envKey = getProviderLocationEnvKey(provider); - setInput( - gcpLocation ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : ""), - ); - break; - } - case "langsmith": - // Prefill from state or the saved config (masked as dots), matching the - // api-key/secret-key steps, so a walk-through Enter keeps the existing - // key instead of submitting empty and clearing it. - setInput(langSmithKey ?? getSavedEnvValue("LANGSMITH_API_KEY") ?? ""); - break; - case "template": - setTemplateSelectionIndex( - Math.max( - 0, - ONBOARDING_TEMPLATES.findIndex( - (template) => template.id === getConfigModeId(onboardingConfig), - ), - ), - ); - setInput(""); - break; - case "wiki-goal": - setInput(onboardingConfig.wikiGoal ?? ""); - break; - case "global-cron-mode": - setCronModeSelectionIndex(0); - setInput(""); - break; - case "global-cron-custom": - setInput( - onboardingConfig.ingestionSchedule?.expression ?? - suggestedCronExpression, - ); - setCronFieldSelectionIndex(0); - setCronReplaceCurrentField(true); - break; - case "global-power-mode": - setPowerModeSelectionIndex(0); - setInput(""); - break; - case "source-menu": - // Park the cursor on the "Continue" row so Enter keeps sources as-is. - setSourceSelectionIndex(activeSourceOptions.length); - setInput(""); - break; - case "source-description": - setSourceDescriptionSelectionIndex(0); - setInput(""); - break; - case "source-confirm-continue": - setSourceContinueSelectionIndex(0); - setInput(""); - break; - case "final": - setFinalSelectionIndex(0); - setInput(""); - break; - case "code-repo-confirm": - setCodeRepoSelectionIndex(0); - setInput(""); - break; - case "code-repo-path": - setCodeRepoPathInput(codeRepoRoot); - break; - default: - setInput(""); - } - } - - /** - * Commit the current step's typed value into state so stepping back with Esc - * preserves it rather than discarding an unsubmitted edit. Only text-input - * steps carry a value here; selection steps commit on their own submit. - */ - function captureInputForStep(from: PromptStep): void { - const trimmed = input.trim(); - switch (from) { - case "api-key": - case "external-cli-auth": - if (trimmed) setApiKey(trimmed); - break; - case "secret-key": - if (trimmed) setSecretKey(trimmed); - break; - case "base-url": - if (trimmed) setBaseUrl(trimmed); - break; - case "region": - if (trimmed) setRegion(trimmed); - break; - case "gcp-project": - if (trimmed) setGcpProject(trimmed); - break; - case "gcp-location": - if (trimmed) setGcpLocation(trimmed); - break; - case "langsmith": - setLangSmithKey(trimmed); - break; - case "source-langsmith-key": - // Keep an unsubmitted key edit on the draft so Esc does not lose it. - setLangsmithDraft((draft) => - draft ? { ...draft, apiKey: trimmed } : draft, - ); - break; - case "source-langsmith-projects": - // Keep an unsubmitted list edit on the draft so Esc does not lose it. - setLangsmithDraft((draft) => - draft - ? { - ...draft, - projects: [ - ...new Set( - input - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0), - ), - ], - } - : draft, - ); - break; - case "wiki-goal": - // Keep an unsubmitted goal edit in-session (not yet persisted) so - // stepping back and forward does not lose it. - if (trimmed) { - setOnboardingConfig((config) => ({ ...config, wikiGoal: trimmed })); - } - break; - default: - break; - } - } - - useInput((inputValue, key) => { - if ( - isSaving || - isAuthRunning || - (isLoggingIn && step !== "oauth-login") || - step === null - ) { - return; - } - - // Esc retraces the actual path taken via the navigation history stack, so - // it works through the branchy source sub-flow too. It commits the current - // field first (so an unsubmitted edit is kept) and is a no-op at the start. - if (key.escape) { - const target = navHistory.current[navHistory.current.length - 1]; - if (target !== undefined) { - captureInputForStep(step); - navHistory.current.pop(); - setStep(target, { back: true }); - seedInputForStep(target); - setError(null); - setNotice(null); - } - return; - } - - if (step === "oauth-login") { - if ( - input.length === 0 && - (inputValue === "c" || inputValue === "C") && - !key.ctrl && - !key.meta - ) { - if (loginUrl) { - copyToClipboard(loginUrl); - setCopied(true); - } - - return; - } - - if (key.return) { - const pasted = input.trim(); - - if (pasted.length > 0) { - submitManualLogin(pasted); - } else if (!isLoggingIn) { - setLoginAttempt((attempt) => attempt + 1); - } - - return; - } - - if (key.backspace || key.delete) { - setInput((value) => value.slice(0, -1)); - return; - } - - const sanitizedInput = sanitizeInputChunk(inputValue); - - if (sanitizedInput && !key.ctrl && !key.meta) { - setError(null); - setInput((value) => value + sanitizedInput); - } - - return; - } - - if ( - step === "external-cli-auth" && - key.tab && - externalCliAuth.kind !== "checking" && - externalCliAuth.kind !== "logging-in" - ) { - void launchExternalCliLogin(); - return; - } - - if (step === "provider") { - handleMenuInput(key, () => - setProviderSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - SELECTABLE_OPENWIKI_PROVIDERS.length, - ), - ), - ); - return; - } - - if (step === "model" && !isCustomModelInput) { - handleMenuInput(key, () => - setModelSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - getModelSelectionOptions(provider).length, - ), - ), - ); - return; - } - - if (step === "run-mode") { - handleMenuInput(key, () => - setRunModeSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - RUN_MODE_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "source-langsmith-workspaces") { - handleMenuInput(key, () => - setLangsmithWorkspaceSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - // workspaces + "Add a workspace" + "Done". - langsmithWorkspaces.length + 2, - ), - ), - ); - return; - } - - if (step === "source-langsmith-region") { - handleMenuInput(key, () => - setLangsmithRegionSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - LANGSMITH_REGION_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "code-repo-confirm") { - handleMenuInput(key, () => - setCodeRepoSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - CODE_REPO_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "source-menu") { - handleMenuInput(key, () => - setSourceSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - activeSourceOptions.length + 1, - ), - ), - ); - return; - } - - if (step === "template") { - handleMenuInput(key, () => - setTemplateSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - ONBOARDING_TEMPLATES.length, - ), - ), - ); - return; - } - - if (step === "global-cron-mode") { - handleMenuInput(key, () => - setCronModeSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - CRON_MODE_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "global-power-mode") { - handleMenuInput(key, () => - setPowerModeSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - POWER_MODE_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "source-description") { - handleMenuInput(key, () => - setSourceDescriptionSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - getSourceDescriptionOptionCount(selectedSource), - ), - ), - ); - return; - } - - if (step === "source-confirm-continue") { - handleMenuInput(key, () => - setSourceContinueSelectionIndex((index) => - moveSelectionIndex( - index, - key.upArrow ? -1 : 1, - SOURCE_CONTINUE_OPTIONS.length, - ), - ), - ); - return; - } - - if (step === "final") { - handleMenuInput(key, () => - setFinalSelectionIndex((index) => - moveSelectionIndex(index, key.upArrow ? -1 : 1, FINAL_OPTIONS.length), - ), - ); - return; - } - - if (step === "source-auth") { - if (key.return) { - void submit(); - } - return; - } - - if (step === "global-cron-custom") { - if (key.return) { - void submit(); - return; - } - - const didHandleCronInput = handleCronEditorInput({ - currentFieldIndex: cronFieldSelectionIndex, - currentValue: input, - fallbackExpression: suggestedCronExpression, - inputValue, - key, - replaceCurrentField: cronReplaceCurrentField, - setCurrentFieldIndex: setCronFieldSelectionIndex, - setReplaceCurrentField: setCronReplaceCurrentField, - setValue: setInput, - }); - - if (didHandleCronInput) { - setError(null); - } - - return; - } - - if (step === "code-repo-path") { - if (key.return) { - void submit(); - return; - } - - if (key.backspace || key.delete) { - setCodeRepoPathInput((value) => value.slice(0, -1)); - return; - } - - const sanitizedInput = sanitizeInputChunk(inputValue); - - if (sanitizedInput && !key.ctrl && !key.meta) { - setError(null); - setCodeRepoPathInput((value) => value + sanitizedInput); - } - - return; - } - - if (key.return) { - void submit(); - return; - } - - if (key.backspace || key.delete) { - setInput((value) => value.slice(0, -1)); - return; - } - - const sanitizedInput = sanitizeInputChunk(inputValue); - - if (sanitizedInput && !key.ctrl && !key.meta) { - setInput((value) => value + sanitizedInput); - } - }); - - function handleMenuInput(key: PromptInputKey, move: () => void) { - if (key.upArrow || key.downArrow) { - setError(null); - move(); - return; - } - - if (key.return) { - void submit(); - } - } - - async function submit() { - setError(null); - setNotice(null); - - if (step === "run-mode") { - const selectedOption = - RUN_MODE_OPTIONS[runModeSelectionIndex] ?? RUN_MODE_OPTIONS[0]; - - setSelectedMode(selectedOption.id); - setRunModeSelectionIndex(getRunModeSelectionIndex(selectedOption.id)); - setInput(""); - const nextOnboardingConfig = ensureRunModeConfig( - onboardingConfig, - selectedOption.id, - ); - - if (nextOnboardingConfig !== onboardingConfig) { - await saveConfig(nextOnboardingConfig); - } - - const nextStep = getInitialStep( - modelIdOverride, - provider, - nextOnboardingConfig, - selectedOption.id, - false, - ); - - if (nextStep) { - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedOption.id, - }); - return; - } - - if (step === "code-repo-confirm") { - const selectedOption = - CODE_REPO_OPTIONS[codeRepoSelectionIndex] ?? CODE_REPO_OPTIONS[0]; - - if (selectedOption === "Edit path") { - setCodeRepoPathInput(codeRepoRoot); - setStep("code-repo-path"); - return; - } - - setCodeRepoConfirmed(true); - continueAfterCodeRepoConfirmed(codeRepoRoot); - return; - } - - if (step === "code-repo-path") { - try { - const repoRoot = await validateLocalDirectoryPath(codeRepoPathInput); - setCodeRepoRoot(repoRoot); - setCodeRepoConfirmed(true); - setCodeRepoPathInput(""); - continueAfterCodeRepoConfirmed(repoRoot); - } catch (pathError) { - setError(getErrorMessage(pathError)); - } - return; - } - - if (step === "provider") { - const selectedProvider = - SELECTABLE_OPENWIKI_PROVIDERS[providerSelectionIndex] ?? - DEFAULT_PROVIDER; - // Credentials are provider-specific, so switching providers must not carry - // the previous provider's key/secret/etc. across (otherwise seedInputForStep - // prefills it and empty-submit-keeps would save it under the new provider). - const switchedProvider = selectedProvider !== provider; - - setProvider(selectedProvider); - setProviderConfirmed(true); - - if (switchedProvider) { - setApiKey(null); - setSecretKey(null); - setBaseUrl(null); - setRegion(null); - setGcpProject(null); - setGcpLocation(null); - setOauthTokens(null); - setModelId(null); - } - - setProviderSelectionIndex(getProviderSelectionIndex(selectedProvider)); - setModelSelectionIndex( - getModelSelectionIndex( - selectedProvider, - getDefaultModelId(selectedProvider), - ), - ); - setInput(""); - const providerChanged = - process.env[OPENWIKI_PROVIDER_ENV_KEY] !== selectedProvider; - setForceModelStep(providerChanged); - const nextStep = - nextSetupStep( - "provider", - selectedProvider, - selectedMode, - allowModeSelection, - ) ?? - getNextStepAfterProvider( - selectedProvider, - modelIdOverride, - onboardingConfig, - selectedMode, - providerChanged, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && - shouldStartWithCustomModelInput(selectedProvider), - ); - // On a switch the closure still holds the old provider/apiKey, so - // seedInputForStep would re-seed stale values; leave the field empty and - // let a later visit seed from the new provider's own env. - if (switchedProvider) { - setInput(""); - } else { - seedInputForStep(nextStep); - } - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: selectedProvider, - runMode: selectedMode, - }); - return; - } - - if (step === "api-key" || step === "external-cli-auth") { - const trimmedInput = input.trim(); - const usesExternalCli = step === "external-cli-auth"; - // Empty submit keeps an existing key (session or env). For an external - // CLI session it deliberately saves nothing: the CLI remains the token - // owner and the runtime resolves it again for this process only. - const nextApiKey = - trimmedInput.length > 0 - ? trimmedInput - : usesExternalCli && externalCliAuth.kind === "detected" - ? null - : apiKey; - - if ( - nextApiKey === null && - !(usesExternalCli && externalCliAuth.kind === "detected") && - !isCredentialConfigured(provider) - ) { - setError( - `${getProviderApiKeyEnvKey(provider) ?? "API key"} is required.`, - ); - return; - } - - if (trimmedInput.length > 0) { - setApiKey(trimmedInput); - } - setInput(""); - const nextStep = - nextSetupStep(step, provider, selectedMode, allowModeSelection) ?? - getNextStepAfterApiKey( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "secret-key") { - const trimmedInput = input.trim(); - // Empty submit keeps an existing secret key (see the api-key step). - const nextSecretKey = trimmedInput.length > 0 ? trimmedInput : secretKey; - - if (nextSecretKey === null && !isSecretKeyConfigured(provider)) { - setError( - `${getProviderSecretKeyEnvKey(provider) ?? "Secret key"} is required.`, - ); - return; - } - - if (trimmedInput.length > 0) { - setSecretKey(trimmedInput); - } - setInput(""); - const nextStep = - nextSetupStep( - "secret-key", - provider, - selectedMode, - allowModeSelection, - ) ?? - getNextStepAfterSecretKey( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "region") { - const trimmedInput = input.trim(); - const configuredRegion = resolveProviderRegion(provider); - const credentialRepairMessage = getAwsCredentialRepairMessage(provider); - - if (credentialRepairMessage) { - setError(credentialRepairMessage); - return; - } - - if (trimmedInput.length === 0 && !configuredRegion) { - const regionEnvKeys = getProviderRegionEnvKeys(provider); - setError( - `Set one of ${regionEnvKeys.join(", ") || "the supported region variables"}.`, - ); - return; - } - - const nextRegion = trimmedInput.length > 0 ? trimmedInput : region; - - if (trimmedInput.length > 0) { - setRegion(trimmedInput); - } - setInput(""); - const nextStep = - nextSetupStep("region", provider, selectedMode, allowModeSelection) ?? - getNextStepAfterRegion( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "gcp-project") { - const trimmedInput = input.trim(); - - if (trimmedInput.length === 0) { - setError( - `${getProviderProjectEnvKey(provider) ?? "GCP project"} is required.`, - ); - return; - } - - if (/\s/u.test(trimmedInput)) { - setError("Enter a valid Google Cloud project ID (no spaces)."); - return; - } - - setGcpProject(trimmedInput); - setInput(""); - // gcp-location always follows gcp-project (gemini-enterprise); seed it so a - // previously entered location is restored instead of arriving blank. - seedInputForStep("gcp-location"); - setStep("gcp-location"); - return; - } - - if (step === "gcp-location") { - const trimmedInput = input.trim(); - - if (/\s/u.test(trimmedInput)) { - setError( - `Enter a valid location (no spaces), or leave blank for ${DEFAULT_VERTEX_LOCATION}.`, - ); - return; - } - - const nextGcpLocation = trimmedInput.length > 0 ? trimmedInput : null; - - setGcpLocation(nextGcpLocation); - setInput(""); - const nextStep = - nextSetupStep( - "gcp-location", - provider, - selectedMode, - allowModeSelection, - ) ?? - getNextStepAfterGcpLocation( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "base-url") { - const trimmedInput = input.trim(); - - if (trimmedInput.length === 0) { - setError( - `${getProviderBaseUrlEnvKey(provider) ?? "Base URL"} is required.`, - ); - return; - } - - const baseUrlWarnings = getProviderBaseUrlWarnings( - provider, - trimmedInput, - ); - if (baseUrlWarnings.length > 0) { - setError(`Enter a valid base URL: ${baseUrlWarnings.join(", ")}.`); - return; - } - - setBaseUrl(trimmedInput); - setInput(""); - const nextStep = - nextSetupStep("base-url", provider, selectedMode, allowModeSelection) ?? - getNextStepAfterBaseUrl( - provider, - modelIdOverride, - onboardingConfig, - selectedMode, - forceModelStep, - ); - - if (nextStep) { - setIsCustomModelInput( - nextStep === "model" && shouldStartWithCustomModelInput(provider), - ); - seedInputForStep(nextStep); - setStep(nextStep); - return; - } - - await completeSetup({ - nextApiKey: apiKey, - nextBaseUrl: trimmedInput, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey: langSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "model") { - const selectedModelId = getSelectedModelId( - provider, - modelSelectionIndex, - input, - isCustomModelInput, - ); - - if (!selectedModelId) { - setError("Paste a valid model ID."); - return; - } - - if (selectedModelId === "custom") { - setIsCustomModelInput(true); - setInput(""); - return; - } - - setModelId(selectedModelId); - setInput(""); - setIsCustomModelInput(false); - - // Sequential: always visit LangSmith next (the next spine step). Seed it - // from state so a key entered earlier and stepped past is not dropped. - seedInputForStep("langsmith"); - setStep("langsmith"); - return; - } - - if (step === "langsmith") { - const nextLangSmithKey = input.trim(); - - setLangSmithKey(nextLangSmithKey); - setInput(""); - - await continueAfterCredentials({ - nextApiKey: apiKey, - nextBaseUrl: baseUrl, - nextSecretKey: secretKey, - nextRegion: region, - nextGcpLocation: gcpLocation, - nextGcpProject: gcpProject, - nextLangSmithKey, - nextModelId: modelId, - nextOAuthTokens: oauthTokens, - nextProvider: provider, - runMode: selectedMode, - }); - return; - } - - if (step === "wiki-goal") { - const wikiGoal = input.trim(); - - if (wikiGoal.length === 0) { - setError("Describe what this wiki should understand."); - return; - } - - const nextConfig = { - ...onboardingConfig, - wikiGoal, - }; - await saveConfigForCurrentMode(nextConfig); - setInput(""); - - if (isCodeMode(nextConfig)) { - setStep("final"); - return; - } - - setCronModeSelectionIndex(0); - setCronFieldSelectionIndex(0); - setCronReplaceCurrentField(true); - setStep("global-cron-mode"); - return; - } - - if (step === "template") { - const selectedTemplate = - ONBOARDING_TEMPLATES[templateSelectionIndex] ?? ONBOARDING_TEMPLATES[0]; - const nextConfig = { - ...onboardingConfig, - modeId: selectedTemplate.id, - modeName: selectedTemplate.name, - templateId: selectedTemplate.id, - templateName: selectedTemplate.name, - }; - await saveConfig(nextConfig); - // Keep the existing goal when the template is unchanged (so re-walking is - // idempotent); use the template's suggested goal when it actually changed. - const keepExistingGoal = - selectedTemplate.id === getConfigModeId(onboardingConfig) && - onboardingConfig.wikiGoal !== undefined && - onboardingConfig.wikiGoal.length > 0; - setInput( - keepExistingGoal - ? (onboardingConfig.wikiGoal ?? "") - : selectedTemplate.suggestedGoal, - ); - setStep("wiki-goal"); - return; - } - - if (step === "source-menu") { - if (sourceSelectionIndex >= activeSourceOptions.length) { - // Code mode auto-configures the repo, so its sources are all optional; - // "Continue" always advances to the wiki brief rather than nagging. - if (isCodeMode(onboardingConfig)) { - advanceAfterCodeSources(); - return; - } - - if ( - getConnectedSourceCount(onboardingConfig, activeSourceOptions) > 0 - ) { - setStep("final"); - return; - } - - setSourceContinueSelectionIndex(0); - setStep("source-confirm-continue"); - return; - } - - const source = - activeSourceOptions[sourceSelectionIndex] ?? activeSourceOptions[0]; - const firstMissingSecretIndex = source.secretInputs.findIndex((secret) => - needsEnvValue(secret), - ); - setSelectedSourceId(source.id); - setSourceState({ secretValues: {} }); - setSourceDescriptionSelectionIndex(0); - setSecretInputIndex( - firstMissingSecretIndex === -1 ? 0 : firstMissingSecretIndex, - ); - setInput(""); - setCronModeSelectionIndex(0); - setPowerModeSelectionIndex(0); - setCronFieldSelectionIndex(0); - setCronReplaceCurrentField(true); - - if ( - source.secretInputs.some((secretInput) => needsEnvValue(secretInput)) - ) { - setStep("source-secret"); - return; - } - - continueAfterSourceCredentialSetup(source); - return; - } - - if (step === "source-secret") { - const currentSecretInput = selectedSource.secretInputs[secretInputIndex]; - if (!currentSecretInput) { - continueAfterSourceCredentialSetup(selectedSource); - return; - } - - const trimmedInput = input.trim(); - if (trimmedInput.length === 0 && !currentSecretInput.optional) { - setError(`${currentSecretInput.envKey} is required.`); - return; - } - - const nextSecretValues = { - ...sourceState.secretValues, - ...(trimmedInput.length > 0 - ? { [currentSecretInput.envKey]: trimmedInput } - : {}), - }; - setSourceState((state) => ({ - ...state, - secretValues: nextSecretValues, - })); - setInput(""); - - const nextIndex = secretInputIndex + 1; - const nextMissingIndex = selectedSource.secretInputs.findIndex( - (secretInput, index) => - index >= nextIndex && - needsEnvValue(secretInput) && - nextSecretValues[secretInput.envKey] === undefined, - ); - - if (nextMissingIndex !== -1) { - setSecretInputIndex(nextMissingIndex); - return; - } - - await saveOpenWikiEnv(nextSecretValues); - continueAfterSourceCredentialSetup(selectedSource); - return; - } - - if (step === "source-auth") { - await authorizeSelectedSource(); - return; - } - - if (step === "source-path") { - const repoPath = normalizeLocalPath(input); - - if (repoPath.length === 0) { - setError("Enter a local repository directory."); - return; - } - - try { - const connectorConfig = await configureLocalGitRepo(repoPath); - setSourceState((state) => ({ ...state, connectorConfig })); - setInput(""); - setStep("source-description"); - } catch (setupError) { - setError(getErrorMessage(setupError)); - } - return; - } - - if (step === "source-langsmith-workspaces") { - const workspaceCount = langsmithWorkspaces.length; - if (langsmithWorkspaceSelectionIndex < workspaceCount) { - // Edit an existing workspace: load it into the draft and walk the fields. - const existing = langsmithWorkspaces[langsmithWorkspaceSelectionIndex]; - setLangsmithEditingIndex(langsmithWorkspaceSelectionIndex); - setLangsmithDraft({ ...existing }); - setLangsmithRegionSelectionIndex( - getLangsmithRegionSelectionIndex(existing.region), - ); - setStep("source-langsmith-region"); - return; - } - if (langsmithWorkspaceSelectionIndex === workspaceCount) { - // Add a workspace with a fresh key env var name. - setLangsmithEditingIndex(workspaceCount); - setLangsmithDraft({ - apiKey: "", - apiKeyEnv: nextLangSmithApiKeyEnv( - langsmithWorkspaces.map((workspace) => workspace.apiKeyEnv), - ), - projects: [], - region: "us", - }); - setLangsmithRegionSelectionIndex( - getLangsmithRegionSelectionIndex("us"), - ); - setStep("source-langsmith-region"); - return; - } - // Done. - returnToSourceMenu(); - return; - } - - if (step === "source-langsmith-region") { - const selectedOption = - LANGSMITH_REGION_OPTIONS[langsmithRegionSelectionIndex] ?? - LANGSMITH_REGION_OPTIONS[0]; - setLangsmithDraft((draft) => - draft ? { ...draft, region: selectedOption.id } : draft, - ); - seedInputForStep("source-langsmith-key"); - setStep("source-langsmith-key"); - return; - } - - if (step === "source-langsmith-key") { - const nextKey = input.trim(); - setLangsmithDraft((draft) => - draft ? { ...draft, apiKey: nextKey } : draft, - ); - // setLangsmithDraft has not applied yet this tick, so seed the projects - // field from the current draft rather than via seedInputForStep. - setInput((langsmithDraft?.projects ?? []).join(", ")); - setStep("source-langsmith-projects"); - return; - } - - if (step === "source-langsmith-projects") { - // Commit the workspace with its exact project set; nothing is written here, - // the file + keys are committed at the final step. - const names = [ - ...new Set( - input - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0), - ), - ]; - commitLangsmithWorkspace(names); - returnToWorkspacesMenu(); - return; - } - - if (step === "source-description") { - if (sourceDescriptionSelectionIndex >= selectedSource.examples.length) { - setInput(""); - setStep("source-description-custom"); - return; - } - - const selectedExample = - selectedSource.examples[sourceDescriptionSelectionIndex] ?? ""; - await saveSelectedSourceDescription(selectedExample); - return; - } - - if (step === "source-description-custom") { - await saveSelectedSourceDescription(input.trim()); - return; - } - - if (step === "global-cron-mode") { - const selectedMode = CRON_MODE_OPTIONS[cronModeSelectionIndex]; - - if (selectedMode === "Enter custom cron") { - setInput(suggestedCronExpression); - setCronFieldSelectionIndex(0); - setCronReplaceCurrentField(true); - setStep("global-cron-custom"); - return; - } - - await saveModeSchedule(suggestedCronExpression); - return; - } - - if (step === "global-cron-custom") { - const validation = validateCronExpression(input); - - if (!validation.valid) { - setError(validation.error); - return; - } - - await saveModeSchedule(validation.expression); - return; - } - - if (step === "global-power-mode") { - const selectedMode = POWER_MODE_OPTIONS[powerModeSelectionIndex]; - - if (selectedMode === "Set up Mac wake/sleep window") { - await saveGlobalMacPowerWindow(); - return; - } - - setSourceSelectionIndex(0); - setSourceState({ secretValues: {} }); - setInput(""); - setStep("source-menu"); - return; - } - - if (step === "source-confirm-continue") { - const selectedAction = - SOURCE_CONTINUE_OPTIONS[sourceContinueSelectionIndex]; - if (selectedAction === "Go back to connections") { - returnToSourceMenu(); - setStep("source-menu"); - return; - } - - setStep("final"); - return; - } - - if (step === "final") { - // Commit the LangSmith workspaces as the exact set (WYSIWYG add/edit/remove), - // only when the sub-menu was opened — so an aborted or untouched setup never - // rewrites openwiki/.langsmith.json. - if (selectedMode === "code" && langsmithSourcesTouched) { - try { - // Freshly-entered keys go to ~/.openwiki/.env (never committed); an empty - // apiKey keeps the existing saved key. - const keyUpdates: Record = {}; - for (const workspace of langsmithWorkspaces) { - if (workspace.apiKey.length > 0) { - keyUpdates[workspace.apiKeyEnv] = workspace.apiKey; - } - } - if (Object.keys(keyUpdates).length > 0) { - await saveOpenWikiEnv(keyUpdates); - } - await saveLangSmithSetup( - codeRepoRoot, - langsmithWorkspaces.map((workspace) => ({ - apiKeyEnv: workspace.apiKeyEnv, - projects: workspace.projects, - region: workspace.region, - })), - ); - } catch (writeError) { - setError(getErrorMessage(writeError)); - return; - } - } - const runIngestionNow = - FINAL_OPTIONS[finalSelectionIndex] === "Run ingestion now"; - const nextConfig = { - ...onboardingConfig, - completedAt: new Date().toISOString(), - }; - await saveConfigForCurrentMode(nextConfig); - onComplete({ - mode: selectedMode, - modelId: - modelId ?? - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - null, - onboardingCompleted: true, - provider, - repoRoot: - selectedMode === "code" && codeRepoConfirmed - ? codeRepoRoot - : undefined, - runIngestionNow, - savedApiKey: apiKey !== null || oauthTokens !== null, - savedBaseUrl: baseUrl !== null, - savedGcpLocation: gcpLocation !== null, - savedGcpProject: gcpProject !== null, - savedLangSmithKey: langSmithKey !== null && langSmithKey.length > 0, - savedModelId: modelId !== null, - savedProvider: process.env[OPENWIKI_PROVIDER_ENV_KEY] !== provider, - savedRegion: region !== null, - savedSecretKey: secretKey !== null, - shouldContinueToRun: runIngestionNow, - }); - } - } - - async function saveSelectedSourceDescription(description: string) { - const connectorConfig = - selectedSourceId === "web-search" || selectedSourceId === "hackernews" - ? getStaticSourceConfig(selectedSourceId, description) - : sourceState.connectorConfig; - - const sourceInstanceId = createSourceInstanceId( - selectedSourceId, - onboardingConfig, - ); - const sourceInstance = { - connectedAt: new Date().toISOString(), - connectorConfig, - connectorId: selectedSourceId, - id: sourceInstanceId, - ingestionGoal: description.length > 0 ? description : undefined, - name: createSourceInstanceName( - selectedSource, - description, - onboardingConfig, - ), - }; - const nextConfig = addSourceInstanceConfig( - onboardingConfig, - sourceInstance, - ); - await saveConfig(nextConfig); - setSourceState((state) => ({ - ...state, - connectorConfig, - })); - setInput(""); - returnToSourceMenu(); - } - - type CompleteSetupOptions = { - nextApiKey: string | null; - nextBaseUrl: string | null; - nextGcpLocation: string | null; - nextGcpProject: string | null; - nextLangSmithKey: string | null; - nextModelId: string | null; - nextOAuthTokens?: CodexTokens | null; - nextProvider: OpenWikiProvider; - nextRegion: string | null; - nextSecretKey: string | null; - runMode: OpenWikiRunMode; - }; - - async function continueAfterCredentials(options: CompleteSetupOptions) { - await saveCredentialUpdates(options); - - // Explicit --init walks the whole tail; enter at its first step rather than - // skipping steps that are already configured. - if (walkAllSteps) { - if (options.runMode === "code") { - setCodeRepoRoot(getDefaultCodeRepoRootPath()); - setCodeRepoSelectionIndex(0); - setStep("code-repo-confirm"); - return; - } - - // Personal mode fixes the template from the run mode, so skip the - // redundant Code/Personal chooser and walk straight into the wiki brief. - // Seed the existing goal so Enter keeps it (idempotent re-walk), else the - // template's suggested goal. - setInput( - onboardingConfig.wikiGoal ?? - getTemplateGoal(getConfigModeId(onboardingConfig)), - ); - setStep("wiki-goal"); - return; - } - - if (options.runMode === "code" && !isOnboardingComplete(onboardingConfig)) { - setCodeRepoRoot(getDefaultCodeRepoRootPath()); - setCodeRepoSelectionIndex(0); - setStep("code-repo-confirm"); - return; - } - - if (!getConfigModeId(onboardingConfig)) { - setStep("template"); - return; - } - - if (!onboardingConfig.wikiGoal) { - setInput(getTemplateGoal(getConfigModeId(onboardingConfig))); - setStep("wiki-goal"); - return; - } - - if (!onboardingConfig.ingestionSchedule) { - setCronModeSelectionIndex(0); - setStep("global-cron-mode"); - return; - } - - if (!isOnboardingComplete(onboardingConfig)) { - setStep("source-menu"); - return; - } - - await completeSetup(options); - } - - function continueAfterCodeRepoConfirmed(repoRoot: string) { - setCodeRepoRoot(repoRoot); - // Preload committed LangSmith projects (once) so the source menu shows them - // and edits build on them (fail-open on the read). - if (!langsmithPreloadedRef.current) { - langsmithPreloadedRef.current = true; - void loadLangSmithSetup(repoRoot) - .then((existing) => - setLangsmithWorkspaces( - existing.map((workspace) => ({ - apiKey: "", - apiKeyEnv: workspace.apiKeyEnv, - projects: workspace.projects, - region: workspace.region, - })), - ), - ) - .catch(() => {}); - } - // Code mode auto-configures the repo itself; the source menu then offers the - // optional LangSmith trace sources before the wiki brief. - setSourceSelectionIndex(0); - setSourceState({ secretValues: {} }); - setStep("source-menu"); - } - - // Continues past the code-mode source menu into the wiki brief. Walks wiki-goal - // on --init even when set; otherwise only when unset. Seeds the existing goal so - // Enter keeps it (idempotent). - function advanceAfterCodeSources() { - if (walkAllSteps || !onboardingConfig.wikiGoal) { - setInput( - onboardingConfig.wikiGoal ?? - getTemplateGoal(getConfigModeId(onboardingConfig)), - ); - setStep("wiki-goal"); - return; - } - - setStep("final"); - } - - async function completeSetup(options: CompleteSetupOptions) { - await saveCredentialUpdates(options); - - onComplete({ - modelId: - options.nextModelId ?? - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - null, - onboardingCompleted: isOnboardingComplete(onboardingConfig), - provider: options.nextProvider, - repoRoot: - options.runMode === "code" && codeRepoConfirmed - ? codeRepoRoot - : undefined, - mode: options.runMode, - runIngestionNow: false, - savedApiKey: - options.nextApiKey !== null || options.nextOAuthTokens != null, - savedBaseUrl: options.nextBaseUrl !== null, - savedRegion: options.nextRegion !== null, - savedSecretKey: options.nextSecretKey !== null, - savedGcpLocation: options.nextGcpLocation !== null, - savedGcpProject: options.nextGcpProject !== null, - savedLangSmithKey: - options.nextLangSmithKey !== null && - options.nextLangSmithKey.length > 0, - savedModelId: options.nextModelId !== null, - savedProvider: - process.env[OPENWIKI_PROVIDER_ENV_KEY] !== options.nextProvider, - shouldContinueToRun: true, - }); - } - - async function saveCredentialUpdates({ - nextApiKey, - nextBaseUrl, - nextGcpLocation, - nextGcpProject, - nextLangSmithKey, - nextModelId, - nextOAuthTokens = oauthTokens, - nextProvider, - nextRegion, - nextSecretKey, - }: CompleteSetupOptions) { - setIsSaving(true); - - try { - const updates: Record = {}; - - if (process.env[OPENWIKI_PROVIDER_ENV_KEY] !== nextProvider) { - updates[OPENWIKI_PROVIDER_ENV_KEY] = nextProvider; - } - - if (nextApiKey !== null) { - const apiKeyEnvKey = getProviderApiKeyEnvKey(nextProvider); - - if (apiKeyEnvKey) { - updates[apiKeyEnvKey] = nextApiKey; - } - } - - if (nextOAuthTokens) { - Object.assign(updates, codexTokensToEnv(nextOAuthTokens)); - } - - if (nextBaseUrl !== null) { - const baseUrlEnvKey = getProviderBaseUrlEnvKey(nextProvider); - - if (baseUrlEnvKey) { - updates[baseUrlEnvKey] = nextBaseUrl; - } - } - - if (nextSecretKey !== null) { - const secretKeyEnvKey = getProviderSecretKeyEnvKey(nextProvider); - - if (secretKeyEnvKey) { - updates[secretKeyEnvKey] = nextSecretKey; - } - } - - if (nextRegion !== null) { - const regionEnvKey = getProviderRegionEnvKey(nextProvider); - - if (regionEnvKey) { - updates[regionEnvKey] = nextRegion; - } - } - - if (nextGcpProject !== null) { - const projectEnvKey = getProviderProjectEnvKey(nextProvider); - - if (projectEnvKey) { - updates[projectEnvKey] = nextGcpProject; - } - } - - if (nextGcpLocation !== null) { - const locationEnvKey = getProviderLocationEnvKey(nextProvider); - - if (locationEnvKey) { - updates[locationEnvKey] = nextGcpLocation; - } - } - - if (nextModelId !== null) { - updates[OPENWIKI_MODEL_ID_ENV_KEY] = nextModelId; - } - - if (nextLangSmithKey !== null) { - updates.LANGSMITH_API_KEY = nextLangSmithKey; - - if (nextLangSmithKey.length > 0) { - updates.LANGCHAIN_PROJECT = "openwiki"; - updates.LANGCHAIN_TRACING_V2 = "true"; - } else { - // Blank input must act as an off switch: without this, a - // LANGCHAIN_TRACING_V2=true saved by an earlier setup stays in - // ~/.openwiki/.env and tracing silently remains enabled. - updates.LANGCHAIN_TRACING_V2 = "false"; - } - } - - if (Object.keys(updates).length > 0) { - await saveOpenWikiEnv(updates); - } - } catch (saveError) { - onError(getErrorMessage(saveError)); - } finally { - setIsSaving(false); - } - } - - async function authorizeSelectedSource() { - setIsAuthRunning(true); - setError(null); - setNotice(null); - - try { - if (selectedSource.id === "git-repo") { - await configureLocalGitRepo(); - } else if (selectedSource.authProvider) { - const authResult = await runOAuthAuth(selectedSource.authProvider, { - onAuthorizationUrl: ({ copiedToClipboard, openedBrowser, url }) => { - setSourceState((state) => ({ - ...state, - authUrl: url, - copiedAuthUrlToClipboard: copiedToClipboard, - })); - setNotice( - openedBrowser - ? "Opened browser for authorization. Complete the flow to continue." - : copiedToClipboard - ? "Open the authorization URL from your clipboard to continue." - : "Open the authorization URL below to continue.", - ); - }, - silent: true, - }); - await configureAuthProvider(authResult.provider, { force: false }); - } - - setInput(""); - setStep("source-description"); - } catch (authError) { - setError(getErrorMessage(authError)); - } finally { - setIsAuthRunning(false); - } - } - - function continueAfterSourceCredentialSetup(source: SourceSetupOption) { - if (source.authProvider) { - setStep("source-auth"); - return; - } - - if (source.id === "langsmith") { - // Open the workspace sub-menu (add/edit/remove). Opening it arms the - // WYSIWYG write on completion. - setLangsmithSourcesTouched(true); - setLangsmithWorkspaceSelectionIndex(0); - setStep("source-langsmith-workspaces"); - return; - } - - try { - if (source.id === "git-repo") { - setInput(getDefaultLocalGitRepoPath()); - setStep("source-path"); - return; - } else if (source.id === "web-search" || source.id === "hackernews") { - setSourceState((state) => ({ - ...state, - connectorConfig: getStaticSourceConfig(source.id, ""), - })); - } - - setStep("source-description"); - } catch (setupError) { - setError(getErrorMessage(setupError)); - } - } - - /** - * Folds the in-progress draft into langsmithWorkspaces at the editing index. An - * empty project list removes the workspace (WYSIWYG). - */ - function commitLangsmithWorkspace(names: string[]): void { - const draft = langsmithDraft; - setLangsmithWorkspaces((list) => { - const next = [...list]; - if (names.length === 0) { - if (langsmithEditingIndex < next.length) { - next.splice(langsmithEditingIndex, 1); - } - return next; - } - if (!draft) { - return next; - } - const workspace = { ...draft, projects: names }; - if (langsmithEditingIndex >= next.length) { - next.push(workspace); - } else { - next[langsmithEditingIndex] = workspace; - } - return next; - }); - } - - /** - * Returns to the workspace sub-menu as a back-navigation: unwind history through - * it so Esc from the refreshed sub-menu goes to the source menu, not back down - * into the edited workspace's field steps. - */ - function returnToWorkspacesMenu() { - setInput(""); - setLangsmithDraft(null); - const index = navHistory.current.lastIndexOf("source-langsmith-workspaces"); - if (index >= 0) { - navHistory.current.length = index; - } - setStep("source-langsmith-workspaces", { back: true }); - } - - function returnToSourceMenu() { - setSourceSelectionIndex(activeSourceOptions.length); - setSourceState({ secretValues: {} }); - setInput(""); - // Returning to the menu is a back-navigation: unwind history through the menu - // so Escape from the refreshed menu goes to the step BEFORE it (repo-confirm), - // not back down into the source's child steps (which would show empty fields). - const menuIndex = navHistory.current.lastIndexOf("source-menu"); - if (menuIndex >= 0) { - navHistory.current.length = menuIndex; - } - setStep("source-menu", { back: true }); - } - - async function configureLocalGitRepo( - repoPathInput = getDefaultLocalGitRepoPath(), - ): Promise> { - const sourceId = "git-repo"; - const repoPath = normalizeLocalPath(repoPathInput); - const repoId = sanitizeRepoId(path.basename(repoPath) || "repo"); - const configPath = getConnectorConfigPath(sourceId); - const connectorConfig = { - repos: [ - { - id: repoId, - path: repoPath, - }, - ], - }; - await import("node:fs/promises").then( - async ({ chmod, mkdir, stat, writeFile }) => { - const repoStat = await stat(repoPath); - if (!repoStat.isDirectory()) { - throw new Error(`${repoPath} is not a directory.`); - } - - await mkdir(path.dirname(configPath), { - recursive: true, - mode: 0o700, - }); - await writeFile( - configPath, - `${JSON.stringify(connectorConfig, null, 2)}\n`, - { - encoding: "utf8", - mode: 0o600, - }, - ); - await chmod(configPath, 0o600); - }, - ); - return connectorConfig; - } - - async function saveModeSchedule(cronExpression: string) { - setIsSaving(true); - - try { - const result = await installConnectorSchedule({ - connectorId: "git-repo", - cronExpression, - cwd: process.cwd(), - }); - const nextConfig: OpenWikiOnboardingConfig = { - ...onboardingConfig, - ingestionSchedule: { - description: result.description, - expression: result.expression, - launchAgentPath: result.launchAgentPath, - updatedAt: new Date().toISOString(), - warning: result.warning, - }, - }; - await saveConfig(nextConfig); - setSourceState((state) => ({ - ...state, - savedScheduleWarning: result.warning, - })); - setPowerModeSelectionIndex(0); - setStep("global-power-mode"); - } catch (scheduleError) { - setError(getErrorMessage(scheduleError)); - } finally { - setIsSaving(false); - } - } - - async function saveGlobalMacPowerWindow() { - setIsSaving(true); - - try { - const configForPower = await readOpenWikiOnboardingConfig(); - const result = await installOpenWikiPowerSchedule(configForPower); - const nextConfig: OpenWikiOnboardingConfig = { - ...configForPower, - powerManagement: { - ...configForPower.powerManagement, - pmset: { - days: result.days, - enabled: result.enabled, - sleepTime: result.sleepTime, - updatedAt: new Date().toISOString(), - wakeTime: result.wakeTime, - warning: result.warning, - }, - }, - }; - await saveConfig(nextConfig); - setSourceSelectionIndex(0); - setSourceState({ - secretValues: {}, - savedScheduleWarning: result.warning, - }); - setInput(""); - setStep("source-menu"); - } catch (powerError) { - setError(getErrorMessage(powerError)); - } finally { - setIsSaving(false); - } - } - - async function saveConfig(config: OpenWikiOnboardingConfig) { - setIsSaving(true); - try { - await saveOpenWikiOnboardingConfig(config); - setOnboardingConfig(config); - } catch (saveError) { - onError(getErrorMessage(saveError)); - } finally { - setIsSaving(false); - } - } - - async function saveConfigForCurrentMode(config: OpenWikiOnboardingConfig) { - if (!isCodeMode(config)) { - await saveConfig(config); - return; - } - - setIsSaving(true); - try { - if (config.wikiGoal?.trim()) { - await saveRepositoryWikiInstructions(codeRepoRoot, config.wikiGoal); - } - await saveOpenWikiOnboardingConfig({ - ...config, - wikiGoal: undefined, - }); - setOnboardingConfig(config); - } catch (saveError) { - onError(getErrorMessage(saveError)); - } finally { - setIsSaving(false); - } - } - - function submitManualLogin(pasted: string): void { - const handle = loginHandleRef.current; - - if (!handle) { - setError("Login is still starting. Try again in a moment."); - return; - } - - const errorMessage = handle.submitManual(pasted); - - if (errorMessage) { - setError(errorMessage); - return; - } - - setInput(""); - setError(null); - } - - const needsCredentialPrompt = - !hasValidConfiguredProvider() || - needsAwsCredentialRepair(provider) || - needsCredentialStep(provider) || - needsSecretKeyStep(provider) || - needsBaseUrlStep(provider) || - needsRegionStep(provider) || - (modelIdOverride === null && - process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || - needsLangSmithStep(); - const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); - const primaryCredentialStep = credentialStep(provider); - const projectEnvKey = getProviderProjectEnvKey(provider); - const locationEnvKey = getProviderLocationEnvKey(provider); - - // A shell export wins over saved config at runtime. List any wizard-managed - // keys present in the shell so their precedence is not a surprise and the - // "from shell" rows below are explained. Presence only, not a value compare; - // key names only, never values. - const shadowedShellKeys = getWizardManagedEnvKeys(provider).filter( - (key) => getShellEnvValue(key) !== undefined, - ); - const isSingleShadow = shadowedShellKeys.length === 1; - const shadowedShellWarning = - shadowedShellKeys.length === 0 - ? null - : `${ - isSingleShadow ? "This key was" : "These keys were" - } detected in your shell and ${ - isSingleShadow ? "overrides" : "override" - } saved config: ${shadowedShellKeys.join(", ")}. Runs use the shell ` + - `value${isSingleShadow ? "" : "s"}; unset ${ - isSingleShadow ? "it" : "them" - } to use your saved config.`; - - return ( - - - - {shadowedShellWarning ? ( - - ⚠ {shadowedShellWarning} - - ) : null} - - - Detected from your command - - - {selectedMode === "code" ? ( - - ) : null} - - - - - Set up - - - {providerUsesAwsSdkCredentials(provider) ? ( - - ) : providerUsesOAuth(provider) || primaryCredentialStep ? ( - - ) : null} - {providerRequiresSecretKey(provider) ? ( - - ) : null} - {projectEnvKey ? ( - - ) : null} - {projectEnvKey && locationEnvKey ? ( - - ) : null} - {providerRequiresBaseUrl(provider) ? ( - - ) : null} - {providerRequiresRegion(provider) ? ( - - ) : null} - - 0 - ? "configured" - : "skipped" - : process.env.LANGSMITH_API_KEY - ? "configured" - : "not set" - } - /> - {selectedMode === "personal" ? ( - - ) : null} - {selectedMode === "personal" ? ( - - ) : null} - {selectedMode === "personal" ? ( - 0 - ? "done" - : "pending" - } - detail={`${getConnectedSourceCount( - onboardingConfig, - activeSourceOptions, - )} configured`} - /> - ) : null} - - - - {step === "oauth-login" ? ( - - ) : ( - - {step ? ( - - ) : ( - Inspecting OpenWiki setup... - )} - - )} - - {navHistory.current.length > 0 ? ( - - esc to go back - - ) : null} - - {needsCredentialPrompt ? ( - - - Secrets are masked and saved only after setup. - - - ) : null} - {notice ? ( - - {notice} - - ) : null} - {error ? ( - - {error} - - ) : null} - {sourceState.savedScheduleWarning ? ( - - {sourceState.savedScheduleWarning} - - ) : null} - {isSaving ? ( - - Writing OpenWiki setup... - - ) : null} - {isAuthRunning ? ( - - Waiting for the browser authorization callback... - - ) : null} - - ); -} - -function Prompt({ - codeRepoPathInput, - codeRepoRoot, - codeRepoSelectionIndex, - externalCliAuth, - cronFieldSelectionIndex, - cronModeSelectionIndex, - finalSelectionIndex, - input, - inputDisplayWidth, - isCustomModelInput, - langsmithDraft, - langsmithRegionSelectionIndex, - langsmithWorkspaceSelectionIndex, - langsmithWorkspaces, - modelSelectionIndex, - onboardingConfig, - powerModeSelectionIndex, - provider, - providerSelectionIndex, - runModeSelectionIndex, - secretInputIndex, - selectedMode, - selectedSource, - sourceOptions, - sourceContinueSelectionIndex, - sourceDescriptionSelectionIndex, - sourceSelectionIndex, - sourceState, - step, - suggestedCronDescription, - suggestedCronExpression, - templateSelectionIndex, -}: { - codeRepoPathInput: string; - codeRepoRoot: string; - codeRepoSelectionIndex: number; - externalCliAuth: ExternalCliAuthState; - cronFieldSelectionIndex: number; - cronModeSelectionIndex: number; - finalSelectionIndex: number; - input: string; - inputDisplayWidth: number; - isCustomModelInput: boolean; - langsmithDraft: LangsmithWorkspaceDraft | null; - langsmithRegionSelectionIndex: number; - langsmithWorkspaceSelectionIndex: number; - langsmithWorkspaces: LangsmithWorkspaceDraft[]; - modelSelectionIndex: number; - onboardingConfig: OpenWikiOnboardingConfig; - powerModeSelectionIndex: number; - provider: OpenWikiProvider; - providerSelectionIndex: number; - runModeSelectionIndex: number; - secretInputIndex: number; - selectedMode: OpenWikiRunMode; - selectedSource: SourceSetupOption; - sourceOptions: readonly SourceSetupOption[]; - sourceContinueSelectionIndex: number; - sourceDescriptionSelectionIndex: number; - sourceSelectionIndex: number; - sourceState: SourceSetupState; - step: PromptStep; - suggestedCronDescription: string; - suggestedCronExpression: string; - templateSelectionIndex: number; -}) { - if (step === "run-mode") { - const selectedMode = - RUN_MODE_OPTIONS[runModeSelectionIndex] ?? RUN_MODE_OPTIONS[0]; - - return ( - - Choose what OpenWiki should initialize. - {RUN_MODE_OPTIONS.map((option, index) => ( - - {" "} - {option.name} ({option.id}) - - ))} - - {selectedMode.name} - {selectedMode.description} - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "provider") { - return ( - - Choose a model provider. - {SELECTABLE_OPENWIKI_PROVIDERS.map((providerOption, index) => ( - - {" "} - {getProviderLabel(providerOption)} - ({providerOption}) - {providerOption === DEFAULT_PROVIDER ? ( - default - ) : null} - - ))} - Use up/down arrows, then press Enter. - - ); - } - - if (step === "api-key") { - return ( - - Paste your {getApiKeyFieldLabel(provider)}. - - Press Enter to save it. - - ); - } - - if (step === "external-cli-auth") { - return ( - - ); - } - - if (step === "secret-key") { - return ( - - Paste your {getProviderLabel(provider)} secret access key. - - Press Enter to save it. - - ); - } - - if (step === "gcp-project") { - return ( - - Enter the Google Cloud project ID with Vertex AI access. - - $ {getProviderProjectEnvKey(provider)}={" "} - {input} - - - OpenWiki authenticates with Google Application Default Credentials - (run: gcloud auth application-default login). Press Enter to save it. - - - ); - } - - if (step === "gcp-location") { - return ( - - - Enter a Vertex AI location, or press Enter to use{" "} - {DEFAULT_VERTEX_LOCATION}. - - - $ {getProviderLocationEnvKey(provider)}={" "} - {input} - - - For example global, europe-west1, or us-east5. Press Enter to - continue. - - - ); - } - - if (step === "base-url") { - return ( - - Enter the {getProviderLabel(provider)} base URL. - - $ {getProviderBaseUrlEnvKey(provider)}={" "} - {input} - - - For example an OpenAI-compatible gateway endpoint (such as a LiteLLM - gateway). Press Enter to save it. - - - ); - } - - if (step === "region") { - const resolvedRegion = resolveProviderRegion(provider); - const credentialRepairMessage = getAwsCredentialRepairMessage(provider); - - return ( - - {credentialRepairMessage ? ( - ⚠ {credentialRepairMessage} - ) : null} - - Enter the {getProviderLabel(provider)} region - {resolvedRegion ? `, or press Enter to keep ${resolvedRegion}` : ""}. - - - $ {getProviderRegionEnvKey(provider)}={" "} - {input} - - - Uses {getProviderRegionEnvKeys(provider).join(", ")}. For example - us-east-1. - - - ); - } - - if (step === "model") { - if (isCustomModelInput) { - return ( - - Paste a custom model ID. - - Press Enter to save it. - - ); - } - - return ( - - - Choose {getProviderArticle(provider)} {getProviderLabel(provider)}{" "} - model. - - {getModelSelectionOptions(provider).map((option, index) => { - if (option.kind === "custom") { - return ( - - {" "} - Custom model ID - - ); - } - - return ( - - {" "} - {option.label} {option.id} - {option.id === getDefaultModelId(provider) ? ( - default - ) : null} - - ); - })} - Use up/down arrows, then press Enter. - - ); - } - - if (step === "langsmith") { - return ( - - Optional: paste a LangSmith API key for tracing. - - Press Enter with an empty value to skip. - - ); - } - - if (step === "template") { - const selectedTemplate = - ONBOARDING_TEMPLATES[templateSelectionIndex] ?? ONBOARDING_TEMPLATES[0]; - - return ( - - Choose how OpenWiki should run. - {ONBOARDING_TEMPLATES.map((template, index) => ( - - {" "} - {template.name} - - ))} - - {selectedTemplate.name} - {selectedTemplate.description} - {selectedTemplate.suggestedSources.length > 0 ? ( - - Suggested sources: {selectedTemplate.suggestedSources.join(", ")} - - ) : ( - Start from a blank wiki brief. - )} - - - Press Enter, then edit the brief on the next step. - - - ); - } - - if (step === "wiki-goal") { - return ( - - Customize what this wiki should understand. - {getConfigModeName(onboardingConfig) ? ( - Mode: {getConfigModeName(onboardingConfig)} - ) : null} - - Edit the brief below. Keep what is useful, delete what is not. - - - Edit wiki brief - - - Press Enter to continue. - - ); - } - - if (step === "code-repo-confirm") { - return ( - - Use this repository? - - {codeRepoRoot} - - - OpenWiki will run in this directory and write the initial openwiki/ - folder there. - - - {CODE_REPO_OPTIONS.map((option, index) => ( - - {" "} - {option} - - ))} - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "code-repo-path") { - return ( - - Choose the repository directory. - - Enter an existing directory. OpenWiki will write openwiki/ there. - - - Press Enter to confirm this path. - - ); - } - - if (step === "source-menu") { - // LangSmith workspaces live in state until setup completes (not onboarding - // source instances), so count them here for the menu's configured display. - const langsmithWorkspaceCount = sourceOptions.some( - (source) => source.id === "langsmith", - ) - ? langsmithWorkspaces.length - : 0; - const configuredCount = - getConnectedSourceCount(onboardingConfig, sourceOptions) + - langsmithWorkspaceCount; - - return ( - - Configure sources for this mode. - {sourceOptions.map((source, index) => { - const isLangsmith = source.id === "langsmith"; - const sourceInstances = getSourceInstances( - onboardingConfig, - source.id, - ); - const count = isLangsmith - ? langsmithWorkspaces.length - : sourceInstances.length; - return ( - - - {" "} - {getSourceMenuLabel(source, count)}{" "} - 0} - /> - - {isLangsmith - ? langsmithWorkspaces.map((workspace) => ( - - {" "}- {getLangsmithRegionLabel(workspace.region)}:{" "} - {workspace.projects.join(", ")} - - )) - : sourceInstances.map((sourceInstance) => ( - - {" "}- {sourceInstance.name ?? sourceInstance.id}{" "} - ({sourceInstance.id}) - - ))} - - ); - })} - - Next - - {" "} - Continue{" "} - {configuredCount === 0 ? ( - (no sources configured) - ) : null} - - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "source-path") { - return ( - - Choose the local Git repository directory. - - Default is the directory where you started OpenWiki. Edit it to use a - different checkout. - - - Press Enter to save this source. - - ); - } - - if (step === "source-secret") { - const secretInput = selectedSource.secretInputs[secretInputIndex]; - return ( - - {selectedSource.displayName} setup - {selectedSource.instructions.map((instruction, index) => ( - - {index + 1}. {instruction} - - ))} - {secretInput ? ( - - Enter credential - - - {secretInput.optional - ? "Press Enter with an empty value to skip." - : "Press Enter to save this value."} - - - ) : null} - - ); - } - - if (step === "source-auth") { - return ( - - {selectedSource.displayName} authorization - {sourceState.authUrl ? ( - - ) : ( - - Press Enter to open the authorization URL and wait for the callback. - - )} - - ); - } - - if (step === "source-description") { - return ( - - {getSourceDescriptionPrompt(selectedSource)} - - Choose an example description, or write your own. - - {selectedSource.examples.map((example, index) => ( - - {" "} - {example} - - ))} - - = selectedSource.examples.length - } - />{" "} - Custom description - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "source-description-custom") { - return ( - - {getSourceDescriptionPrompt(selectedSource)} - - Type what OpenWiki should focus on for this source. - - - Optional. Press Enter to continue. - - ); - } - - if (step === "source-langsmith-workspaces") { - const workspaceCount = langsmithWorkspaces.length; - return ( - - LangSmith workspaces to document. - - A LangSmith key is region-bound, so each workspace has its own region - and key. Select one to edit (clear its projects to remove it). - - {langsmithWorkspaces.map((workspace, index) => ( - - {" "} - {getLangsmithRegionLabel(workspace.region)}:{" "} - {workspace.projects.join(", ")} - - ))} - - - {" "} - Add a workspace - - - {" "} - Done - - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "source-langsmith-key") { - const apiKeyEnv = langsmithDraft?.apiKeyEnv ?? "OPENWIKI_LANGSMITH_API_KEY"; - return ( - - LangSmith API key for this workspace. - - The connector's own read key (not your app's tracing key). - Saved to ~/.openwiki/.env as {apiKeyEnv}, never committed. - - - - Press Enter to confirm (empty keeps the saved key). - - - ); - } - - if (step === "source-langsmith-projects") { - return ( - - Which projects should this wiki document in this workspace? - - Comma-separated project names (as in LANGCHAIN_PROJECT). Written to - openwiki/.langsmith.json. - - - Press Enter to confirm. - - ); - } - - if (step === "source-langsmith-region") { - const selectedRegion = - LANGSMITH_REGION_OPTIONS[langsmithRegionSelectionIndex] ?? - LANGSMITH_REGION_OPTIONS[0]; - - return ( - - Which LangSmith region is this workspace in? - {LANGSMITH_REGION_OPTIONS.map((option, index) => ( - - {" "} - {option.name} ({option.host}) - - ))} - - {selectedRegion.description} - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "global-cron-mode") { - return ( - - - {isCodeMode(onboardingConfig) - ? "When should GitHub Actions refresh this code wiki?" - : "When should OpenWiki run all ingestion?"} - - - {isCodeMode(onboardingConfig) - ? "OpenWiki will write a scheduled GitHub Actions workflow for this repository." - : "All configured sources run sequentially at this time."} - - Suggested: {suggestedCronDescription} - {CRON_MODE_OPTIONS.map((option, index) => ( - - {" "} - {option} - - ))} - Use up/down arrows, then press Enter. - - ); - } - - if (step === "global-cron-custom") { - const validation = validateCronExpression(input); - return ( - - - {isCodeMode(onboardingConfig) - ? "Enter one GitHub Actions cron schedule for this code wiki." - : "Enter one cron schedule for all ingestion."} - - - {input ? ( - - {validation.valid ? validation.description : validation.error} - - ) : ( - Example: 0 2 * * * - )} - - Type in each field. Use right/left arrows or Tab to move; spaces also - move fields. - - Press Enter to save a valid schedule. - - ); - } - - if (step === "global-power-mode") { - return ( - - Keep your Mac awake for scheduled refreshes? - - OpenWiki can use macOS pmset to wake 2 minutes before the shared - ingestion schedule and sleep 30 minutes after it. - - {sourceState.savedScheduleWarning ? ( - {sourceState.savedScheduleWarning} - ) : null} - - {POWER_MODE_OPTIONS.map((option, index) => ( - - {" "} - {option} - - ))} - - - macOS has one global repeat power schedule. Setting this can replace - an existing pmset repeat wake/sleep schedule. - - - ); - } - - if (step === "source-confirm-continue") { - const missingSources = sourceOptions.filter( - (source) => getSourceInstanceCount(onboardingConfig, source.id) === 0, - ); - return ( - - Some sources for this mode are not configured yet. - {missingSources.map((source) => ( - - - {source.displayName} - - ))} - - {SOURCE_CONTINUE_OPTIONS.map((option, index) => ( - - {" "} - {option} - - ))} - - Use up/down arrows, then press Enter. - - ); - } - - if (step === "final") { - return ( - - Setup is complete. - {FINAL_OPTIONS.map((option, index) => { - const label = getFinalOptionLabel(option, selectedMode); - return ( - - {" "} - {label} - - ); - })} - - {selectedMode === "code" - ? "Run now writes the initial openwiki/ directory. Open chat skips the initial run." - : "Run now executes one source-specific ingestion and wiki update per configured source. Run later opens chat so you can start ingestion when you are ready."} - - - ); - } - - return null; -} - -function mask(value: string): string { - if (value.length === 0) { - return ""; - } - - return "*".repeat(value.length); -} - -function ExternalCliAuthPrompt({ - authState, - input, - provider, -}: { - authState: ExternalCliAuthState; - input: string; - provider: OpenWikiProvider; -}) { - const adapter = getExternalCliAuthAdapter(provider); - const envKey = getProviderApiKeyEnvKey(provider) ?? "API key"; - - if (!adapter) { - return null; - } - - if (authState.kind === "idle" || authState.kind === "checking") { - return ( - - Checking for an existing {adapter.credentialDescription}... - - ); - } - - if (authState.kind === "logging-in") { - return ( - - Running `{adapter.loginCommand}` — follow the prompts in this - terminal... - - ); - } - - if (authState.kind === "detected") { - return ( - - Detected an existing {adapter.credentialDescription}. - - Press Enter to use it, Tab to sign in again, or paste a different - token below. - - - $ {envKey}={" "} - - {input.length > 0 ? mask(input) : ``} - - - - ); - } - - return ( - - No {adapter.credentialDescription} detected. - {authState.kind === "login-failed" ? ( - - `{adapter.loginCommand}` did not complete successfully. - - ) : null} - {authState.kind === "not-detected" && authState.cliAvailable ? ( - - Press Tab to run `{adapter.loginCommand}`, or paste a token below. - - ) : ( - - {adapter.installHint} You can also paste a token below for CI or other - headless use. - - )} - - $ {envKey}={" "} - {mask(input)} - - Press Enter to save it. - - ); -} - -function SetupHeader() { - return ( - - - - OpenWiki - {" "} - first-run setup - - Configure the model, wiki scope, and sources. - - ); -} - -type SetupStepState = "current" | "done" | "optional" | "pending"; - -/** - * Resolve a checklist row's status. The active step wins, so navigating back to - * an already-done step shows the current-row cursor rather than a check; a done - * step reads done; anything else falls to its resting status. - */ -export function resolveStepStatus( - id: PromptStep, - activeStep: PromptStep | null, - done: boolean, - resting: "optional" | "pending" = "pending", -): SetupStepState { - if (id === activeStep) { - return "current"; - } - if (done) { - return "done"; - } - return resting; -} - -/** - * Progress glyph per status: a check for done, an arrow for the active row, a - * hollow circle for not-started (and optional). Single cell wide so every row's - * label column lines up without padding the marker. - */ -const STEP_GLYPH: Record = { - done: "✓", - current: "❯", - optional: "○", - pending: "○", -}; - -/** Color per status. Optionality is conveyed by the detail text, not the glyph. */ -const STEP_COLOR: Record = { - done: "green", - current: "cyan", - optional: "gray", - pending: "gray", -}; - -function SetupStep({ - detail, - label, - state, -}: { - detail: string; - label: string; - state: SetupStepState; -}) { - return ( - - {STEP_GLYPH[state]}{" "} - - {label.padEnd(16)} - {" "} - {detail} - - ); -} - -function SetupPanel({ - children, - title, -}: { - children: React.ReactNode; - title: string; -}) { - return ( - - - {title} - - {children} - - ); -} - -function SelectionMarker({ isSelected }: { isSelected: boolean }) { - return ( - {isSelected ? ">" : " "} - ); -} - -function SourceConnectionStatus({ - count, - isConfigured, -}: { - count: number; - isConfigured: boolean; -}) { - return ( - - {isConfigured - ? `[configured${count > 1 ? ` x${count}` : ""}]` - : "[not configured]"} - - ); -} - -function OAuthAuthorizationLink({ - authProvider, - copiedToClipboard, - url, -}: { - authProvider?: AuthProviderId; - copiedToClipboard: boolean; - url: string; -}) { - return ( - - - - {formatTerminalHyperlink(url, "Open authorization URL")} - - - - {getOAuthAuthorizationStatusText({ - authProvider, - copiedToClipboard, - })} - - - ); -} - -export function getOAuthAuthorizationStatusText({ - authProvider, - copiedToClipboard, -}: { - authProvider?: AuthProviderId; - copiedToClipboard: boolean; -}): string { - if (copiedToClipboard) { - return "Full URL copied to clipboard. Use the link above if your terminal supports it."; - } - - const authCommand = authProvider - ? `openwiki auth ${authProvider}` - : "openwiki auth "; - - return `Use the terminal link above. If it is not clickable, cancel and run ${authCommand} in a plain terminal.`; -} - -function OAuthLoginPrompt({ - copied, - input, - isLoggingIn, - loginUrl, - provider, -}: { - copied: boolean; - input: string; - isLoggingIn: boolean; - loginUrl: string | null; - provider: OpenWikiProvider; -}) { - return ( - - - ChatGPT login - - - Sign in with your {getProviderLabel(provider)} account to authorize - OpenWiki. - - {loginUrl ? ( - - - Opening your browser. If it does not open, copy this URL: - - - {loginUrl} - - - Press c to copy the URL - {copied ? (copied) : null} - - - - If the browser cannot reach this machine, paste the redirect URL - or authorization code and press Enter: - - - > - {input.length > 0 ? ( - {input} - ) : ( - (paste here) - )} - - - - ) : ( - Starting the ChatGPT login... - )} - - {isLoggingIn - ? "Waiting for browser sign-in or pasted URL..." - : "Login failed. Press Enter to retry."} - - - ); -} - -function BorderedInput({ - borderColor = "cyan", - maxDisplayWidth, - marginTop, - prefix, - secret = false, - showCursor = true, - value, -}: { - borderColor?: "cyan" | "gray"; - maxDisplayWidth: number; - marginTop?: number; - prefix?: string; - secret?: boolean; - showCursor?: boolean; - value: string; -}) { - const prompt = prefix ? "$ " : "> "; - const prefixText = prefix ? `${prefix} ` : ""; - const valueDisplayWidth = Math.max( - 1, - maxDisplayWidth - prompt.length - prefixText.length - (showCursor ? 1 : 0), - ); - - return ( - - - {prompt} - {prefixText ? {prefixText} : null} - - - - ); -} - -function BorderedMultilineInput({ - borderColor = "cyan", - maxDisplayWidth, - marginTop, - showCursor = true, - value, -}: { - borderColor?: "cyan" | "gray"; - maxDisplayWidth: number; - marginTop?: number; - showCursor?: boolean; - value: string; -}) { - return ( - - - > - {value ? {value} : null} - {showCursor ? : null} - - - ); -} - -function InputValueWithCursor({ - maxDisplayWidth, - secret = false, - showCursor = true, - value, -}: { - maxDisplayWidth: number; - secret?: boolean; - showCursor?: boolean; - value: string; -}) { - if (secret) { - const displayValue = getSingleLineInputDisplayValue( - formatSecretInputDisplay(value), - maxDisplayWidth, - ); - - return ( - <> - 0 ? "yellow" : "gray"}>{displayValue} - {showCursor ? : null} - - ); - } - - const displayValue = getSingleLineInputDisplayValue(value, maxDisplayWidth); - - return ( - <> - {displayValue ? {displayValue} : null} - {showCursor ? : null} - - ); -} - -function formatSecretInputDisplay(value: string): string { - // Empty renders as nothing (just the cursor); dots for the entered length, - // matching the non-secret inputs rather than printing a literal "empty". - return "•".repeat(value.length); -} - -function formatTerminalHyperlink(url: string, label: string): string { - return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`; -} - -function getSingleLineInputDisplayValue( - value: string, - maxLength: number, -): string { - if (maxLength <= 0) { - return ""; - } - - if (value.length <= maxLength) { - return value; - } - - if (maxLength <= 3) { - return value.slice(-maxLength); - } - - return `...${value.slice(-(maxLength - 3))}`; -} - -function SegmentedCronInput({ - activeFieldIndex, - expression, - fallbackExpression, - maxDisplayWidth, -}: { - activeFieldIndex: number; - expression: string; - fallbackExpression: string; - maxDisplayWidth: number; -}) { - const fields = getCronFields(expression, fallbackExpression); - const fieldDisplayWidth = Math.max( - 8, - Math.min(14, Math.floor(maxDisplayWidth / CRON_FIELD_LABELS.length) - 1), - ); - - return ( - - - {fields.map((field, index) => ( - - {CRON_FIELD_LABELS[index]} - - - ))} - - Cron: {fields.join(" ")} - - ); -} - -export function getInitialStep( - modelIdOverride: string | null, - provider: OpenWikiProvider, - onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), - mode: OpenWikiRunMode = "code", - allowModeSelection = false, - walkAll = false, -): PromptStep | null { - if (walkAll) { - // Explicit --init: always start at the top and walk every applicable step, - // even ones already configured, instead of skipping to the first unset one. - return orderedSetupSteps(provider, mode, allowModeSelection)[0] ?? null; - } - - if (allowModeSelection) { - return "run-mode"; - } - - if (!hasValidConfiguredProvider()) { - return "provider"; - } - - if (needsAwsCredentialRepair(provider)) { - return "region"; - } - - const nextCredentialStep = credentialStep(provider); - - if (needsCredentialStep(provider) && nextCredentialStep) { - return nextCredentialStep; - } - - if (needsSecretKeyStep(provider)) { - return "secret-key"; - } - - if (needsGcpProjectStep(provider)) { - return "gcp-project"; - } - - if (needsBaseUrlStep(provider)) { - return "base-url"; - } - - if (needsRegionStep(provider)) { - return "region"; - } - - if ( - modelIdOverride === null && - process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined - ) { - return "model"; - } - - if (!process.env.LANGSMITH_API_KEY) { - return "langsmith"; - } - - if (mode === "code" && !isOnboardingComplete(onboardingConfig)) { - return "code-repo-confirm"; - } - - if (!getConfigModeId(onboardingConfig)) { - return "template"; - } - - if (!onboardingConfig.wikiGoal) { - return "wiki-goal"; - } - - if (!isCodeMode(onboardingConfig) && !onboardingConfig.ingestionSchedule) { - return "global-cron-mode"; - } - - if (!isOnboardingComplete(onboardingConfig)) { - return "source-menu"; - } - - return null; -} - -export function getNextStepAfterProvider( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), - mode: OpenWikiRunMode = "code", - forceModelStep = false, -): PromptStep | null { - if (needsAwsCredentialRepair(provider)) { - return "region"; - } - - const nextCredentialStep = credentialStep(provider); - - if (needsCredentialStep(provider) && nextCredentialStep) { - return nextCredentialStep; - } - - return getNextStepAfterApiKey( - provider, - modelIdOverride, - onboardingConfig, - mode, - forceModelStep, - ); -} - -function getNextStepAfterApiKey( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, - forceModelStep = false, -): PromptStep | null { - if (needsSecretKeyStep(provider)) { - return "secret-key"; - } - - return getNextStepAfterSecretKey( - provider, - modelIdOverride, - onboardingConfig, - mode, - forceModelStep, - ); -} - -function getNextStepAfterSecretKey( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, - forceModelStep = false, -): PromptStep | null { - if (needsGcpProjectStep(provider)) { - return "gcp-project"; - } - - return getNextStepAfterGcpLocation( - provider, - modelIdOverride, - onboardingConfig, - mode, - forceModelStep, - ); -} - -function getNextStepAfterGcpLocation( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), - mode: OpenWikiRunMode = "code", - forceModelStep = false, -): PromptStep | null { - if (needsBaseUrlStep(provider)) { - return "base-url"; - } - - return getNextStepAfterBaseUrl( - provider, - modelIdOverride, - onboardingConfig, - mode, - forceModelStep, - ); -} - -function getNextStepAfterBaseUrl( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, - forceModelStep = false, -): PromptStep | null { - if (needsRegionStep(provider)) { - return "region"; - } - - return getNextStepAfterRegion( - provider, - modelIdOverride, - onboardingConfig, - mode, - forceModelStep, - ); -} - -function getNextStepAfterRegion( - provider: OpenWikiProvider, - modelIdOverride: string | null, - onboardingConfig: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, - forceModelStep = false, -): PromptStep | null { - if ( - modelIdOverride === null && - (forceModelStep || process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) - ) { - return "model"; - } - - if (!process.env.LANGSMITH_API_KEY) { - return "langsmith"; - } - - if (mode === "code" && !isOnboardingComplete(onboardingConfig)) { - return "code-repo-confirm"; - } - - if (!getConfigModeId(onboardingConfig)) { - return "template"; - } - - if (!onboardingConfig.wikiGoal) { - return "wiki-goal"; - } - - if (!isCodeMode(onboardingConfig) && !onboardingConfig.ingestionSchedule) { - return "global-cron-mode"; - } - - if (!isOnboardingComplete(onboardingConfig)) { - return "source-menu"; - } - - return null; -} - -export function ensureRunModeConfig( - config: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, -): OpenWikiOnboardingConfig { - if (getConfigModeId(config) === mode) { - return mode === "code" && config.wikiGoal !== undefined - ? { ...config, wikiGoal: undefined } - : config; - } - - const runModeTemplate = ONBOARDING_TEMPLATES.find( - (option) => option.id === mode, - ); - if (!runModeTemplate) { - return config; - } - - return { - ...config, - modeId: runModeTemplate.id, - modeName: runModeTemplate.name, - templateId: runModeTemplate.id, - templateName: runModeTemplate.name, - ...(mode === "code" ? { wikiGoal: undefined } : {}), - }; -} - -export async function hydrateRunModeConfig( - config: OpenWikiOnboardingConfig, - mode: OpenWikiRunMode, - repoRoot: string, -): Promise { - if (mode !== "code") { - return config; - } - - const wikiGoal = await readRepositoryWikiInstructions(repoRoot); - - return { ...config, wikiGoal }; -} - -function getRunModeSelectionIndex(mode: OpenWikiRunMode): number { - const index = RUN_MODE_OPTIONS.findIndex((option) => option.id === mode); - return index === -1 ? 0 : index; -} - -function getLangsmithRegionSelectionIndex(region: LangSmithRegion): number { - const index = LANGSMITH_REGION_OPTIONS.findIndex( - (option) => option.id === region, - ); - return index === -1 ? 0 : index; -} - -function getLangsmithRegionLabel(region: LangSmithRegion): string { - const option = LANGSMITH_REGION_OPTIONS.find((item) => item.id === region); - return option ? `${option.name} (${option.host})` : region; -} - -function getRunModeName(mode: OpenWikiRunMode): string { - return RUN_MODE_OPTIONS.find((option) => option.id === mode)?.name ?? mode; -} - -function getSourceOption(sourceId: ConnectorId): SourceSetupOption { - return ( - SOURCE_OPTIONS.find((source) => source.id === sourceId) ?? SOURCE_OPTIONS[0] - ); -} - -function getConfigModeId(config: OpenWikiOnboardingConfig): string | undefined { - return config.modeId ?? config.templateId; -} - -function getConfigModeName( - config: OpenWikiOnboardingConfig, -): string | undefined { - return config.modeName ?? config.templateName; -} - -function isCodeMode(config: OpenWikiOnboardingConfig): boolean { - return getConfigModeId(config) === "code"; -} - -function needsEnvValue(secretInput: SourceSecretInput): boolean { - return !process.env[secretInput.envKey]; -} - -function addSourceInstanceConfig( - config: OpenWikiOnboardingConfig, - sourceInstance: OpenWikiOnboardingConfig["sourceInstances"][number], -): OpenWikiOnboardingConfig { - const sourceInstances = [...config.sourceInstances, sourceInstance]; - return { - ...config, - sourceInstances, - sources: deriveLegacySources(sourceInstances), - }; -} - -function deriveLegacySources( - sourceInstances: OpenWikiOnboardingConfig["sourceInstances"], -): OpenWikiOnboardingConfig["sources"] { - const sources: OpenWikiOnboardingConfig["sources"] = {}; - - for (const sourceInstance of sourceInstances) { - if (!sources[sourceInstance.connectorId]) { - sources[sourceInstance.connectorId] = { - connectedAt: sourceInstance.connectedAt, - connectorConfig: sourceInstance.connectorConfig, - ingestionGoal: sourceInstance.ingestionGoal, - }; - } - } - - return sources; -} - -function getSourceInstanceCount( - config: OpenWikiOnboardingConfig, - sourceId: ConnectorId, -): number { - return getSourceInstances(config, sourceId).length; -} - -function getSourceInstances( - config: OpenWikiOnboardingConfig, - sourceId: ConnectorId, -): OpenWikiOnboardingConfig["sourceInstances"] { - return config.sourceInstances.filter( - (sourceInstance) => sourceInstance.connectorId === sourceId, - ); -} - -function getConnectedSourceCount( - config: OpenWikiOnboardingConfig, - sourceOptions: readonly SourceSetupOption[], -): number { - const sourceIds = new Set(sourceOptions.map((source) => source.id)); - return config.sourceInstances.filter((sourceInstance) => - sourceIds.has(sourceInstance.connectorId), - ).length; -} - -function createSourceInstanceId( - sourceId: ConnectorId, - config: OpenWikiOnboardingConfig, -): string { - const sourceCount = getSourceInstanceCount(config, sourceId) + 1; - return `${sourceId}-${sourceCount}`; -} - -function createSourceInstanceName( - source: SourceSetupOption, - description: string, - config: OpenWikiOnboardingConfig, -): string { - const sourceCount = getSourceInstanceCount(config, source.id) + 1; - const trimmedDescription = description.trim(); - const suffix = trimmedDescription.length > 0 ? `: ${trimmedDescription}` : ""; - return `${source.displayName} ${sourceCount}${suffix}`.slice(0, 120); -} - -function isSourceStep(step: PromptStep | null): boolean { - return Boolean(step?.startsWith("source-")); -} - -function isScheduleStep(step: PromptStep | null): boolean { - return Boolean(step?.startsWith("global-")); -} - -/** - * Label for the provider's primary credential input. Bedrock authenticates - * with an IAM access key ID (paired with a secret access key), not a single - * opaque API key, so its prompt reads differently from every other provider. - */ -function getApiKeyFieldLabel(provider: OpenWikiProvider): string { - return provider === "bedrock" - ? `${getProviderLabel(provider)} access key ID` - : `${getProviderLabel(provider)} API key`; -} - -function hasValidConfiguredProvider(): boolean { - return normalizeProvider(process.env[OPENWIKI_PROVIDER_ENV_KEY]) !== null; -} - -function getModelSetupDetail( - modelIdOverride: string | null, - provider: OpenWikiProvider, -): string { - if (modelIdOverride) { - return `using ${modelIdOverride} for this run`; - } - - if (process.env[OPENWIKI_MODEL_ID_ENV_KEY]) { - return process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? ""; - } - - return `default ${getDefaultModelId(provider)}`; -} - -function getModelSelectionOptions( - provider: OpenWikiProvider, -): ModelSelectionOption[] { - return [ - ...getProviderModelOptions(provider).map((model) => ({ - id: model.id, - kind: "preset" as const, - label: model.label, - })), - { kind: "custom" }, - ]; -} - -function shouldStartWithCustomModelInput(provider: OpenWikiProvider): boolean { - return getProviderModelOptions(provider).length === 0; -} - -function getSelectedModelId( - provider: OpenWikiProvider, - selectedIndex: number, - input: string, - isCustomInput: boolean, -): string | null { - if (!isCustomInput) { - const selectedOption = getModelSelectionOptions(provider)[selectedIndex]; - - if (!selectedOption) { - return null; - } - - return selectedOption.kind === "custom" ? "custom" : selectedOption.id; - } - - const normalizedModelId = normalizeModelId(input); - - return isValidModelId(normalizedModelId) ? normalizedModelId : null; -} - -function getProviderSelectionIndex(provider: OpenWikiProvider): number { - const selectedIndex = SELECTABLE_OPENWIKI_PROVIDERS.findIndex( - (providerOption) => providerOption === provider, - ); - - return selectedIndex === -1 ? 0 : selectedIndex; -} - -function getModelSelectionIndex( - provider: OpenWikiProvider, - selectedModelId: string, -): number { - const selectedIndex = getModelSelectionOptions(provider).findIndex( - (option) => option.kind === "preset" && option.id === selectedModelId, - ); - - return selectedIndex === -1 ? 0 : selectedIndex; -} - -function moveSelectionIndex( - currentIndex: number, - offset: number, - itemCount: number, -): number { - if (itemCount <= 0) { - return 0; - } - - return (currentIndex + offset + itemCount) % itemCount; -} - -function getInputDisplayWidth(stdoutColumns: number | undefined): number { - const defaultWidth = 64; - - if (!stdoutColumns || stdoutColumns <= 0) { - return defaultWidth; - } - - return Math.max(24, Math.min(96, stdoutColumns - 16)); -} - -function getProviderArticle(provider: OpenWikiProvider): "a" | "an" { - return provider === "baseten" || - provider === "fireworks" || - provider === "gemini" || - provider === "gemini-enterprise" || - provider === "nebius" - ? "a" - : "an"; -} - -function getTemplateGoal(templateId: string | undefined): string { - return ( - ONBOARDING_TEMPLATES.find((template) => template.id === templateId) - ?.suggestedGoal ?? "" - ); -} - -function getSourceMenuLabel( - source: SourceSetupOption, - sourceInstanceCount: number, -): string { - return sourceInstanceCount > 0 - ? `Add another ${source.displayName}` - : `Add ${source.displayName}`; -} - -function getTemplateSourceOptions( - templateId: string | undefined, -): readonly SourceSetupOption[] { - const template = - ONBOARDING_TEMPLATES.find((option) => option.id === templateId) ?? - ONBOARDING_TEMPLATES[0]; - const sourceIds = new Set(template.sourceIds); - const sourceOptions = SOURCE_OPTIONS.filter((source) => - sourceIds.has(source.id), - ); - - return sourceOptions.length > 0 ? sourceOptions : SOURCE_OPTIONS; -} - -function getSourceDescriptionPrompt(source: SourceSetupOption): string { - if (source.id === "web-search") { - return "Describe the topics, companies, or pages OpenWiki should search for."; - } - - if (source.id === "hackernews") { - return "Describe the topics, keywords, users, or story types OpenWiki should watch on Hacker News."; - } - - if (source.id === "git-repo") { - return "Describe what OpenWiki should understand about this repository."; - } - - return `Describe what OpenWiki should look for in ${source.displayName}.`; -} - -function getFinalOptionLabel( - option: (typeof FINAL_OPTIONS)[number], - mode: OpenWikiRunMode, -): string { - if (mode !== "code") { - return option; - } - - return option === "Run ingestion now" ? "Run OpenWiki now" : "Open chat"; -} - -function getSourceDescriptionOptionCount(source: SourceSetupOption): number { - return source.examples.length + 1; -} - -function handleCronEditorInput({ - currentFieldIndex, - currentValue, - fallbackExpression, - inputValue, - key, - replaceCurrentField, - setCurrentFieldIndex, - setReplaceCurrentField, - setValue, -}: { - currentFieldIndex: number; - currentValue: string; - fallbackExpression: string; - inputValue: string; - key: PromptInputKey; - replaceCurrentField: boolean; - setCurrentFieldIndex: React.Dispatch>; - setReplaceCurrentField: React.Dispatch>; - setValue: React.Dispatch>; -}): boolean { - if (key.leftArrow) { - setCurrentFieldIndex((index) => Math.max(0, index - 1)); - setReplaceCurrentField(true); - return true; - } - - if (key.rightArrow || key.tab || inputValue === " " || inputValue === "\t") { - setCurrentFieldIndex((index) => - Math.min(CRON_FIELD_LABELS.length - 1, index + 1), - ); - setReplaceCurrentField(true); - return true; - } - - if (key.backspace || key.delete) { - const fields = getCronFields(currentValue, fallbackExpression); - const currentField = fields[currentFieldIndex] ?? ""; - if (currentField.length === 0 && currentFieldIndex > 0) { - setCurrentFieldIndex(currentFieldIndex - 1); - setReplaceCurrentField(false); - return true; - } - - fields[currentFieldIndex] = currentField.slice(0, -1); - setValue(fields.join(" ")); - setReplaceCurrentField(false); - return true; - } - - if (key.ctrl || key.meta) { - return false; - } - - const pastedFields = parseCronFieldPaste(inputValue); - if (pastedFields.length > 1) { - const fields = getCronFields(currentValue, fallbackExpression); - pastedFields.forEach((field, offset) => { - const fieldIndex = currentFieldIndex + offset; - if (fieldIndex < CRON_FIELD_LABELS.length) { - fields[fieldIndex] = field; - } - }); - setValue(fields.join(" ")); - setCurrentFieldIndex((index) => - Math.min(CRON_FIELD_LABELS.length - 1, index + pastedFields.length - 1), - ); - setReplaceCurrentField(true); - return true; - } - - const sanitizedInput = sanitizeCronInputChunk(inputValue); - - if (!sanitizedInput) { - return false; - } - - const fields = getCronFields(currentValue, fallbackExpression); - fields[currentFieldIndex] = replaceCurrentField - ? sanitizedInput - : `${fields[currentFieldIndex] ?? ""}${sanitizedInput}`; - setValue(fields.join(" ")); - setReplaceCurrentField(false); - return true; -} - -function getCronFields( - expression: string, - fallbackExpression: string, -): string[] { - const source = - expression.trim().length > 0 ? expression.trim() : fallbackExpression; - const fields = source.split(/\s+/u); - - return CRON_FIELD_LABELS.map((_, index) => fields[index] ?? ""); -} - -function parseCronFieldPaste(inputValue: string): string[] { - if (inputValue.trim().length === 0) { - return []; - } - - if (/\s/u.test(inputValue)) { - return inputValue - .trim() - .split(/\s+/u) - .map((field) => sanitizeCronInputChunk(field)) - .filter((field) => field.length > 0); - } - - const compactValue = sanitizeCronInputChunk(inputValue); - - if (/^[0-9*]{5}$/u.test(compactValue)) { - return compactValue.split(""); - } - - return []; -} - -function sanitizeInputChunk(value: string): string { - return value.replace(/[\r\n]/gu, ""); -} - -function sanitizeCronInputChunk(value: string): string { - return value.replace(/[^A-Za-z0-9*,/?#LW.-]/gu, ""); -} - -function sanitizeRepoId(value: string): string { - return value.replace(/[^A-Za-z0-9._-]/gu, "-").slice(0, 80) || "repo"; -} - -function getDefaultLocalGitRepoPath(): string { - return process.cwd(); -} - -function getDefaultCodeRepoRootPath(): string { - return findNearestGitRepoRoot(process.cwd()) ?? process.cwd(); -} - -export function findNearestGitRepoRoot(startPath: string): string | null { - let currentPath = path.resolve(startPath); - - while (true) { - if (existsSync(path.join(currentPath, ".git"))) { - return currentPath; - } - - const parentPath = path.dirname(currentPath); - if (parentPath === currentPath) { - return null; - } - - currentPath = parentPath; - } -} - -async function validateLocalDirectoryPath(value: string): Promise { - const normalizedPath = normalizeLocalPath(value); - - if (normalizedPath.length === 0) { - throw new Error("Enter a local directory."); - } - - const { stat } = await import("node:fs/promises"); - const pathStat = await stat(normalizedPath); - - if (!pathStat.isDirectory()) { - throw new Error(`${normalizedPath} is not a directory.`); - } - - return normalizedPath; -} - -function normalizeLocalPath(value: string): string { - const trimmedValue = value.trim(); - if (trimmedValue.length === 0) { - return ""; - } - - if (trimmedValue === "~") { - return homedir(); - } - - if (trimmedValue.startsWith("~/") || trimmedValue.startsWith("~\\")) { - return path.resolve(homedir(), trimmedValue.slice(2)); - } - - return path.resolve(trimmedValue); -} - -function getStaticSourceConfig( - sourceId: ConnectorId, - query: string, -): Record { - const queries = query.trim().length > 0 ? [query.trim()] : []; - - if (sourceId === "web-search") { - return { - enabled: true, - includeAnswer: true, - includeImages: false, - includeRawContent: false, - maxResults: 5, - queries, - searchDepth: "basic", - timeRange: "day", - topic: "general", - }; - } - - if (sourceId === "hackernews") { - return { - enabled: true, - feeds: ["top", "new"], - maxItemsPerFeed: 30, - maxResultsPerQuery: 20, - queries, - queryTags: ["story"], - }; - } - - return { - enabled: true, - }; -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function InitSetup(props: InitSetupProps) { + return ; } diff --git a/src/setup/credentials/components.tsx b/src/setup/credentials/components.tsx new file mode 100644 index 000000000..a28116e4d --- /dev/null +++ b/src/setup/credentials/components.tsx @@ -0,0 +1,1278 @@ +import type React from "react"; +import { Box, Text } from "ink"; +import { + DEFAULT_PROVIDER, + DEFAULT_VERTEX_LOCATION, + getDefaultModelId, + getProviderApiKeyEnvKey, + getProviderBaseUrlEnvKey, + getProviderLabel, + getProviderLocationEnvKey, + getProviderProjectEnvKey, + getProviderRegionEnvKey, + getProviderRegionEnvKeys, + getProviderSecretKeyEnvKey, + OPENWIKI_MODEL_ID_ENV_KEY, + type OpenWikiProvider, + resolveProviderRegion, + SELECTABLE_OPENWIKI_PROVIDERS, +} from "../../config/constants.js"; +import type { AuthProviderId } from "../../auth/types.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import { + getExternalCliAuthAdapter, + type ExternalCliAuthState, +} from "../../auth/external-cli-auth.js"; +import { validateCronExpression } from "../../scheduling/schedules.js"; +import type { OpenWikiOnboardingConfig } from "../onboarding.js"; +import { + getApiKeyFieldLabel, + getConfigModeName, + getConnectedSourceCount, + getCronFields, + getFinalOptionLabel, + getLangsmithRegionLabel, + getModelSelectionOptions, + getProviderArticle, + getSourceDescriptionPrompt, + getSourceInstanceCount, + getSourceInstances, + getSourceMenuLabel, + isCodeMode, +} from "./steps.js"; +import { + formatSecretInputDisplay, + formatTerminalHyperlink, + getAwsCredentialRepairMessage, + getOAuthAuthorizationStatusText, + getSingleLineInputDisplayValue, + mask, +} from "./format.js"; +import { + CODE_REPO_OPTIONS, + CRON_FIELD_LABELS, + CRON_MODE_OPTIONS, + FINAL_OPTIONS, + LANGSMITH_REGION_OPTIONS, + ONBOARDING_TEMPLATES, + POWER_MODE_OPTIONS, + RUN_MODE_OPTIONS, + SOURCE_CONTINUE_OPTIONS, + STEP_COLOR, + STEP_GLYPH, +} from "./constants.js"; +import type { + LangsmithWorkspaceDraft, + PromptStep, + SetupStepState, + SourceSetupOption, + SourceSetupState, +} from "./types.js"; + +export function Prompt({ + codeRepoPathInput, + codeRepoRoot, + codeRepoSelectionIndex, + externalCliAuth, + cronFieldSelectionIndex, + cronModeSelectionIndex, + finalSelectionIndex, + input, + inputDisplayWidth, + isCustomModelInput, + langsmithDraft, + langsmithRegionSelectionIndex, + langsmithWorkspaceSelectionIndex, + langsmithWorkspaces, + modelSelectionIndex, + onboardingConfig, + powerModeSelectionIndex, + provider, + providerSelectionIndex, + runModeSelectionIndex, + secretInputIndex, + selectedMode, + selectedSource, + sourceOptions, + sourceContinueSelectionIndex, + sourceDescriptionSelectionIndex, + sourceSelectionIndex, + sourceState, + step, + suggestedCronDescription, + suggestedCronExpression, + templateSelectionIndex, +}: { + codeRepoPathInput: string; + codeRepoRoot: string; + codeRepoSelectionIndex: number; + externalCliAuth: ExternalCliAuthState; + cronFieldSelectionIndex: number; + cronModeSelectionIndex: number; + finalSelectionIndex: number; + input: string; + inputDisplayWidth: number; + isCustomModelInput: boolean; + langsmithDraft: LangsmithWorkspaceDraft | null; + langsmithRegionSelectionIndex: number; + langsmithWorkspaceSelectionIndex: number; + langsmithWorkspaces: LangsmithWorkspaceDraft[]; + modelSelectionIndex: number; + onboardingConfig: OpenWikiOnboardingConfig; + powerModeSelectionIndex: number; + provider: OpenWikiProvider; + providerSelectionIndex: number; + runModeSelectionIndex: number; + secretInputIndex: number; + selectedMode: OpenWikiRunMode; + selectedSource: SourceSetupOption; + sourceOptions: readonly SourceSetupOption[]; + sourceContinueSelectionIndex: number; + sourceDescriptionSelectionIndex: number; + sourceSelectionIndex: number; + sourceState: SourceSetupState; + step: PromptStep; + suggestedCronDescription: string; + suggestedCronExpression: string; + templateSelectionIndex: number; +}) { + if (step === "run-mode") { + const selectedMode = + RUN_MODE_OPTIONS[runModeSelectionIndex] ?? RUN_MODE_OPTIONS[0]; + + return ( + + Choose what OpenWiki should initialize. + {RUN_MODE_OPTIONS.map((option, index) => ( + + {" "} + {option.name} ({option.id}) + + ))} + + {selectedMode.name} + {selectedMode.description} + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "provider") { + return ( + + Choose a model provider. + {SELECTABLE_OPENWIKI_PROVIDERS.map((providerOption, index) => ( + + {" "} + {getProviderLabel(providerOption)} + ({providerOption}) + {providerOption === DEFAULT_PROVIDER ? ( + default + ) : null} + + ))} + Use up/down arrows, then press Enter. + + ); + } + + if (step === "api-key") { + return ( + + Paste your {getApiKeyFieldLabel(provider)}. + + Press Enter to save it. + + ); + } + + if (step === "external-cli-auth") { + return ( + + ); + } + + if (step === "secret-key") { + return ( + + Paste your {getProviderLabel(provider)} secret access key. + + Press Enter to save it. + + ); + } + + if (step === "gcp-project") { + return ( + + Enter the Google Cloud project ID with Vertex AI access. + + $ {getProviderProjectEnvKey(provider)}={" "} + {input} + + + OpenWiki authenticates with Google Application Default Credentials + (run: gcloud auth application-default login). Press Enter to save it. + + + ); + } + + if (step === "gcp-location") { + return ( + + + Enter a Vertex AI location, or press Enter to use{" "} + {DEFAULT_VERTEX_LOCATION}. + + + $ {getProviderLocationEnvKey(provider)}={" "} + {input} + + + For example global, europe-west1, or us-east5. Press Enter to + continue. + + + ); + } + + if (step === "base-url") { + return ( + + Enter the {getProviderLabel(provider)} base URL. + + $ {getProviderBaseUrlEnvKey(provider)}={" "} + {input} + + + For example an OpenAI-compatible gateway endpoint (such as a LiteLLM + gateway). Press Enter to save it. + + + ); + } + + if (step === "region") { + const resolvedRegion = resolveProviderRegion(provider); + const credentialRepairMessage = getAwsCredentialRepairMessage(provider); + + return ( + + {credentialRepairMessage ? ( + ⚠ {credentialRepairMessage} + ) : null} + + Enter the {getProviderLabel(provider)} region + {resolvedRegion ? `, or press Enter to keep ${resolvedRegion}` : ""}. + + + $ {getProviderRegionEnvKey(provider)}={" "} + {input} + + + Uses {getProviderRegionEnvKeys(provider).join(", ")}. For example + us-east-1. + + + ); + } + + if (step === "model") { + if (isCustomModelInput) { + return ( + + Paste a custom model ID. + + Press Enter to save it. + + ); + } + + return ( + + + Choose {getProviderArticle(provider)} {getProviderLabel(provider)}{" "} + model. + + {getModelSelectionOptions(provider).map((option, index) => { + if (option.kind === "custom") { + return ( + + {" "} + Custom model ID + + ); + } + + return ( + + {" "} + {option.label} {option.id} + {option.id === getDefaultModelId(provider) ? ( + default + ) : null} + + ); + })} + Use up/down arrows, then press Enter. + + ); + } + + if (step === "langsmith") { + return ( + + Optional: paste a LangSmith API key for tracing. + + Press Enter with an empty value to skip. + + ); + } + + if (step === "template") { + const selectedTemplate = + ONBOARDING_TEMPLATES[templateSelectionIndex] ?? ONBOARDING_TEMPLATES[0]; + + return ( + + Choose how OpenWiki should run. + {ONBOARDING_TEMPLATES.map((template, index) => ( + + {" "} + {template.name} + + ))} + + {selectedTemplate.name} + {selectedTemplate.description} + {selectedTemplate.suggestedSources.length > 0 ? ( + + Suggested sources: {selectedTemplate.suggestedSources.join(", ")} + + ) : ( + Start from a blank wiki brief. + )} + + + Press Enter, then edit the brief on the next step. + + + ); + } + + if (step === "wiki-goal") { + return ( + + Customize what this wiki should understand. + {getConfigModeName(onboardingConfig) ? ( + Mode: {getConfigModeName(onboardingConfig)} + ) : null} + + Edit the brief below. Keep what is useful, delete what is not. + + + Edit wiki brief + + + Press Enter to continue. + + ); + } + + if (step === "code-repo-confirm") { + return ( + + Use this repository? + + {codeRepoRoot} + + + OpenWiki will run in this directory and write the initial openwiki/ + folder there. + + + {CODE_REPO_OPTIONS.map((option, index) => ( + + {" "} + {option} + + ))} + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "code-repo-path") { + return ( + + Choose the repository directory. + + Enter an existing directory. OpenWiki will write openwiki/ there. + + + Press Enter to confirm this path. + + ); + } + + if (step === "source-menu") { + // LangSmith workspaces live in state until setup completes (not onboarding + // source instances), so count them here for the menu's configured display. + const langsmithWorkspaceCount = sourceOptions.some( + (source) => source.id === "langsmith", + ) + ? langsmithWorkspaces.length + : 0; + const configuredCount = + getConnectedSourceCount(onboardingConfig, sourceOptions) + + langsmithWorkspaceCount; + + return ( + + Configure sources for this mode. + {sourceOptions.map((source, index) => { + const isLangsmith = source.id === "langsmith"; + const sourceInstances = getSourceInstances( + onboardingConfig, + source.id, + ); + const count = isLangsmith + ? langsmithWorkspaces.length + : sourceInstances.length; + return ( + + + {" "} + {getSourceMenuLabel(source, count)}{" "} + 0} + /> + + {isLangsmith + ? langsmithWorkspaces.map((workspace) => ( + + {" "}- {getLangsmithRegionLabel(workspace.region)}:{" "} + {workspace.projects.join(", ")} + + )) + : sourceInstances.map((sourceInstance) => ( + + {" "}- {sourceInstance.name ?? sourceInstance.id}{" "} + ({sourceInstance.id}) + + ))} + + ); + })} + + Next + + {" "} + Continue{" "} + {configuredCount === 0 ? ( + (no sources configured) + ) : null} + + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "source-path") { + return ( + + Choose the local Git repository directory. + + Default is the directory where you started OpenWiki. Edit it to use a + different checkout. + + + Press Enter to save this source. + + ); + } + + if (step === "source-secret") { + const secretInput = selectedSource.secretInputs[secretInputIndex]; + return ( + + {selectedSource.displayName} setup + {selectedSource.instructions.map((instruction, index) => ( + + {index + 1}. {instruction} + + ))} + {secretInput ? ( + + Enter credential + + + {secretInput.optional + ? "Press Enter with an empty value to skip." + : "Press Enter to save this value."} + + + ) : null} + + ); + } + + if (step === "source-auth") { + return ( + + {selectedSource.displayName} authorization + {sourceState.authUrl ? ( + + ) : ( + + Press Enter to open the authorization URL and wait for the callback. + + )} + + ); + } + + if (step === "source-description") { + return ( + + {getSourceDescriptionPrompt(selectedSource)} + + Choose an example description, or write your own. + + {selectedSource.examples.map((example, index) => ( + + {" "} + {example} + + ))} + + = selectedSource.examples.length + } + />{" "} + Custom description + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "source-description-custom") { + return ( + + {getSourceDescriptionPrompt(selectedSource)} + + Type what OpenWiki should focus on for this source. + + + Optional. Press Enter to continue. + + ); + } + + if (step === "source-langsmith-workspaces") { + const workspaceCount = langsmithWorkspaces.length; + return ( + + LangSmith workspaces to document. + + A LangSmith key is region-bound, so each workspace has its own region + and key. Select one to edit (clear its projects to remove it). + + {langsmithWorkspaces.map((workspace, index) => ( + + {" "} + {getLangsmithRegionLabel(workspace.region)}:{" "} + {workspace.projects.join(", ")} + + ))} + + + {" "} + Add a workspace + + + {" "} + Done + + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "source-langsmith-key") { + const apiKeyEnv = langsmithDraft?.apiKeyEnv ?? "OPENWIKI_LANGSMITH_API_KEY"; + return ( + + LangSmith API key for this workspace. + + The connector's own read key (not your app's tracing key). + Saved to ~/.openwiki/.env as {apiKeyEnv}, never committed. + + + + Press Enter to confirm (empty keeps the saved key). + + + ); + } + + if (step === "source-langsmith-projects") { + return ( + + Which projects should this wiki document in this workspace? + + Comma-separated project names (as in LANGCHAIN_PROJECT). Written to + openwiki/.langsmith.json. + + + Press Enter to confirm. + + ); + } + + if (step === "source-langsmith-region") { + const selectedRegion = + LANGSMITH_REGION_OPTIONS[langsmithRegionSelectionIndex] ?? + LANGSMITH_REGION_OPTIONS[0]; + + return ( + + Which LangSmith region is this workspace in? + {LANGSMITH_REGION_OPTIONS.map((option, index) => ( + + {" "} + {option.name} ({option.host}) + + ))} + + {selectedRegion.description} + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "global-cron-mode") { + return ( + + + {isCodeMode(onboardingConfig) + ? "When should GitHub Actions refresh this code wiki?" + : "When should OpenWiki run all ingestion?"} + + + {isCodeMode(onboardingConfig) + ? "OpenWiki will write a scheduled GitHub Actions workflow for this repository." + : "All configured sources run sequentially at this time."} + + Suggested: {suggestedCronDescription} + {CRON_MODE_OPTIONS.map((option, index) => ( + + {" "} + {option} + + ))} + Use up/down arrows, then press Enter. + + ); + } + + if (step === "global-cron-custom") { + const validation = validateCronExpression(input); + return ( + + + {isCodeMode(onboardingConfig) + ? "Enter one GitHub Actions cron schedule for this code wiki." + : "Enter one cron schedule for all ingestion."} + + + {input ? ( + + {validation.valid ? validation.description : validation.error} + + ) : ( + Example: 0 2 * * * + )} + + Type in each field. Use right/left arrows or Tab to move; spaces also + move fields. + + Press Enter to save a valid schedule. + + ); + } + + if (step === "global-power-mode") { + return ( + + Keep your Mac awake for scheduled refreshes? + + OpenWiki can use macOS pmset to wake 2 minutes before the shared + ingestion schedule and sleep 30 minutes after it. + + {sourceState.savedScheduleWarning ? ( + {sourceState.savedScheduleWarning} + ) : null} + + {POWER_MODE_OPTIONS.map((option, index) => ( + + {" "} + {option} + + ))} + + + macOS has one global repeat power schedule. Setting this can replace + an existing pmset repeat wake/sleep schedule. + + + ); + } + + if (step === "source-confirm-continue") { + const missingSources = sourceOptions.filter( + (source) => getSourceInstanceCount(onboardingConfig, source.id) === 0, + ); + return ( + + Some sources for this mode are not configured yet. + {missingSources.map((source) => ( + + - {source.displayName} + + ))} + + {SOURCE_CONTINUE_OPTIONS.map((option, index) => ( + + {" "} + {option} + + ))} + + Use up/down arrows, then press Enter. + + ); + } + + if (step === "final") { + return ( + + Setup is complete. + {FINAL_OPTIONS.map((option, index) => { + const label = getFinalOptionLabel(option, selectedMode); + return ( + + {" "} + {label} + + ); + })} + + {selectedMode === "code" + ? "Run now writes the initial openwiki/ directory. Open chat skips the initial run." + : "Run now executes one source-specific ingestion and wiki update per configured source. Run later opens chat so you can start ingestion when you are ready."} + + + ); + } + + return null; +} + +export function ExternalCliAuthPrompt({ + authState, + input, + provider, +}: { + authState: ExternalCliAuthState; + input: string; + provider: OpenWikiProvider; +}) { + const adapter = getExternalCliAuthAdapter(provider); + const envKey = getProviderApiKeyEnvKey(provider) ?? "API key"; + + if (!adapter) { + return null; + } + + if (authState.kind === "idle" || authState.kind === "checking") { + return ( + + Checking for an existing {adapter.credentialDescription}... + + ); + } + + if (authState.kind === "logging-in") { + return ( + + Running `{adapter.loginCommand}` — follow the prompts in this + terminal... + + ); + } + + if (authState.kind === "detected") { + return ( + + Detected an existing {adapter.credentialDescription}. + + Press Enter to use it, Tab to sign in again, or paste a different + token below. + + + $ {envKey}={" "} + + {input.length > 0 ? mask(input) : ``} + + + + ); + } + + return ( + + No {adapter.credentialDescription} detected. + {authState.kind === "login-failed" ? ( + + `{adapter.loginCommand}` did not complete successfully. + + ) : null} + {authState.kind === "not-detected" && authState.cliAvailable ? ( + + Press Tab to run `{adapter.loginCommand}`, or paste a token below. + + ) : ( + + {adapter.installHint} You can also paste a token below for CI or other + headless use. + + )} + + $ {envKey}={" "} + {mask(input)} + + Press Enter to save it. + + ); +} + +export function SetupHeader() { + return ( + + + + OpenWiki + {" "} + first-run setup + + Configure the model, wiki scope, and sources. + + ); +} + +export function SetupStep({ + detail, + label, + state, +}: { + detail: string; + label: string; + state: SetupStepState; +}) { + return ( + + {STEP_GLYPH[state]}{" "} + + {label.padEnd(16)} + {" "} + {detail} + + ); +} + +export function SetupPanel({ + children, + title, +}: { + children: React.ReactNode; + title: string; +}) { + return ( + + + {title} + + {children} + + ); +} + +export function SelectionMarker({ isSelected }: { isSelected: boolean }) { + return ( + {isSelected ? ">" : " "} + ); +} + +export function SourceConnectionStatus({ + count, + isConfigured, +}: { + count: number; + isConfigured: boolean; +}) { + return ( + + {isConfigured + ? `[configured${count > 1 ? ` x${count}` : ""}]` + : "[not configured]"} + + ); +} + +export function OAuthAuthorizationLink({ + authProvider, + copiedToClipboard, + url, +}: { + authProvider?: AuthProviderId; + copiedToClipboard: boolean; + url: string; +}) { + return ( + + + + {formatTerminalHyperlink(url, "Open authorization URL")} + + + + {getOAuthAuthorizationStatusText({ + authProvider, + copiedToClipboard, + })} + + + ); +} + +export function OAuthLoginPrompt({ + copied, + input, + isLoggingIn, + loginUrl, + provider, +}: { + copied: boolean; + input: string; + isLoggingIn: boolean; + loginUrl: string | null; + provider: OpenWikiProvider; +}) { + return ( + + + ChatGPT login + + + Sign in with your {getProviderLabel(provider)} account to authorize + OpenWiki. + + {loginUrl ? ( + + + Opening your browser. If it does not open, copy this URL: + + + {loginUrl} + + + Press c to copy the URL + {copied ? (copied) : null} + + + + If the browser cannot reach this machine, paste the redirect URL + or authorization code and press Enter: + + + > + {input.length > 0 ? ( + {input} + ) : ( + (paste here) + )} + + + + ) : ( + Starting the ChatGPT login... + )} + + {isLoggingIn + ? "Waiting for browser sign-in or pasted URL..." + : "Login failed. Press Enter to retry."} + + + ); +} + +export function BorderedInput({ + borderColor = "cyan", + maxDisplayWidth, + marginTop, + prefix, + secret = false, + showCursor = true, + value, +}: { + borderColor?: "cyan" | "gray"; + maxDisplayWidth: number; + marginTop?: number; + prefix?: string; + secret?: boolean; + showCursor?: boolean; + value: string; +}) { + const prompt = prefix ? "$ " : "> "; + const prefixText = prefix ? `${prefix} ` : ""; + const valueDisplayWidth = Math.max( + 1, + maxDisplayWidth - prompt.length - prefixText.length - (showCursor ? 1 : 0), + ); + + return ( + + + {prompt} + {prefixText ? {prefixText} : null} + + + + ); +} + +export function BorderedMultilineInput({ + borderColor = "cyan", + maxDisplayWidth, + marginTop, + showCursor = true, + value, +}: { + borderColor?: "cyan" | "gray"; + maxDisplayWidth: number; + marginTop?: number; + showCursor?: boolean; + value: string; +}) { + return ( + + + > + {value ? {value} : null} + {showCursor ? : null} + + + ); +} + +export function InputValueWithCursor({ + maxDisplayWidth, + secret = false, + showCursor = true, + value, +}: { + maxDisplayWidth: number; + secret?: boolean; + showCursor?: boolean; + value: string; +}) { + if (secret) { + const displayValue = getSingleLineInputDisplayValue( + formatSecretInputDisplay(value), + maxDisplayWidth, + ); + + return ( + <> + 0 ? "yellow" : "gray"}>{displayValue} + {showCursor ? : null} + + ); + } + + const displayValue = getSingleLineInputDisplayValue(value, maxDisplayWidth); + + return ( + <> + {displayValue ? {displayValue} : null} + {showCursor ? : null} + + ); +} + +export function SegmentedCronInput({ + activeFieldIndex, + expression, + fallbackExpression, + maxDisplayWidth, +}: { + activeFieldIndex: number; + expression: string; + fallbackExpression: string; + maxDisplayWidth: number; +}) { + const fields = getCronFields(expression, fallbackExpression); + const fieldDisplayWidth = Math.max( + 8, + Math.min(14, Math.floor(maxDisplayWidth / CRON_FIELD_LABELS.length) - 1), + ); + + return ( + + + {fields.map((field, index) => ( + + {CRON_FIELD_LABELS[index]} + + + ))} + + Cron: {fields.join(" ")} + + ); +} diff --git a/src/setup/credentials/constants.ts b/src/setup/credentials/constants.ts new file mode 100644 index 000000000..5f63608e5 --- /dev/null +++ b/src/setup/credentials/constants.ts @@ -0,0 +1,248 @@ +import { + OPENWIKI_GOOGLE_CLIENT_ID_ENV_KEY, + OPENWIKI_GOOGLE_CLIENT_SECRET_ENV_KEY, + OPENWIKI_TAVILY_API_KEY_ENV_KEY, + OPENWIKI_X_CLIENT_ID_ENV_KEY, +} from "../../config/constants.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import type { LangSmithRegion } from "../../connectors/sources/langsmith/setup.js"; +import type { + OnboardingMode, + SourceSetupOption, + SetupStepState, +} from "./types.js"; + +export const ONBOARDING_TEMPLATES = [ + { + description: + "Maintain a structured project wiki from a local Git repository, with code-oriented pages for architecture, workflows, source maps, and operational guidance.", + id: "code", + name: "Code", + sourceIds: ["langsmith"], + suggestedSources: ["Local Git repository"], + suggestedGoal: "A code wiki for this repository.", + }, + { + description: + "A personal assistant wiki that builds memory from email, notes, social/research sources, and web search so you can ask about projects, priorities, people, and recurring context.", + id: "personal", + name: "Personal", + sourceIds: [ + "git-repo", + "google", + "notion", + "web-search", + "hackernews", + "x", + ], + suggestedSources: [ + "Gmail", + "Notion", + "Web Search (Tavily)", + "Hacker News", + "X/Twitter", + ], + suggestedGoal: + "Your personal brain. Track active projects, people, organizations, decisions, commitments, follow-ups, useful links, recurring themes, and fresh external signals. Organize the wiki so a personal assistant can answer what changed, what matters, what needs attention, and where supporting evidence came from. Be selective: summarize durable context and explicit action items, not every raw item.", + }, +] as const satisfies readonly OnboardingMode[]; + +export const RUN_MODE_OPTIONS = [ + { + description: + "Build a local personal brain wiki in ~/.openwiki/wiki from configured sources.", + id: "personal", + name: "Personal", + }, + { + description: + "Build repository documentation in ./openwiki for this codebase.", + id: "code", + name: "Code", + }, +] as const satisfies readonly { + description: string; + id: OpenWikiRunMode; + name: string; +}[]; + +export const LANGSMITH_REGION_OPTIONS = [ + { + description: "US workspaces. The default.", + host: "https://api.smith.langchain.com", + id: "us", + name: "US", + }, + { + description: "EU workspaces.", + host: "https://eu.api.smith.langchain.com", + id: "eu", + name: "EU", + }, +] as const satisfies readonly { + description: string; + host: string; + id: LangSmithRegion; + name: string; +}[]; + +export const SOURCE_OPTIONS = [ + { + displayName: "Local Git repository", + examples: [ + "Track architecture notes from this repo.", + "Summarize recent commits and changed files.", + ], + id: "git-repo", + instructions: [ + "Choose the local repository directory OpenWiki should read.", + "The default is the current working directory, and you can replace it with another path.", + "You can add more repositories later in the connector config file.", + ], + secretInputs: [], + }, + { + displayName: "LangSmith traces", + examples: ["support-bot-prod", "chat-agent"], + id: "langsmith", + instructions: [ + "Document how your agent runs, grounded in its LangSmith traces.", + "List the projects to document; written to openwiki/.langsmith.json (committed).", + ], + // No secret input: the LangSmith key is captured by the earlier `langsmith` + // spine step (and provided as a CI secret), and used at pull time, not here. + secretInputs: [], + }, + { + authProvider: "notion", + displayName: "Notion", + examples: [ + "Ingest product specs, meeting notes, and research pages.", + "Prioritize pages related to Applied AI and customer feedback.", + ], + id: "notion", + instructions: [ + "OpenWiki uses Notion's hosted MCP OAuth flow.", + "No client ID, client secret, or pasted Notion token is required.", + "Approve access in the browser window when it opens.", + ], + secretInputs: [], + }, + { + authProvider: "gmail", + displayName: "Gmail", + examples: [ + "Capture important project email threads from the last 24 hours.", + "Look for vendor updates, customer feedback, and action items.", + ], + id: "google", + instructions: [ + "Create OAuth credentials in Google Cloud for a desktop or web app.", + "Enable the Gmail API for the Google Cloud project.", + "Add http://127.0.0.1:53682/callback as an authorized redirect URI.", + "Paste the client ID and client secret below.", + ], + secretInputs: [ + { + envKey: OPENWIKI_GOOGLE_CLIENT_ID_ENV_KEY, + label: "Google OAuth client ID", + }, + { + envKey: OPENWIKI_GOOGLE_CLIENT_SECRET_ENV_KEY, + label: "Google OAuth client secret", + secret: true, + }, + ], + }, + { + displayName: "Web Search (Tavily)", + examples: [ + "Track a company, product category, or technical topic.", + "Find launch posts, docs, pricing pages, and recent articles.", + ], + id: "web-search", + instructions: [ + "Create a Tavily account and API key.", + "Paste the Tavily API key below.", + "Describe the topics, companies, or pages OpenWiki should search for on the next screen.", + ], + secretInputs: [ + { + envKey: OPENWIKI_TAVILY_API_KEY_ENV_KEY, + label: "Tavily API key", + secret: true, + }, + ], + }, + { + displayName: "Hacker News", + examples: [ + "Monitor threads about AI agents, evals, infrastructure, and startups.", + "Capture notable discussions and links related to my research topics.", + ], + id: "hackernews", + instructions: [ + "No account setup is required for Hacker News.", + "OpenWiki uses public Hacker News feed and search APIs.", + "Describe the topics, keywords, users, or story types OpenWiki should watch on the next screen.", + ], + secretInputs: [], + }, + { + authProvider: "x", + displayName: "X / Twitter", + examples: [ + "Track my home timeline, bookmarks, and key lists.", + "Summarize tweets from AI researchers and product announcements.", + ], + id: "x", + instructions: [ + "Create an X OAuth 2.0 app.", + "Use a native app or public client when possible.", + "Add http://127.0.0.1:53682/callback as a callback URI.", + "Paste the OAuth client ID below.", + ], + secretInputs: [ + { + envKey: OPENWIKI_X_CLIENT_ID_ENV_KEY, + label: "X OAuth client ID", + }, + ], + }, +] as const satisfies readonly SourceSetupOption[]; + +export const CRON_MODE_OPTIONS = [ + "Use suggested schedule", + "Enter custom cron", +] as const; +export const POWER_MODE_OPTIONS = [ + "Set up Mac wake/sleep window", + "Skip power setup", +] as const; +export const CRON_FIELD_LABELS = ["minute", "hour", "day", "month", "weekday"]; +export const SOURCE_CONTINUE_OPTIONS = [ + "Go back to connections", + "Continue without all sources", +] as const; +export const FINAL_OPTIONS = ["Run ingestion now", "Run later"] as const; +export const CODE_REPO_OPTIONS = ["Confirm and continue", "Edit path"] as const; + +/** + * Progress glyph per status: a check for done, an arrow for the active row, a + * hollow circle for not-started (and optional). Single cell wide so every row's + * label column lines up without padding the marker. + */ +export const STEP_GLYPH: Record = { + done: "✓", + current: "❯", + optional: "○", + pending: "○", +}; + +/** Color per status. Optionality is conveyed by the detail text, not the glyph. */ +export const STEP_COLOR: Record = { + done: "green", + current: "cyan", + optional: "gray", + pending: "gray", +}; diff --git a/src/setup/credentials/format.ts b/src/setup/credentials/format.ts new file mode 100644 index 000000000..ba039a30e --- /dev/null +++ b/src/setup/credentials/format.ts @@ -0,0 +1,208 @@ +import { spawn } from "node:child_process"; +import { + getMissingProviderEnvKey, + getProviderApiKeyEnvKey, + getProviderSecretKeyEnvKey, + providerUsesAwsSdkCredentials, + providerUsesOAuth, + AWS_ACCESS_KEY_ID_ENV_KEY, + AWS_SECRET_ACCESS_KEY_ENV_KEY, + AWS_SESSION_TOKEN_ENV_KEY, + AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, + BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, + BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, + OPENAI_CHATGPT_EMAIL_ENV_KEY, + OPENAI_CHATGPT_PLAN_ENV_KEY, + type OpenWikiProvider, +} from "../../config/constants.js"; +import { openWikiEnvPath } from "../../config/env.js"; +import { + formatChatGptAccount, + type CodexTokens, +} from "../../agent/openai-chatgpt-oauth.js"; +import type { AuthProviderId } from "../../auth/types.js"; +import { isCredentialConfigured } from "./steps.js"; + +export function getAwsCredentialRepairMessage( + provider: OpenWikiProvider, +): string | null { + if (!providerUsesAwsSdkCredentials(provider)) { + return null; + } + + const missingEnvKey = getMissingProviderEnvKey(provider); + + if (!missingEnvKey) { + return null; + } + + const pair = + missingEnvKey === BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY || + missingEnvKey === BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY + ? `${BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY} and ${BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY}` + : `${AWS_ACCESS_KEY_ID_ENV_KEY} and ${AWS_SECRET_ACCESS_KEY_ENV_KEY}`; + + return `${missingEnvKey} is missing or blank. Set both ${pair}, or unset both in your shell and ${openWikiEnvPath}, then restart OpenWiki.`; +} + +export function getCredentialSetupDetail( + provider: OpenWikiProvider, + tokens: CodexTokens | null = null, +): string { + if (providerUsesOAuth(provider)) { + if (!isCredentialConfigured(provider) && !tokens) { + return "sign in with your ChatGPT account"; + } + + const account = formatChatGptAccount( + tokens?.email ?? process.env[OPENAI_CHATGPT_EMAIL_ENV_KEY] ?? null, + tokens?.planType ?? process.env[OPENAI_CHATGPT_PLAN_ENV_KEY] ?? null, + ); + + return account ? `signed in as ${account}` : "signed in with ChatGPT"; + } + + if (providerUsesAwsSdkCredentials(provider)) { + if (process.env[AWS_BEARER_TOKEN_BEDROCK_ENV_KEY]?.trim()) { + return "Bedrock bearer token (takes precedence)"; + } + + const missingEnvKey = getMissingProviderEnvKey(provider); + + if (missingEnvKey) { + if ( + missingEnvKey === BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY || + missingEnvKey === BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY + ) { + return "incomplete legacy Bedrock keys; set both or clear both"; + } + + if ( + missingEnvKey === AWS_ACCESS_KEY_ID_ENV_KEY || + missingEnvKey === AWS_SECRET_ACCESS_KEY_ENV_KEY + ) { + return "incomplete standard AWS credentials; set the full set or unset it"; + } + + return `incomplete AWS credential configuration (${missingEnvKey})`; + } + + const legacyApiKey = getProviderApiKeyEnvKey(provider); + const legacySecretKey = getProviderSecretKeyEnvKey(provider); + const usesLegacyKeys = Boolean( + legacyApiKey && + legacySecretKey && + process.env[legacyApiKey]?.trim() && + process.env[legacySecretKey]?.trim(), + ); + + const ignoresOrphanSessionToken = Boolean( + process.env[AWS_SESSION_TOKEN_ENV_KEY]?.trim() && + !process.env[AWS_ACCESS_KEY_ID_ENV_KEY]?.trim() && + !process.env[AWS_SECRET_ACCESS_KEY_ENV_KEY]?.trim(), + ); + + return usesLegacyKeys + ? "legacy Bedrock keys (take precedence)" + : ignoresOrphanSessionToken + ? "AWS SDK default credential chain (orphan AWS_SESSION_TOKEN ignored)" + : "AWS SDK default credential chain"; + } + + const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); + + return isCredentialConfigured(provider) + ? "available from environment" + : apiKeyEnvKey + ? `save ${apiKeyEnvKey} to ${openWikiEnvPath}` + : "configure Google Cloud credentials"; +} + +/** + * Copies text to the terminal's clipboard using the OSC 52 escape sequence. + * This targets the user's local terminal emulator even when OpenWiki runs over + * SSH, unlike shelling out to a host clipboard utility. + */ +export function copyToClipboard(text: string): void { + const encoded = Buffer.from(text, "utf8").toString("base64"); + + process.stdout.write(`\u001b]52;c;${encoded}\u0007`); +} + +export function openLoginUrl(url: string): void { + try { + const child = + process.platform === "win32" + ? spawn("cmd", ["/c", "start", '""', `"${url}"`], { + detached: true, + stdio: "ignore", + windowsVerbatimArguments: true, + }) + : spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { + detached: true, + stdio: "ignore", + }); + + child.on("error", () => { + // The URL is also rendered for manual use on headless/SSH machines. + }); + child.unref(); + } catch { + // Ignore spawn failures; the URL is still rendered for manual use. + } +} + +export function mask(value: string): string { + if (value.length === 0) { + return ""; + } + + return "*".repeat(value.length); +} + +export function getOAuthAuthorizationStatusText({ + authProvider, + copiedToClipboard, +}: { + authProvider?: AuthProviderId; + copiedToClipboard: boolean; +}): string { + if (copiedToClipboard) { + return "Full URL copied to clipboard. Use the link above if your terminal supports it."; + } + + const authCommand = authProvider + ? `openwiki auth ${authProvider}` + : "openwiki auth "; + + return `Use the terminal link above. If it is not clickable, cancel and run ${authCommand} in a plain terminal.`; +} + +export function formatSecretInputDisplay(value: string): string { + // Empty renders as nothing (just the cursor); dots for the entered length, + // matching the non-secret inputs rather than printing a literal "empty". + return "•".repeat(value.length); +} + +export function formatTerminalHyperlink(url: string, label: string): string { + return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`; +} + +export function getSingleLineInputDisplayValue( + value: string, + maxLength: number, +): string { + if (maxLength <= 0) { + return ""; + } + + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 3) { + return value.slice(-maxLength); + } + + return `...${value.slice(-(maxLength - 3))}`; +} diff --git a/src/setup/credentials/persistence.ts b/src/setup/credentials/persistence.ts new file mode 100644 index 000000000..14b62b6ae --- /dev/null +++ b/src/setup/credentials/persistence.ts @@ -0,0 +1,124 @@ +import { codexTokensToEnv } from "../../agent/openai-chatgpt-oauth.js"; +import { + getProviderApiKeyEnvKey, + getProviderBaseUrlEnvKey, + getProviderLocationEnvKey, + getProviderProjectEnvKey, + getProviderRegionEnvKey, + getProviderSecretKeyEnvKey, + OPENWIKI_MODEL_ID_ENV_KEY, + OPENWIKI_PROVIDER_ENV_KEY, +} from "../../config/constants.js"; +import type { CompleteSetupOptions } from "./types.js"; + +/** + * Build the `~/.openwiki/.env` update map from the values the wizard collected. + * + * Pure: it computes which keys to write and their values, but performs no IO and + * mutates nothing. The caller resolves the oauth-token fallback and persists the + * result (via `saveOpenWikiEnv`), which is what owns the file permissions. A key + * is included only when the wizard collected a value for it, so untouched + * settings are left as-is. The provider key is written only when it actually + * changes, so a re-run that keeps the same provider does not churn the file. + * + * @param options - the credential/config values collected this session. + * + * @param env - the environment to compare against for the provider-changed + * check; injected so tests can pass a fabricated `NodeJS.ProcessEnv` instead of + * reading the real one. + */ +export function buildCredentialEnvUpdates( + options: CompleteSetupOptions, + env: NodeJS.ProcessEnv, +): Record { + const { + nextApiKey, + nextBaseUrl, + nextGcpLocation, + nextGcpProject, + nextLangSmithKey, + nextModelId, + nextOAuthTokens, + nextProvider, + nextRegion, + nextSecretKey, + } = options; + + const updates: Record = {}; + + if (env[OPENWIKI_PROVIDER_ENV_KEY] !== nextProvider) { + updates[OPENWIKI_PROVIDER_ENV_KEY] = nextProvider; + } + + if (nextApiKey !== null) { + const apiKeyEnvKey = getProviderApiKeyEnvKey(nextProvider); + + if (apiKeyEnvKey) { + updates[apiKeyEnvKey] = nextApiKey; + } + } + + if (nextOAuthTokens) { + Object.assign(updates, codexTokensToEnv(nextOAuthTokens)); + } + + if (nextBaseUrl !== null) { + const baseUrlEnvKey = getProviderBaseUrlEnvKey(nextProvider); + + if (baseUrlEnvKey) { + updates[baseUrlEnvKey] = nextBaseUrl; + } + } + + if (nextSecretKey !== null) { + const secretKeyEnvKey = getProviderSecretKeyEnvKey(nextProvider); + + if (secretKeyEnvKey) { + updates[secretKeyEnvKey] = nextSecretKey; + } + } + + if (nextRegion !== null) { + const regionEnvKey = getProviderRegionEnvKey(nextProvider); + + if (regionEnvKey) { + updates[regionEnvKey] = nextRegion; + } + } + + if (nextGcpProject !== null) { + const projectEnvKey = getProviderProjectEnvKey(nextProvider); + + if (projectEnvKey) { + updates[projectEnvKey] = nextGcpProject; + } + } + + if (nextGcpLocation !== null) { + const locationEnvKey = getProviderLocationEnvKey(nextProvider); + + if (locationEnvKey) { + updates[locationEnvKey] = nextGcpLocation; + } + } + + if (nextModelId !== null) { + updates[OPENWIKI_MODEL_ID_ENV_KEY] = nextModelId; + } + + if (nextLangSmithKey !== null) { + updates.LANGSMITH_API_KEY = nextLangSmithKey; + + if (nextLangSmithKey.length > 0) { + updates.LANGCHAIN_PROJECT = "openwiki"; + updates.LANGCHAIN_TRACING_V2 = "true"; + } else { + // Blank input must act as an off switch: without this, a + // LANGCHAIN_TRACING_V2=true saved by an earlier setup stays in + // ~/.openwiki/.env and tracing silently remains enabled. + updates.LANGCHAIN_TRACING_V2 = "false"; + } + } + + return updates; +} diff --git a/src/setup/credentials/steps.ts b/src/setup/credentials/steps.ts new file mode 100644 index 000000000..346439528 --- /dev/null +++ b/src/setup/credentials/steps.ts @@ -0,0 +1,1150 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import type * as React from "react"; +import { + getMissingProviderEnvKey, + getProviderApiKeyEnvKey, + getProviderBaseUrlEnvKey, + getProviderLocationEnvKey, + getProviderProjectEnvKey, + getProviderRegionEnvKey, + getProviderSecretKeyEnvKey, + getProviderLabel, + getDefaultModelId, + getProviderModelOptions, + normalizeModelId, + isValidModelId, + providerRequiresApiKey, + providerRequiresBaseUrl, + providerRequiresRegion, + providerRequiresSecretKey, + providerUsesAwsSdkCredentials, + providerUsesExternalCliAuth, + providerUsesOAuth, + resolveConfiguredProvider, + resolveProviderRegion, + normalizeProvider, + OPENWIKI_MODEL_ID_ENV_KEY, + OPENWIKI_PROVIDER_ENV_KEY, + SELECTABLE_OPENWIKI_PROVIDERS, + type OpenWikiProvider, +} from "../../config/constants.js"; +import { + readCodexTokensFromEnv, + isChatGptTokenExpired, +} from "../../agent/openai-chatgpt-oauth.js"; +import { + createEmptyOnboardingConfig, + isOnboardingComplete, + isOpenWikiOnboardingCompleteSync, + isRepositoryCodeOnboardingCompleteSync, + readRepositoryWikiInstructions, + type OpenWikiOnboardingConfig, +} from "../onboarding.js"; +import type { + PromptStep, + SetupStepState, + SourceSetupOption, + ModelSelectionOption, + SourceSecretInput, + PromptInputKey, +} from "./types.js"; +import { + ONBOARDING_TEMPLATES, + RUN_MODE_OPTIONS, + LANGSMITH_REGION_OPTIONS, + SOURCE_OPTIONS, + CRON_FIELD_LABELS, + FINAL_OPTIONS, +} from "./constants.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import type { LangSmithRegion } from "../../connectors/sources/langsmith/setup.js"; +import type { ConnectorId } from "../../connectors/types.js"; + +export function needsCredentialSetup( + modelIdOverride: string | null = null, + mode: OpenWikiRunMode = "personal", +): boolean { + const provider = resolveConfiguredProvider(); + + const needsCredentials = + !hasValidConfiguredProvider() || + needsAwsCredentialRepair(provider) || + needsCredentialStep(provider) || + needsSecretKeyStep(provider) || + needsBaseUrlStep(provider) || + needsRegionStep(provider) || + (modelIdOverride === null && + process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || + needsLangSmithStep(); + + if (needsCredentials) { + return true; + } + + return mode === "code" + ? !isRepositoryCodeOnboardingCompleteSync(getDefaultCodeRepoRootPath()) + : !isOpenWikiOnboardingCompleteSync(); +} + +export function needsAwsCredentialRepair(provider: OpenWikiProvider): boolean { + return ( + providerUsesAwsSdkCredentials(provider) && + getMissingProviderEnvKey(provider) !== null + ); +} + +/** + * Whether the provider still needs its primary credential collected. For + * `oauth` providers this is a valid, non-expired stored token; for API-key + * providers it is a pasted key; for keyless providers (gemini-enterprise) it is + * the required GCP project id. + */ +export function needsCredentialStep(provider: OpenWikiProvider): boolean { + if (providerUsesOAuth(provider)) { + return !hasValidStoredToken(); + } + + return ( + getMissingProviderEnvKey(provider) !== null && + credentialStep(provider) !== null + ); +} + +/** The step that collects the provider's primary credential. */ +export function credentialStep(provider: OpenWikiProvider): PromptStep | null { + if (providerUsesOAuth(provider)) { + return "oauth-login"; + } + + if (providerUsesAwsSdkCredentials(provider)) { + return null; + } + + if (providerUsesExternalCliAuth(provider)) { + return "external-cli-auth"; + } + + if (providerRequiresApiKey(provider)) { + return "api-key"; + } + + return getProviderProjectEnvKey(provider) ? "gcp-project" : null; +} + +/** + * Every managed env key the wizard lets you set for a provider, in checklist + * order: the provider selection, its credential keys, the model, and the + * LangSmith tracing key. Used to detect which of them a shell export is + * currently shadowing (a shell var wins at runtime and would silently override + * the choice made here). Returns key names only, never values. + */ +export function getWizardManagedEnvKeys(provider: OpenWikiProvider): string[] { + return [ + OPENWIKI_PROVIDER_ENV_KEY, + getProviderApiKeyEnvKey(provider), + getProviderSecretKeyEnvKey(provider), + getProviderProjectEnvKey(provider), + getProviderLocationEnvKey(provider), + getProviderBaseUrlEnvKey(provider), + getProviderRegionEnvKey(provider), + OPENWIKI_MODEL_ID_ENV_KEY, + "LANGSMITH_API_KEY", + ].filter((key): key is string => key !== undefined); +} + +/** + * The setup steps that apply to a provider and run mode, in the order the wizard + * walks them. Unlike the skip-based waterfall in {@link getInitialStep}, this + * includes steps already satisfied by the environment, so navigation can reach + * and re-edit an auto-skipped step. The provider's primary credential step + * ({@link credentialStep}) is emitted once; for keyless providers that step is + * the GCP project, so it is not appended again below. + */ +export function orderedSetupSteps( + provider: OpenWikiProvider, + mode: OpenWikiRunMode, + allowModeSelection: boolean, +): PromptStep[] { + const steps: PromptStep[] = []; + + if (allowModeSelection) { + steps.push("run-mode"); + } + + steps.push("provider"); + + const primary = credentialStep(provider); + if (primary) { + steps.push(primary); + } + + if (providerRequiresSecretKey(provider)) { + steps.push("secret-key"); + } + if (getProviderProjectEnvKey(provider) && primary !== "gcp-project") { + steps.push("gcp-project"); + } + if ( + getProviderProjectEnvKey(provider) && + getProviderLocationEnvKey(provider) + ) { + steps.push("gcp-location"); + } + if (providerRequiresBaseUrl(provider)) { + steps.push("base-url"); + } + if (providerRequiresRegion(provider)) { + steps.push("region"); + } + + steps.push("model"); + steps.push("langsmith"); + + // Personal mode's template is fixed by the run mode, so it skips the + // Code/Personal chooser and walks straight into the wiki brief. Only code + // mode needs a spine step after langsmith (repo confirmation). + if (mode === "code") { + steps.push("code-repo-confirm"); + } + + return steps; +} + +/** + * The step after `step` in the applicable spine, or null when `step` is the last + * spine step or outside it. Drives forward navigation: Enter advances to the + * next applicable step in order rather than skipping ones already satisfied by + * the environment, so setup reads as a sequential walk. + */ +export function nextSetupStep( + step: PromptStep | null, + provider: OpenWikiProvider, + mode: OpenWikiRunMode, + allowModeSelection: boolean, +): PromptStep | null { + if (step === null) { + return null; + } + const spine = orderedSetupSteps(provider, mode, allowModeSelection); + const index = spine.indexOf(step); + return index >= 0 && index + 1 < spine.length ? spine[index + 1] : null; +} + +export function hasValidStoredToken( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const tokens = readCodexTokensFromEnv(env); + + return tokens !== null && !isChatGptTokenExpired(tokens.expiresAtMs); +} + +export function needsGcpProjectStep(provider: OpenWikiProvider): boolean { + const projectEnvKey = getProviderProjectEnvKey(provider); + + return projectEnvKey ? !process.env[projectEnvKey] : false; +} + +export function needsBaseUrlStep(provider: OpenWikiProvider): boolean { + if (!providerRequiresBaseUrl(provider)) { + return false; + } + + return !isBaseUrlConfigured(provider); +} + +export function isBaseUrlConfigured(provider: OpenWikiProvider): boolean { + const baseUrlEnvKey = getProviderBaseUrlEnvKey(provider); + + return baseUrlEnvKey ? Boolean(process.env[baseUrlEnvKey]) : false; +} + +export function needsSecretKeyStep(provider: OpenWikiProvider): boolean { + if (!providerRequiresSecretKey(provider)) { + return false; + } + + return !isSecretKeyConfigured(provider); +} + +export function isSecretKeyConfigured(provider: OpenWikiProvider): boolean { + const secretKeyEnvKey = getProviderSecretKeyEnvKey(provider); + + return secretKeyEnvKey ? Boolean(process.env[secretKeyEnvKey]) : false; +} + +export function needsRegionStep(provider: OpenWikiProvider): boolean { + if (!providerRequiresRegion(provider)) { + return false; + } + + return !isRegionConfigured(provider); +} + +/** + * Whether the optional LangSmith tracing step still needs to be shown. + * + * The step is optional, so "answered" must include skipping it. Skipping does + * not persist `LANGSMITH_API_KEY` — `saveOpenWikiEnv` strips empty values, so + * the key is simply absent afterwards. What the step always records instead is + * `LANGCHAIN_TRACING_V2` (`"false"` on skip, `"true"` when a key is entered), + * which survives because it is non-empty. So the step is unanswered only when + * neither a key is present (e.g. from a shell export) nor a tracing decision + * has been recorded. + */ +export function needsLangSmithStep( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return !env.LANGSMITH_API_KEY && env.LANGCHAIN_TRACING_V2 === undefined; +} + +export function isRegionConfigured(provider: OpenWikiProvider): boolean { + return resolveProviderRegion(provider) !== undefined; +} + +export function isCredentialConfigured(provider: OpenWikiProvider): boolean { + return providerUsesOAuth(provider) + ? hasValidStoredToken() + : getMissingProviderEnvKey(provider) === null; +} + +/** + * Resolve a checklist row's status. The active step wins, so navigating back to + * an already-done step shows the current-row cursor rather than a check; a done + * step reads done; anything else falls to its resting status. + */ +export function resolveStepStatus( + id: PromptStep, + activeStep: PromptStep | null, + done: boolean, + resting: "optional" | "pending" = "pending", +): SetupStepState { + if (id === activeStep) { + return "current"; + } + if (done) { + return "done"; + } + return resting; +} + +export function getInitialStep( + modelIdOverride: string | null, + provider: OpenWikiProvider, + onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), + mode: OpenWikiRunMode = "code", + allowModeSelection = false, + walkAll = false, +): PromptStep | null { + if (walkAll) { + // Explicit --init: always start at the top and walk every applicable step, + // even ones already configured, instead of skipping to the first unset one. + return orderedSetupSteps(provider, mode, allowModeSelection)[0] ?? null; + } + + if (allowModeSelection) { + return "run-mode"; + } + + if (!hasValidConfiguredProvider()) { + return "provider"; + } + + if (needsAwsCredentialRepair(provider)) { + return "region"; + } + + const nextCredentialStep = credentialStep(provider); + + if (needsCredentialStep(provider) && nextCredentialStep) { + return nextCredentialStep; + } + + if (needsSecretKeyStep(provider)) { + return "secret-key"; + } + + if (needsGcpProjectStep(provider)) { + return "gcp-project"; + } + + if (needsBaseUrlStep(provider)) { + return "base-url"; + } + + if (needsRegionStep(provider)) { + return "region"; + } + + if ( + modelIdOverride === null && + process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined + ) { + return "model"; + } + + if (needsLangSmithStep()) { + return "langsmith"; + } + + if (mode === "code" && !isOnboardingComplete(onboardingConfig)) { + return "code-repo-confirm"; + } + + if (!getConfigModeId(onboardingConfig)) { + return "template"; + } + + if (!onboardingConfig.wikiGoal) { + return "wiki-goal"; + } + + if (!isCodeMode(onboardingConfig) && !onboardingConfig.ingestionSchedule) { + return "global-cron-mode"; + } + + if (!isOnboardingComplete(onboardingConfig)) { + return "source-menu"; + } + + return null; +} + +export function getNextStepAfterProvider( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), + mode: OpenWikiRunMode = "code", + forceModelStep = false, +): PromptStep | null { + if (needsAwsCredentialRepair(provider)) { + return "region"; + } + + const nextCredentialStep = credentialStep(provider); + + if (needsCredentialStep(provider) && nextCredentialStep) { + return nextCredentialStep; + } + + return getNextStepAfterApiKey( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +export function getNextStepAfterApiKey( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + if (needsSecretKeyStep(provider)) { + return "secret-key"; + } + + return getNextStepAfterSecretKey( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +export function getNextStepAfterSecretKey( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + if (needsGcpProjectStep(provider)) { + return "gcp-project"; + } + + return getNextStepAfterGcpLocation( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +export function getNextStepAfterGcpLocation( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig = createEmptyOnboardingConfig(), + mode: OpenWikiRunMode = "code", + forceModelStep = false, +): PromptStep | null { + if (needsBaseUrlStep(provider)) { + return "base-url"; + } + + return getNextStepAfterBaseUrl( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +export function getNextStepAfterBaseUrl( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + if (needsRegionStep(provider)) { + return "region"; + } + + return getNextStepAfterRegion( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +export function getNextStepAfterRegion( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + if ( + modelIdOverride === null && + (forceModelStep || process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) + ) { + return "model"; + } + + if (needsLangSmithStep()) { + return "langsmith"; + } + + if (mode === "code" && !isOnboardingComplete(onboardingConfig)) { + return "code-repo-confirm"; + } + + if (!getConfigModeId(onboardingConfig)) { + return "template"; + } + + if (!onboardingConfig.wikiGoal) { + return "wiki-goal"; + } + + if (!isCodeMode(onboardingConfig) && !onboardingConfig.ingestionSchedule) { + return "global-cron-mode"; + } + + if (!isOnboardingComplete(onboardingConfig)) { + return "source-menu"; + } + + return null; +} + +export function ensureRunModeConfig( + config: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, +): OpenWikiOnboardingConfig { + if (getConfigModeId(config) === mode) { + return mode === "code" && config.wikiGoal !== undefined + ? { ...config, wikiGoal: undefined } + : config; + } + + const runModeTemplate = ONBOARDING_TEMPLATES.find( + (option) => option.id === mode, + ); + if (!runModeTemplate) { + return config; + } + + return { + ...config, + modeId: runModeTemplate.id, + modeName: runModeTemplate.name, + templateId: runModeTemplate.id, + templateName: runModeTemplate.name, + ...(mode === "code" ? { wikiGoal: undefined } : {}), + }; +} + +export async function hydrateRunModeConfig( + config: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + repoRoot: string, +): Promise { + if (mode !== "code") { + return config; + } + + const wikiGoal = await readRepositoryWikiInstructions(repoRoot); + + return { ...config, wikiGoal }; +} + +export function getRunModeSelectionIndex(mode: OpenWikiRunMode): number { + const index = RUN_MODE_OPTIONS.findIndex((option) => option.id === mode); + return index === -1 ? 0 : index; +} + +export function getLangsmithRegionSelectionIndex( + region: LangSmithRegion, +): number { + const index = LANGSMITH_REGION_OPTIONS.findIndex( + (option) => option.id === region, + ); + return index === -1 ? 0 : index; +} + +export function getLangsmithRegionLabel(region: LangSmithRegion): string { + const option = LANGSMITH_REGION_OPTIONS.find((item) => item.id === region); + return option ? `${option.name} (${option.host})` : region; +} + +export function getRunModeName(mode: OpenWikiRunMode): string { + return RUN_MODE_OPTIONS.find((option) => option.id === mode)?.name ?? mode; +} + +export function getSourceOption(sourceId: ConnectorId): SourceSetupOption { + return ( + SOURCE_OPTIONS.find((source) => source.id === sourceId) ?? SOURCE_OPTIONS[0] + ); +} + +export function getConfigModeId( + config: OpenWikiOnboardingConfig, +): string | undefined { + return config.modeId ?? config.templateId; +} + +export function getConfigModeName( + config: OpenWikiOnboardingConfig, +): string | undefined { + return config.modeName ?? config.templateName; +} + +export function isCodeMode(config: OpenWikiOnboardingConfig): boolean { + return getConfigModeId(config) === "code"; +} + +export function hasValidConfiguredProvider(): boolean { + return normalizeProvider(process.env[OPENWIKI_PROVIDER_ENV_KEY]) !== null; +} + +export function getDefaultCodeRepoRootPath(): string { + return findNearestGitRepoRoot(process.cwd()) ?? process.cwd(); +} + +export function findNearestGitRepoRoot(startPath: string): string | null { + let currentPath = path.resolve(startPath); + + while (true) { + if (existsSync(path.join(currentPath, ".git"))) { + return currentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return null; + } + + currentPath = parentPath; + } +} + +export function needsEnvValue(secretInput: SourceSecretInput): boolean { + return !process.env[secretInput.envKey]; +} + +export function addSourceInstanceConfig( + config: OpenWikiOnboardingConfig, + sourceInstance: OpenWikiOnboardingConfig["sourceInstances"][number], +): OpenWikiOnboardingConfig { + const sourceInstances = [...config.sourceInstances, sourceInstance]; + return { + ...config, + sourceInstances, + sources: deriveLegacySources(sourceInstances), + }; +} + +export function deriveLegacySources( + sourceInstances: OpenWikiOnboardingConfig["sourceInstances"], +): OpenWikiOnboardingConfig["sources"] { + const sources: OpenWikiOnboardingConfig["sources"] = {}; + + for (const sourceInstance of sourceInstances) { + if (!sources[sourceInstance.connectorId]) { + sources[sourceInstance.connectorId] = { + connectedAt: sourceInstance.connectedAt, + connectorConfig: sourceInstance.connectorConfig, + ingestionGoal: sourceInstance.ingestionGoal, + }; + } + } + + return sources; +} + +export function getSourceInstanceCount( + config: OpenWikiOnboardingConfig, + sourceId: ConnectorId, +): number { + return getSourceInstances(config, sourceId).length; +} + +export function getSourceInstances( + config: OpenWikiOnboardingConfig, + sourceId: ConnectorId, +): OpenWikiOnboardingConfig["sourceInstances"] { + return config.sourceInstances.filter( + (sourceInstance) => sourceInstance.connectorId === sourceId, + ); +} + +export function getConnectedSourceCount( + config: OpenWikiOnboardingConfig, + sourceOptions: readonly SourceSetupOption[], +): number { + const sourceIds = new Set(sourceOptions.map((source) => source.id)); + return config.sourceInstances.filter((sourceInstance) => + sourceIds.has(sourceInstance.connectorId), + ).length; +} + +export function createSourceInstanceId( + sourceId: ConnectorId, + config: OpenWikiOnboardingConfig, +): string { + const sourceCount = getSourceInstanceCount(config, sourceId) + 1; + return `${sourceId}-${sourceCount}`; +} + +export function createSourceInstanceName( + source: SourceSetupOption, + description: string, + config: OpenWikiOnboardingConfig, +): string { + const sourceCount = getSourceInstanceCount(config, source.id) + 1; + const trimmedDescription = description.trim(); + const suffix = trimmedDescription.length > 0 ? `: ${trimmedDescription}` : ""; + return `${source.displayName} ${sourceCount}${suffix}`.slice(0, 120); +} + +export function isSourceStep(step: PromptStep | null): boolean { + return Boolean(step?.startsWith("source-")); +} + +export function isScheduleStep(step: PromptStep | null): boolean { + return Boolean(step?.startsWith("global-")); +} + +/** + * Label for the provider's primary credential input. Bedrock authenticates + * with an IAM access key ID (paired with a secret access key), not a single + * opaque API key, so its prompt reads differently from every other provider. + */ +export function getApiKeyFieldLabel(provider: OpenWikiProvider): string { + return provider === "bedrock" + ? `${getProviderLabel(provider)} access key ID` + : `${getProviderLabel(provider)} API key`; +} + +export function getModelSetupDetail( + modelIdOverride: string | null, + provider: OpenWikiProvider, +): string { + if (modelIdOverride) { + return `using ${modelIdOverride} for this run`; + } + + if (process.env[OPENWIKI_MODEL_ID_ENV_KEY]) { + return process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? ""; + } + + return `default ${getDefaultModelId(provider)}`; +} + +export function getModelSelectionOptions( + provider: OpenWikiProvider, +): ModelSelectionOption[] { + return [ + ...getProviderModelOptions(provider).map((model) => ({ + id: model.id, + kind: "preset" as const, + label: model.label, + })), + { kind: "custom" }, + ]; +} + +export function shouldStartWithCustomModelInput( + provider: OpenWikiProvider, +): boolean { + return getProviderModelOptions(provider).length === 0; +} + +export function getSelectedModelId( + provider: OpenWikiProvider, + selectedIndex: number, + input: string, + isCustomInput: boolean, +): string | null { + if (!isCustomInput) { + const selectedOption = getModelSelectionOptions(provider)[selectedIndex]; + + if (!selectedOption) { + return null; + } + + return selectedOption.kind === "custom" ? "custom" : selectedOption.id; + } + + const normalizedModelId = normalizeModelId(input); + + return isValidModelId(normalizedModelId) ? normalizedModelId : null; +} + +export function getProviderSelectionIndex(provider: OpenWikiProvider): number { + const selectedIndex = SELECTABLE_OPENWIKI_PROVIDERS.findIndex( + (providerOption) => providerOption === provider, + ); + + return selectedIndex === -1 ? 0 : selectedIndex; +} + +export function getModelSelectionIndex( + provider: OpenWikiProvider, + selectedModelId: string, +): number { + const selectedIndex = getModelSelectionOptions(provider).findIndex( + (option) => option.kind === "preset" && option.id === selectedModelId, + ); + + return selectedIndex === -1 ? 0 : selectedIndex; +} + +export function moveSelectionIndex( + currentIndex: number, + offset: number, + itemCount: number, +): number { + if (itemCount <= 0) { + return 0; + } + + return (currentIndex + offset + itemCount) % itemCount; +} + +export function getInputDisplayWidth( + stdoutColumns: number | undefined, +): number { + const defaultWidth = 64; + + if (!stdoutColumns || stdoutColumns <= 0) { + return defaultWidth; + } + + return Math.max(24, Math.min(96, stdoutColumns - 16)); +} + +export function getProviderArticle(provider: OpenWikiProvider): "a" | "an" { + return provider === "baseten" || + provider === "fireworks" || + provider === "gemini" || + provider === "gemini-enterprise" || + provider === "nebius" + ? "a" + : "an"; +} + +export function getTemplateGoal(templateId: string | undefined): string { + return ( + ONBOARDING_TEMPLATES.find((template) => template.id === templateId) + ?.suggestedGoal ?? "" + ); +} + +export function getSourceMenuLabel( + source: SourceSetupOption, + sourceInstanceCount: number, +): string { + return sourceInstanceCount > 0 + ? `Add another ${source.displayName}` + : `Add ${source.displayName}`; +} + +export function getTemplateSourceOptions( + templateId: string | undefined, +): readonly SourceSetupOption[] { + const template = + ONBOARDING_TEMPLATES.find((option) => option.id === templateId) ?? + ONBOARDING_TEMPLATES[0]; + const sourceIds = new Set(template.sourceIds); + const sourceOptions = SOURCE_OPTIONS.filter((source) => + sourceIds.has(source.id), + ); + + return sourceOptions.length > 0 ? sourceOptions : SOURCE_OPTIONS; +} + +export function getSourceDescriptionPrompt(source: SourceSetupOption): string { + if (source.id === "web-search") { + return "Describe the topics, companies, or pages OpenWiki should search for."; + } + + if (source.id === "hackernews") { + return "Describe the topics, keywords, users, or story types OpenWiki should watch on Hacker News."; + } + + if (source.id === "git-repo") { + return "Describe what OpenWiki should understand about this repository."; + } + + return `Describe what OpenWiki should look for in ${source.displayName}.`; +} + +export function getFinalOptionLabel( + option: (typeof FINAL_OPTIONS)[number], + mode: OpenWikiRunMode, +): string { + if (mode !== "code") { + return option; + } + + return option === "Run ingestion now" ? "Run OpenWiki now" : "Open chat"; +} + +export function getSourceDescriptionOptionCount( + source: SourceSetupOption, +): number { + return source.examples.length + 1; +} + +export function handleCronEditorInput({ + currentFieldIndex, + currentValue, + fallbackExpression, + inputValue, + key, + replaceCurrentField, + setCurrentFieldIndex, + setReplaceCurrentField, + setValue, +}: { + currentFieldIndex: number; + currentValue: string; + fallbackExpression: string; + inputValue: string; + key: PromptInputKey; + replaceCurrentField: boolean; + setCurrentFieldIndex: React.Dispatch>; + setReplaceCurrentField: React.Dispatch>; + setValue: React.Dispatch>; +}): boolean { + if (key.leftArrow) { + setCurrentFieldIndex((index) => Math.max(0, index - 1)); + setReplaceCurrentField(true); + return true; + } + + if (key.rightArrow || key.tab || inputValue === " " || inputValue === "\t") { + setCurrentFieldIndex((index) => + Math.min(CRON_FIELD_LABELS.length - 1, index + 1), + ); + setReplaceCurrentField(true); + return true; + } + + if (key.backspace || key.delete) { + const fields = getCronFields(currentValue, fallbackExpression); + const currentField = fields[currentFieldIndex] ?? ""; + if (currentField.length === 0 && currentFieldIndex > 0) { + setCurrentFieldIndex(currentFieldIndex - 1); + setReplaceCurrentField(false); + return true; + } + + fields[currentFieldIndex] = currentField.slice(0, -1); + setValue(fields.join(" ")); + setReplaceCurrentField(false); + return true; + } + + if (key.ctrl || key.meta) { + return false; + } + + const pastedFields = parseCronFieldPaste(inputValue); + if (pastedFields.length > 1) { + const fields = getCronFields(currentValue, fallbackExpression); + pastedFields.forEach((field, offset) => { + const fieldIndex = currentFieldIndex + offset; + if (fieldIndex < CRON_FIELD_LABELS.length) { + fields[fieldIndex] = field; + } + }); + setValue(fields.join(" ")); + setCurrentFieldIndex((index) => + Math.min(CRON_FIELD_LABELS.length - 1, index + pastedFields.length - 1), + ); + setReplaceCurrentField(true); + return true; + } + + const sanitizedInput = sanitizeCronInputChunk(inputValue); + + if (!sanitizedInput) { + return false; + } + + const fields = getCronFields(currentValue, fallbackExpression); + fields[currentFieldIndex] = replaceCurrentField + ? sanitizedInput + : `${fields[currentFieldIndex] ?? ""}${sanitizedInput}`; + setValue(fields.join(" ")); + setReplaceCurrentField(false); + return true; +} + +export function getCronFields( + expression: string, + fallbackExpression: string, +): string[] { + const source = + expression.trim().length > 0 ? expression.trim() : fallbackExpression; + const fields = source.split(/\s+/u); + + return CRON_FIELD_LABELS.map((_, index) => fields[index] ?? ""); +} + +export function parseCronFieldPaste(inputValue: string): string[] { + if (inputValue.trim().length === 0) { + return []; + } + + if (/\s/u.test(inputValue)) { + return inputValue + .trim() + .split(/\s+/u) + .map((field) => sanitizeCronInputChunk(field)) + .filter((field) => field.length > 0); + } + + const compactValue = sanitizeCronInputChunk(inputValue); + + if (/^[0-9*]{5}$/u.test(compactValue)) { + return compactValue.split(""); + } + + return []; +} + +export function sanitizeInputChunk(value: string): string { + return value.replace(/[\r\n]/gu, ""); +} + +export function sanitizeCronInputChunk(value: string): string { + return value.replace(/[^A-Za-z0-9*,/?#LW.-]/gu, ""); +} + +export function sanitizeRepoId(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/gu, "-").slice(0, 80) || "repo"; +} + +export function getDefaultLocalGitRepoPath(): string { + return process.cwd(); +} + +export async function validateLocalDirectoryPath( + value: string, +): Promise { + const normalizedPath = normalizeLocalPath(value); + + if (normalizedPath.length === 0) { + throw new Error("Enter a local directory."); + } + + const { stat } = await import("node:fs/promises"); + const pathStat = await stat(normalizedPath); + + if (!pathStat.isDirectory()) { + throw new Error(`${normalizedPath} is not a directory.`); + } + + return normalizedPath; +} + +export function normalizeLocalPath(value: string): string { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) { + return ""; + } + + if (trimmedValue === "~") { + return homedir(); + } + + if (trimmedValue.startsWith("~/") || trimmedValue.startsWith("~\\")) { + return path.resolve(homedir(), trimmedValue.slice(2)); + } + + return path.resolve(trimmedValue); +} + +export function getStaticSourceConfig( + sourceId: ConnectorId, + query: string, +): Record { + const queries = query.trim().length > 0 ? [query.trim()] : []; + + if (sourceId === "web-search") { + return { + enabled: true, + includeAnswer: true, + includeImages: false, + includeRawContent: false, + maxResults: 5, + queries, + searchDepth: "basic", + timeRange: "day", + topic: "general", + }; + } + + if (sourceId === "hackernews") { + return { + enabled: true, + feeds: ["top", "new"], + maxItemsPerFeed: 30, + maxResultsPerQuery: 20, + queries, + queryTags: ["story"], + }; + } + + return { + enabled: true, + }; +} + +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/setup/credentials/types.ts b/src/setup/credentials/types.ts new file mode 100644 index 000000000..0fb00d766 --- /dev/null +++ b/src/setup/credentials/types.ts @@ -0,0 +1,170 @@ +import type { CodexTokens } from "../../agent/openai-chatgpt-oauth.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import type { OpenWikiProvider } from "../../config/constants.js"; +import type { AuthProviderId } from "../../auth/types.js"; +import type { ConnectorId } from "../../connectors/types.js"; +import type { LangSmithRegion } from "../../connectors/sources/langsmith/setup.js"; + +export type InitSetupResult = { + mode: OpenWikiRunMode; + modelId: string | null; + onboardingCompleted: boolean; + provider: OpenWikiProvider | null; + repoRoot?: string; + runIngestionNow: boolean; + savedApiKey: boolean; + savedBaseUrl: boolean; + savedGcpLocation: boolean; + savedGcpProject: boolean; + savedLangSmithKey: boolean; + savedModelId: boolean; + savedProvider: boolean; + savedRegion: boolean; + savedSecretKey: boolean; + shouldContinueToRun: boolean; +}; + +export type InitSetupProps = { + allowModeSelection?: boolean; + mode: OpenWikiRunMode; + modelIdOverride?: string | null; + onComplete: (result: InitSetupResult) => void; + onError: (message: string) => void; + /** + * When true (explicit `--init`), walk every applicable step even when it is + * already configured, so the run can review/change any of them. When false + * the wizard skips satisfied steps and collects only what is missing. + */ + walkAllSteps?: boolean; +}; + +export type PromptStep = + | "api-key" + | "base-url" + | "code-repo-confirm" + | "code-repo-path" + | "external-cli-auth" + | "final" + | "gcp-location" + | "gcp-project" + | "langsmith" + | "model" + | "oauth-login" + | "provider" + | "region" + | "run-mode" + | "secret-key" + | "source-auth" + | "global-cron-custom" + | "global-cron-mode" + | "global-power-mode" + | "source-description" + | "source-description-custom" + | "source-langsmith-key" + | "source-langsmith-projects" + | "source-langsmith-region" + | "source-langsmith-workspaces" + | "source-menu" + | "source-path" + | "source-confirm-continue" + | "source-secret" + | "template" + | "wiki-goal"; + +export type SourceSetupOption = { + authProvider?: AuthProviderId; + displayName: string; + examples: string[]; + id: ConnectorId; + instructions: string[]; + secretInputs: SourceSecretInput[]; +}; + +export type SourceSecretInput = { + envKey: string; + label: string; + optional?: boolean; + secret?: boolean; +}; + +export type SourceSetupState = { + authUrl?: string; + connectorConfig?: Record; + copiedAuthUrlToClipboard?: boolean; + savedScheduleWarning?: string; + secretValues: Record; +}; + +export type PromptInputKey = { + backspace?: boolean; + ctrl?: boolean; + delete?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + meta?: boolean; + return?: boolean; + rightArrow?: boolean; + tab?: boolean; + upArrow?: boolean; +}; + +export type ModelSelectionOption = + | { + id: string; + kind: "preset"; + label: string; + } + | { + kind: "custom"; + }; + +export type OnboardingMode = { + description: string; + id: string; + name: string; + sourceIds: ConnectorId[]; + suggestedSources: string[]; + suggestedGoal: string; +}; + +/** + * One LangSmith workspace as the wizard edits it. `apiKey` holds a value entered + * this session (empty = keep the committed key); it is written to ~/.openwiki/.env + * under `apiKeyEnv` on completion, never committed. + */ +export interface LangsmithWorkspaceDraft { + apiKeyEnv: string; + region: LangSmithRegion; + apiKey: string; + projects: string[]; +} + +export type SetupStepState = "current" | "done" | "optional" | "pending"; + +/** + * The credential/config values collected by the wizard that get persisted to + * `~/.openwiki/.env` on completion. Each `next*` field is the value to write for + * that provider setting, or null when the wizard did not collect one (in which + * case that key is left untouched). + */ +export interface CompleteSetupOptions { + nextApiKey: string | null; + nextBaseUrl: string | null; + nextGcpLocation: string | null; + nextGcpProject: string | null; + nextLangSmithKey: string | null; + nextModelId: string | null; + + /** + * OAuth tokens to persist for providers that authenticate by browser login. + * + * @default the wizard's current `oauthTokens` state (resolved by the caller + * when this field is omitted; an explicit null means "no tokens") + */ + nextOAuthTokens?: CodexTokens | null; + + nextProvider: OpenWikiProvider; + nextRegion: string | null; + nextSecretKey: string | null; + runMode: OpenWikiRunMode; +} diff --git a/src/setup/credentials/use-init-setup.ts b/src/setup/credentials/use-init-setup.ts new file mode 100644 index 000000000..18bb27ef9 --- /dev/null +++ b/src/setup/credentials/use-init-setup.ts @@ -0,0 +1,2559 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import path from "node:path"; +import { useInput, useStdin, useStdout } from "ink"; +import { configureAuthProvider } from "../../auth/configure.js"; +import { runOAuthAuth } from "../../auth/oauth.js"; +import { + DEFAULT_PROVIDER, + DEFAULT_VERTEX_LOCATION, + getDefaultModelId, + getProviderApiKeyEnvKey, + getProviderBaseUrlEnvKey, + getProviderBaseUrlWarnings, + getProviderLocationEnvKey, + getProviderProjectEnvKey, + getProviderRegionEnvKey, + getProviderRegionEnvKeys, + getProviderSecretKeyEnvKey, + OPENWIKI_MODEL_ID_ENV_KEY, + OPENWIKI_PROVIDER_ENV_KEY, + type OpenWikiProvider, + providerUsesExternalCliAuth, + resolveConfiguredProvider, + resolveProviderRegion, + SELECTABLE_OPENWIKI_PROVIDERS, +} from "../../config/constants.js"; +import { + type ChatGptLoginHandle, + type CodexTokens, + loginWithChatGPT, +} from "../../agent/openai-chatgpt-oauth.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import { + loadLangSmithSetup, + nextLangSmithApiKeyEnv, + saveLangSmithSetup, +} from "../../connectors/sources/langsmith/setup.js"; +import type { ConnectorId } from "../../connectors/types.js"; +import { + detectExternalCliCredential, + isExternalCliAvailable, + runExternalCliLogin, + type ExternalCliAuthState, +} from "../../auth/external-cli-auth.js"; +import { getConnectorConfigPath } from "../../config/openwiki-home.js"; +import { getSavedEnvValue, saveOpenWikiEnv } from "../../config/env.js"; +import { + createEmptyOnboardingConfig, + isOnboardingComplete, + readOpenWikiOnboardingConfig, + saveRepositoryWikiInstructions, + saveOpenWikiOnboardingConfig, + type OpenWikiOnboardingConfig, +} from "../onboarding.js"; +import { + getSuggestedCronExpression, + installOpenWikiPowerSchedule, + installConnectorSchedule, + validateCronExpression, +} from "../../scheduling/schedules.js"; + +import { + addSourceInstanceConfig, + createSourceInstanceId, + createSourceInstanceName, + ensureRunModeConfig, + getConfigModeId, + getConnectedSourceCount, + getDefaultCodeRepoRootPath, + getDefaultLocalGitRepoPath, + getErrorMessage, + getInitialStep, + getInputDisplayWidth, + getLangsmithRegionSelectionIndex, + getModelSelectionIndex, + getModelSelectionOptions, + getNextStepAfterApiKey, + getNextStepAfterBaseUrl, + getNextStepAfterGcpLocation, + getNextStepAfterProvider, + getNextStepAfterRegion, + getNextStepAfterSecretKey, + getProviderSelectionIndex, + getRunModeSelectionIndex, + getSelectedModelId, + getSourceDescriptionOptionCount, + getSourceOption, + getStaticSourceConfig, + getTemplateGoal, + getTemplateSourceOptions, + handleCronEditorInput, + hydrateRunModeConfig, + isCodeMode, + isCredentialConfigured, + isSecretKeyConfigured, + moveSelectionIndex, + needsEnvValue, + needsLangSmithStep, + nextSetupStep, + normalizeLocalPath, + sanitizeInputChunk, + sanitizeRepoId, + shouldStartWithCustomModelInput, + validateLocalDirectoryPath, +} from "./steps.js"; +import { + copyToClipboard, + getAwsCredentialRepairMessage, + openLoginUrl, +} from "./format.js"; +import { + CODE_REPO_OPTIONS, + CRON_MODE_OPTIONS, + FINAL_OPTIONS, + LANGSMITH_REGION_OPTIONS, + ONBOARDING_TEMPLATES, + POWER_MODE_OPTIONS, + RUN_MODE_OPTIONS, + SOURCE_CONTINUE_OPTIONS, +} from "./constants.js"; +import type { + CompleteSetupOptions, + InitSetupProps, + LangsmithWorkspaceDraft, + PromptInputKey, + PromptStep, + SourceSetupOption, + SourceSetupState, +} from "./types.js"; +import { buildCredentialEnvUpdates } from "./persistence.js"; +import type { InitSetupViewProps } from "./view.js"; + +/** + * The controller behind `InitSetup`: it owns the entire setup state machine + * (state, refs, effects, keyboard routing, and completion/persistence) and + * returns the fully-wired presentational props for `InitSetupView`. Splitting it + * out keeps `credentials.tsx` a thin composition root and isolates the + * hard-to-unit-test Ink keyboard flow in one file (excluded from coverage). + */ +export function useInitSetup({ + allowModeSelection = false, + mode, + modelIdOverride = null, + onComplete, + onError, + walkAllSteps = false, +}: InitSetupProps): InitSetupViewProps { + const { stdout } = useStdout(); + const initialProvider = resolveConfiguredProvider(); + const [step, setStepRaw] = useState(null); + const navHistory = useRef([]); + // Guards the mount effect so the initial step is seeded once per mount, not + // re-seeded when the effect re-fires on parent re-renders. + const didInitializeRef = useRef(false); + // Seed the LangSmith selection from the committed config only once, so + // navigating back and re-confirming the repo does not clobber in-progress edits + // (the file is not written until setup completes). + const langsmithPreloadedRef = useRef(false); + /** + * Advance to a step, recording the current step on the back-navigation + * history unless this is a back move. A ref-backed stack so Esc can retrace + * the actual path taken (including the branchy source sub-flow), which a + * linear spine cannot model. + */ + function setStep(next: PromptStep | null, opts?: { back?: boolean }): void { + if (!opts?.back && step !== null && next !== null && next !== step) { + navHistory.current.push(step); + } + setStepRaw(next); + } + const [selectedMode, setSelectedMode] = useState(mode); + const [provider, setProvider] = useState(initialProvider); + const [apiKey, setApiKey] = useState(null); + const [baseUrl, setBaseUrl] = useState(null); + const [secretKey, setSecretKey] = useState(null); + const [region, setRegion] = useState(null); + const [gcpProject, setGcpProject] = useState(null); + const [gcpLocation, setGcpLocation] = useState(null); + const [modelId, setModelId] = useState(null); + const [langSmithKey, setLangSmithKey] = useState(null); + // LangSmith workspaces as the wizard edits them (region + key + projects), + // seeded from the committed config; committed on completion. + const [langsmithWorkspaces, setLangsmithWorkspaces] = useState< + LangsmithWorkspaceDraft[] + >([]); + // The workspace currently being added or edited, folded into langsmithWorkspaces + // when its projects step is confirmed. + const [langsmithDraft, setLangsmithDraft] = + useState(null); + // Index of the workspace being edited; === langsmithWorkspaces.length for a new + // one. + const [langsmithEditingIndex, setLangsmithEditingIndex] = useState(0); + const [ + langsmithWorkspaceSelectionIndex, + setLangsmithWorkspaceSelectionIndex, + ] = useState(0); + const [langsmithRegionSelectionIndex, setLangsmithRegionSelectionIndex] = + useState(0); + // True once the LangSmith workspaces were opened this run; guards the WYSIWYG + // write so an untouched setup never rewrites openwiki/.langsmith.json. + const [langsmithSourcesTouched, setLangsmithSourcesTouched] = useState(false); + // True once the user confirms a provider this session. Provider always holds a + // default value, so a null-check cannot detect the in-session choice. + const [providerConfirmed, setProviderConfirmed] = useState(false); + const [input, setInput] = useState(""); + const [onboardingConfig, setOnboardingConfig] = + useState(() => createEmptyOnboardingConfig()); + const [sourceState, setSourceState] = useState({ + secretValues: {}, + }); + const [selectedSourceId, setSelectedSourceId] = + useState("git-repo"); + const [secretInputIndex, setSecretInputIndex] = useState(0); + const [providerSelectionIndex, setProviderSelectionIndex] = useState(() => + getProviderSelectionIndex(initialProvider), + ); + const [modelSelectionIndex, setModelSelectionIndex] = useState(() => + getModelSelectionIndex( + initialProvider, + modelIdOverride ?? + process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? + getDefaultModelId(initialProvider), + ), + ); + const [runModeSelectionIndex, setRunModeSelectionIndex] = useState(() => + getRunModeSelectionIndex(mode), + ); + const [sourceSelectionIndex, setSourceSelectionIndex] = useState(0); + const [sourceDescriptionSelectionIndex, setSourceDescriptionSelectionIndex] = + useState(0); + const [templateSelectionIndex, setTemplateSelectionIndex] = useState(0); + const [cronModeSelectionIndex, setCronModeSelectionIndex] = useState(0); + const [powerModeSelectionIndex, setPowerModeSelectionIndex] = useState(0); + const [cronFieldSelectionIndex, setCronFieldSelectionIndex] = useState(0); + const [cronReplaceCurrentField, setCronReplaceCurrentField] = useState(true); + const [sourceContinueSelectionIndex, setSourceContinueSelectionIndex] = + useState(0); + const [finalSelectionIndex, setFinalSelectionIndex] = useState(0); + const [codeRepoSelectionIndex, setCodeRepoSelectionIndex] = useState(0); + const [codeRepoRoot, setCodeRepoRoot] = useState(() => + getDefaultCodeRepoRootPath(), + ); + // Dedicated buffer for the code-repo-path field, kept separate from the shared + // `input` (which seedInputForStep prefills with credentials on other steps) so + // a secret never shares the buffer that feeds the thread-id path hash. + const [codeRepoPathInput, setCodeRepoPathInput] = useState(""); + const [codeRepoConfirmed, setCodeRepoConfirmed] = useState(false); + const [isCustomModelInput, setIsCustomModelInput] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [externalCliAuth, setExternalCliAuth] = useState({ + kind: "idle", + }); + const externalCliProbeProvider = useRef(null); + const { setRawMode } = useStdin(); + const [isAuthRunning, setIsAuthRunning] = useState(false); + const [oauthTokens, setOauthTokens] = useState(null); + const [loginUrl, setLoginUrl] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [loginAttempt, setLoginAttempt] = useState(0); + const [copied, setCopied] = useState(false); + const [forceModelStep, setForceModelStep] = useState(false); + const loginHandleRef = useRef(null); + + const activeSourceOptions = useMemo( + () => getTemplateSourceOptions(getConfigModeId(onboardingConfig)), + [onboardingConfig.modeId, onboardingConfig.templateId], + ); + const selectedSource = getSourceOption(selectedSourceId); + const suggestedCronExpression = useMemo( + () => getSuggestedCronExpression(onboardingConfig), + [onboardingConfig], + ); + const suggestedCronDescription = useMemo(() => { + const validation = validateCronExpression(suggestedCronExpression); + return validation.valid ? validation.description : suggestedCronExpression; + }, [suggestedCronExpression]); + const inputDisplayWidth = getInputDisplayWidth(stdout.columns); + + useEffect(() => { + let cancelled = false; + + readOpenWikiOnboardingConfig() + .then(async (config) => { + // Seed the initial step exactly once per mount. onComplete/onError are + // inline parent closures in the deps, so this effect re-fires on parent + // re-renders; without this guard a re-fire would reset step back to the + // first step (getInitialStep with walkAll always returns it). + if (cancelled || didInitializeRef.current) { + return; + } + didInitializeRef.current = true; + + const defaultRepoRoot = getDefaultCodeRepoRootPath(); + const configForMode = allowModeSelection + ? config + : await hydrateRunModeConfig( + ensureRunModeConfig(config, mode), + mode, + defaultRepoRoot, + ); + if (configForMode !== config) { + await saveOpenWikiOnboardingConfig({ + ...configForMode, + wikiGoal: mode === "code" ? undefined : configForMode.wikiGoal, + }); + } + setOnboardingConfig(configForMode); + const initialStep = getInitialStep( + modelIdOverride, + initialProvider, + configForMode, + mode, + allowModeSelection, + walkAllSteps, + ); + + if (initialStep === null) { + onComplete({ + mode, + modelId: + modelIdOverride ?? process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? null, + onboardingCompleted: true, + provider: initialProvider, + runIngestionNow: false, + savedApiKey: false, + savedBaseUrl: false, + savedGcpLocation: false, + savedGcpProject: false, + savedLangSmithKey: false, + savedModelId: false, + savedProvider: false, + savedRegion: false, + savedSecretKey: false, + shouldContinueToRun: true, + }); + return; + } + + setProvider(initialProvider); + setProviderSelectionIndex(getProviderSelectionIndex(initialProvider)); + setModelSelectionIndex( + getModelSelectionIndex( + initialProvider, + modelIdOverride ?? + process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? + getDefaultModelId(initialProvider), + ), + ); + setIsCustomModelInput( + initialStep === "model" && + shouldStartWithCustomModelInput(initialProvider), + ); + if (initialStep === "wiki-goal") { + setInput(getTemplateGoal(getConfigModeId(config))); + } + if (initialStep === "code-repo-confirm") { + setCodeRepoRoot(defaultRepoRoot); + setCodeRepoSelectionIndex(0); + } + setStep(initialStep); + }) + .catch((loadError: unknown) => { + if (!cancelled) { + onError(getErrorMessage(loadError)); + } + }); + + return () => { + cancelled = true; + }; + }, [ + allowModeSelection, + initialProvider, + modelIdOverride, + onComplete, + onError, + mode, + ]); + + // Drive the browser OAuth login whenever the wizard enters the oauth-login + // step or the user retries after a failure. + useEffect(() => { + if (step !== "oauth-login") { + return; + } + + let cancelled = false; + + setIsLoggingIn(true); + setLoginUrl(null); + setCopied(false); + setInput(""); + setError(null); + loginHandleRef.current = null; + + void (async () => { + try { + const tokens = await loginWithChatGPT( + (url) => { + if (cancelled) { + return; + } + + setLoginUrl(url); + openLoginUrl(url); + }, + (handle) => { + if (!cancelled) { + loginHandleRef.current = handle; + } + }, + ); + + if (cancelled) { + return; + } + + setOauthTokens(tokens); + setIsLoggingIn(false); + + const nextStep = + nextSetupStep( + "oauth-login", + provider, + selectedMode, + allowModeSelection, + ) ?? + getNextStepAfterApiKey( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: tokens, + nextProvider: provider, + runMode: selectedMode, + }); + } catch (loginError) { + if (cancelled) { + return; + } + + setIsLoggingIn(false); + setError(getErrorMessage(loginError)); + } + })(); + + return () => { + cancelled = true; + }; + }, [step, loginAttempt]); + + useEffect(() => { + if ( + step !== "external-cli-auth" || + !providerUsesExternalCliAuth(provider) || + externalCliProbeProvider.current === provider + ) { + return; + } + + externalCliProbeProvider.current = provider; + + let cancelled = false; + setExternalCliAuth({ kind: "checking" }); + + void (async () => { + const credential = await detectExternalCliCredential(provider); + + if (cancelled) { + return; + } + + if (credential) { + setExternalCliAuth({ kind: "detected" }); + return; + } + + const cliAvailable = await isExternalCliAvailable(provider); + + if (cancelled) { + return; + } + + setExternalCliAuth({ kind: "not-detected", cliAvailable }); + })(); + + return () => { + cancelled = true; + }; + }, [step, provider]); + + async function launchExternalCliLogin() { + setExternalCliAuth({ kind: "logging-in" }); + setRawMode?.(false); + + try { + const success = await runExternalCliLogin(provider); + + if (!success) { + setExternalCliAuth({ kind: "login-failed" }); + return; + } + + const credential = await detectExternalCliCredential(provider); + + setExternalCliAuth( + credential + ? { kind: "detected" } + : { kind: "not-detected", cliAvailable: true }, + ); + } finally { + setRawMode?.(true); + } + } + + /** + * Pre-fill the input or selection for a step reached via navigation, so a done + * step opens ready to edit. Secret steps are pre-filled with the stored key, + * which renders as dots (formatSecretInputDisplay), never raw. + */ + function seedInputForStep(target: PromptStep): void { + switch (target) { + case "provider": + setProviderSelectionIndex(getProviderSelectionIndex(provider)); + break; + case "run-mode": + setRunModeSelectionIndex(getRunModeSelectionIndex(selectedMode)); + break; + case "source-langsmith-region": + setLangsmithRegionSelectionIndex( + getLangsmithRegionSelectionIndex(langsmithDraft?.region ?? "us"), + ); + break; + case "source-langsmith-key": + // Prefill an edited workspace's key from ~/.openwiki/.env so it can be + // kept or replaced; a new workspace starts empty. + setInput( + langsmithDraft?.apiKey || + (langsmithDraft + ? (getSavedEnvValue(langsmithDraft.apiKeyEnv) ?? "") + : ""), + ); + break; + case "source-langsmith-projects": + setInput((langsmithDraft?.projects ?? []).join(", ")); + break; + case "model": { + // Point the cursor at the saved model (or the --modelId override), not + // the provider default, so it matches the checklist on a re-walk. + const seededModelId = + modelId ?? + modelIdOverride ?? + getSavedEnvValue(OPENWIKI_MODEL_ID_ENV_KEY) ?? + getDefaultModelId(provider); + setModelSelectionIndex(getModelSelectionIndex(provider, seededModelId)); + // Preset-less providers (e.g. Bedrock) take the model as free text, so + // restore the saved id into the field; selection-based providers drive + // off the index and keep the input empty. + setInput( + shouldStartWithCustomModelInput(provider) + ? (modelId ?? + modelIdOverride ?? + getSavedEnvValue(OPENWIKI_MODEL_ID_ENV_KEY) ?? + "") + : "", + ); + break; + } + case "api-key": { + const envKey = getProviderApiKeyEnvKey(provider); + setInput(apiKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); + break; + } + case "external-cli-auth": { + const envKey = getProviderApiKeyEnvKey(provider); + setInput(apiKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); + break; + } + case "secret-key": { + const envKey = getProviderSecretKeyEnvKey(provider); + setInput(secretKey ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); + break; + } + case "base-url": { + const envKey = getProviderBaseUrlEnvKey(provider); + setInput(baseUrl ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); + break; + } + case "region": { + const envKey = getProviderRegionEnvKey(provider); + setInput(region ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : "")); + break; + } + case "gcp-project": { + const envKey = getProviderProjectEnvKey(provider); + setInput( + gcpProject ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : ""), + ); + break; + } + case "gcp-location": { + const envKey = getProviderLocationEnvKey(provider); + setInput( + gcpLocation ?? (envKey ? (getSavedEnvValue(envKey) ?? "") : ""), + ); + break; + } + case "langsmith": + // Prefill from state or the saved config (masked as dots), matching the + // api-key/secret-key steps, so a walk-through Enter keeps the existing + // key instead of submitting empty and clearing it. + setInput(langSmithKey ?? getSavedEnvValue("LANGSMITH_API_KEY") ?? ""); + break; + case "template": + setTemplateSelectionIndex( + Math.max( + 0, + ONBOARDING_TEMPLATES.findIndex( + (template) => template.id === getConfigModeId(onboardingConfig), + ), + ), + ); + setInput(""); + break; + case "wiki-goal": + setInput(onboardingConfig.wikiGoal ?? ""); + break; + case "global-cron-mode": + setCronModeSelectionIndex(0); + setInput(""); + break; + case "global-cron-custom": + setInput( + onboardingConfig.ingestionSchedule?.expression ?? + suggestedCronExpression, + ); + setCronFieldSelectionIndex(0); + setCronReplaceCurrentField(true); + break; + case "global-power-mode": + setPowerModeSelectionIndex(0); + setInput(""); + break; + case "source-menu": + // Park the cursor on the "Continue" row so Enter keeps sources as-is. + setSourceSelectionIndex(activeSourceOptions.length); + setInput(""); + break; + case "source-description": + setSourceDescriptionSelectionIndex(0); + setInput(""); + break; + case "source-confirm-continue": + setSourceContinueSelectionIndex(0); + setInput(""); + break; + case "final": + setFinalSelectionIndex(0); + setInput(""); + break; + case "code-repo-confirm": + setCodeRepoSelectionIndex(0); + setInput(""); + break; + case "code-repo-path": + setCodeRepoPathInput(codeRepoRoot); + break; + default: + setInput(""); + } + } + + /** + * Commit the current step's typed value into state so stepping back with Esc + * preserves it rather than discarding an unsubmitted edit. Only text-input + * steps carry a value here; selection steps commit on their own submit. + */ + function captureInputForStep(from: PromptStep): void { + const trimmed = input.trim(); + switch (from) { + case "api-key": + case "external-cli-auth": + if (trimmed) setApiKey(trimmed); + break; + case "secret-key": + if (trimmed) setSecretKey(trimmed); + break; + case "base-url": + if (trimmed) setBaseUrl(trimmed); + break; + case "region": + if (trimmed) setRegion(trimmed); + break; + case "gcp-project": + if (trimmed) setGcpProject(trimmed); + break; + case "gcp-location": + if (trimmed) setGcpLocation(trimmed); + break; + case "langsmith": + setLangSmithKey(trimmed); + break; + case "source-langsmith-key": + // Keep an unsubmitted key edit on the draft so Esc does not lose it. + setLangsmithDraft((draft) => + draft ? { ...draft, apiKey: trimmed } : draft, + ); + break; + case "source-langsmith-projects": + // Keep an unsubmitted list edit on the draft so Esc does not lose it. + setLangsmithDraft((draft) => + draft + ? { + ...draft, + projects: [ + ...new Set( + input + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ), + ], + } + : draft, + ); + break; + case "wiki-goal": + // Keep an unsubmitted goal edit in-session (not yet persisted) so + // stepping back and forward does not lose it. + if (trimmed) { + setOnboardingConfig((config) => ({ ...config, wikiGoal: trimmed })); + } + break; + default: + break; + } + } + + useInput((inputValue, key) => { + if ( + isSaving || + isAuthRunning || + (isLoggingIn && step !== "oauth-login") || + step === null + ) { + return; + } + + // Esc retraces the actual path taken via the navigation history stack, so + // it works through the branchy source sub-flow too. It commits the current + // field first (so an unsubmitted edit is kept) and is a no-op at the start. + if (key.escape) { + const target = navHistory.current[navHistory.current.length - 1]; + if (target !== undefined) { + captureInputForStep(step); + navHistory.current.pop(); + setStep(target, { back: true }); + seedInputForStep(target); + setError(null); + setNotice(null); + } + return; + } + + if (step === "oauth-login") { + if ( + input.length === 0 && + (inputValue === "c" || inputValue === "C") && + !key.ctrl && + !key.meta + ) { + if (loginUrl) { + copyToClipboard(loginUrl); + setCopied(true); + } + + return; + } + + if (key.return) { + const pasted = input.trim(); + + if (pasted.length > 0) { + submitManualLogin(pasted); + } else if (!isLoggingIn) { + setLoginAttempt((attempt) => attempt + 1); + } + + return; + } + + if (key.backspace || key.delete) { + setInput((value) => value.slice(0, -1)); + return; + } + + const sanitizedInput = sanitizeInputChunk(inputValue); + + if (sanitizedInput && !key.ctrl && !key.meta) { + setError(null); + setInput((value) => value + sanitizedInput); + } + + return; + } + + if ( + step === "external-cli-auth" && + key.tab && + externalCliAuth.kind !== "checking" && + externalCliAuth.kind !== "logging-in" + ) { + void launchExternalCliLogin(); + return; + } + + if (step === "provider") { + handleMenuInput(key, () => + setProviderSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + SELECTABLE_OPENWIKI_PROVIDERS.length, + ), + ), + ); + return; + } + + if (step === "model" && !isCustomModelInput) { + handleMenuInput(key, () => + setModelSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + getModelSelectionOptions(provider).length, + ), + ), + ); + return; + } + + if (step === "run-mode") { + handleMenuInput(key, () => + setRunModeSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + RUN_MODE_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "source-langsmith-workspaces") { + handleMenuInput(key, () => + setLangsmithWorkspaceSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + // workspaces + "Add a workspace" + "Done". + langsmithWorkspaces.length + 2, + ), + ), + ); + return; + } + + if (step === "source-langsmith-region") { + handleMenuInput(key, () => + setLangsmithRegionSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + LANGSMITH_REGION_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "code-repo-confirm") { + handleMenuInput(key, () => + setCodeRepoSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + CODE_REPO_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "source-menu") { + handleMenuInput(key, () => + setSourceSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + activeSourceOptions.length + 1, + ), + ), + ); + return; + } + + if (step === "template") { + handleMenuInput(key, () => + setTemplateSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + ONBOARDING_TEMPLATES.length, + ), + ), + ); + return; + } + + if (step === "global-cron-mode") { + handleMenuInput(key, () => + setCronModeSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + CRON_MODE_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "global-power-mode") { + handleMenuInput(key, () => + setPowerModeSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + POWER_MODE_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "source-description") { + handleMenuInput(key, () => + setSourceDescriptionSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + getSourceDescriptionOptionCount(selectedSource), + ), + ), + ); + return; + } + + if (step === "source-confirm-continue") { + handleMenuInput(key, () => + setSourceContinueSelectionIndex((index) => + moveSelectionIndex( + index, + key.upArrow ? -1 : 1, + SOURCE_CONTINUE_OPTIONS.length, + ), + ), + ); + return; + } + + if (step === "final") { + handleMenuInput(key, () => + setFinalSelectionIndex((index) => + moveSelectionIndex(index, key.upArrow ? -1 : 1, FINAL_OPTIONS.length), + ), + ); + return; + } + + if (step === "source-auth") { + if (key.return) { + void submit(); + } + return; + } + + if (step === "global-cron-custom") { + if (key.return) { + void submit(); + return; + } + + const didHandleCronInput = handleCronEditorInput({ + currentFieldIndex: cronFieldSelectionIndex, + currentValue: input, + fallbackExpression: suggestedCronExpression, + inputValue, + key, + replaceCurrentField: cronReplaceCurrentField, + setCurrentFieldIndex: setCronFieldSelectionIndex, + setReplaceCurrentField: setCronReplaceCurrentField, + setValue: setInput, + }); + + if (didHandleCronInput) { + setError(null); + } + + return; + } + + if (step === "code-repo-path") { + if (key.return) { + void submit(); + return; + } + + if (key.backspace || key.delete) { + setCodeRepoPathInput((value) => value.slice(0, -1)); + return; + } + + const sanitizedInput = sanitizeInputChunk(inputValue); + + if (sanitizedInput && !key.ctrl && !key.meta) { + setError(null); + setCodeRepoPathInput((value) => value + sanitizedInput); + } + + return; + } + + if (key.return) { + void submit(); + return; + } + + if (key.backspace || key.delete) { + setInput((value) => value.slice(0, -1)); + return; + } + + const sanitizedInput = sanitizeInputChunk(inputValue); + + if (sanitizedInput && !key.ctrl && !key.meta) { + setInput((value) => value + sanitizedInput); + } + }); + + function handleMenuInput(key: PromptInputKey, move: () => void) { + if (key.upArrow || key.downArrow) { + setError(null); + move(); + return; + } + + if (key.return) { + void submit(); + } + } + + async function submit() { + setError(null); + setNotice(null); + + if (step === "run-mode") { + const selectedOption = + RUN_MODE_OPTIONS[runModeSelectionIndex] ?? RUN_MODE_OPTIONS[0]; + + setSelectedMode(selectedOption.id); + setRunModeSelectionIndex(getRunModeSelectionIndex(selectedOption.id)); + setInput(""); + const nextOnboardingConfig = ensureRunModeConfig( + onboardingConfig, + selectedOption.id, + ); + + if (nextOnboardingConfig !== onboardingConfig) { + await saveConfig(nextOnboardingConfig); + } + + const nextStep = getInitialStep( + modelIdOverride, + provider, + nextOnboardingConfig, + selectedOption.id, + false, + ); + + if (nextStep) { + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedOption.id, + }); + return; + } + + if (step === "code-repo-confirm") { + const selectedOption = + CODE_REPO_OPTIONS[codeRepoSelectionIndex] ?? CODE_REPO_OPTIONS[0]; + + if (selectedOption === "Edit path") { + setCodeRepoPathInput(codeRepoRoot); + setStep("code-repo-path"); + return; + } + + setCodeRepoConfirmed(true); + continueAfterCodeRepoConfirmed(codeRepoRoot); + return; + } + + if (step === "code-repo-path") { + try { + const repoRoot = await validateLocalDirectoryPath(codeRepoPathInput); + setCodeRepoRoot(repoRoot); + setCodeRepoConfirmed(true); + setCodeRepoPathInput(""); + continueAfterCodeRepoConfirmed(repoRoot); + } catch (pathError) { + setError(getErrorMessage(pathError)); + } + return; + } + + if (step === "provider") { + const selectedProvider = + SELECTABLE_OPENWIKI_PROVIDERS[providerSelectionIndex] ?? + DEFAULT_PROVIDER; + // Credentials are provider-specific, so switching providers must not carry + // the previous provider's key/secret/etc. across (otherwise seedInputForStep + // prefills it and empty-submit-keeps would save it under the new provider). + const switchedProvider = selectedProvider !== provider; + + setProvider(selectedProvider); + setProviderConfirmed(true); + + if (switchedProvider) { + setApiKey(null); + setSecretKey(null); + setBaseUrl(null); + setRegion(null); + setGcpProject(null); + setGcpLocation(null); + setOauthTokens(null); + setModelId(null); + } + + setProviderSelectionIndex(getProviderSelectionIndex(selectedProvider)); + setModelSelectionIndex( + getModelSelectionIndex( + selectedProvider, + getDefaultModelId(selectedProvider), + ), + ); + setInput(""); + const providerChanged = + process.env[OPENWIKI_PROVIDER_ENV_KEY] !== selectedProvider; + setForceModelStep(providerChanged); + const nextStep = + nextSetupStep( + "provider", + selectedProvider, + selectedMode, + allowModeSelection, + ) ?? + getNextStepAfterProvider( + selectedProvider, + modelIdOverride, + onboardingConfig, + selectedMode, + providerChanged, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && + shouldStartWithCustomModelInput(selectedProvider), + ); + // On a switch the closure still holds the old provider/apiKey, so + // seedInputForStep would re-seed stale values; leave the field empty and + // let a later visit seed from the new provider's own env. + if (switchedProvider) { + setInput(""); + } else { + seedInputForStep(nextStep); + } + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: selectedProvider, + runMode: selectedMode, + }); + return; + } + + if (step === "api-key" || step === "external-cli-auth") { + const trimmedInput = input.trim(); + const usesExternalCli = step === "external-cli-auth"; + // Empty submit keeps an existing key (session or env). For an external + // CLI session it deliberately saves nothing: the CLI remains the token + // owner and the runtime resolves it again for this process only. + const nextApiKey = + trimmedInput.length > 0 + ? trimmedInput + : usesExternalCli && externalCliAuth.kind === "detected" + ? null + : apiKey; + + if ( + nextApiKey === null && + !(usesExternalCli && externalCliAuth.kind === "detected") && + !isCredentialConfigured(provider) + ) { + setError( + `${getProviderApiKeyEnvKey(provider) ?? "API key"} is required.`, + ); + return; + } + + if (trimmedInput.length > 0) { + setApiKey(trimmedInput); + } + setInput(""); + const nextStep = + nextSetupStep(step, provider, selectedMode, allowModeSelection) ?? + getNextStepAfterApiKey( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "secret-key") { + const trimmedInput = input.trim(); + // Empty submit keeps an existing secret key (see the api-key step). + const nextSecretKey = trimmedInput.length > 0 ? trimmedInput : secretKey; + + if (nextSecretKey === null && !isSecretKeyConfigured(provider)) { + setError( + `${getProviderSecretKeyEnvKey(provider) ?? "Secret key"} is required.`, + ); + return; + } + + if (trimmedInput.length > 0) { + setSecretKey(trimmedInput); + } + setInput(""); + const nextStep = + nextSetupStep( + "secret-key", + provider, + selectedMode, + allowModeSelection, + ) ?? + getNextStepAfterSecretKey( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "region") { + const trimmedInput = input.trim(); + const configuredRegion = resolveProviderRegion(provider); + const credentialRepairMessage = getAwsCredentialRepairMessage(provider); + + if (credentialRepairMessage) { + setError(credentialRepairMessage); + return; + } + + if (trimmedInput.length === 0 && !configuredRegion) { + const regionEnvKeys = getProviderRegionEnvKeys(provider); + setError( + `Set one of ${regionEnvKeys.join(", ") || "the supported region variables"}.`, + ); + return; + } + + const nextRegion = trimmedInput.length > 0 ? trimmedInput : region; + + if (trimmedInput.length > 0) { + setRegion(trimmedInput); + } + setInput(""); + const nextStep = + nextSetupStep("region", provider, selectedMode, allowModeSelection) ?? + getNextStepAfterRegion( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "gcp-project") { + const trimmedInput = input.trim(); + + if (trimmedInput.length === 0) { + setError( + `${getProviderProjectEnvKey(provider) ?? "GCP project"} is required.`, + ); + return; + } + + if (/\s/u.test(trimmedInput)) { + setError("Enter a valid Google Cloud project ID (no spaces)."); + return; + } + + setGcpProject(trimmedInput); + setInput(""); + // gcp-location always follows gcp-project (gemini-enterprise); seed it so a + // previously entered location is restored instead of arriving blank. + seedInputForStep("gcp-location"); + setStep("gcp-location"); + return; + } + + if (step === "gcp-location") { + const trimmedInput = input.trim(); + + if (/\s/u.test(trimmedInput)) { + setError( + `Enter a valid location (no spaces), or leave blank for ${DEFAULT_VERTEX_LOCATION}.`, + ); + return; + } + + const nextGcpLocation = trimmedInput.length > 0 ? trimmedInput : null; + + setGcpLocation(nextGcpLocation); + setInput(""); + const nextStep = + nextSetupStep( + "gcp-location", + provider, + selectedMode, + allowModeSelection, + ) ?? + getNextStepAfterGcpLocation( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "base-url") { + const trimmedInput = input.trim(); + + if (trimmedInput.length === 0) { + setError( + `${getProviderBaseUrlEnvKey(provider) ?? "Base URL"} is required.`, + ); + return; + } + + const baseUrlWarnings = getProviderBaseUrlWarnings( + provider, + trimmedInput, + ); + if (baseUrlWarnings.length > 0) { + setError(`Enter a valid base URL: ${baseUrlWarnings.join(", ")}.`); + return; + } + + setBaseUrl(trimmedInput); + setInput(""); + const nextStep = + nextSetupStep("base-url", provider, selectedMode, allowModeSelection) ?? + getNextStepAfterBaseUrl( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + seedInputForStep(nextStep); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: trimmedInput, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "model") { + const selectedModelId = getSelectedModelId( + provider, + modelSelectionIndex, + input, + isCustomModelInput, + ); + + if (!selectedModelId) { + setError("Paste a valid model ID."); + return; + } + + if (selectedModelId === "custom") { + setIsCustomModelInput(true); + setInput(""); + return; + } + + setModelId(selectedModelId); + setInput(""); + setIsCustomModelInput(false); + + // LangSmith is the next spine step, but it is optional: once the user has + // recorded a tracing decision (LANGCHAIN_TRACING_V2 set, or a key present) + // do not re-prompt on a later setup pass. An explicit --init re-walk + // (walkAllSteps) still visits it so the whole setup can be reconfigured. + // getInitialStep guards direct entry at the step; this guards the forward + // walk, which every no-region provider reaches through the model step. + if (walkAllSteps || needsLangSmithStep()) { + // Seed from state so a key entered earlier and stepped past is not + // dropped. + seedInputForStep("langsmith"); + setStep("langsmith"); + return; + } + + // Skip straight to the credential save, preserving the recorded decision + // (nextLangSmithKey: langSmithKey, never rewritten) and using the freshly + // selected model id, since the setModelId state update above is not yet + // visible in this closure. + await continueAfterCredentials({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey: langSmithKey, + nextModelId: selectedModelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "langsmith") { + const nextLangSmithKey = input.trim(); + + setLangSmithKey(nextLangSmithKey); + setInput(""); + + await continueAfterCredentials({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextSecretKey: secretKey, + nextRegion: region, + nextGcpLocation: gcpLocation, + nextGcpProject: gcpProject, + nextLangSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "wiki-goal") { + const wikiGoal = input.trim(); + + if (wikiGoal.length === 0) { + setError("Describe what this wiki should understand."); + return; + } + + const nextConfig = { + ...onboardingConfig, + wikiGoal, + }; + await saveConfigForCurrentMode(nextConfig); + setInput(""); + + if (isCodeMode(nextConfig)) { + setStep("final"); + return; + } + + setCronModeSelectionIndex(0); + setCronFieldSelectionIndex(0); + setCronReplaceCurrentField(true); + setStep("global-cron-mode"); + return; + } + + if (step === "template") { + const selectedTemplate = + ONBOARDING_TEMPLATES[templateSelectionIndex] ?? ONBOARDING_TEMPLATES[0]; + const nextConfig = { + ...onboardingConfig, + modeId: selectedTemplate.id, + modeName: selectedTemplate.name, + templateId: selectedTemplate.id, + templateName: selectedTemplate.name, + }; + await saveConfig(nextConfig); + // Keep the existing goal when the template is unchanged (so re-walking is + // idempotent); use the template's suggested goal when it actually changed. + const keepExistingGoal = + selectedTemplate.id === getConfigModeId(onboardingConfig) && + onboardingConfig.wikiGoal !== undefined && + onboardingConfig.wikiGoal.length > 0; + setInput( + keepExistingGoal + ? (onboardingConfig.wikiGoal ?? "") + : selectedTemplate.suggestedGoal, + ); + setStep("wiki-goal"); + return; + } + + if (step === "source-menu") { + if (sourceSelectionIndex >= activeSourceOptions.length) { + // Code mode auto-configures the repo, so its sources are all optional; + // "Continue" always advances to the wiki brief rather than nagging. + if (isCodeMode(onboardingConfig)) { + advanceAfterCodeSources(); + return; + } + + if ( + getConnectedSourceCount(onboardingConfig, activeSourceOptions) > 0 + ) { + setStep("final"); + return; + } + + setSourceContinueSelectionIndex(0); + setStep("source-confirm-continue"); + return; + } + + const source = + activeSourceOptions[sourceSelectionIndex] ?? activeSourceOptions[0]; + const firstMissingSecretIndex = source.secretInputs.findIndex((secret) => + needsEnvValue(secret), + ); + setSelectedSourceId(source.id); + setSourceState({ secretValues: {} }); + setSourceDescriptionSelectionIndex(0); + setSecretInputIndex( + firstMissingSecretIndex === -1 ? 0 : firstMissingSecretIndex, + ); + setInput(""); + setCronModeSelectionIndex(0); + setPowerModeSelectionIndex(0); + setCronFieldSelectionIndex(0); + setCronReplaceCurrentField(true); + + if ( + source.secretInputs.some((secretInput) => needsEnvValue(secretInput)) + ) { + setStep("source-secret"); + return; + } + + continueAfterSourceCredentialSetup(source); + return; + } + + if (step === "source-secret") { + const currentSecretInput = selectedSource.secretInputs[secretInputIndex]; + if (!currentSecretInput) { + continueAfterSourceCredentialSetup(selectedSource); + return; + } + + const trimmedInput = input.trim(); + if (trimmedInput.length === 0 && !currentSecretInput.optional) { + setError(`${currentSecretInput.envKey} is required.`); + return; + } + + const nextSecretValues = { + ...sourceState.secretValues, + ...(trimmedInput.length > 0 + ? { [currentSecretInput.envKey]: trimmedInput } + : {}), + }; + setSourceState((state) => ({ + ...state, + secretValues: nextSecretValues, + })); + setInput(""); + + const nextIndex = secretInputIndex + 1; + const nextMissingIndex = selectedSource.secretInputs.findIndex( + (secretInput, index) => + index >= nextIndex && + needsEnvValue(secretInput) && + nextSecretValues[secretInput.envKey] === undefined, + ); + + if (nextMissingIndex !== -1) { + setSecretInputIndex(nextMissingIndex); + return; + } + + await saveOpenWikiEnv(nextSecretValues); + continueAfterSourceCredentialSetup(selectedSource); + return; + } + + if (step === "source-auth") { + await authorizeSelectedSource(); + return; + } + + if (step === "source-path") { + const repoPath = normalizeLocalPath(input); + + if (repoPath.length === 0) { + setError("Enter a local repository directory."); + return; + } + + try { + const connectorConfig = await configureLocalGitRepo(repoPath); + setSourceState((state) => ({ ...state, connectorConfig })); + setInput(""); + setStep("source-description"); + } catch (setupError) { + setError(getErrorMessage(setupError)); + } + return; + } + + if (step === "source-langsmith-workspaces") { + const workspaceCount = langsmithWorkspaces.length; + if (langsmithWorkspaceSelectionIndex < workspaceCount) { + // Edit an existing workspace: load it into the draft and walk the fields. + const existing = langsmithWorkspaces[langsmithWorkspaceSelectionIndex]; + setLangsmithEditingIndex(langsmithWorkspaceSelectionIndex); + setLangsmithDraft({ ...existing }); + setLangsmithRegionSelectionIndex( + getLangsmithRegionSelectionIndex(existing.region), + ); + setStep("source-langsmith-region"); + return; + } + if (langsmithWorkspaceSelectionIndex === workspaceCount) { + // Add a workspace with a fresh key env var name. + setLangsmithEditingIndex(workspaceCount); + setLangsmithDraft({ + apiKey: "", + apiKeyEnv: nextLangSmithApiKeyEnv( + langsmithWorkspaces.map((workspace) => workspace.apiKeyEnv), + ), + projects: [], + region: "us", + }); + setLangsmithRegionSelectionIndex( + getLangsmithRegionSelectionIndex("us"), + ); + setStep("source-langsmith-region"); + return; + } + // Done. + returnToSourceMenu(); + return; + } + + if (step === "source-langsmith-region") { + const selectedOption = + LANGSMITH_REGION_OPTIONS[langsmithRegionSelectionIndex] ?? + LANGSMITH_REGION_OPTIONS[0]; + setLangsmithDraft((draft) => + draft ? { ...draft, region: selectedOption.id } : draft, + ); + seedInputForStep("source-langsmith-key"); + setStep("source-langsmith-key"); + return; + } + + if (step === "source-langsmith-key") { + const nextKey = input.trim(); + setLangsmithDraft((draft) => + draft ? { ...draft, apiKey: nextKey } : draft, + ); + // setLangsmithDraft has not applied yet this tick, so seed the projects + // field from the current draft rather than via seedInputForStep. + setInput((langsmithDraft?.projects ?? []).join(", ")); + setStep("source-langsmith-projects"); + return; + } + + if (step === "source-langsmith-projects") { + // Commit the workspace with its exact project set; nothing is written here, + // the file + keys are committed at the final step. + const names = [ + ...new Set( + input + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ), + ]; + commitLangsmithWorkspace(names); + returnToWorkspacesMenu(); + return; + } + + if (step === "source-description") { + if (sourceDescriptionSelectionIndex >= selectedSource.examples.length) { + setInput(""); + setStep("source-description-custom"); + return; + } + + const selectedExample = + selectedSource.examples[sourceDescriptionSelectionIndex] ?? ""; + await saveSelectedSourceDescription(selectedExample); + return; + } + + if (step === "source-description-custom") { + await saveSelectedSourceDescription(input.trim()); + return; + } + + if (step === "global-cron-mode") { + const selectedMode = CRON_MODE_OPTIONS[cronModeSelectionIndex]; + + if (selectedMode === "Enter custom cron") { + setInput(suggestedCronExpression); + setCronFieldSelectionIndex(0); + setCronReplaceCurrentField(true); + setStep("global-cron-custom"); + return; + } + + await saveModeSchedule(suggestedCronExpression); + return; + } + + if (step === "global-cron-custom") { + const validation = validateCronExpression(input); + + if (!validation.valid) { + setError(validation.error); + return; + } + + await saveModeSchedule(validation.expression); + return; + } + + if (step === "global-power-mode") { + const selectedMode = POWER_MODE_OPTIONS[powerModeSelectionIndex]; + + if (selectedMode === "Set up Mac wake/sleep window") { + await saveGlobalMacPowerWindow(); + return; + } + + setSourceSelectionIndex(0); + setSourceState({ secretValues: {} }); + setInput(""); + setStep("source-menu"); + return; + } + + if (step === "source-confirm-continue") { + const selectedAction = + SOURCE_CONTINUE_OPTIONS[sourceContinueSelectionIndex]; + if (selectedAction === "Go back to connections") { + returnToSourceMenu(); + setStep("source-menu"); + return; + } + + setStep("final"); + return; + } + + if (step === "final") { + // Commit the LangSmith workspaces as the exact set (WYSIWYG add/edit/remove), + // only when the sub-menu was opened — so an aborted or untouched setup never + // rewrites openwiki/.langsmith.json. + if (selectedMode === "code" && langsmithSourcesTouched) { + try { + // Freshly-entered keys go to ~/.openwiki/.env (never committed); an empty + // apiKey keeps the existing saved key. + const keyUpdates: Record = {}; + for (const workspace of langsmithWorkspaces) { + if (workspace.apiKey.length > 0) { + keyUpdates[workspace.apiKeyEnv] = workspace.apiKey; + } + } + if (Object.keys(keyUpdates).length > 0) { + await saveOpenWikiEnv(keyUpdates); + } + await saveLangSmithSetup( + codeRepoRoot, + langsmithWorkspaces.map((workspace) => ({ + apiKeyEnv: workspace.apiKeyEnv, + projects: workspace.projects, + region: workspace.region, + })), + ); + } catch (writeError) { + setError(getErrorMessage(writeError)); + return; + } + } + const runIngestionNow = + FINAL_OPTIONS[finalSelectionIndex] === "Run ingestion now"; + const nextConfig = { + ...onboardingConfig, + completedAt: new Date().toISOString(), + }; + await saveConfigForCurrentMode(nextConfig); + onComplete({ + mode: selectedMode, + modelId: + modelId ?? + modelIdOverride ?? + process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? + null, + onboardingCompleted: true, + provider, + repoRoot: + selectedMode === "code" && codeRepoConfirmed + ? codeRepoRoot + : undefined, + runIngestionNow, + savedApiKey: apiKey !== null || oauthTokens !== null, + savedBaseUrl: baseUrl !== null, + savedGcpLocation: gcpLocation !== null, + savedGcpProject: gcpProject !== null, + savedLangSmithKey: langSmithKey !== null && langSmithKey.length > 0, + savedModelId: modelId !== null, + savedProvider: process.env[OPENWIKI_PROVIDER_ENV_KEY] !== provider, + savedRegion: region !== null, + savedSecretKey: secretKey !== null, + shouldContinueToRun: runIngestionNow, + }); + } + } + + async function saveSelectedSourceDescription(description: string) { + const connectorConfig = + selectedSourceId === "web-search" || selectedSourceId === "hackernews" + ? getStaticSourceConfig(selectedSourceId, description) + : sourceState.connectorConfig; + + const sourceInstanceId = createSourceInstanceId( + selectedSourceId, + onboardingConfig, + ); + const sourceInstance = { + connectedAt: new Date().toISOString(), + connectorConfig, + connectorId: selectedSourceId, + id: sourceInstanceId, + ingestionGoal: description.length > 0 ? description : undefined, + name: createSourceInstanceName( + selectedSource, + description, + onboardingConfig, + ), + }; + const nextConfig = addSourceInstanceConfig( + onboardingConfig, + sourceInstance, + ); + await saveConfig(nextConfig); + setSourceState((state) => ({ + ...state, + connectorConfig, + })); + setInput(""); + returnToSourceMenu(); + } + + async function continueAfterCredentials(options: CompleteSetupOptions) { + await saveCredentialUpdates(options); + + // Explicit --init walks the whole tail; enter at its first step rather than + // skipping steps that are already configured. + if (walkAllSteps) { + if (options.runMode === "code") { + setCodeRepoRoot(getDefaultCodeRepoRootPath()); + setCodeRepoSelectionIndex(0); + setStep("code-repo-confirm"); + return; + } + + // Personal mode fixes the template from the run mode, so skip the + // redundant Code/Personal chooser and walk straight into the wiki brief. + // Seed the existing goal so Enter keeps it (idempotent re-walk), else the + // template's suggested goal. + setInput( + onboardingConfig.wikiGoal ?? + getTemplateGoal(getConfigModeId(onboardingConfig)), + ); + setStep("wiki-goal"); + return; + } + + if (options.runMode === "code" && !isOnboardingComplete(onboardingConfig)) { + setCodeRepoRoot(getDefaultCodeRepoRootPath()); + setCodeRepoSelectionIndex(0); + setStep("code-repo-confirm"); + return; + } + + if (!getConfigModeId(onboardingConfig)) { + setStep("template"); + return; + } + + if (!onboardingConfig.wikiGoal) { + setInput(getTemplateGoal(getConfigModeId(onboardingConfig))); + setStep("wiki-goal"); + return; + } + + if (!onboardingConfig.ingestionSchedule) { + setCronModeSelectionIndex(0); + setStep("global-cron-mode"); + return; + } + + if (!isOnboardingComplete(onboardingConfig)) { + setStep("source-menu"); + return; + } + + await completeSetup(options); + } + + function continueAfterCodeRepoConfirmed(repoRoot: string) { + setCodeRepoRoot(repoRoot); + // Preload committed LangSmith projects (once) so the source menu shows them + // and edits build on them (fail-open on the read). + if (!langsmithPreloadedRef.current) { + langsmithPreloadedRef.current = true; + void loadLangSmithSetup(repoRoot) + .then((existing) => + setLangsmithWorkspaces( + existing.map((workspace) => ({ + apiKey: "", + apiKeyEnv: workspace.apiKeyEnv, + projects: workspace.projects, + region: workspace.region, + })), + ), + ) + .catch(() => {}); + } + // Code mode auto-configures the repo itself; the source menu then offers the + // optional LangSmith trace sources before the wiki brief. + setSourceSelectionIndex(0); + setSourceState({ secretValues: {} }); + setStep("source-menu"); + } + + // Continues past the code-mode source menu into the wiki brief. Walks wiki-goal + // on --init even when set; otherwise only when unset. Seeds the existing goal so + // Enter keeps it (idempotent). + function advanceAfterCodeSources() { + if (walkAllSteps || !onboardingConfig.wikiGoal) { + setInput( + onboardingConfig.wikiGoal ?? + getTemplateGoal(getConfigModeId(onboardingConfig)), + ); + setStep("wiki-goal"); + return; + } + + setStep("final"); + } + + async function completeSetup(options: CompleteSetupOptions) { + await saveCredentialUpdates(options); + + onComplete({ + modelId: + options.nextModelId ?? + modelIdOverride ?? + process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? + null, + onboardingCompleted: isOnboardingComplete(onboardingConfig), + provider: options.nextProvider, + repoRoot: + options.runMode === "code" && codeRepoConfirmed + ? codeRepoRoot + : undefined, + mode: options.runMode, + runIngestionNow: false, + savedApiKey: + options.nextApiKey !== null || options.nextOAuthTokens != null, + savedBaseUrl: options.nextBaseUrl !== null, + savedRegion: options.nextRegion !== null, + savedSecretKey: options.nextSecretKey !== null, + savedGcpLocation: options.nextGcpLocation !== null, + savedGcpProject: options.nextGcpProject !== null, + savedLangSmithKey: + options.nextLangSmithKey !== null && + options.nextLangSmithKey.length > 0, + savedModelId: options.nextModelId !== null, + savedProvider: + process.env[OPENWIKI_PROVIDER_ENV_KEY] !== options.nextProvider, + shouldContinueToRun: true, + }); + } + + async function saveCredentialUpdates(options: CompleteSetupOptions) { + setIsSaving(true); + + try { + const updates = buildCredentialEnvUpdates( + { + ...options, + // Preserve the original default-param semantics: an omitted + // (undefined) field falls back to the current oauth tokens, but an + // explicit null stays null. + nextOAuthTokens: + options.nextOAuthTokens === undefined + ? oauthTokens + : options.nextOAuthTokens, + }, + process.env, + ); + + if (Object.keys(updates).length > 0) { + await saveOpenWikiEnv(updates); + } + } catch (saveError) { + onError(getErrorMessage(saveError)); + } finally { + setIsSaving(false); + } + } + + async function authorizeSelectedSource() { + setIsAuthRunning(true); + setError(null); + setNotice(null); + + try { + if (selectedSource.id === "git-repo") { + await configureLocalGitRepo(); + } else if (selectedSource.authProvider) { + const authResult = await runOAuthAuth(selectedSource.authProvider, { + onAuthorizationUrl: ({ copiedToClipboard, openedBrowser, url }) => { + setSourceState((state) => ({ + ...state, + authUrl: url, + copiedAuthUrlToClipboard: copiedToClipboard, + })); + setNotice( + openedBrowser + ? "Opened browser for authorization. Complete the flow to continue." + : copiedToClipboard + ? "Open the authorization URL from your clipboard to continue." + : "Open the authorization URL below to continue.", + ); + }, + silent: true, + }); + await configureAuthProvider(authResult.provider, { force: false }); + } + + setInput(""); + setStep("source-description"); + } catch (authError) { + setError(getErrorMessage(authError)); + } finally { + setIsAuthRunning(false); + } + } + + function continueAfterSourceCredentialSetup(source: SourceSetupOption) { + if (source.authProvider) { + setStep("source-auth"); + return; + } + + if (source.id === "langsmith") { + // Open the workspace sub-menu (add/edit/remove). Opening it arms the + // WYSIWYG write on completion. + setLangsmithSourcesTouched(true); + setLangsmithWorkspaceSelectionIndex(0); + setStep("source-langsmith-workspaces"); + return; + } + + try { + if (source.id === "git-repo") { + setInput(getDefaultLocalGitRepoPath()); + setStep("source-path"); + return; + } else if (source.id === "web-search" || source.id === "hackernews") { + setSourceState((state) => ({ + ...state, + connectorConfig: getStaticSourceConfig(source.id, ""), + })); + } + + setStep("source-description"); + } catch (setupError) { + setError(getErrorMessage(setupError)); + } + } + + /** + * Folds the in-progress draft into langsmithWorkspaces at the editing index. An + * empty project list removes the workspace (WYSIWYG). + */ + function commitLangsmithWorkspace(names: string[]): void { + const draft = langsmithDraft; + setLangsmithWorkspaces((list) => { + const next = [...list]; + if (names.length === 0) { + if (langsmithEditingIndex < next.length) { + next.splice(langsmithEditingIndex, 1); + } + return next; + } + if (!draft) { + return next; + } + const workspace = { ...draft, projects: names }; + if (langsmithEditingIndex >= next.length) { + next.push(workspace); + } else { + next[langsmithEditingIndex] = workspace; + } + return next; + }); + } + + /** + * Returns to the workspace sub-menu as a back-navigation: unwind history through + * it so Esc from the refreshed sub-menu goes to the source menu, not back down + * into the edited workspace's field steps. + */ + function returnToWorkspacesMenu() { + setInput(""); + setLangsmithDraft(null); + const index = navHistory.current.lastIndexOf("source-langsmith-workspaces"); + if (index >= 0) { + navHistory.current.length = index; + } + setStep("source-langsmith-workspaces", { back: true }); + } + + function returnToSourceMenu() { + setSourceSelectionIndex(activeSourceOptions.length); + setSourceState({ secretValues: {} }); + setInput(""); + // Returning to the menu is a back-navigation: unwind history through the menu + // so Escape from the refreshed menu goes to the step BEFORE it (repo-confirm), + // not back down into the source's child steps (which would show empty fields). + const menuIndex = navHistory.current.lastIndexOf("source-menu"); + if (menuIndex >= 0) { + navHistory.current.length = menuIndex; + } + setStep("source-menu", { back: true }); + } + + async function configureLocalGitRepo( + repoPathInput = getDefaultLocalGitRepoPath(), + ): Promise> { + const sourceId = "git-repo"; + const repoPath = normalizeLocalPath(repoPathInput); + const repoId = sanitizeRepoId(path.basename(repoPath) || "repo"); + const configPath = getConnectorConfigPath(sourceId); + const connectorConfig = { + repos: [ + { + id: repoId, + path: repoPath, + }, + ], + }; + await import("node:fs/promises").then( + async ({ chmod, mkdir, stat, writeFile }) => { + const repoStat = await stat(repoPath); + if (!repoStat.isDirectory()) { + throw new Error(`${repoPath} is not a directory.`); + } + + await mkdir(path.dirname(configPath), { + recursive: true, + mode: 0o700, + }); + await writeFile( + configPath, + `${JSON.stringify(connectorConfig, null, 2)}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); + await chmod(configPath, 0o600); + }, + ); + return connectorConfig; + } + + async function saveModeSchedule(cronExpression: string) { + setIsSaving(true); + + try { + const result = await installConnectorSchedule({ + connectorId: "git-repo", + cronExpression, + cwd: process.cwd(), + }); + const nextConfig: OpenWikiOnboardingConfig = { + ...onboardingConfig, + ingestionSchedule: { + description: result.description, + expression: result.expression, + launchAgentPath: result.launchAgentPath, + updatedAt: new Date().toISOString(), + warning: result.warning, + }, + }; + await saveConfig(nextConfig); + setSourceState((state) => ({ + ...state, + savedScheduleWarning: result.warning, + })); + setPowerModeSelectionIndex(0); + setStep("global-power-mode"); + } catch (scheduleError) { + setError(getErrorMessage(scheduleError)); + } finally { + setIsSaving(false); + } + } + + async function saveGlobalMacPowerWindow() { + setIsSaving(true); + + try { + const configForPower = await readOpenWikiOnboardingConfig(); + const result = await installOpenWikiPowerSchedule(configForPower); + const nextConfig: OpenWikiOnboardingConfig = { + ...configForPower, + powerManagement: { + ...configForPower.powerManagement, + pmset: { + days: result.days, + enabled: result.enabled, + sleepTime: result.sleepTime, + updatedAt: new Date().toISOString(), + wakeTime: result.wakeTime, + warning: result.warning, + }, + }, + }; + await saveConfig(nextConfig); + setSourceSelectionIndex(0); + setSourceState({ + secretValues: {}, + savedScheduleWarning: result.warning, + }); + setInput(""); + setStep("source-menu"); + } catch (powerError) { + setError(getErrorMessage(powerError)); + } finally { + setIsSaving(false); + } + } + + async function saveConfig(config: OpenWikiOnboardingConfig) { + setIsSaving(true); + try { + await saveOpenWikiOnboardingConfig(config); + setOnboardingConfig(config); + } catch (saveError) { + onError(getErrorMessage(saveError)); + } finally { + setIsSaving(false); + } + } + + async function saveConfigForCurrentMode(config: OpenWikiOnboardingConfig) { + if (!isCodeMode(config)) { + await saveConfig(config); + return; + } + + setIsSaving(true); + try { + if (config.wikiGoal?.trim()) { + await saveRepositoryWikiInstructions(codeRepoRoot, config.wikiGoal); + } + await saveOpenWikiOnboardingConfig({ + ...config, + wikiGoal: undefined, + }); + setOnboardingConfig(config); + } catch (saveError) { + onError(getErrorMessage(saveError)); + } finally { + setIsSaving(false); + } + } + + function submitManualLogin(pasted: string): void { + const handle = loginHandleRef.current; + + if (!handle) { + setError("Login is still starting. Try again in a moment."); + return; + } + + const errorMessage = handle.submitManual(pasted); + + if (errorMessage) { + setError(errorMessage); + return; + } + + setInput(""); + setError(null); + } + + return { + allowModeSelection, + step, + selectedMode, + provider, + providerConfirmed, + apiKey, + oauthTokens, + secretKey, + gcpProject, + gcpLocation, + baseUrl, + region, + modelId, + modelIdOverride, + langSmithKey, + onboardingConfig, + copied, + input, + isLoggingIn, + loginUrl, + codeRepoPathInput, + codeRepoRoot, + externalCliAuth, + codeRepoSelectionIndex, + cronFieldSelectionIndex, + cronModeSelectionIndex, + finalSelectionIndex, + isCustomModelInput, + langsmithDraft, + langsmithRegionSelectionIndex, + langsmithWorkspaceSelectionIndex, + langsmithWorkspaces, + modelSelectionIndex, + powerModeSelectionIndex, + providerSelectionIndex, + runModeSelectionIndex, + secretInputIndex, + sourceContinueSelectionIndex, + sourceDescriptionSelectionIndex, + sourceSelectionIndex, + sourceState, + templateSelectionIndex, + notice, + error, + isSaving, + isAuthRunning, + activeSourceOptions, + selectedSource, + suggestedCronExpression, + suggestedCronDescription, + inputDisplayWidth, + navHistoryLength: navHistory.current.length, + }; +} diff --git a/src/setup/credentials/view.tsx b/src/setup/credentials/view.tsx new file mode 100644 index 000000000..89e8eb5ea --- /dev/null +++ b/src/setup/credentials/view.tsx @@ -0,0 +1,649 @@ +import { Box, Text } from "ink"; +import { + DEFAULT_VERTEX_LOCATION, + getMissingProviderEnvKey, + getProviderApiKeyEnvKey, + getProviderLabel, + getProviderLocationEnvKey, + getProviderProjectEnvKey, + OPENWIKI_MODEL_ID_ENV_KEY, + type OpenWikiProvider, + providerRequiresBaseUrl, + providerRequiresRegion, + providerRequiresSecretKey, + providerUsesAwsSdkCredentials, + providerUsesOAuth, +} from "../../config/constants.js"; +import type { CodexTokens } from "../../agent/openai-chatgpt-oauth.js"; +import type { OpenWikiRunMode } from "../../cli/commands.js"; +import type { ExternalCliAuthState } from "../../auth/external-cli-auth.js"; +import { getShellEnvValue } from "../../config/env.js"; +import type { OpenWikiOnboardingConfig } from "../onboarding.js"; +import { + credentialStep, + getConnectedSourceCount, + getModelSetupDetail, + getRunModeName, + getWizardManagedEnvKeys, + hasValidConfiguredProvider, + isBaseUrlConfigured, + isCredentialConfigured, + isRegionConfigured, + isScheduleStep, + isSecretKeyConfigured, + isSourceStep, + needsAwsCredentialRepair, + needsBaseUrlStep, + needsCredentialStep, + needsLangSmithStep, + needsRegionStep, + needsSecretKeyStep, + resolveStepStatus, +} from "./steps.js"; +import { getCredentialSetupDetail } from "./format.js"; +import type { + LangsmithWorkspaceDraft, + PromptStep, + SourceSetupOption, + SourceSetupState, +} from "./types.js"; +import { + OAuthLoginPrompt, + Prompt, + SetupHeader, + SetupPanel, + SetupStep, +} from "./components.js"; + +/** + * Props for {@link InitSetupView}: the full snapshot of wizard state, props, + * and derived values the setup summary reads. Every field is read-only render + * input; the view calls no setters or handlers. + */ +export interface InitSetupViewProps { + /** + * Whether the run-mode row is a selectable wizard step rather than a fixed, + * already-decided value. + */ + allowModeSelection: boolean; + + /** The step currently in focus, or null while the wizard is still seeding. */ + step: PromptStep | null; + + /** The run mode being configured (code vs personal). */ + selectedMode: OpenWikiRunMode; + + /** The provider selected for this run. */ + provider: OpenWikiProvider; + + /** True once the user confirms a provider this session. */ + providerConfirmed: boolean; + + /** API key entered this session, or null when none was typed. */ + apiKey: string | null; + + /** OAuth tokens obtained this session, or null when none were obtained. */ + oauthTokens: CodexTokens | null; + + /** Secret key entered this session, or null when none was typed. */ + secretKey: string | null; + + /** GCP project entered this session, or null when none was typed. */ + gcpProject: string | null; + + /** GCP location entered this session, or null when none was typed. */ + gcpLocation: string | null; + + /** Base URL entered this session, or null when none was typed. */ + baseUrl: string | null; + + /** Region entered this session, or null when none was typed. */ + region: string | null; + + /** Model ID chosen this session, or null when none was chosen. */ + modelId: string | null; + + /** Model ID forced by the caller (`--model`), or null when not overridden. */ + modelIdOverride: string | null; + + /** LangSmith key entered this session, or null when none was typed. */ + langSmithKey: string | null; + + /** The onboarding config as the wizard has edited it so far. */ + onboardingConfig: OpenWikiOnboardingConfig; + + /** True once the OAuth login URL was copied to the clipboard. */ + copied: boolean; + + /** The shared single-line input buffer for the active prompt. */ + input: string; + + /** True while the OAuth browser sign-in is in progress. */ + isLoggingIn: boolean; + + /** The OAuth login URL to display, or null before one is issued. */ + loginUrl: string | null; + + /** Dedicated buffer for the code-repo-path field. */ + codeRepoPathInput: string; + + /** The resolved code-repo root path shown on the confirm step. */ + codeRepoRoot: string; + + /** State of the external CLI credential probe/login. */ + externalCliAuth: ExternalCliAuthState; + + /** Selection cursor for the code-repo confirm menu. */ + codeRepoSelectionIndex: number; + + /** Active field cursor for the segmented cron input. */ + cronFieldSelectionIndex: number; + + /** Selection cursor for the cron mode menu. */ + cronModeSelectionIndex: number; + + /** Selection cursor for the final menu. */ + finalSelectionIndex: number; + + /** True while the user is entering a custom model ID. */ + isCustomModelInput: boolean; + + /** The LangSmith workspace currently being added or edited, or null. */ + langsmithDraft: LangsmithWorkspaceDraft | null; + + /** Selection cursor for the LangSmith region menu. */ + langsmithRegionSelectionIndex: number; + + /** Selection cursor for the LangSmith workspaces menu. */ + langsmithWorkspaceSelectionIndex: number; + + /** LangSmith workspaces as the wizard has edited them. */ + langsmithWorkspaces: LangsmithWorkspaceDraft[]; + + /** Selection cursor for the model menu. */ + modelSelectionIndex: number; + + /** Selection cursor for the power-mode menu. */ + powerModeSelectionIndex: number; + + /** Selection cursor for the provider menu. */ + providerSelectionIndex: number; + + /** Selection cursor for the run-mode menu. */ + runModeSelectionIndex: number; + + /** Cursor for the current source secret input field. */ + secretInputIndex: number; + + /** Selection cursor for the source-confirm-continue menu. */ + sourceContinueSelectionIndex: number; + + /** Selection cursor for the source description menu. */ + sourceDescriptionSelectionIndex: number; + + /** Selection cursor for the source menu. */ + sourceSelectionIndex: number; + + /** State of the in-progress source setup (secret values, auth, warnings). */ + sourceState: SourceSetupState; + + /** Selection cursor for the onboarding template menu. */ + templateSelectionIndex: number; + + /** Transient status notice to surface, or null when none. */ + notice: string | null; + + /** Transient error to surface, or null when none. */ + error: string | null; + + /** True while the wizard is writing the setup to disk. */ + isSaving: boolean; + + /** True while waiting for the browser authorization callback. */ + isAuthRunning: boolean; + + /** The active source options for the current mode/template. */ + activeSourceOptions: readonly SourceSetupOption[]; + + /** The source option currently selected in the source sub-flow. */ + selectedSource: SourceSetupOption; + + /** The suggested cron expression for the current onboarding config. */ + suggestedCronExpression: string; + + /** The human-readable description of the suggested cron expression. */ + suggestedCronDescription: string; + + /** The computed display width for single-line inputs. */ + inputDisplayWidth: number; + + /** + * The length of the back-navigation history stack; controls the "esc to go + * back" hint. Passed as a plain number so the view stays ref-free. + */ + navHistoryLength: number; +} + +/** + * Presentational, side-effect-free view of the setup wizard. Renders the + * detected-command summary, the set-up step list, the active prompt panel, and + * the status/error/saving panels from the props snapshot. It calls no setters + * and no handlers. + */ +export function InitSetupView({ + allowModeSelection, + step, + selectedMode, + provider, + providerConfirmed, + apiKey, + oauthTokens, + secretKey, + gcpProject, + gcpLocation, + baseUrl, + region, + modelId, + modelIdOverride, + langSmithKey, + onboardingConfig, + copied, + input, + isLoggingIn, + loginUrl, + codeRepoPathInput, + codeRepoRoot, + externalCliAuth, + codeRepoSelectionIndex, + cronFieldSelectionIndex, + cronModeSelectionIndex, + finalSelectionIndex, + isCustomModelInput, + langsmithDraft, + langsmithRegionSelectionIndex, + langsmithWorkspaceSelectionIndex, + langsmithWorkspaces, + modelSelectionIndex, + powerModeSelectionIndex, + providerSelectionIndex, + runModeSelectionIndex, + secretInputIndex, + sourceContinueSelectionIndex, + sourceDescriptionSelectionIndex, + sourceSelectionIndex, + sourceState, + templateSelectionIndex, + notice, + error, + isSaving, + isAuthRunning, + activeSourceOptions, + selectedSource, + suggestedCronExpression, + suggestedCronDescription, + inputDisplayWidth, + navHistoryLength, +}: InitSetupViewProps) { + const needsCredentialPrompt = + !hasValidConfiguredProvider() || + needsAwsCredentialRepair(provider) || + needsCredentialStep(provider) || + needsSecretKeyStep(provider) || + needsBaseUrlStep(provider) || + needsRegionStep(provider) || + (modelIdOverride === null && + process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || + needsLangSmithStep(); + const apiKeyEnvKey = getProviderApiKeyEnvKey(provider); + const primaryCredentialStep = credentialStep(provider); + const projectEnvKey = getProviderProjectEnvKey(provider); + const locationEnvKey = getProviderLocationEnvKey(provider); + + // A shell export wins over saved config at runtime. List any wizard-managed + // keys present in the shell so their precedence is not a surprise and the + // "from shell" rows below are explained. Presence only, not a value compare; + // key names only, never values. + const shadowedShellKeys = getWizardManagedEnvKeys(provider).filter( + (key) => getShellEnvValue(key) !== undefined, + ); + const isSingleShadow = shadowedShellKeys.length === 1; + const shadowedShellWarning = + shadowedShellKeys.length === 0 + ? null + : `${ + isSingleShadow ? "This key was" : "These keys were" + } detected in your shell and ${ + isSingleShadow ? "overrides" : "override" + } saved config: ${shadowedShellKeys.join(", ")}. Runs use the shell ` + + `value${isSingleShadow ? "" : "s"}; unset ${ + isSingleShadow ? "it" : "them" + } to use your saved config.`; + + return ( + + + + {shadowedShellWarning ? ( + + ⚠ {shadowedShellWarning} + + ) : null} + + + Detected from your command + + + {selectedMode === "code" ? ( + + ) : null} + + + + + Set up + + + {providerUsesAwsSdkCredentials(provider) ? ( + + ) : providerUsesOAuth(provider) || primaryCredentialStep ? ( + + ) : null} + {providerRequiresSecretKey(provider) ? ( + + ) : null} + {projectEnvKey ? ( + + ) : null} + {projectEnvKey && locationEnvKey ? ( + + ) : null} + {providerRequiresBaseUrl(provider) ? ( + + ) : null} + {providerRequiresRegion(provider) ? ( + + ) : null} + + 0 + ? "configured" + : "skipped" + : process.env.LANGSMITH_API_KEY + ? "configured" + : // A recorded tracing decision with no key means the step was + // seen and declined on an earlier run, so it reads "skipped". + process.env.LANGCHAIN_TRACING_V2 !== undefined + ? "skipped" + : "not set" + } + /> + {selectedMode === "personal" ? ( + + ) : null} + {selectedMode === "personal" ? ( + + ) : null} + {selectedMode === "personal" ? ( + 0 + ? "done" + : "pending" + } + detail={`${getConnectedSourceCount( + onboardingConfig, + activeSourceOptions, + )} configured`} + /> + ) : null} + + + + {step === "oauth-login" ? ( + + ) : ( + + {step ? ( + + ) : ( + Inspecting OpenWiki setup... + )} + + )} + + {navHistoryLength > 0 ? ( + + esc to go back + + ) : null} + + {needsCredentialPrompt ? ( + + + Secrets are masked and saved only after setup. + + + ) : null} + {notice ? ( + + {notice} + + ) : null} + {error ? ( + + {error} + + ) : null} + {sourceState.savedScheduleWarning ? ( + + {sourceState.savedScheduleWarning} + + ) : null} + {isSaving ? ( + + Writing OpenWiki setup... + + ) : null} + {isAuthRunning ? ( + + Waiting for the browser authorization callback... + + ) : null} + + ); +} diff --git a/test/setup/credentials/components.test.tsx b/test/setup/credentials/components.test.tsx new file mode 100644 index 000000000..6eef0a802 --- /dev/null +++ b/test/setup/credentials/components.test.tsx @@ -0,0 +1,702 @@ +import React from "react"; +import { render } from "ink-testing-library"; +import { describe, expect, test } from "vitest"; + +import { STEP_GLYPH } from "../../../src/setup/credentials/constants.ts"; +import { + BorderedInput, + BorderedMultilineInput, + ExternalCliAuthPrompt, + InputValueWithCursor, + OAuthAuthorizationLink, + OAuthLoginPrompt, + Prompt, + SegmentedCronInput, + SelectionMarker, + SetupHeader, + SetupPanel, + SetupStep, + SourceConnectionStatus, +} from "../../../src/setup/credentials/components.tsx"; +import { + getSourceOption, + getTemplateSourceOptions, +} from "../../../src/setup/credentials/steps.ts"; +import { createEmptyOnboardingConfig } from "../../../src/setup/onboarding.ts"; +import { stripAnsi as plain } from "../../cli/components/ansi.ts"; + +/** Renders a component and returns its ANSI-stripped final frame. */ +function frameOf(element: React.ReactElement): string { + return plain(render(element).lastFrame()); +} + +describe("SetupHeader", () => { + test("labels the first-run setup and its purpose", () => { + const frame = frameOf(); + expect(frame).toContain("OpenWiki"); + expect(frame).toContain("first-run setup"); + expect(frame).toContain("Configure the model, wiki scope, and sources."); + }); +}); + +describe("SetupStep", () => { + test("renders the state glyph, padded label, and detail", () => { + const frame = frameOf( + , + ); + expect(frame).toContain(STEP_GLYPH.current); + expect(frame).toContain("Model"); + expect(frame).toContain("default sonnet"); + }); +}); + +describe("SetupPanel", () => { + test("renders its title above the children", () => { + const frame = frameOf( + + + , + ); + expect(frame).toContain("Provider"); + expect(frame).toContain("Anthropic"); + }); +}); + +describe("SelectionMarker", () => { + test("shows a caret only when selected", () => { + expect(frameOf()).toContain(">"); + expect(frameOf()).not.toContain(">"); + }); +}); + +describe("SourceConnectionStatus", () => { + test("reports configured, plural counts, and unconfigured", () => { + expect( + frameOf(), + ).toContain("[configured]"); + expect( + frameOf(), + ).toContain("[configured x3]"); + expect( + frameOf(), + ).toContain("[not configured]"); + }); +}); + +describe("OAuthAuthorizationLink", () => { + test("renders the link label and the copied-to-clipboard status", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("Open authorization URL"); + expect(frame).toContain("copied to clipboard"); + }); +}); + +describe("OAuthLoginPrompt", () => { + test("surfaces the login url and copy hint once a url is available", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("https://chatgpt.example/device"); + expect(frame).toContain("to copy the URL"); + expect(frame).toContain("Waiting for browser sign-in"); + }); + + test("shows the starting message before a url exists", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("Starting the ChatGPT login"); + }); +}); + +describe("InputValueWithCursor", () => { + test("shows plain text as entered", () => { + expect( + frameOf(), + ).toContain("hello"); + }); + + test("masks a secret to bullets and never leaks the raw value", () => { + const frame = frameOf( + , + ); + expect(frame).not.toContain("sk-topsecret"); + expect(frame).toContain("•"); + }); +}); + +describe("BorderedInput", () => { + test("renders the value with a shell-style prefix when given one", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("OPENAI_API_KEY"); + expect(frame).toContain("abc"); + }); +}); + +describe("BorderedMultilineInput", () => { + test("renders the multi-line value", () => { + expect( + frameOf(), + ).toContain("a goal"); + }); +}); + +describe("SegmentedCronInput", () => { + test("renders every cron field label and the joined expression", () => { + const frame = frameOf( + , + ); + for (const label of ["minute", "hour", "day", "month", "weekday"]) { + expect(frame).toContain(label); + } + expect(frame).toContain("Cron: 0 9 * * 1"); + }); +}); + +describe("ExternalCliAuthPrompt", () => { + test("masks a pasted token in the detected state", () => { + const frame = frameOf( + , + ); + expect(frame).not.toContain("ghp-secrettoken"); + expect(frame).toContain("Detected an existing"); + }); + + test("prompts to run the login command when a cli is available", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("No"); + expect(frame).toContain("Press Tab to run"); + }); + + test("renders nothing for a provider without an external CLI adapter", () => { + const frame = frameOf( + , + ); + expect(frame).toBe(""); + }); + + test("shows the checking message while probing for a credential", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("Checking for an existing"); + }); + + test("shows the login-in-progress message while signing in", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("follow the prompts in this"); + }); + + test("reports a failed login when the command did not complete", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("did not complete successfully"); + }); + + test("shows the install hint when no cli is available", () => { + const frame = frameOf( + , + ); + expect(frame).toContain("Install"); + expect(frame).not.toContain("Press Tab to run"); + }); +}); + +/** + * Builds a full, valid props bag for the {@link Prompt} step router. Each test + * overrides only the fields for the step under test, so the render is always + * fully typed regardless of which branch is exercised. + */ +function makePromptProps( + overrides: Partial> = {}, +): React.ComponentProps { + return { + codeRepoPathInput: "", + codeRepoRoot: "/tmp/repo", + codeRepoSelectionIndex: 0, + externalCliAuth: { kind: "idle" }, + cronFieldSelectionIndex: 0, + cronModeSelectionIndex: 0, + finalSelectionIndex: 0, + input: "", + inputDisplayWidth: 64, + isCustomModelInput: false, + langsmithDraft: null, + langsmithRegionSelectionIndex: 0, + langsmithWorkspaceSelectionIndex: 0, + langsmithWorkspaces: [], + modelSelectionIndex: 0, + onboardingConfig: createEmptyOnboardingConfig(), + powerModeSelectionIndex: 0, + provider: "anthropic", + providerSelectionIndex: 0, + runModeSelectionIndex: 0, + secretInputIndex: 0, + selectedMode: "personal", + selectedSource: getSourceOption("git-repo"), + sourceOptions: getTemplateSourceOptions(undefined), + sourceContinueSelectionIndex: 0, + sourceDescriptionSelectionIndex: 0, + sourceSelectionIndex: 0, + sourceState: { secretValues: {} }, + step: "provider", + suggestedCronDescription: "At 02:00", + suggestedCronExpression: "0 2 * * *", + templateSelectionIndex: 0, + ...overrides, + }; +} + +/** Renders the Prompt step router and returns its ANSI-stripped final frame. */ +function promptFrame( + overrides: Partial> = {}, +): string { + return frameOf(); +} + +describe("Prompt", () => { + test("run-mode lists the initialization choices", () => { + const frame = promptFrame({ step: "run-mode" }); + expect(frame).toContain("Choose what OpenWiki should initialize."); + expect(frame).toContain("Use up/down arrows, then press Enter."); + }); + + test("provider lists the selectable model providers", () => { + const frame = promptFrame({ step: "provider" }); + expect(frame).toContain("Choose a model provider."); + }); + + test("api-key prompts to paste the provider key and masks it", () => { + const frame = promptFrame({ + step: "api-key", + provider: "anthropic", + input: "sk-secretvalue", + }); + expect(frame).toContain("Paste your"); + expect(frame).toContain("ANTHROPIC_API_KEY="); + expect(frame).not.toContain("sk-secretvalue"); + }); + + test("external-cli-auth delegates to the external CLI prompt", () => { + const frame = promptFrame({ + step: "external-cli-auth", + provider: "copilot", + externalCliAuth: { kind: "detected" }, + }); + expect(frame).toContain("Detected an existing"); + }); + + test("secret-key prompts for the provider secret access key and masks it", () => { + const frame = promptFrame({ + step: "secret-key", + provider: "bedrock", + input: "aws-secret-value", + }); + expect(frame).toContain("secret access key"); + expect(frame).not.toContain("aws-secret-value"); + }); + + test("gcp-project prompts for the Vertex project id", () => { + const frame = promptFrame({ + step: "gcp-project", + provider: "gemini-enterprise", + input: "my-proj", + }); + expect(frame).toContain("Google Cloud project ID"); + expect(frame).toContain("my-proj"); + }); + + test("gcp-location prompts for a Vertex location", () => { + const frame = promptFrame({ + step: "gcp-location", + provider: "gemini-enterprise", + }); + expect(frame).toContain("Vertex AI location"); + }); + + test("base-url prompts for the provider base URL", () => { + const frame = promptFrame({ + step: "base-url", + provider: "openai-compatible", + input: "https://api.local/v1", + }); + expect(frame).toContain("base URL"); + expect(frame).toContain("https://api.local/v1"); + }); + + test("region prompts for the provider region", () => { + const frame = promptFrame({ step: "region", provider: "bedrock" }); + expect(frame).toContain("region"); + expect(frame).toContain("us-east-1"); + }); + + test("model lists the provider model options by default", () => { + const frame = promptFrame({ step: "model", provider: "anthropic" }); + expect(frame).toContain("model."); + expect(frame).toContain("Custom model ID"); + }); + + test("model in custom-input mode prompts for a pasted model id", () => { + const frame = promptFrame({ + step: "model", + isCustomModelInput: true, + input: "claude-custom", + }); + expect(frame).toContain("Paste a custom model ID."); + expect(frame).toContain("claude-custom"); + }); + + test("langsmith offers the optional tracing key and masks it", () => { + const frame = promptFrame({ step: "langsmith", input: "ls-secretkey" }); + expect(frame).toContain("Optional: paste a LangSmith API key"); + expect(frame).not.toContain("ls-secretkey"); + }); + + test("template shows suggested sources for a template that has them", () => { + const frame = promptFrame({ step: "template", templateSelectionIndex: 0 }); + expect(frame).toContain("Choose how OpenWiki should run."); + expect(frame).toContain("Suggested sources:"); + }); + + test("wiki-goal shows the brief editor and the config mode name", () => { + const frame = promptFrame({ + step: "wiki-goal", + onboardingConfig: { + ...createEmptyOnboardingConfig(), + modeId: "code", + modeName: "Code wiki", + }, + input: "document the repo", + }); + expect(frame).toContain("Edit wiki brief"); + expect(frame).toContain("Mode: Code wiki"); + expect(frame).toContain("document the repo"); + }); + + test("code-repo-confirm shows the detected repository root", () => { + const frame = promptFrame({ + step: "code-repo-confirm", + codeRepoRoot: "/home/me/project", + }); + expect(frame).toContain("Use this repository?"); + expect(frame).toContain("/home/me/project"); + }); + + test("code-repo-path prompts for the repository directory", () => { + const frame = promptFrame({ + step: "code-repo-path", + codeRepoPathInput: "/home/me/other", + }); + expect(frame).toContain("Choose the repository directory."); + expect(frame).toContain("/home/me/other"); + }); + + test("source-menu flags no sources configured on an empty config", () => { + const frame = promptFrame({ step: "source-menu" }); + expect(frame).toContain("Configure sources for this mode."); + expect(frame).toContain("(no sources configured)"); + }); + + test("source-menu counts a configured source instance", () => { + const frame = promptFrame({ + step: "source-menu", + onboardingConfig: { + ...createEmptyOnboardingConfig(), + sourceInstances: [{ connectorId: "git-repo", id: "git-repo:1" }], + }, + sourceOptions: [getSourceOption("git-repo")], + }); + expect(frame).toContain("[configured]"); + expect(frame).not.toContain("(no sources configured)"); + }); + + test("source-menu lists a named source instance under its connector", () => { + const frame = promptFrame({ + step: "source-menu", + onboardingConfig: { + ...createEmptyOnboardingConfig(), + sourceInstances: [ + { connectorId: "git-repo", id: "git-repo:1", name: "My checkout" }, + ], + }, + sourceOptions: [getSourceOption("git-repo")], + }); + expect(frame).toContain("My checkout"); + expect(frame).toContain("(git-repo:1)"); + }); + + test("source-menu lists configured LangSmith workspaces by region", () => { + const frame = promptFrame({ + step: "source-menu", + sourceOptions: [getSourceOption("langsmith")], + langsmithWorkspaces: [ + { + apiKeyEnv: "OPENWIKI_LANGSMITH_API_KEY_ACME", + apiKey: "", + region: "us", + projects: ["proj-a"], + }, + ], + }); + expect(frame).toContain("proj-a"); + expect(frame).not.toContain("(no sources configured)"); + }); + + test("source-langsmith-workspaces lists an existing workspace", () => { + const frame = promptFrame({ + step: "source-langsmith-workspaces", + langsmithWorkspaces: [ + { + apiKeyEnv: "OPENWIKI_LANGSMITH_API_KEY_ACME", + apiKey: "", + region: "eu", + projects: ["proj-b"], + }, + ], + }); + expect(frame).toContain("proj-b"); + }); + + test("source-path prompts for the local Git repository directory", () => { + const frame = promptFrame({ step: "source-path", input: "/repo/here" }); + expect(frame).toContain("Choose the local Git repository directory."); + expect(frame).toContain("/repo/here"); + }); + + test("source-secret renders the credential input for a source that needs one", () => { + const frame = promptFrame({ + step: "source-secret", + selectedSource: getSourceOption("web-search"), + input: "tvly-secret", + }); + expect(frame).toContain("setup"); + expect(frame).toContain("Enter credential"); + expect(frame).not.toContain("tvly-secret"); + }); + + test("source-auth shows the callback hint before an auth URL exists", () => { + const frame = promptFrame({ + step: "source-auth", + selectedSource: getSourceOption("notion"), + }); + expect(frame).toContain("authorization"); + expect(frame).toContain("Press Enter to open the authorization URL"); + }); + + test("source-auth renders the authorization link once a URL is available", () => { + const frame = promptFrame({ + step: "source-auth", + selectedSource: getSourceOption("notion"), + sourceState: { + secretValues: {}, + authUrl: "https://auth.example/authorize", + }, + }); + expect(frame).toContain("Open authorization URL"); + }); + + test("source-description lists example descriptions and a custom option", () => { + const frame = promptFrame({ + step: "source-description", + selectedSource: getSourceOption("git-repo"), + }); + expect(frame).toContain("Custom description"); + }); + + test("source-description-custom offers a free-form description editor", () => { + const frame = promptFrame({ + step: "source-description-custom", + selectedSource: getSourceOption("git-repo"), + input: "focus on the API", + }); + expect(frame).toContain("Type what OpenWiki should focus on"); + expect(frame).toContain("focus on the API"); + }); + + test("source-langsmith-workspaces lists the add and done actions", () => { + const frame = promptFrame({ step: "source-langsmith-workspaces" }); + expect(frame).toContain("LangSmith workspaces to document."); + expect(frame).toContain("Add a workspace"); + expect(frame).toContain("Done"); + }); + + test("source-langsmith-key uses the draft env key and masks the value", () => { + const frame = promptFrame({ + step: "source-langsmith-key", + langsmithDraft: { + apiKeyEnv: "OPENWIKI_LANGSMITH_API_KEY_ACME", + apiKey: "", + region: "us", + projects: [], + }, + input: "lsv2-secret", + }); + expect(frame).toContain("OPENWIKI_LANGSMITH_API_KEY_ACME="); + expect(frame).not.toContain("lsv2-secret"); + }); + + test("source-langsmith-key falls back to a default env key without a draft", () => { + const frame = promptFrame({ step: "source-langsmith-key" }); + expect(frame).toContain("OPENWIKI_LANGSMITH_API_KEY="); + }); + + test("source-langsmith-projects prompts for comma-separated projects", () => { + const frame = promptFrame({ + step: "source-langsmith-projects", + input: "proj-a, proj-b", + }); + expect(frame).toContain("Which projects should this wiki document"); + expect(frame).toContain("proj-a, proj-b"); + }); + + test("source-langsmith-region lists the LangSmith regions", () => { + const frame = promptFrame({ step: "source-langsmith-region" }); + expect(frame).toContain("Which LangSmith region"); + expect(frame).toContain("US"); + expect(frame).toContain("EU"); + }); + + test("global-cron-mode uses code-mode copy for a code wiki", () => { + const frame = promptFrame({ + step: "global-cron-mode", + onboardingConfig: { ...createEmptyOnboardingConfig(), modeId: "code" }, + }); + expect(frame).toContain("GitHub Actions refresh this code wiki"); + expect(frame).toContain("Suggested: At 02:00"); + }); + + test("global-cron-mode uses personal copy for a non-code wiki", () => { + const frame = promptFrame({ + step: "global-cron-mode", + onboardingConfig: { + ...createEmptyOnboardingConfig(), + modeId: "personal", + }, + }); + expect(frame).toContain("run all ingestion"); + }); + + test("global-cron-custom shows the example hint when the field is empty", () => { + const frame = promptFrame({ step: "global-cron-custom", input: "" }); + expect(frame).toContain("Example: 0 2 * * *"); + }); + + test("global-cron-custom describes a valid entered schedule", () => { + const frame = promptFrame({ + step: "global-cron-custom", + input: "0 2 * * *", + }); + expect(frame).toContain("At 02:00 AM"); + expect(frame).not.toContain("Example: 0 2 * * *"); + }); + + test("global-cron-custom surfaces the error for an invalid schedule", () => { + const frame = promptFrame({ + step: "global-cron-custom", + input: "99 2 * * *", + }); + expect(frame).toContain("Constraint error"); + }); + + test("global-power-mode explains the macOS wake schedule", () => { + const frame = promptFrame({ step: "global-power-mode" }); + expect(frame).toContain("Keep your Mac awake"); + }); + + test("global-power-mode surfaces a saved schedule warning when present", () => { + const frame = promptFrame({ + step: "global-power-mode", + sourceState: { secretValues: {}, savedScheduleWarning: "pmset drift" }, + }); + expect(frame).toContain("pmset drift"); + }); + + test("source-confirm-continue lists the unconfigured sources", () => { + const frame = promptFrame({ + step: "source-confirm-continue", + sourceOptions: [getSourceOption("web-search")], + onboardingConfig: createEmptyOnboardingConfig(), + }); + expect(frame).toContain("not configured yet"); + expect(frame).toContain("Web Search (Tavily)"); + }); + + test("final uses code-mode copy for a code wiki", () => { + const frame = promptFrame({ step: "final", selectedMode: "code" }); + expect(frame).toContain("Setup is complete."); + expect(frame).toContain("Run now writes the initial openwiki/ directory"); + }); + + test("final uses personal-mode copy for a personal wiki", () => { + const frame = promptFrame({ step: "final", selectedMode: "personal" }); + expect(frame).toContain("Setup is complete."); + expect(frame).toContain("one source-specific ingestion"); + }); + + test("renders nothing for a step the router does not handle", () => { + const frame = promptFrame({ step: "oauth-login" }); + expect(frame).toBe(""); + }); +}); diff --git a/test/setup/credentials/format.test.ts b/test/setup/credentials/format.test.ts new file mode 100644 index 000000000..6848b3b24 --- /dev/null +++ b/test/setup/credentials/format.test.ts @@ -0,0 +1,326 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +// Partial mock: only spawn is stubbed for openLoginUrl; execFile and the rest +// stay real so transitive importers (e.g. windows-acl) still resolve them. +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + +import { + getProviderApiKeyEnvKey, + getProviderSecretKeyEnvKey, + AWS_ACCESS_KEY_ID_ENV_KEY, + AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, + AWS_SECRET_ACCESS_KEY_ENV_KEY, + AWS_SESSION_TOKEN_ENV_KEY, +} from "../../../src/config/constants.ts"; +import { + copyToClipboard, + formatSecretInputDisplay, + formatTerminalHyperlink, + getAwsCredentialRepairMessage, + getCredentialSetupDetail, + getOAuthAuthorizationStatusText, + getSingleLineInputDisplayValue, + mask, + openLoginUrl, +} from "../../../src/setup/credentials/format.ts"; + +const MANAGED_KEYS = [ + "OPENAI_CHATGPT_ACCESS_TOKEN", + "OPENAI_CHATGPT_REFRESH_TOKEN", + "OPENAI_CHATGPT_ACCOUNT_ID", + "OPENAI_CHATGPT_EXPIRES_AT", + "OPENAI_CHATGPT_EMAIL", + "OPENAI_CHATGPT_PLAN", + getProviderApiKeyEnvKey("openai"), + getProviderApiKeyEnvKey("bedrock"), + getProviderSecretKeyEnvKey("bedrock"), + AWS_ACCESS_KEY_ID_ENV_KEY, + AWS_SECRET_ACCESS_KEY_ENV_KEY, + AWS_SESSION_TOKEN_ENV_KEY, + AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, +].filter((key): key is string => key !== undefined); + +let snapshot: Record; + +function set(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +beforeEach(() => { + snapshot = {}; + for (const key of MANAGED_KEYS) { + snapshot[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MANAGED_KEYS) { + set(key, snapshot[key]); + } + vi.restoreAllMocks(); +}); + +describe("mask", () => { + test("returns empty for empty input and one asterisk per character otherwise", () => { + expect(mask("")).toBe(""); + expect(mask("abc")).toBe("***"); + }); +}); + +describe("formatSecretInputDisplay", () => { + test("renders one bullet per entered character, nothing when empty", () => { + expect(formatSecretInputDisplay("")).toBe(""); + expect(formatSecretInputDisplay("ab")).toBe("••"); + }); +}); + +describe("formatTerminalHyperlink", () => { + test("wraps the label in an OSC 8 hyperlink pointing at the url", () => { + const link = formatTerminalHyperlink("https://example.com", "click"); + + expect(link).toBe("]8;;https://example.comclick]8;;"); + }); +}); + +describe("getSingleLineInputDisplayValue", () => { + test("returns empty when there is no room to render", () => { + expect(getSingleLineInputDisplayValue("hello", 0)).toBe(""); + }); + + test("returns the value unchanged when it fits", () => { + expect(getSingleLineInputDisplayValue("hello", 10)).toBe("hello"); + }); + + test("keeps only the tail when the budget is too small for an ellipsis", () => { + expect(getSingleLineInputDisplayValue("hello", 3)).toBe("llo"); + }); + + test("ellipsizes from the left when the value overflows a larger budget", () => { + expect(getSingleLineInputDisplayValue("abcdefgh", 5)).toBe("...gh"); + }); +}); + +describe("getOAuthAuthorizationStatusText", () => { + test("reports the clipboard fallback once the url is copied", () => { + expect( + getOAuthAuthorizationStatusText({ + authProvider: "notion", + copiedToClipboard: true, + }), + ).toContain("copied to clipboard"); + }); + + test("names the provider-specific auth command when not copied", () => { + expect( + getOAuthAuthorizationStatusText({ + authProvider: "notion", + copiedToClipboard: false, + }), + ).toContain("openwiki auth notion"); + }); + + test("falls back to a generic auth command without a provider", () => { + expect( + getOAuthAuthorizationStatusText({ copiedToClipboard: false }), + ).toContain("openwiki auth "); + }); +}); + +describe("getAwsCredentialRepairMessage", () => { + test("returns null for a non-aws provider", () => { + expect(getAwsCredentialRepairMessage("openai")).toBeNull(); + }); + + test("returns null when the bedrock credential set is not partially configured", () => { + expect(getAwsCredentialRepairMessage("bedrock")).toBeNull(); + }); + + test("explains which half of a partial legacy pair is missing", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + const secretKey = getProviderSecretKeyEnvKey("bedrock"); + if (!accessKey || !secretKey) { + throw new Error("bedrock must define a legacy key pair"); + } + + set(accessKey, "AKIAEXAMPLE"); + const message = getAwsCredentialRepairMessage("bedrock"); + + expect(message).toContain(secretKey); + expect(message).toContain("missing or blank"); + }); + + test("names the standard AWS key pair when the legacy pair is empty but the standard pair is partial", () => { + set(AWS_ACCESS_KEY_ID_ENV_KEY, "AKIASTANDARD"); + const message = getAwsCredentialRepairMessage("bedrock"); + + expect(message).toContain(AWS_SECRET_ACCESS_KEY_ENV_KEY); + expect(message).toContain( + `${AWS_ACCESS_KEY_ID_ENV_KEY} and ${AWS_SECRET_ACCESS_KEY_ENV_KEY}`, + ); + expect(message).toContain("missing or blank"); + }); +}); + +describe("getCredentialSetupDetail", () => { + test("tells an api-key provider to save its key when none is present", () => { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + + const detail = getCredentialSetupDetail("openai"); + expect(detail).toContain("save"); + expect(detail).toContain(apiKey); + }); + + test("reports an api-key provider satisfied once the key is in the environment", () => { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + + set(apiKey, "sk-test"); + expect(getCredentialSetupDetail("openai")).toBe( + "available from environment", + ); + }); + + test("prompts an oauth provider to sign in when no token is stored", () => { + expect(getCredentialSetupDetail("openai-chatgpt")).toBe( + "sign in with your ChatGPT account", + ); + }); + + test("describes the aws-sdk credential chain for bedrock", () => { + expect(getCredentialSetupDetail("bedrock")).toBe( + "AWS SDK default credential chain", + ); + }); + + test("labels an oauth provider with the account decoded from passed-in tokens", () => { + const tokens = { + access: "access-token", + refresh: "refresh-token", + expiresAtMs: 0, + accountId: "acct-123", + email: "user@example.com", + planType: "plus", + }; + + expect(getCredentialSetupDetail("openai-chatgpt", tokens)).toBe( + "signed in as user@example.com (Plus)", + ); + }); + + test("falls back to a generic signed-in label when the account cannot be formatted", () => { + const tokens = { + access: "access-token", + refresh: "refresh-token", + expiresAtMs: 0, + accountId: "acct-123", + email: null, + planType: null, + }; + + expect(getCredentialSetupDetail("openai-chatgpt", tokens)).toBe( + "signed in with ChatGPT", + ); + }); + + test("reports the bedrock bearer token as taking precedence when set", () => { + set(AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, "bearer-token"); + + expect(getCredentialSetupDetail("bedrock")).toBe( + "Bedrock bearer token (takes precedence)", + ); + }); + + test("flags a partial legacy bedrock key pair", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + if (!accessKey) throw new Error("bedrock must define a legacy api key"); + + set(accessKey, "AKIAEXAMPLE"); + + expect(getCredentialSetupDetail("bedrock")).toBe( + "incomplete legacy Bedrock keys; set both or clear both", + ); + }); + + test("flags a partial standard AWS key pair when the legacy pair is empty", () => { + set(AWS_ACCESS_KEY_ID_ENV_KEY, "AKIASTANDARD"); + + expect(getCredentialSetupDetail("bedrock")).toBe( + "incomplete standard AWS credentials; set the full set or unset it", + ); + }); + + test("reports a complete legacy bedrock pair as taking precedence", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + const secretKey = getProviderSecretKeyEnvKey("bedrock"); + if (!accessKey || !secretKey) { + throw new Error("bedrock must define a legacy key pair"); + } + + set(accessKey, "AKIAEXAMPLE"); + set(secretKey, "secret-example"); + + expect(getCredentialSetupDetail("bedrock")).toBe( + "legacy Bedrock keys (take precedence)", + ); + }); + + test("notes that an orphan AWS session token is ignored by the credential chain", () => { + set(AWS_SESSION_TOKEN_ENV_KEY, "session-token"); + + expect(getCredentialSetupDetail("bedrock")).toBe( + "AWS SDK default credential chain (orphan AWS_SESSION_TOKEN ignored)", + ); + }); +}); + +describe("copyToClipboard", () => { + test("emits the OSC 52 sequence carrying the base64-encoded text", () => { + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string) => { + writes.push(chunk); + return true; + }) as typeof process.stdout.write); + + copyToClipboard("hello"); + + const encoded = Buffer.from("hello", "utf8").toString("base64"); + expect(writes).toEqual([`]52;c;${encoded}`]); + }); +}); + +describe("openLoginUrl", () => { + test("spawns a detached opener carrying the url and never throws on error", () => { + const child = { + on: vi.fn(), + unref: vi.fn(), + }; + spawnMock.mockReset(); + spawnMock.mockReturnValue(child); + + openLoginUrl("https://login.example/authorize"); + + expect(spawnMock).toHaveBeenCalledTimes(1); + // The url appears in the argument vector on every platform (bare on + // darwin/linux, quoted inside the cmd args on win32). + expect(JSON.stringify(spawnMock.mock.calls[0])).toContain( + "https://login.example/authorize", + ); + // An error on the child must be swallowed (the url is also rendered). + const errorHandler = child.on.mock.calls.find( + (call) => call[0] === "error", + )?.[1] as (() => void) | undefined; + expect(() => errorHandler?.()).not.toThrow(); + expect(child.unref).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/setup/credentials/init-setup-langsmith-skip.test.tsx b/test/setup/credentials/init-setup-langsmith-skip.test.tsx new file mode 100644 index 000000000..d0bf5b61e --- /dev/null +++ b/test/setup/credentials/init-setup-langsmith-skip.test.tsx @@ -0,0 +1,179 @@ +import React from "react"; +import { render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { InitSetup } from "../../../src/setup/credentials.tsx"; +import { getProviderApiKeyEnvKey } from "../../../src/config/constants.ts"; +import { stripAnsi as plain } from "../../cli/components/ansi.ts"; + +// The wizard's config load and credential save touch ~/.openwiki. Stub the +// readers/writers so the state machine runs end to end without reading or +// writing real user state: an empty onboarding config forces the code tail to +// stop at code-repo-confirm, and the env writer is a no-op. +vi.mock("../../../src/setup/onboarding.ts", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + readOpenWikiOnboardingConfig: vi.fn(() => + Promise.resolve(actual.createEmptyOnboardingConfig()), + ), + saveOpenWikiOnboardingConfig: vi.fn(() => Promise.resolve()), + }; +}); + +vi.mock("../../../src/config/env.ts", async (importOriginal) => { + const actual = + await importOriginal(); + + return { ...actual, saveOpenWikiEnv: vi.fn(() => Promise.resolve()) }; +}); + +/** Environment keys this suite drives; snapshotted and restored around each test. */ +const MANAGED_KEYS = [ + "OPENWIKI_PROVIDER", + "OPENWIKI_MODEL_ID", + "ANTHROPIC_API_KEY", + "LANGSMITH_API_KEY", + "LANGCHAIN_TRACING_V2", +]; + +let snapshot: Record; + +/** Sets or clears a managed env key. */ +function set(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +beforeEach(() => { + snapshot = {}; + for (const key of MANAGED_KEYS) { + snapshot[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MANAGED_KEYS) { + set(key, snapshot[key]); + } + vi.clearAllMocks(); +}); + +/** Yields to microtasks and one macrotask so pending async work can settle. */ +async function tick(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Polls the current frame until `predicate` holds, ticking between checks. The + * mount's config-load effect resolves asynchronously (hydrateRunModeConfig does + * real work), so a fixed delay is flaky; this waits only as long as needed. + */ +async function waitForFrame( + lastFrame: () => string | undefined, + predicate: (frame: string) => boolean, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const frame = plain(lastFrame()); + if (predicate(frame)) { + return frame; + } + await tick(); + } + return plain(lastFrame()); +} + +/** + * Puts the wizard at the model step for Anthropic: provider and key are present + * so getInitialStep skips them, the model id is unset so the model step shows. + * The caller sets the LangSmith signal before invoking. + */ +function seedProviderAndKey(): void { + const apiKeyEnvKey = getProviderApiKeyEnvKey("anthropic"); + if (!apiKeyEnvKey) { + throw new Error("anthropic must define an api key env var"); + } + set("OPENWIKI_PROVIDER", "anthropic"); + set(apiKeyEnvKey, "sk-test"); + set("OPENWIKI_MODEL_ID", undefined); +} + +/** The LangSmith prompt's distinctive body text (never shown on other steps). */ +const LANGSMITH_PROMPT = "for tracing"; + +describe("InitSetup model step -> LangSmith routing (live state machine)", () => { + test("skips LangSmith after the model when a tracing decision was recorded", async () => { + seedProviderAndKey(); + // Recorded decline: LANGCHAIN_TRACING_V2 present means the optional step is + // already answered, so a later pass must not re-prompt. + set("LANGCHAIN_TRACING_V2", "false"); + + const onComplete = vi.fn(); + const onError = vi.fn(); + const { stdin, lastFrame } = render( + , + ); + + const modelFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("Choose an"), + ); + expect(modelFrame).toContain("Choose an"); + expect(modelFrame).not.toContain(LANGSMITH_PROMPT); + + // Enter selects the highlighted (default) model and advances. + stdin.write("\r"); + // The model step must not route to LangSmith: wait for it to leave the model + // prompt, then confirm the LangSmith prompt was skipped. + const nextFrame = await waitForFrame( + lastFrame, + (frame) => !frame.includes("Choose an"), + ); + expect(nextFrame).not.toContain(LANGSMITH_PROMPT); + expect(onError).not.toHaveBeenCalled(); + }); + + test("shows LangSmith after the model when no tracing decision exists", async () => { + seedProviderAndKey(); + // Neither a key nor a recorded decision: the optional step is genuinely + // unanswered, so the forward walk must still visit it. + set("LANGSMITH_API_KEY", undefined); + set("LANGCHAIN_TRACING_V2", undefined); + + const onComplete = vi.fn(); + const onError = vi.fn(); + const { stdin, lastFrame } = render( + , + ); + + const modelFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("Choose an"), + ); + expect(modelFrame).toContain("Choose an"); + + stdin.write("\r"); + const nextFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(LANGSMITH_PROMPT), + ); + expect(nextFrame).toContain(LANGSMITH_PROMPT); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/test/setup/credentials/persistence.test.ts b/test/setup/credentials/persistence.test.ts new file mode 100644 index 000000000..77179f673 --- /dev/null +++ b/test/setup/credentials/persistence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; + +import { buildCredentialEnvUpdates } from "../../../src/setup/credentials/persistence.ts"; +import type { CompleteSetupOptions } from "../../../src/setup/credentials/types.ts"; +import type { CodexTokens } from "../../../src/agent/openai-chatgpt-oauth.ts"; + +/** + * A `CompleteSetupOptions` with every collectible field defaulted to "not + * collected" (null). Each test overrides only the fields it exercises, so an + * assertion about one provider setting is not entangled with the others. + */ +function makeOptions( + overrides: Partial = {}, +): CompleteSetupOptions { + return { + nextApiKey: null, + nextBaseUrl: null, + nextGcpLocation: null, + nextGcpProject: null, + nextLangSmithKey: null, + nextModelId: null, + nextProvider: "anthropic", + nextRegion: null, + nextSecretKey: null, + runMode: "code", + ...overrides, + }; +} + +/** A fabricated environment so tests never read the real `~/.openwiki/.env`. */ +function makeEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { ...overrides }; +} + +describe("buildCredentialEnvUpdates", () => { + test("writes the provider only when it differs from the current env", () => { + const changed = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic" }), + makeEnv({ OPENWIKI_PROVIDER: "openai" }), + ); + expect(changed.OPENWIKI_PROVIDER).toBe("anthropic"); + + const unchanged = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(unchanged).not.toHaveProperty("OPENWIKI_PROVIDER"); + }); + + test("maps the api key onto the selected provider's env key", () => { + const anthropic = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic", nextApiKey: "sk-ant-123" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(anthropic.ANTHROPIC_API_KEY).toBe("sk-ant-123"); + expect(anthropic).not.toHaveProperty("OPENAI_API_KEY"); + + const openai = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "openai", nextApiKey: "sk-oai-456" }), + makeEnv({ OPENWIKI_PROVIDER: "openai" }), + ); + expect(openai.OPENAI_API_KEY).toBe("sk-oai-456"); + expect(openai).not.toHaveProperty("ANTHROPIC_API_KEY"); + }); + + test("maps bedrock secret key and region onto their provider env keys", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ + nextProvider: "bedrock", + nextApiKey: "AKIA-access", + nextSecretKey: "aws-secret", + nextRegion: "us-east-1", + }), + makeEnv({ OPENWIKI_PROVIDER: "bedrock" }), + ); + expect(updates.BEDROCK_AWS_ACCESS_KEY_ID).toBe("AKIA-access"); + expect(updates.BEDROCK_AWS_SECRET_ACCESS_KEY).toBe("aws-secret"); + expect(updates.BEDROCK_AWS_REGION).toBe("us-east-1"); + }); + + test("maps gcp project and location onto the vertex provider env keys", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ + nextProvider: "gemini-enterprise", + nextGcpProject: "my-project", + nextGcpLocation: "us-central1", + }), + makeEnv({ OPENWIKI_PROVIDER: "gemini-enterprise" }), + ); + expect(updates.GOOGLE_CLOUD_PROJECT).toBe("my-project"); + expect(updates.GOOGLE_CLOUD_LOCATION).toBe("us-central1"); + }); + + test("writes the model id when one was collected", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic", nextModelId: "claude-x" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(updates.OPENWIKI_MODEL_ID).toBe("claude-x"); + }); + + test("turns tracing on and pins the project for a non-empty langsmith key", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic", nextLangSmithKey: "ls-key" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(updates.LANGSMITH_API_KEY).toBe("ls-key"); + expect(updates.LANGCHAIN_PROJECT).toBe("openwiki"); + expect(updates.LANGCHAIN_TRACING_V2).toBe("true"); + }); + + test("treats a blank langsmith key as an explicit tracing off switch", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic", nextLangSmithKey: "" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(updates.LANGSMITH_API_KEY).toBe(""); + expect(updates.LANGCHAIN_TRACING_V2).toBe("false"); + expect(updates).not.toHaveProperty("LANGCHAIN_PROJECT"); + }); + + test("expands oauth tokens into their env keys when present", () => { + const tokens: CodexTokens = { + access: "access-token", + refresh: "refresh-token", + expiresAtMs: 1_700_000_000_000, + accountId: "acct-1", + email: null, + planType: null, + }; + const updates = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "openai-chatgpt", nextOAuthTokens: tokens }), + makeEnv({ OPENWIKI_PROVIDER: "openai-chatgpt" }), + ); + expect(updates.OPENAI_CHATGPT_ACCESS_TOKEN).toBe("access-token"); + expect(updates.OPENAI_CHATGPT_REFRESH_TOKEN).toBe("refresh-token"); + expect(updates.OPENAI_CHATGPT_ACCOUNT_ID).toBe("acct-1"); + }); + + test("omits every setting the wizard did not collect", () => { + const updates = buildCredentialEnvUpdates( + makeOptions({ nextProvider: "anthropic" }), + makeEnv({ OPENWIKI_PROVIDER: "anthropic" }), + ); + expect(updates).toEqual({}); + }); +}); diff --git a/test/setup/credentials/steps-derivations.test.ts b/test/setup/credentials/steps-derivations.test.ts new file mode 100644 index 000000000..d56e9f674 --- /dev/null +++ b/test/setup/credentials/steps-derivations.test.ts @@ -0,0 +1,605 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { + getDefaultModelId, + getProviderLabel, + getProviderModelOptions, + OPENWIKI_MODEL_ID_ENV_KEY, + SELECTABLE_OPENWIKI_PROVIDERS, +} from "../../../src/config/constants.ts"; +import type { OpenWikiProvider } from "../../../src/config/constants.ts"; +import { + createEmptyOnboardingConfig, + type OnboardingSourceInstanceConfig, + type OpenWikiOnboardingConfig, +} from "../../../src/setup/onboarding.ts"; +import { + ONBOARDING_TEMPLATES, + SOURCE_OPTIONS, +} from "../../../src/setup/credentials/constants.ts"; +import type { SourceSetupOption } from "../../../src/setup/credentials/types.ts"; +import { + addSourceInstanceConfig, + createSourceInstanceId, + createSourceInstanceName, + deriveLegacySources, + getApiKeyFieldLabel, + getConnectedSourceCount, + getCronFields, + getDefaultLocalGitRepoPath, + getErrorMessage, + getFinalOptionLabel, + getInputDisplayWidth, + getModelSelectionIndex, + getModelSelectionOptions, + getModelSetupDetail, + getProviderArticle, + getProviderSelectionIndex, + getSelectedModelId, + getSourceDescriptionOptionCount, + getSourceDescriptionPrompt, + getSourceInstanceCount, + getSourceInstances, + getSourceMenuLabel, + getStaticSourceConfig, + getTemplateGoal, + getTemplateSourceOptions, + handleCronEditorInput, + isScheduleStep, + isSourceStep, + moveSelectionIndex, + needsEnvValue, + normalizeLocalPath, + parseCronFieldPaste, + sanitizeCronInputChunk, + sanitizeInputChunk, + sanitizeRepoId, + shouldStartWithCustomModelInput, + validateLocalDirectoryPath, +} from "../../../src/setup/credentials/steps.ts"; + +/** Provider that ships preset model options (used for model-selection tests). */ +const PROVIDER_WITH_PRESETS: OpenWikiProvider = + SELECTABLE_OPENWIKI_PROVIDERS.find( + (provider) => getProviderModelOptions(provider).length > 0, + ) ?? "anthropic"; + +/** A real source option so fixtures match the production shape exactly. */ +function sourceOption(id: string): SourceSetupOption { + const option = SOURCE_OPTIONS.find((source) => source.id === id); + if (!option) throw new Error(`no source option for ${id}`); + return option; +} + +/** An onboarding config carrying the given source instances. */ +function configWith( + ...instances: OnboardingSourceInstanceConfig[] +): OpenWikiOnboardingConfig { + return { ...createEmptyOnboardingConfig(), sourceInstances: instances }; +} + +/** A source instance fixture with only the fields the helpers read. */ +function instance( + connectorId: OnboardingSourceInstanceConfig["connectorId"], + overrides: Partial = {}, +): OnboardingSourceInstanceConfig { + return { + connectorId, + id: `${connectorId}-fixture`, + ingestionGoal: "goal", + ...overrides, + }; +} + +describe("string + error helpers", () => { + test("sanitizeInputChunk drops carriage returns and newlines only", () => { + expect(sanitizeInputChunk("a\r\nb\nc")).toBe("abc"); + expect(sanitizeInputChunk("plain text 1!")).toBe("plain text 1!"); + }); + + test("sanitizeCronInputChunk keeps only cron-legal characters", () => { + expect(sanitizeCronInputChunk("*/5")).toBe("*/5"); + // Spaces and punctuation outside the allowlist are stripped. + expect(sanitizeCronInputChunk("1 2!")).toBe("12"); + expect(sanitizeCronInputChunk("Mon#3,L-W")).toBe("Mon#3,L-W"); + }); + + test("sanitizeRepoId allowlists, truncates to 80 chars, and never empties", () => { + expect(sanitizeRepoId("my repo/name")).toBe("my-repo-name"); + expect(sanitizeRepoId("")).toBe("repo"); + expect(sanitizeRepoId("!!!")).toBe("---"); + expect(sanitizeRepoId("a".repeat(200))).toHaveLength(80); + }); + + test("getErrorMessage unwraps Error and stringifies everything else", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom"); + expect(getErrorMessage("raw")).toBe("raw"); + expect(getErrorMessage(42)).toBe("42"); + }); +}); + +describe("path helpers", () => { + test("getDefaultLocalGitRepoPath is the process working directory", () => { + expect(getDefaultLocalGitRepoPath()).toBe(process.cwd()); + }); + + test("normalizeLocalPath expands ~ and resolves relative paths", () => { + expect(normalizeLocalPath("")).toBe(""); + expect(normalizeLocalPath(" ")).toBe(""); + expect(normalizeLocalPath("~")).toBe(homedir()); + expect(normalizeLocalPath("~/sub")).toBe(path.resolve(homedir(), "sub")); + // A Windows-style tilde prefix is expanded the same way. + expect(normalizeLocalPath("~\\sub")).toBe(path.resolve(homedir(), "sub")); + expect(normalizeLocalPath("./rel")).toBe(path.resolve("./rel")); + }); + + test("validateLocalDirectoryPath resolves a real directory and rejects empties", async () => { + await expect(validateLocalDirectoryPath(process.cwd())).resolves.toBe( + process.cwd(), + ); + await expect(validateLocalDirectoryPath(" ")).rejects.toThrow( + "Enter a local directory.", + ); + // A path that exists but is a file, not a directory, is rejected. + await expect( + validateLocalDirectoryPath(`${process.cwd()}/package.json`), + ).rejects.toThrow("is not a directory."); + }); +}); + +describe("step-kind predicates", () => { + test("isSourceStep matches the source- prefix", () => { + expect(isSourceStep("source-auth")).toBe(true); + expect(isSourceStep("api-key")).toBe(false); + expect(isSourceStep(null)).toBe(false); + }); + + test("isScheduleStep matches the global- prefix", () => { + expect(isScheduleStep("global-cron-mode")).toBe(true); + expect(isScheduleStep("api-key")).toBe(false); + expect(isScheduleStep(null)).toBe(false); + }); +}); + +describe("model selection", () => { + test("getApiKeyFieldLabel names an access key id only for bedrock", () => { + expect(getApiKeyFieldLabel("bedrock")).toContain("access key ID"); + expect(getApiKeyFieldLabel("openai")).toBe( + `${getProviderLabel("openai")} API key`, + ); + }); + + test("getProviderArticle picks the grammatically correct article", () => { + expect(getProviderArticle("gemini")).toBe("a"); + expect(getProviderArticle("anthropic")).toBe("an"); + expect(getProviderArticle("openai")).toBe("an"); + }); + + test("getModelSelectionOptions lists every preset then a custom trailer", () => { + const options = getModelSelectionOptions(PROVIDER_WITH_PRESETS); + const presetIds = getProviderModelOptions(PROVIDER_WITH_PRESETS).map( + (model) => model.id, + ); + + expect(options.at(-1)).toEqual({ kind: "custom" }); + expect( + options + .filter((option) => option.kind === "preset") + .map((option) => (option.kind === "preset" ? option.id : "")), + ).toEqual(presetIds); + }); + + test("shouldStartWithCustomModelInput iff the provider has no presets", () => { + for (const provider of SELECTABLE_OPENWIKI_PROVIDERS) { + expect(shouldStartWithCustomModelInput(provider)).toBe( + getProviderModelOptions(provider).length === 0, + ); + } + }); + + test("getSelectedModelId resolves preset index, custom trailer, and misses", () => { + const options = getModelSelectionOptions(PROVIDER_WITH_PRESETS); + const firstPreset = options[0]; + if (firstPreset?.kind !== "preset") { + throw new Error("expected a preset at index 0"); + } + + expect(getSelectedModelId(PROVIDER_WITH_PRESETS, 0, "", false)).toBe( + firstPreset.id, + ); + expect( + getSelectedModelId(PROVIDER_WITH_PRESETS, options.length - 1, "", false), + ).toBe("custom"); + // Out of range and blank custom input both resolve to no selection. + expect( + getSelectedModelId(PROVIDER_WITH_PRESETS, 999, "", false), + ).toBeNull(); + expect( + getSelectedModelId(PROVIDER_WITH_PRESETS, 0, " ", true), + ).toBeNull(); + // A valid custom id is normalized (trimmed) and returned verbatim. + expect( + getSelectedModelId(PROVIDER_WITH_PRESETS, 0, " my-custom-model ", true), + ).toBe("my-custom-model"); + }); + + test("getModelSelectionIndex finds a preset and defaults unknown ids to 0", () => { + const presetId = getProviderModelOptions(PROVIDER_WITH_PRESETS)[0]?.id; + if (!presetId) throw new Error("expected at least one preset model"); + + expect( + getModelSelectionIndex(PROVIDER_WITH_PRESETS, presetId), + ).toBeGreaterThanOrEqual(0); + expect(getModelSelectionIndex(PROVIDER_WITH_PRESETS, "no-such-model")).toBe( + 0, + ); + }); + + test("getProviderSelectionIndex mirrors the selectable provider order", () => { + for (const provider of SELECTABLE_OPENWIKI_PROVIDERS) { + expect(getProviderSelectionIndex(provider)).toBe( + SELECTABLE_OPENWIKI_PROVIDERS.indexOf(provider), + ); + } + // An unknown provider falls back to the first selectable index. + expect(getProviderSelectionIndex("bogus" as never)).toBe(0); + }); +}); + +describe("getModelSetupDetail", () => { + const priorModelId = process.env[OPENWIKI_MODEL_ID_ENV_KEY]; + + afterEach(() => { + if (priorModelId === undefined) { + delete process.env[OPENWIKI_MODEL_ID_ENV_KEY]; + } else { + process.env[OPENWIKI_MODEL_ID_ENV_KEY] = priorModelId; + } + }); + + test("prefers an explicit per-run override", () => { + delete process.env[OPENWIKI_MODEL_ID_ENV_KEY]; + expect(getModelSetupDetail("custom-model", "anthropic")).toBe( + "using custom-model for this run", + ); + }); + + test("falls back to the configured env model, then the provider default", () => { + process.env[OPENWIKI_MODEL_ID_ENV_KEY] = "env-model"; + expect(getModelSetupDetail(null, "anthropic")).toBe("env-model"); + + delete process.env[OPENWIKI_MODEL_ID_ENV_KEY]; + expect(getModelSetupDetail(null, "anthropic")).toBe( + `default ${getDefaultModelId("anthropic")}`, + ); + }); +}); + +describe("index + width math", () => { + test("moveSelectionIndex wraps around and guards an empty list", () => { + expect(moveSelectionIndex(0, -1, 3)).toBe(2); + expect(moveSelectionIndex(2, 1, 3)).toBe(0); + expect(moveSelectionIndex(1, 1, 3)).toBe(2); + expect(moveSelectionIndex(0, 1, 0)).toBe(0); + }); + + test("getInputDisplayWidth defaults and clamps to the 24..96 band", () => { + expect(getInputDisplayWidth(undefined)).toBe(64); + expect(getInputDisplayWidth(0)).toBe(64); + expect(getInputDisplayWidth(40)).toBe(24); + expect(getInputDisplayWidth(100)).toBe(84); + expect(getInputDisplayWidth(1000)).toBe(96); + }); +}); + +describe("template + source labelling", () => { + test("getTemplateGoal returns a known template goal, empty for unknown", () => { + const template = ONBOARDING_TEMPLATES[0]; + expect(getTemplateGoal(template.id)).toBe(template.suggestedGoal ?? ""); + expect(getTemplateGoal("no-such-template")).toBe(""); + }); + + test("getTemplateSourceOptions falls back to all sources for unknown ids", () => { + const options = getTemplateSourceOptions("no-such-template"); + expect(options.length).toBeGreaterThan(0); + for (const option of options) { + expect(SOURCE_OPTIONS).toContain(option); + } + }); + + test("getSourceMenuLabel switches to 'another' once one is connected", () => { + const option = sourceOption("git-repo"); + expect(getSourceMenuLabel(option, 0)).toBe(`Add ${option.displayName}`); + expect(getSourceMenuLabel(option, 2)).toBe( + `Add another ${option.displayName}`, + ); + }); + + test("getSourceDescriptionPrompt is source-specific with a generic fallback", () => { + expect(getSourceDescriptionPrompt(sourceOption("web-search"))).toContain( + "search for", + ); + expect(getSourceDescriptionPrompt(sourceOption("hackernews"))).toContain( + "Hacker News", + ); + expect(getSourceDescriptionPrompt(sourceOption("git-repo"))).toContain( + "repository", + ); + // Any other source falls back to the generic, name-interpolated prompt. + const generic = sourceOption("langsmith"); + expect(getSourceDescriptionPrompt(generic)).toBe( + `Describe what OpenWiki should look for in ${generic.displayName}.`, + ); + }); + + test("getSourceDescriptionOptionCount is the examples plus the free-form entry", () => { + const option = sourceOption("git-repo"); + expect(getSourceDescriptionOptionCount(option)).toBe( + option.examples.length + 1, + ); + }); + + test("getFinalOptionLabel rewrites the labels only in code mode", () => { + expect(getFinalOptionLabel("Run ingestion now", "personal")).toBe( + "Run ingestion now", + ); + expect(getFinalOptionLabel("Run ingestion now", "code")).toBe( + "Run OpenWiki now", + ); + expect(getFinalOptionLabel("Run later", "code")).toBe("Open chat"); + }); +}); + +describe("getStaticSourceConfig", () => { + test("builds a web-search config carrying the trimmed query", () => { + expect(getStaticSourceConfig("web-search", " langchain ")).toMatchObject({ + enabled: true, + queries: ["langchain"], + topic: "general", + searchDepth: "basic", + }); + expect(getStaticSourceConfig("web-search", " ").queries).toEqual([]); + }); + + test("builds a hackernews config with feeds and query tags", () => { + expect(getStaticSourceConfig("hackernews", "ai")).toMatchObject({ + enabled: true, + feeds: ["top", "new"], + queries: ["ai"], + queryTags: ["story"], + }); + }); + + test("defaults every other source to just enabled", () => { + expect(getStaticSourceConfig("git-repo", "ignored")).toEqual({ + enabled: true, + }); + }); +}); + +describe("source instance derivation", () => { + test("deriveLegacySources keeps the first instance per connector", () => { + const sources = deriveLegacySources([ + instance("git-repo", { ingestionGoal: "first" }), + instance("git-repo", { ingestionGoal: "second" }), + instance("web-search", { ingestionGoal: "web" }), + ]); + + expect(Object.keys(sources).sort()).toEqual(["git-repo", "web-search"]); + expect(sources["git-repo"]?.ingestionGoal).toBe("first"); + }); + + test("addSourceInstanceConfig appends and re-derives legacy sources", () => { + const base = configWith(instance("git-repo")); + const next = addSourceInstanceConfig(base, instance("web-search")); + + expect(next.sourceInstances).toHaveLength(2); + expect(Object.keys(next.sources).sort()).toEqual([ + "git-repo", + "web-search", + ]); + // The input config is not mutated. + expect(base.sourceInstances).toHaveLength(1); + }); + + test("getSourceInstances / getSourceInstanceCount filter by connector", () => { + const config = configWith( + instance("git-repo"), + instance("git-repo"), + instance("web-search"), + ); + + expect(getSourceInstanceCount(config, "git-repo")).toBe(2); + expect(getSourceInstances(config, "web-search")).toHaveLength(1); + expect(getSourceInstanceCount(config, "hackernews")).toBe(0); + }); + + test("getConnectedSourceCount counts instances within the option set", () => { + const config = configWith( + instance("git-repo"), + instance("web-search"), + instance("hackernews"), + ); + + expect(getConnectedSourceCount(config, [sourceOption("git-repo")])).toBe(1); + expect( + getConnectedSourceCount(config, [ + sourceOption("git-repo"), + sourceOption("web-search"), + ]), + ).toBe(2); + }); + + test("createSourceInstanceId numbers sequentially per connector", () => { + const empty = createEmptyOnboardingConfig(); + expect(createSourceInstanceId("git-repo", empty)).toBe("git-repo-1"); + expect( + createSourceInstanceId("git-repo", configWith(instance("git-repo"))), + ).toBe("git-repo-2"); + }); + + test("createSourceInstanceName appends the trimmed description and caps length", () => { + const option = sourceOption("git-repo"); + const empty = createEmptyOnboardingConfig(); + + expect(createSourceInstanceName(option, " my repo ", empty)).toBe( + `${option.displayName} 1: my repo`, + ); + expect(createSourceInstanceName(option, " ", empty)).toBe( + `${option.displayName} 1`, + ); + expect( + createSourceInstanceName(option, "x".repeat(200), empty).length, + ).toBeLessThanOrEqual(120); + }); +}); + +describe("needsEnvValue", () => { + const envKey = "OPENWIKI_STEPS_TEST_SECRET"; + + afterEach(() => { + delete process.env[envKey]; + }); + + test("is true only when the referenced env var is unset or blank", () => { + delete process.env[envKey]; + expect(needsEnvValue({ envKey, label: "Secret", secret: true })).toBe(true); + + process.env[envKey] = "present"; + expect(needsEnvValue({ envKey, label: "Secret", secret: true })).toBe( + false, + ); + }); +}); + +describe("cron field editing", () => { + test("getCronFields splits an expression, padding missing fields", () => { + expect(getCronFields("* * * * *", "0 0 * * *")).toEqual([ + "*", + "*", + "*", + "*", + "*", + ]); + // A blank expression falls back to the provided default. + expect(getCronFields("", "0 9 * * 1")).toEqual(["0", "9", "*", "*", "1"]); + expect(getCronFields("1 2", "0 0 * * *")).toEqual(["1", "2", "", "", ""]); + }); + + test("parseCronFieldPaste splits whitespace and 5-digit compact forms", () => { + expect(parseCronFieldPaste("1 2 3 4 5")).toEqual(["1", "2", "3", "4", "5"]); + expect(parseCronFieldPaste("12345")).toEqual(["1", "2", "3", "4", "5"]); + expect(parseCronFieldPaste("")).toEqual([]); + // A non-numeric compact string is not a paste. + expect(parseCronFieldPaste("abcde")).toEqual([]); + }); + + test("handleCronEditorInput moves fields and edits the value via setters", () => { + const setValue = vi.fn(); + const setCurrentFieldIndex = vi.fn(); + const setReplaceCurrentField = vi.fn(); + const call = ( + inputValue: string, + key: Parameters[0]["key"], + currentValue = "* * * * *", + currentFieldIndex = 0, + replaceCurrentField = true, + ): boolean => + handleCronEditorInput({ + currentFieldIndex, + currentValue, + fallbackExpression: "0 0 * * *", + inputValue, + key, + replaceCurrentField, + setCurrentFieldIndex, + setReplaceCurrentField, + setValue, + }); + + // Right arrow advances the active field. + expect(call("", { rightArrow: true })).toBe(true); + const advance = setCurrentFieldIndex.mock.calls[0]?.[0] as ( + index: number, + ) => number; + expect(advance(0)).toBe(1); + + // A ctrl chord is not consumed. + expect(call("c", { ctrl: true })).toBe(false); + + // A legal character writes the joined expression back. + setValue.mockClear(); + expect(call("5", {}, "* * * * *", 0, true)).toBe(true); + expect(setValue).toHaveBeenCalledWith("5 * * * *"); + }); + + test("handleCronEditorInput handles arrows, backspace, paste, and appends", () => { + const setValue = vi.fn(); + const setCurrentFieldIndex = vi.fn(); + const setReplaceCurrentField = vi.fn(); + const call = ( + inputValue: string, + key: Parameters[0]["key"], + currentValue = "* * * * *", + currentFieldIndex = 0, + replaceCurrentField = true, + ): boolean => + handleCronEditorInput({ + currentFieldIndex, + currentValue, + fallbackExpression: "0 0 * * *", + inputValue, + key, + replaceCurrentField, + setCurrentFieldIndex, + setReplaceCurrentField, + setValue, + }); + + // Left arrow steps the active field back, clamped at zero. + expect(call("", { leftArrow: true }, "* * * * *", 2)).toBe(true); + const back = setCurrentFieldIndex.mock.calls[0]?.[0] as ( + index: number, + ) => number; + expect(back(2)).toBe(1); + expect(back(0)).toBe(0); + + // Backspace on a non-empty field trims its last character. + setValue.mockClear(); + expect(call("", { backspace: true }, "12 * * * *", 0)).toBe(true); + expect(setValue).toHaveBeenCalledWith("1 * * * *"); + + // Backspace on an already-empty field hops to the previous field instead. + setValue.mockClear(); + setCurrentFieldIndex.mockClear(); + expect(call("", { backspace: true }, "1", 1)).toBe(true); + expect(setCurrentFieldIndex).toHaveBeenCalledWith(0); + expect(setValue).not.toHaveBeenCalled(); + + // A multi-field paste distributes across the fields from the cursor. + setValue.mockClear(); + expect(call("1 2 3", {}, "* * * * *", 0)).toBe(true); + expect(setValue).toHaveBeenCalledWith("1 2 3 * *"); + + // A paste that overflows the field list drops the fields past the end. + setValue.mockClear(); + setCurrentFieldIndex.mockClear(); + expect(call("7 8 9", {}, "* * * * *", 4)).toBe(true); + expect(setValue).toHaveBeenCalledWith("* * * * 7"); + // The cursor updater clamps to the last field after a long paste. + const clamp = setCurrentFieldIndex.mock.calls[0]?.[0] as ( + index: number, + ) => number; + expect(clamp(4)).toBe(4); + + // Input that sanitizes to nothing is not consumed. + expect(call("!", {}, "* * * * *", 0)).toBe(false); + + // With replace disabled, a legal character appends to the current field. + setValue.mockClear(); + expect(call("2", {}, "1 * * * *", 0, false)).toBe(true); + expect(setValue).toHaveBeenCalledWith("12 * * * *"); + }); +}); diff --git a/test/setup/credentials/steps.test.ts b/test/setup/credentials/steps.test.ts new file mode 100644 index 000000000..44109b47a --- /dev/null +++ b/test/setup/credentials/steps.test.ts @@ -0,0 +1,971 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + getProviderApiKeyEnvKey, + getProviderBaseUrlEnvKey, + getProviderProjectEnvKey, + getProviderRegionEnvKey, + getProviderSecretKeyEnvKey, + AWS_ACCESS_KEY_ID_ENV_KEY, + AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, + AWS_SECRET_ACCESS_KEY_ENV_KEY, +} from "../../../src/config/constants.ts"; +import { + createEmptyOnboardingConfig, + isOpenWikiOnboardingCompleteSync, + isRepositoryCodeOnboardingCompleteSync, +} from "../../../src/setup/onboarding.ts"; +import type { OpenWikiOnboardingConfig } from "../../../src/setup/onboarding.ts"; +import { + credentialStep, + ensureRunModeConfig, + findNearestGitRepoRoot, + getConfigModeId, + getConfigModeName, + getDefaultCodeRepoRootPath, + getInitialStep, + getLangsmithRegionLabel, + getLangsmithRegionSelectionIndex, + getNextStepAfterApiKey, + getNextStepAfterBaseUrl, + getNextStepAfterGcpLocation, + getNextStepAfterProvider, + getNextStepAfterRegion, + getNextStepAfterSecretKey, + getRunModeName, + getRunModeSelectionIndex, + getSourceOption, + getWizardManagedEnvKeys, + hasValidStoredToken, + hydrateRunModeConfig, + isBaseUrlConfigured, + isCodeMode, + isCredentialConfigured, + isRegionConfigured, + isSecretKeyConfigured, + needsAwsCredentialRepair, + needsBaseUrlStep, + needsCredentialSetup, + needsCredentialStep, + needsGcpProjectStep, + needsLangSmithStep, + needsRegionStep, + needsSecretKeyStep, + nextSetupStep, + orderedSetupSteps, + resolveStepStatus, +} from "../../../src/setup/credentials/steps.ts"; + +// hydrateRunModeConfig is the only function here that reads the filesystem +// (repository wiki instructions). Stub just that one onboarding export so the +// code-mode branch is deterministic; every other onboarding helper stays real. +// +// The two *Sync completeness probes read the real ~/.openwiki directory, so they +// are also stubbed here as vi.fn() (defaulting to "not complete", the value for a +// machine with no onboarding file) and driven per test via vi.mocked below. Only +// needsCredentialSetup consults them; every other function under test uses the +// in-memory isOnboardingComplete instead. +vi.mock("../../../src/setup/onboarding.ts", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readRepositoryWikiInstructions: () => Promise.resolve("hydrated repo goal"), + isOpenWikiOnboardingCompleteSync: vi.fn(() => false), + isRepositoryCodeOnboardingCompleteSync: vi.fn(() => false), + }; +}); + +/** + * The applicable setup spine per provider in personal mode with no mode chooser. + * This is a pure function of the provider (it never reads the environment), so + * these sequences are the wizard's static decision table for every provider. + */ +const SPINE_BY_PROVIDER: Record = { + openai: ["provider", "api-key", "model", "langsmith"], + "openai-chatgpt": ["provider", "oauth-login", "model", "langsmith"], + anthropic: ["provider", "api-key", "model", "langsmith"], + copilot: ["provider", "external-cli-auth", "model", "langsmith"], + gemini: ["provider", "api-key", "model", "langsmith"], + "gemini-enterprise": [ + "provider", + "gcp-project", + "gcp-location", + "model", + "langsmith", + ], + openrouter: ["provider", "api-key", "model", "langsmith"], + "openai-compatible": [ + "provider", + "api-key", + "base-url", + "model", + "langsmith", + ], + bedrock: ["provider", "region", "model", "langsmith"], + fireworks: ["provider", "api-key", "model", "langsmith"], + baseten: ["provider", "api-key", "model", "langsmith"], + nebius: ["provider", "api-key", "model", "langsmith"], + nvidia: ["provider", "api-key", "model", "langsmith"], +}; + +/** Every environment key any test in this file reads or writes. */ +const MANAGED_KEYS = [ + "OPENWIKI_PROVIDER", + "OPENWIKI_MODEL_ID", + "LANGSMITH_API_KEY", + "LANGCHAIN_TRACING_V2", + "OPENAI_CHATGPT_ACCESS_TOKEN", + "OPENAI_CHATGPT_REFRESH_TOKEN", + "OPENAI_CHATGPT_ACCOUNT_ID", + "OPENAI_CHATGPT_EXPIRES_AT", + getProviderApiKeyEnvKey("openai"), + getProviderApiKeyEnvKey("openai-compatible"), + getProviderApiKeyEnvKey("bedrock"), + getProviderSecretKeyEnvKey("bedrock"), + getProviderRegionEnvKey("bedrock"), + getProviderProjectEnvKey("gemini-enterprise"), + getProviderBaseUrlEnvKey("openai-compatible"), + AWS_ACCESS_KEY_ID_ENV_KEY, + AWS_SECRET_ACCESS_KEY_ENV_KEY, + AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, +].filter((key): key is string => key !== undefined); + +let snapshot: Record; + +function set(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +/** Builds an onboarding config from the empty base with the given overrides. */ +function config( + overrides: Partial = {}, +): OpenWikiOnboardingConfig { + return { ...createEmptyOnboardingConfig(), ...overrides }; +} + +beforeEach(() => { + snapshot = {}; + for (const key of MANAGED_KEYS) { + snapshot[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MANAGED_KEYS) { + set(key, snapshot[key]); + } +}); + +describe("orderedSetupSteps", () => { + for (const [provider, spine] of Object.entries(SPINE_BY_PROVIDER)) { + test(`walks ${provider} in the expected order`, () => { + expect(orderedSetupSteps(provider as never, "personal", false)).toEqual( + spine, + ); + }); + } + + test("prepends the run-mode chooser when mode selection is allowed", () => { + expect(orderedSetupSteps("openai", "personal", true)).toEqual([ + "run-mode", + ...SPINE_BY_PROVIDER.openai, + ]); + }); + + test("appends repo confirmation in code mode", () => { + expect(orderedSetupSteps("openai", "code", false)).toEqual([ + ...SPINE_BY_PROVIDER.openai, + "code-repo-confirm", + ]); + }); + + test("emits the keyless provider's project credential exactly once", () => { + // gemini-enterprise's primary credential IS the gcp-project step, so it must + // not also be appended by the later project-key branch. + const steps = orderedSetupSteps("gemini-enterprise", "personal", false); + expect(steps.filter((step) => step === "gcp-project")).toHaveLength(1); + }); +}); + +describe("credentialStep", () => { + test.each([ + ["openai-chatgpt", "oauth-login"], + ["bedrock", null], + ["copilot", "external-cli-auth"], + ["openai", "api-key"], + ["gemini-enterprise", "gcp-project"], + ])("maps %s to its primary credential step", (provider, expected) => { + expect(credentialStep(provider as never)).toBe(expected); + }); +}); + +describe("nextSetupStep", () => { + test("returns the following step in the spine", () => { + expect(nextSetupStep("provider", "openai", "personal", false)).toBe( + "api-key", + ); + expect(nextSetupStep("model", "openai", "code", false)).toBe("langsmith"); + expect(nextSetupStep("langsmith", "openai", "code", false)).toBe( + "code-repo-confirm", + ); + }); + + test("returns null for the last step, an off-spine step, or a null step", () => { + expect(nextSetupStep("langsmith", "openai", "personal", false)).toBeNull(); + expect(nextSetupStep("region", "openai", "personal", false)).toBeNull(); + expect(nextSetupStep(null, "openai", "personal", false)).toBeNull(); + }); +}); + +describe("getWizardManagedEnvKeys", () => { + test("lists an api-key provider's managed keys with no undefined entries", () => { + const keys = getWizardManagedEnvKeys("openai"); + + expect(keys).toContain("OPENWIKI_PROVIDER"); + expect(keys).toContain("OPENWIKI_MODEL_ID"); + expect(keys).toContain("LANGSMITH_API_KEY"); + expect(keys).toContain(getProviderApiKeyEnvKey("openai")); + expect(keys.every((key) => typeof key === "string")).toBe(true); + }); + + test("drops the absent api key for a keyless provider but keeps its project", () => { + const keys = getWizardManagedEnvKeys("gemini-enterprise"); + + expect(keys).toContain(getProviderProjectEnvKey("gemini-enterprise")); + // gemini-enterprise has no api-key env var, so the filtered list omits it. + expect(keys).not.toContain(undefined); + }); +}); + +describe("resolveStepStatus", () => { + test("marks the active step current before anything else", () => { + expect(resolveStepStatus("api-key", "api-key", false)).toBe("current"); + }); + + test("marks a finished, non-active step done", () => { + expect(resolveStepStatus("api-key", "provider", true)).toBe("done"); + }); + + test("falls back to the resting status for an unreached step", () => { + expect(resolveStepStatus("api-key", "provider", false)).toBe("pending"); + expect(resolveStepStatus("api-key", "provider", false, "optional")).toBe( + "optional", + ); + }); +}); + +describe("needsLangSmithStep", () => { + test("is unanswered only when neither a key nor a tracing decision exists", () => { + expect(needsLangSmithStep({})).toBe(true); + expect(needsLangSmithStep({ LANGSMITH_API_KEY: "lsv2_key" })).toBe(false); + expect(needsLangSmithStep({ LANGCHAIN_TRACING_V2: "false" })).toBe(false); + }); +}); + +describe("hasValidStoredToken", () => { + const future = String(Date.now() + 60 * 60 * 1000); + const past = String(Date.now() - 60 * 60 * 1000); + + function tokenEnv(expiresAt: string): NodeJS.ProcessEnv { + return { + OPENAI_CHATGPT_ACCESS_TOKEN: "access-token", + OPENAI_CHATGPT_REFRESH_TOKEN: "refresh-token", + OPENAI_CHATGPT_ACCOUNT_ID: "acct_1", + OPENAI_CHATGPT_EXPIRES_AT: expiresAt, + }; + } + + test("is false with no tokens and true with a complete, unexpired set", () => { + expect(hasValidStoredToken({})).toBe(false); + expect(hasValidStoredToken(tokenEnv(future))).toBe(true); + }); + + test("is false once the stored token has expired", () => { + expect(hasValidStoredToken(tokenEnv(past))).toBe(false); + }); +}); + +describe("onboarding-config accessors", () => { + test("getConfigModeId prefers modeId then falls back to templateId", () => { + expect(getConfigModeId(config({ modeId: "code" }))).toBe("code"); + expect(getConfigModeId(config({ templateId: "personal" }))).toBe( + "personal", + ); + expect(getConfigModeId(config())).toBeUndefined(); + }); + + test("getConfigModeName prefers modeName then falls back to templateName", () => { + expect(getConfigModeName(config({ modeName: "Code" }))).toBe("Code"); + expect(getConfigModeName(config({ templateName: "Personal" }))).toBe( + "Personal", + ); + expect(getConfigModeName(config())).toBeUndefined(); + }); + + test("isCodeMode is true only for the code template", () => { + expect(isCodeMode(config({ modeId: "code" }))).toBe(true); + expect(isCodeMode(config({ modeId: "personal" }))).toBe(false); + }); +}); + +describe("run-mode and region label getters", () => { + test("getRunModeName resolves known modes and echoes unknown ones", () => { + expect(getRunModeName("code")).toBe("Code"); + expect(getRunModeName("personal")).toBe("Personal"); + expect(getRunModeName("bogus" as never)).toBe("bogus"); + }); + + test("getRunModeSelectionIndex maps modes to their menu index, defaulting to 0", () => { + expect(getRunModeSelectionIndex("personal")).toBe(0); + expect(getRunModeSelectionIndex("code")).toBe(1); + expect(getRunModeSelectionIndex("bogus" as never)).toBe(0); + }); + + test("langsmith region getters resolve label and index, defaulting to US", () => { + expect(getLangsmithRegionSelectionIndex("us")).toBe(0); + expect(getLangsmithRegionSelectionIndex("eu")).toBe(1); + expect(getLangsmithRegionSelectionIndex("bogus" as never)).toBe(0); + expect(getLangsmithRegionLabel("us")).toBe( + "US (https://api.smith.langchain.com)", + ); + expect(getLangsmithRegionLabel("bogus" as never)).toBe("bogus"); + }); + + test("getSourceOption resolves a known source and falls back to the first", () => { + expect(getSourceOption("langsmith").id).toBe("langsmith"); + expect(getSourceOption("git-repo").id).toBe("git-repo"); + expect(getSourceOption("bogus" as never)).toBe(getSourceOption("git-repo")); + }); +}); + +describe("ensureRunModeConfig", () => { + test("returns the config untouched when the mode already matches (personal)", () => { + const personal = config({ modeId: "personal", modeName: "Personal" }); + expect(ensureRunModeConfig(personal, "personal")).toBe(personal); + }); + + test("strips the personal wiki goal when switching an already-code config", () => { + const code = config({ modeId: "code", wikiGoal: "leftover goal" }); + const result = ensureRunModeConfig(code, "code"); + + expect(result).not.toBe(code); + expect(result.wikiGoal).toBeUndefined(); + expect(result.modeId).toBe("code"); + }); + + test("applies the template fields when the mode changes", () => { + const personal = config({ modeId: "personal", wikiGoal: "keep me" }); + const result = ensureRunModeConfig(personal, "code"); + + expect(result.modeId).toBe("code"); + expect(result.modeName).toBe("Code"); + expect(result.templateId).toBe("code"); + expect(result.templateName).toBe("Code"); + expect(result.wikiGoal).toBeUndefined(); + }); + + test("applies the personal template without touching the wiki goal", () => { + // Switching code -> personal changes the mode but omits the code-only + // wikiGoal reset, so a pre-existing goal is preserved. + const code = config({ modeId: "code", wikiGoal: "keep me" }); + const result = ensureRunModeConfig(code, "personal"); + + expect(result.modeId).toBe("personal"); + expect(result.templateId).toBe("personal"); + expect(result.wikiGoal).toBe("keep me"); + }); + + test("returns the config unchanged for an unknown target mode", () => { + const code = config({ modeId: "code" }); + expect(ensureRunModeConfig(code, "bogus" as never)).toBe(code); + }); +}); + +describe("hydrateRunModeConfig", () => { + test("returns the config unchanged outside code mode", async () => { + const personal = config({ modeId: "personal" }); + await expect( + hydrateRunModeConfig(personal, "personal", "/repo"), + ).resolves.toBe(personal); + }); + + test("loads the repository wiki goal in code mode", async () => { + const result = await hydrateRunModeConfig(config(), "code", "/repo"); + expect(result.wikiGoal).toBe("hydrated repo goal"); + }); +}); + +describe("getInitialStep static branches", () => { + test("walkAll starts at the first applicable step regardless of environment", () => { + expect(getInitialStep(null, "openai", undefined, "code", false, true)).toBe( + "provider", + ); + }); + + test("mode selection wins before any credential probing", () => { + expect(getInitialStep(null, "openai", undefined, "code", true, false)).toBe( + "run-mode", + ); + }); + + test("walkAll with mode selection starts at run-mode", () => { + expect(getInitialStep(null, "openai", undefined, "code", true, true)).toBe( + "run-mode", + ); + }); +}); + +describe("environment-driven credential predicates", () => { + test("bedrock needs the region step until a region is set", () => { + const regionKey = getProviderRegionEnvKey("bedrock"); + if (!regionKey) throw new Error("bedrock must define a region env key"); + + expect(needsRegionStep("bedrock")).toBe(true); + expect(isRegionConfigured("bedrock")).toBe(false); + + set(regionKey, "us-east-1"); + expect(needsRegionStep("bedrock")).toBe(false); + expect(isRegionConfigured("bedrock")).toBe(true); + }); + + test("openai-compatible needs the base-url step until one is set", () => { + const baseUrlKey = getProviderBaseUrlEnvKey("openai-compatible"); + if (!baseUrlKey) + throw new Error("openai-compatible must define a base url"); + + expect(needsBaseUrlStep("openai-compatible")).toBe(true); + expect(isBaseUrlConfigured("openai-compatible")).toBe(false); + + set(baseUrlKey, "https://proxy.example/v1"); + expect(needsBaseUrlStep("openai-compatible")).toBe(false); + expect(isBaseUrlConfigured("openai-compatible")).toBe(true); + }); + + test("gemini-enterprise needs the gcp-project step until a project is set", () => { + const projectKey = getProviderProjectEnvKey("gemini-enterprise"); + if (!projectKey) throw new Error("gemini-enterprise must define a project"); + + expect(needsGcpProjectStep("gemini-enterprise")).toBe(true); + set(projectKey, "my-project"); + expect(needsGcpProjectStep("gemini-enterprise")).toBe(false); + }); + + test("no selectable provider currently requires the secret-key step", () => { + // bedrock is the only provider with a secret-key env var and it is aws-sdk, + // which the requires-secret-key guard excludes, so the step never appears. + expect(needsSecretKeyStep("bedrock")).toBe(false); + expect(needsSecretKeyStep("openai")).toBe(false); + + const secretKey = getProviderSecretKeyEnvKey("bedrock"); + if (!secretKey) throw new Error("bedrock must define a secret key env var"); + expect(isSecretKeyConfigured("bedrock")).toBe(false); + set(secretKey, "aws-secret"); + expect(isSecretKeyConfigured("bedrock")).toBe(true); + }); + + test("bedrock credential repair triggers only on a partial legacy key pair", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + if (!accessKey) throw new Error("bedrock must define a legacy access key"); + + // A fully absent legacy pair is acceptable (the SDK chain resolves it). + expect(needsAwsCredentialRepair("bedrock")).toBe(false); + // Non-aws providers never need aws repair. + expect(needsAwsCredentialRepair("openai")).toBe(false); + + // Half a legacy pair is a misconfiguration the wizard must surface. + set(accessKey, "AKIAEXAMPLE"); + expect(needsAwsCredentialRepair("bedrock")).toBe(true); + }); + + test("api-key credential state tracks the pasted key", () => { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + + expect(isCredentialConfigured("openai")).toBe(false); + expect(needsCredentialStep("openai")).toBe(true); + + set(apiKey, "sk-test"); + expect(isCredentialConfigured("openai")).toBe(true); + expect(needsCredentialStep("openai")).toBe(false); + }); + + test("oauth and aws providers report the right credential-step need", () => { + // oauth with no stored token still needs its login step. + expect(needsCredentialStep("openai-chatgpt")).toBe(true); + expect(isCredentialConfigured("openai-chatgpt")).toBe(false); + // aws-sdk has no discrete credential step (credentialStep is null). + expect(needsCredentialStep("bedrock")).toBe(false); + }); + + test("needsCredentialSetup is true when no provider is configured", () => { + expect(needsCredentialSetup(null)).toBe(true); + + set("OPENWIKI_PROVIDER", "openai"); + // Provider set but its api key is missing, so setup is still required. + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("needsGcpProjectStep is false for a provider without a project key", () => { + // openai has no project env key, so its ternary takes the falsey branch. + expect(needsGcpProjectStep("openai")).toBe(false); + }); + + test("needsBaseUrlStep is false for a provider that does not require one", () => { + // openai does not require a base url, so the guard returns early. + expect(needsBaseUrlStep("openai")).toBe(false); + }); + + test("isBaseUrlConfigured is false when the provider has no base-url key", () => { + // gemini exposes no base-url env key, so the ternary returns its fallback. + expect(isBaseUrlConfigured("gemini")).toBe(false); + }); + + test("needsRegionStep is false for a provider that does not require one", () => { + // openai does not require a region, so the guard returns early. + expect(needsRegionStep("openai")).toBe(false); + }); + + test("isSecretKeyConfigured is false when the provider has no secret key", () => { + // openai exposes no secret-key env key, so the ternary returns its fallback. + expect(isSecretKeyConfigured("openai")).toBe(false); + }); +}); + +/** A minimal, valid ingestion schedule fixture for onboarding gates. */ +const SCHEDULE = { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-01-01T00:00:00Z", +}; + +/** Env for a fully satisfied openai run: provider, key, model, and tracing. */ +function configureCompleteOpenai(): void { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + set("OPENWIKI_PROVIDER", "openai"); + set(apiKey, "sk-test"); + set("OPENWIKI_MODEL_ID", "gpt-test"); + set("LANGSMITH_API_KEY", "lsv2_key"); +} + +describe("needsCredentialSetup waterfall", () => { + test("surfaces aws credential repair for a partial bedrock legacy pair", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + if (!accessKey) throw new Error("bedrock must define a legacy access key"); + set("OPENWIKI_PROVIDER", "bedrock"); + set(accessKey, "AKIAEXAMPLE"); + + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("requires setup when only the model choice is missing", () => { + configureCompleteOpenai(); + set("OPENWIKI_MODEL_ID", undefined); + + // modelIdOverride null + no env model id keeps setup required. + expect(needsCredentialSetup(null)).toBe(true); + // An explicit per-run model override satisfies the model gate, so with the + // sync onboarding probe reporting complete, no setup is needed. + vi.mocked(isOpenWikiOnboardingCompleteSync).mockReturnValue(true); + expect(needsCredentialSetup("gpt-run", "personal")).toBe(false); + vi.mocked(isOpenWikiOnboardingCompleteSync).mockReturnValue(false); + }); + + test("requires setup when only the langsmith decision is missing", () => { + configureCompleteOpenai(); + set("LANGSMITH_API_KEY", undefined); + + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("requires setup when only a base url is missing", () => { + const apiKey = getProviderApiKeyEnvKey("openai-compatible"); + if (!apiKey) throw new Error("openai-compatible must define an api key"); + set("OPENWIKI_PROVIDER", "openai-compatible"); + set(apiKey, "sk-test"); + set("OPENWIKI_MODEL_ID", "gpt-test"); + set("LANGSMITH_API_KEY", "lsv2_key"); + + // Credential and model gates pass but the base-url gate keeps setup on. + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("falls through to the onboarding probe once credentials are complete", () => { + configureCompleteOpenai(); + + // personal mode consults the OpenWiki onboarding probe. + vi.mocked(isOpenWikiOnboardingCompleteSync).mockReturnValue(false); + expect(needsCredentialSetup(null, "personal")).toBe(true); + vi.mocked(isOpenWikiOnboardingCompleteSync).mockReturnValue(true); + expect(needsCredentialSetup(null, "personal")).toBe(false); + + // code mode consults the repository code onboarding probe instead. + vi.mocked(isRepositoryCodeOnboardingCompleteSync).mockReturnValue(false); + expect(needsCredentialSetup(null, "code")).toBe(true); + vi.mocked(isRepositoryCodeOnboardingCompleteSync).mockReturnValue(true); + expect(needsCredentialSetup(null, "code")).toBe(false); + + vi.mocked(isOpenWikiOnboardingCompleteSync).mockReturnValue(false); + vi.mocked(isRepositoryCodeOnboardingCompleteSync).mockReturnValue(false); + }); +}); + +describe("getInitialStep waterfall", () => { + test("routes to provider selection when none is configured", () => { + // OPENWIKI_PROVIDER is cleared by beforeEach, so no provider is valid. + expect(getInitialStep(null, "openai")).toBe("provider"); + }); + + test("routes to region for a bedrock partial legacy pair (aws repair)", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + if (!accessKey) throw new Error("bedrock must define a legacy access key"); + set("OPENWIKI_PROVIDER", "bedrock"); + set(accessKey, "AKIAEXAMPLE"); + + expect(getInitialStep(null, "bedrock")).toBe("region"); + }); + + test("routes to the provider's credential step when it is unmet", () => { + set("OPENWIKI_PROVIDER", "openai"); + // No api key set, so openai still needs its api-key step. + expect(getInitialStep(null, "openai")).toBe("api-key"); + + // gemini-enterprise's primary credential is its gcp project. + set("OPENWIKI_PROVIDER", "gemini-enterprise"); + expect(getInitialStep(null, "gemini-enterprise")).toBe("gcp-project"); + }); + + test("routes to base-url once credentials are met but a base url is missing", () => { + const apiKey = getProviderApiKeyEnvKey("openai-compatible"); + if (!apiKey) throw new Error("openai-compatible must define an api key"); + set("OPENWIKI_PROVIDER", "openai-compatible"); + set(apiKey, "sk-test"); + + expect(getInitialStep(null, "openai-compatible")).toBe("base-url"); + }); + + test("routes to region once credentials are met but a region is missing", () => { + set("OPENWIKI_PROVIDER", "bedrock"); + // A bearer token satisfies the aws credential chain, leaving only region. + set(AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, "bedrock-token"); + + expect(getInitialStep(null, "bedrock")).toBe("region"); + }); + + test("routes to model, then langsmith, once credentials are met", () => { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + set("OPENWIKI_PROVIDER", "openai"); + set(apiKey, "sk-test"); + + // No model id anywhere yet, so the model step comes next. + expect(getInitialStep(null, "openai")).toBe("model"); + + // A per-run override satisfies the model gate; langsmith is next. + expect(getInitialStep("gpt-run", "openai")).toBe("langsmith"); + }); + + test("walks the onboarding tail in personal mode", () => { + configureCompleteOpenai(); + + // Empty config: no mode chosen yet, so the template step comes first. + expect(getInitialStep(null, "openai", config(), "personal")).toBe( + "template", + ); + + // Mode chosen but no wiki goal. + expect( + getInitialStep( + null, + "openai", + config({ modeId: "personal" }), + "personal", + ), + ).toBe("wiki-goal"); + + // Goal set but no schedule (personal mode requires one). + expect( + getInitialStep( + null, + "openai", + config({ modeId: "personal", wikiGoal: "g" }), + "personal", + ), + ).toBe("global-cron-mode"); + + // Schedule set but onboarding not yet completed. + expect( + getInitialStep( + null, + "openai", + config({ + modeId: "personal", + wikiGoal: "g", + ingestionSchedule: SCHEDULE, + }), + "personal", + ), + ).toBe("source-menu"); + + // Fully complete onboarding resolves to no further step. + expect( + getInitialStep( + null, + "openai", + config({ + modeId: "personal", + wikiGoal: "g", + ingestionSchedule: SCHEDULE, + completedAt: "2026-01-01T00:00:00Z", + }), + "personal", + ), + ).toBeNull(); + }); + + test("routes to repo confirmation in incomplete code mode", () => { + configureCompleteOpenai(); + + expect(getInitialStep(null, "openai", config(), "code")).toBe( + "code-repo-confirm", + ); + + // A complete code config skips the repo step and resolves to null. + expect( + getInitialStep( + null, + "openai", + config({ + modeId: "code", + wikiGoal: "g", + completedAt: "2026-01-01T00:00:00Z", + }), + "code", + ), + ).toBeNull(); + }); +}); + +/** + * The LangSmith step is optional, and both entry-point routers (getInitialStep + * and getNextStepAfterRegion) must gate it on the same two-signal check as + * needsLangSmithStep: a recorded tracing decision (LANGCHAIN_TRACING_V2) counts + * as answered even when no key is stored. The row that regressed before this was + * unified is "declined" (LANGCHAIN_TRACING_V2="false", no key): the naive + * `!LANGSMITH_API_KEY` check re-prompted it on every re-run. + */ +describe("LangSmith skip-router gating", () => { + const empty = createEmptyOnboardingConfig(); + + /** Configures a valid openai provider so routing reaches the LangSmith gate. */ + function reachLangSmithGate(): void { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + set("OPENWIKI_PROVIDER", "openai"); + set(apiKey, "sk-test"); + set("OPENWIKI_MODEL_ID", "gpt-test"); + } + + // Each row is [label, LANGSMITH_API_KEY, LANGCHAIN_TRACING_V2, showsLangSmith]. + const cases: Array< + [string, string | undefined, string | undefined, boolean] + > = [ + ["neither key nor decision recorded", undefined, undefined, true], + ["a stored api key", "lsv2_key", undefined, false], + ["a declined tracing decision", undefined, "false", false], + ["an enabled tracing decision", undefined, "true", false], + ]; + + for (const [label, key, tracing, showsLangSmith] of cases) { + test(`getInitialStep ${ + showsLangSmith ? "shows" : "skips" + } LangSmith with ${label}`, () => { + reachLangSmithGate(); + set("LANGSMITH_API_KEY", key); + set("LANGCHAIN_TRACING_V2", tracing); + + // Model id present via env, empty code config: the only fork left is the + // LangSmith gate, then code-repo-confirm once it is satisfied. + expect(getInitialStep(null, "openai", empty, "code")).toBe( + showsLangSmith ? "langsmith" : "code-repo-confirm", + ); + }); + + test(`getNextStepAfterRegion ${ + showsLangSmith ? "shows" : "skips" + } LangSmith with ${label}`, () => { + set("LANGSMITH_API_KEY", key); + set("LANGCHAIN_TRACING_V2", tracing); + + // modelIdOverride satisfies the model gate, so the LangSmith gate is the + // next fork; code mode with an empty config lands on code-repo-confirm. + expect(getNextStepAfterRegion("openai", "gpt-run", empty, "code")).toBe( + showsLangSmith ? "langsmith" : "code-repo-confirm", + ); + }); + } +}); + +describe("getNextStepAfter* chain", () => { + const empty = createEmptyOnboardingConfig(); + + test("getNextStepAfterProvider surfaces aws repair then the credential step", () => { + const accessKey = getProviderApiKeyEnvKey("bedrock"); + if (!accessKey) throw new Error("bedrock must define a legacy access key"); + set(accessKey, "AKIAEXAMPLE"); + expect(getNextStepAfterProvider("bedrock", null, empty)).toBe("region"); + set(accessKey, undefined); + + // openai with no key advances to its api-key step. + expect(getNextStepAfterProvider("openai", null, empty)).toBe("api-key"); + }); + + test("getNextStepAfterProvider cascades through every satisfied gate", () => { + const apiKey = getProviderApiKeyEnvKey("openai"); + if (!apiKey) throw new Error("openai must define an api key env var"); + set(apiKey, "sk-test"); + + // With the credential satisfied and nothing else configured, the whole + // Provider -> ApiKey -> SecretKey -> GcpLocation -> BaseUrl -> Region chain + // falls through to the model step. + expect(getNextStepAfterProvider("openai", null, empty, "personal")).toBe( + "model", + ); + }); + + test("getNextStepAfterSecretKey routes a keyless provider to its gcp project", () => { + // gemini-enterprise needs a gcp project when none is configured. + expect( + getNextStepAfterSecretKey("gemini-enterprise", null, empty, "code"), + ).toBe("gcp-project"); + }); + + test("getNextStepAfterGcpLocation routes to base-url when one is missing", () => { + expect(getNextStepAfterGcpLocation("openai-compatible", null, empty)).toBe( + "base-url", + ); + }); + + test("getNextStepAfterBaseUrl routes to region when one is missing", () => { + expect(getNextStepAfterBaseUrl("bedrock", null, empty, "code")).toBe( + "region", + ); + }); + + test("getNextStepAfterApiKey delegates past the (unreachable) secret-key step", () => { + // No selectable provider requires a secret key, so this delegates straight + // through to the gcp-project probe for a keyless provider. + expect( + getNextStepAfterApiKey("gemini-enterprise", null, empty, "code"), + ).toBe("gcp-project"); + }); + + test("getNextStepAfterRegion forces the model step when asked", () => { + set("OPENWIKI_MODEL_ID", "gpt-test"); + set("LANGSMITH_API_KEY", "lsv2_key"); + // Even with a model id present, forceModelStep re-shows the model step. + expect(getNextStepAfterRegion("openai", null, empty, "code", true)).toBe( + "model", + ); + // Without the force flag, the present model id lets the step be skipped. + expect(getNextStepAfterRegion("openai", null, empty, "code", false)).toBe( + "code-repo-confirm", + ); + }); + + test("getNextStepAfterRegion walks model, langsmith, and the onboarding tail", () => { + // No model id and no override -> model step. + expect(getNextStepAfterRegion("openai", null, empty, "personal")).toBe( + "model", + ); + + // Model satisfied via override, no langsmith key -> langsmith step. + expect(getNextStepAfterRegion("openai", "gpt-run", empty, "personal")).toBe( + "langsmith", + ); + + // Model + langsmith satisfied -> onboarding tail (personal). + set("LANGSMITH_API_KEY", "lsv2_key"); + expect(getNextStepAfterRegion("openai", "gpt-run", empty, "personal")).toBe( + "template", + ); + expect( + getNextStepAfterRegion( + "openai", + "gpt-run", + config({ modeId: "personal" }), + "personal", + ), + ).toBe("wiki-goal"); + expect( + getNextStepAfterRegion( + "openai", + "gpt-run", + config({ modeId: "personal", wikiGoal: "g" }), + "personal", + ), + ).toBe("global-cron-mode"); + expect( + getNextStepAfterRegion( + "openai", + "gpt-run", + config({ + modeId: "personal", + wikiGoal: "g", + ingestionSchedule: SCHEDULE, + }), + "personal", + ), + ).toBe("source-menu"); + expect( + getNextStepAfterRegion( + "openai", + "gpt-run", + config({ + modeId: "personal", + wikiGoal: "g", + ingestionSchedule: SCHEDULE, + completedAt: "2026-01-01T00:00:00Z", + }), + "personal", + ), + ).toBeNull(); + }); + + test("getNextStepAfterRegion routes code mode to repo confirmation", () => { + set("LANGSMITH_API_KEY", "lsv2_key"); + expect(getNextStepAfterRegion("openai", "gpt-run", empty, "code")).toBe( + "code-repo-confirm", + ); + }); +}); + +describe("git repo root discovery", () => { + test("findNearestGitRepoRoot finds the repo containing this test run", () => { + // The vitest process runs inside the OpenWiki git repo. + expect(findNearestGitRepoRoot(process.cwd())).not.toBeNull(); + }); + + test("findNearestGitRepoRoot returns null when no .git ancestor exists", () => { + // The filesystem root has no .git directory above it. + expect(findNearestGitRepoRoot("/")).toBeNull(); + }); + + test("findNearestGitRepoRoot walks up from a nested subdirectory", () => { + // A directory below the repo root forces at least one parent hop before the + // .git directory is found. + const root = findNearestGitRepoRoot(process.cwd()); + expect(findNearestGitRepoRoot(`${process.cwd()}/src/setup`)).toBe(root); + }); + + test("getDefaultCodeRepoRootPath resolves to a real directory string", () => { + expect(typeof getDefaultCodeRepoRootPath()).toBe("string"); + expect(getDefaultCodeRepoRootPath().length).toBeGreaterThan(0); + }); +}); diff --git a/test/setup/credentials/view.test.tsx b/test/setup/credentials/view.test.tsx new file mode 100644 index 000000000..bdaae8546 --- /dev/null +++ b/test/setup/credentials/view.test.tsx @@ -0,0 +1,321 @@ +import React from "react"; +import { render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { + InitSetupView, + type InitSetupViewProps, +} from "../../../src/setup/credentials/view.tsx"; +import { createEmptyOnboardingConfig } from "../../../src/setup/onboarding.ts"; +import { + getSourceOption, + getTemplateSourceOptions, +} from "../../../src/setup/credentials/steps.ts"; +import { stripAnsi as plain } from "../../cli/components/ansi.ts"; + +/** Renders the view and returns its ANSI-stripped final frame. */ +function frameOf(props: InitSetupViewProps): string { + return plain(render().lastFrame()); +} + +/** + * Builds a full, valid props bag with sane defaults. Individual tests override + * only the fields they exercise so the render is always fully typed. + */ +function makeProps( + overrides: Partial = {}, +): InitSetupViewProps { + return { + allowModeSelection: false, + step: "provider", + selectedMode: "personal", + provider: "anthropic", + providerConfirmed: false, + apiKey: null, + oauthTokens: null, + secretKey: null, + gcpProject: null, + gcpLocation: null, + baseUrl: null, + region: null, + modelId: null, + modelIdOverride: null, + langSmithKey: null, + onboardingConfig: createEmptyOnboardingConfig(), + copied: false, + input: "", + isLoggingIn: false, + loginUrl: null, + codeRepoPathInput: "", + codeRepoRoot: "/tmp/repo", + externalCliAuth: { kind: "idle" }, + codeRepoSelectionIndex: 0, + cronFieldSelectionIndex: 0, + cronModeSelectionIndex: 0, + finalSelectionIndex: 0, + isCustomModelInput: false, + langsmithDraft: null, + langsmithRegionSelectionIndex: 0, + langsmithWorkspaceSelectionIndex: 0, + langsmithWorkspaces: [], + modelSelectionIndex: 0, + powerModeSelectionIndex: 0, + providerSelectionIndex: 0, + runModeSelectionIndex: 0, + secretInputIndex: 0, + sourceContinueSelectionIndex: 0, + sourceDescriptionSelectionIndex: 0, + sourceSelectionIndex: 0, + sourceState: { secretValues: {} }, + templateSelectionIndex: 0, + notice: null, + error: null, + isSaving: false, + isAuthRunning: false, + activeSourceOptions: getTemplateSourceOptions(undefined), + selectedSource: getSourceOption("git-repo"), + suggestedCronExpression: "0 2 * * *", + suggestedCronDescription: "At 02:00", + inputDisplayWidth: 64, + navHistoryLength: 0, + ...overrides, + }; +} + +describe("InitSetupView", () => { + const originalLangSmithKey = process.env.LANGSMITH_API_KEY; + const originalTracing = process.env.LANGCHAIN_TRACING_V2; + + beforeEach(() => { + delete process.env.LANGSMITH_API_KEY; + delete process.env.LANGCHAIN_TRACING_V2; + }); + + afterEach(() => { + if (originalLangSmithKey === undefined) { + delete process.env.LANGSMITH_API_KEY; + } else { + process.env.LANGSMITH_API_KEY = originalLangSmithKey; + } + if (originalTracing === undefined) { + delete process.env.LANGCHAIN_TRACING_V2; + } else { + process.env.LANGCHAIN_TRACING_V2 = originalTracing; + } + }); + + test("renders the header and provider/model summary rows", () => { + const frame = frameOf(makeProps({ provider: "anthropic" })); + expect(frame).toContain("OpenWiki"); + expect(frame).toContain("first-run setup"); + expect(frame).toContain("Provider"); + expect(frame).toContain("Model"); + }); + + test("renders the OAuthLoginPrompt branch for the oauth-login step", () => { + const frame = frameOf( + makeProps({ + step: "oauth-login", + provider: "openai-chatgpt", + loginUrl: "https://auth.example/login", + }), + ); + expect(frame).toContain("ChatGPT login"); + expect(frame).toContain("https://auth.example/login"); + }); + + test("renders the Prompt panel for a non-oauth step", () => { + const frame = frameOf(makeProps({ step: "provider" })); + expect(frame).toContain("Prompt"); + expect(frame).toContain("Choose a model provider."); + }); + + test("shows the status and error panels when those props are set", () => { + const frame = frameOf( + makeProps({ notice: "Heads up notice", error: "Something broke" }), + ); + expect(frame).toContain("Status"); + expect(frame).toContain("Heads up notice"); + expect(frame).toContain("Error"); + expect(frame).toContain("Something broke"); + }); + + test("shows the inspecting placeholder and saving panel when applicable", () => { + const frame = frameOf(makeProps({ step: null, isSaving: true })); + expect(frame).toContain("Inspecting OpenWiki setup..."); + expect(frame).toContain("Saving"); + expect(frame).toContain("Writing OpenWiki setup..."); + }); + + test("code mode surfaces the wiki-scope row in the detected section", () => { + const frame = frameOf( + makeProps({ selectedMode: "code", step: "provider" }), + ); + expect(frame).toContain("Wiki scope"); + expect(frame).toContain("openwiki/"); + }); + + test("mode selection marks the run-mode row current on its step", () => { + const frame = frameOf( + makeProps({ allowModeSelection: true, step: "run-mode" }), + ); + expect(frame).toContain("Run mode"); + }); + + test("an AWS SDK provider renders the AWS credentials and region rows", () => { + const frame = frameOf( + makeProps({ provider: "bedrock", step: "region", region: "us-west-2" }), + ); + expect(frame).toContain("AWS credentials"); + expect(frame).toContain("Region"); + expect(frame).toContain("us-west-2"); + }); + + test("an OAuth provider renders the ChatGPT login row", () => { + const frame = frameOf( + makeProps({ + provider: "openai-chatgpt", + step: "provider", + oauthTokens: { + access: "a", + refresh: "r", + expiresAtMs: 1, + accountId: "acct", + email: "me@example.com", + planType: "pro", + }, + }), + ); + expect(frame).toContain("ChatGPT login"); + }); + + test("a Vertex provider renders GCP project and location with entered values", () => { + const frame = frameOf( + makeProps({ + provider: "gemini-enterprise", + step: "gcp-project", + gcpProject: "proj-1", + gcpLocation: "us-central1", + }), + ); + expect(frame).toContain("GCP project"); + expect(frame).toContain("proj-1"); + expect(frame).toContain("GCP location"); + expect(frame).toContain("us-central1"); + }); + + test("an OpenAI-compatible provider renders the base URL row", () => { + const frame = frameOf( + makeProps({ + provider: "openai-compatible", + step: "base-url", + baseUrl: "https://api.local/v1", + }), + ); + expect(frame).toContain("Base URL"); + expect(frame).toContain("https://api.local/v1"); + }); + + test("an API key entered this session marks the provider key configured", () => { + const frame = frameOf( + makeProps({ provider: "openai", step: "model", apiKey: "sk-123" }), + ); + expect(frame).toContain("Provider key"); + expect(frame).toContain("configured"); + }); + + test("a blank LangSmith key entered this session reads as skipped", () => { + const frame = frameOf(makeProps({ langSmithKey: "" })); + expect(frame).toContain("LangSmith"); + expect(frame).toContain("skipped"); + }); + + test("a LangSmith key entered this session is not marked skipped", () => { + const frame = frameOf(makeProps({ langSmithKey: "ls-key" })); + expect(frame).toContain("LangSmith"); + expect(frame).not.toContain("skipped"); + }); + + test("a saved LangSmith key in the environment renders without a skip label", () => { + process.env.LANGSMITH_API_KEY = "ls-env"; + const frame = frameOf(makeProps({ langSmithKey: null })); + expect(frame).toContain("LangSmith"); + expect(frame).not.toContain("skipped"); + }); + + test("a recorded tracing decline (no key, no session value) reads as skipped", () => { + process.env.LANGCHAIN_TRACING_V2 = "false"; + // Only the LangSmith row ever emits "skipped", so its presence proves the + // declined-via-env state no longer reads as the "not set" resting label. + const frame = frameOf(makeProps({ langSmithKey: null })); + expect(frame).toContain("LangSmith"); + expect(frame).toContain("skipped"); + }); + + test("an entered model id is shown as the model detail", () => { + const frame = frameOf( + makeProps({ modelId: "claude-custom", step: "model" }), + ); + expect(frame).toContain("claude-custom"); + }); + + test("a non-empty back history shows the go-back hint", () => { + const frame = frameOf(makeProps({ navHistoryLength: 2 })); + expect(frame).toContain("esc to go back"); + }); + + test("a saved-schedule warning renders the schedule-note panel", () => { + const frame = frameOf( + makeProps({ + sourceState: { secretValues: {}, savedScheduleWarning: "cron drift" }, + }), + ); + expect(frame).toContain("Schedule note"); + expect(frame).toContain("cron drift"); + }); + + test("the authorization panel shows while awaiting the browser callback", () => { + const frame = frameOf(makeProps({ isAuthRunning: true })); + expect(frame).toContain("Authorization"); + expect(frame).toContain("Waiting for the browser authorization callback"); + }); + + test("personal mode marks the schedule row current on a schedule step", () => { + const frame = frameOf( + makeProps({ selectedMode: "personal", step: "global-cron-mode" }), + ); + expect(frame).toContain("Schedule"); + }); + + test("personal mode marks the sources row current on a source step", () => { + const frame = frameOf( + makeProps({ selectedMode: "personal", step: "source-menu" }), + ); + expect(frame).toContain("Sources"); + }); + + test("personal mode surfaces wiki-scope, schedule, and sources as done", () => { + const config = { + ...createEmptyOnboardingConfig(), + wikiGoal: "document the repo", + ingestionSchedule: { + description: "At 02:00", + expression: "0 2 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + sourceInstances: [{ connectorId: "git-repo" as const, id: "git-repo:1" }], + }; + const frame = frameOf( + makeProps({ + selectedMode: "personal", + step: "final", + onboardingConfig: config, + activeSourceOptions: [getSourceOption("git-repo")], + }), + ); + expect(frame).toContain("Schedule"); + expect(frame).toContain("At 02:00"); + expect(frame).toContain("Sources"); + expect(frame).toContain("1 configured"); + }); +}); diff --git a/test/setup/onboarding.test.ts b/test/setup/onboarding.test.ts index d455568c9..5dc22c94e 100644 --- a/test/setup/onboarding.test.ts +++ b/test/setup/onboarding.test.ts @@ -35,6 +35,30 @@ async function seedRawOnboardingJson( ); } +// Writes an arbitrary (possibly invalid) string to onboarding.json so we can +// exercise the JSON.parse failure paths that seedRawOnboardingJson cannot. +async function seedRawOnboardingText( + onboarding: Awaited>, + text: string, +): Promise { + await mkdir(path.dirname(onboarding.openWikiOnboardingPath), { + recursive: true, + }); + await writeFile(onboarding.openWikiOnboardingPath, text, "utf8"); +} + +// Writes INSTRUCTIONS.md under the temp home without going through +// saveOpenWikiOnboardingConfig, so tests can control the raw file contents. +async function seedHomeInstructions( + onboarding: Awaited>, + contents: string, +): Promise { + await mkdir(path.dirname(onboarding.openWikiInstructionsPath), { + recursive: true, + }); + await writeFile(onboarding.openWikiInstructionsPath, contents, "utf8"); +} + afterEach(async () => { vi.resetModules(); @@ -358,4 +382,529 @@ describe("normalizeOnboardingConfig (via readOpenWikiOnboardingConfig)", () => { expect(config.powerManagement).toBeUndefined(); }); + + test("ignores a sources value that is not an object", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: "not-an-object", + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sources).toEqual({}); + expect(config.sourceInstances).toEqual([]); + }); + + test("ignores a sourceInstances value that is not an array", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: "not-an-array", + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toEqual([]); + }); + + test("preserves modeName and templateName when both are present", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + modeId: "personal", + modeName: "Personal wiki", + sourceInstances: [], + sources: {}, + templateId: "personal", + templateName: "Personal template", + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.modeName).toBe("Personal wiki"); + expect(config.templateName).toBe("Personal template"); + }); + + test("skips malformed and unknown source instances", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [ + null, + "not-an-object", + { name: "missing connector id" }, + { connectorId: "totally-bogus" }, + { connectorId: "notion", id: "keep-me" }, + ], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toHaveLength(1); + expect(config.sourceInstances[0]?.connectorId).toBe("notion"); + expect(config.sourceInstances[0]?.id).toBe("keep-me"); + }); + + test("retains a source instance name when it is a string", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [ + { connectorId: "notion", id: "notion-1", name: "Team space" }, + ], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances[0]?.name).toBe("Team space"); + }); + + test("normalizes full source connection fields", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [ + { + connectedAt: "2026-01-01T00:00:00.000Z", + connectorConfig: { token: "abc" }, + connectorId: "notion", + id: "notion-1", + ingestionGoal: "docs", + }, + ], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances[0]).toMatchObject({ + connectedAt: "2026-01-01T00:00:00.000Z", + connectorConfig: { token: "abc" }, + ingestionGoal: "docs", + }); + // Legacy sources should carry the same connection details forward. + expect(config.sources.notion).toMatchObject({ + connectedAt: "2026-01-01T00:00:00.000Z", + connectorConfig: { token: "abc" }, + ingestionGoal: "docs", + }); + }); + + test("derives a single legacy source entry from duplicate connector instances", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [ + { connectorId: "slack", id: "slack-1", ingestionGoal: "primary" }, + { connectorId: "slack", id: "slack-2", ingestionGoal: "secondary" }, + ], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toHaveLength(2); + // deriveLegacySources keeps only the first instance per connector id. + expect(config.sources.slack?.ingestionGoal).toBe("primary"); + }); + + test("fills schedule defaults when the schedule object is empty", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + ingestionSchedule: {}, + sourceInstances: [], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.ingestionSchedule).toEqual({ + description: "", + expression: "", + launchAgentPath: undefined, + pausedAt: undefined, + updatedAt: new Date(0).toISOString(), + warning: undefined, + }); + }); + + test("preserves every optional schedule field when present", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + ingestionSchedule: { + description: "nightly", + expression: "0 3 * * *", + launchAgentPath: "/Library/LaunchAgents/openwiki.plist", + pausedAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warning: "battery only", + }, + sourceInstances: [], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.ingestionSchedule).toEqual({ + description: "nightly", + expression: "0 3 * * *", + launchAgentPath: "/Library/LaunchAgents/openwiki.plist", + pausedAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warning: "battery only", + }); + }); + + test("preserves every optional pmset field when present", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + powerManagement: { + pmset: { + days: "MTWRF", + enabled: true, + sleepTime: "23:00:00", + updatedAt: "2026-01-01T00:00:00.000Z", + wakeTime: "07:00:00", + warning: "requires admin", + }, + }, + sourceInstances: [], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.powerManagement?.pmset).toEqual({ + days: "MTWRF", + enabled: true, + sleepTime: "23:00:00", + updatedAt: "2026-01-01T00:00:00.000Z", + wakeTime: "07:00:00", + warning: "requires admin", + }); + }); +}); + +describe("readOpenWikiOnboardingConfig missing and error paths", () => { + test("returns an empty config with the wiki goal when only instructions exist", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedHomeInstructions(onboarding, "Only the goal survives.\n"); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.wikiGoal).toBe("Only the goal survives."); + expect(config.sourceInstances).toEqual([]); + expect(config.sources).toEqual({}); + expect(config.version).toBe(1); + }); + + test("returns a bare empty config when neither file exists", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config).toEqual({ + sourceInstances: [], + sources: {}, + version: 1, + }); + }); + + test("treats a whitespace-only instructions file as no wiki goal", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: {}, + version: 1, + }); + await seedHomeInstructions(onboarding, " \n\t\n"); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.wikiGoal).toBeUndefined(); + }); + + test("rethrows when the stored onboarding JSON is malformed", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingText(onboarding, "{ not valid json"); + + await expect(onboarding.readOpenWikiOnboardingConfig()).rejects.toThrow(); + }); + + test("rethrows non-ENOENT errors from the instructions file", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: {}, + version: 1, + }); + // A directory at the instructions path makes readFile fail with EISDIR, + // which is not treated as file-not-found and must propagate. + await mkdir(onboarding.openWikiInstructionsPath, { recursive: true }); + + await expect(onboarding.readOpenWikiOnboardingConfig()).rejects.toThrow(); + }); +}); + +describe("readRepositoryWikiInstructions edge cases", () => { + test("returns undefined when the repository has no instructions file", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + await expect( + onboarding.readRepositoryWikiInstructions(repo), + ).resolves.toBeUndefined(); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); + + test("returns undefined for a whitespace-only repository instructions file", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + const instructionsPath = + onboarding.getRepositoryWikiInstructionsPath(repo); + await mkdir(path.dirname(instructionsPath), { recursive: true }); + await writeFile(instructionsPath, " \n", "utf8"); + + await expect( + onboarding.readRepositoryWikiInstructions(repo), + ).resolves.toBeUndefined(); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); + + test("rethrows non-ENOENT errors from the repository instructions file", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + // A directory at the instructions path yields EISDIR, not ENOENT. + await mkdir(onboarding.getRepositoryWikiInstructionsPath(repo), { + recursive: true, + }); + + await expect( + onboarding.readRepositoryWikiInstructions(repo), + ).rejects.toThrow(); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); +}); + +describe("isOnboardingComplete code mode via templateId", () => { + test("treats a templateId of code as code mode when modeId is absent", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + expect( + onboarding.isOnboardingComplete({ + completedAt: "2026-01-01T00:00:00.000Z", + sourceInstances: [], + sources: {}, + templateId: "code", + version: 1, + wikiGoal: "Maintain a code wiki.", + }), + ).toBe(true); + }); +}); + +describe("isOpenWikiOnboardingCompleteSync", () => { + test("returns false when no onboarding.json exists", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + expect(onboarding.isOpenWikiOnboardingCompleteSync()).toBe(false); + }); + + test("returns true for a completed personal config with a schedule", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + await onboarding.saveOpenWikiOnboardingConfig({ + completedAt: "2026-01-01T00:00:00.000Z", + ingestionSchedule: { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + modeId: "personal", + sourceInstances: [], + sources: {}, + templateId: "personal", + version: 1, + wikiGoal: "Track projects and commitments.", + }); + + expect(onboarding.isOpenWikiOnboardingCompleteSync()).toBe(true); + }); + + test("returns false when the instructions file is missing", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + await onboarding.saveOpenWikiOnboardingConfig({ + completedAt: "2026-01-01T00:00:00.000Z", + ingestionSchedule: { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + modeId: "personal", + sourceInstances: [], + sources: {}, + templateId: "personal", + version: 1, + wikiGoal: "Track projects and commitments.", + }); + await rm(onboarding.openWikiInstructionsPath); + + expect(onboarding.isOpenWikiOnboardingCompleteSync()).toBe(false); + }); + + test("returns false when the instructions file is only whitespace", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + + await onboarding.saveOpenWikiOnboardingConfig({ + completedAt: "2026-01-01T00:00:00.000Z", + ingestionSchedule: { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + modeId: "personal", + sourceInstances: [], + sources: {}, + templateId: "personal", + version: 1, + wikiGoal: "Track projects and commitments.", + }); + await seedHomeInstructions(onboarding, " \n"); + + expect(onboarding.isOpenWikiOnboardingCompleteSync()).toBe(false); + }); + + test("returns false when onboarding.json is malformed", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingText(onboarding, "{ not valid json"); + + expect(onboarding.isOpenWikiOnboardingCompleteSync()).toBe(false); + }); +}); + +describe("isRepositoryCodeOnboardingCompleteSync edge cases", () => { + test("returns false when no onboarding.json exists", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + expect(onboarding.isRepositoryCodeOnboardingCompleteSync(repo)).toBe( + false, + ); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); + + test("returns false for a non-code onboarding config", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + await onboarding.saveOpenWikiOnboardingConfig({ + completedAt: "2026-01-01T00:00:00.000Z", + modeId: "personal", + sourceInstances: [], + sources: {}, + templateId: "personal", + version: 1, + }); + await onboarding.saveRepositoryWikiInstructions(repo, "A code wiki."); + + expect(onboarding.isRepositoryCodeOnboardingCompleteSync(repo)).toBe( + false, + ); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); + + test("returns false when the repository instructions are only whitespace", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + await onboarding.saveOpenWikiOnboardingConfig({ + completedAt: "2026-01-01T00:00:00.000Z", + modeId: "code", + sourceInstances: [], + sources: {}, + templateId: "code", + version: 1, + }); + const instructionsPath = + onboarding.getRepositoryWikiInstructionsPath(repo); + await mkdir(path.dirname(instructionsPath), { recursive: true }); + await writeFile(instructionsPath, " \n", "utf8"); + + expect(onboarding.isRepositoryCodeOnboardingCompleteSync(repo)).toBe( + false, + ); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); + + test("returns false when onboarding.json is malformed", async () => { + const home = await createTempHome(); + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-repo-")); + const onboarding = await loadOnboardingModule(home); + + try { + await seedRawOnboardingText(onboarding, "{ not valid json"); + + expect(onboarding.isRepositoryCodeOnboardingCompleteSync(repo)).toBe( + false, + ); + } finally { + await rm(repo, { force: true, recursive: true }); + } + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d594c7f17..4e509892c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,11 +26,20 @@ export default defineConfig({ // extracted into `visualize/client-lib.ts` (fully covered), so new logic // belongs there, not here. Excluded so the aggregate is not dragged by code // a Node unit test can never execute. + // + // `setup/credentials/use-init-setup.ts` is the setup wizard's Ink state + // machine: a stateful `useInput` keyboard flow that ink-testing-library + // cannot exercise cleanly. Its extractable logic lives in tested modules + // (`steps.ts`, `format.ts`, `persistence.ts`) and its rendering in + // `view.tsx` (render-tested); what remains here is the wiring those cannot + // cover. Excluded so the aggregate is not dragged by code a Node unit test + // cannot drive. exclude: [ "src/**/*.d.ts", "src/**/types.ts", "src/telemetry/index.ts", "src/visualize/client.ts", + "src/setup/credentials/use-init-setup.ts", ], reporter: ["text", "text-summary", "html", "json-summary", "lcov"], },