diff --git a/src/lib/agent/runner/harness/pi/tools.ts b/src/lib/agent/runner/harness/pi/tools.ts index 3053eda7e..6519b26b7 100644 --- a/src/lib/agent/runner/harness/pi/tools.ts +++ b/src/lib/agent/runner/harness/pi/tools.ts @@ -18,6 +18,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent'; import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; import { logToFile } from '@utils/debug'; import { + ENV_FILE_PATH_DESCRIPTION, fetchSkillMenu, installSkillById, mergeEnvValues, @@ -114,7 +115,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { promptSnippet: 'check_env_keys(filePath, keys) — see which .env keys exist', parameters: Type.Object({ filePath: Type.String({ - description: 'Path to the .env file, relative to the project root', + description: ENV_FILE_PATH_DESCRIPTION, }), keys: Type.Array(Type.String(), { description: 'Environment variable key names to check', @@ -142,7 +143,7 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { 'set_env_values(filePath, values) — write .env keys (never hardcode secrets in source)', parameters: Type.Object({ filePath: Type.String({ - description: 'Path to the .env file, relative to the project root', + description: ENV_FILE_PATH_DESCRIPTION, }), values: Type.Record(Type.String(), Type.String(), { description: 'Key → literal value', diff --git a/src/lib/programs/__tests__/source-maps-detect-agentic.test.ts b/src/lib/programs/__tests__/source-maps-detect-agentic.test.ts index a13a16dda..6278a1aca 100644 --- a/src/lib/programs/__tests__/source-maps-detect-agentic.test.ts +++ b/src/lib/programs/__tests__/source-maps-detect-agentic.test.ts @@ -9,7 +9,17 @@ describe('SOURCE_MAPS_TARGETS precedence', () => { const rank = (id: string) => ids.indexOf(id); it('covers every automatable variant exactly once', () => { - expect([...ids].sort()).toEqual([...AUTOMATABLE_VARIANTS].sort()); + for (const variant of AUTOMATABLE_VARIANTS) { + expect(ids.filter((id) => id === variant)).toHaveLength(1); + } + }); + + it('ranks unsupported native guards ahead of the iOS target', () => { + expect(SOURCE_MAPS_TARGETS).toContainEqual({ id: 'ios', name: 'iOS' }); + for (const nativeGuard of ['react-native', 'flutter', 'android']) { + expect(rank(nativeGuard)).toBeLessThan(rank('ios')); + } + expect(rank('ios')).toBeLessThan(rank('nextjs')); }); it('ranks bundlers ahead of the generic React variant', () => { @@ -56,6 +66,76 @@ describe('coerceReport', () => { expect(p.reason).toBeUndefined(); }); + it('retains an iOS project with PostHog as instrumentable', () => { + const report = coerceReport({ + repoType: 'single', + projects: [ + { + path: '.', + framework: 'iOS', + targetId: 'ios', + hasPostHog: true, + }, + ], + }); + + expect(report.projects[0]).toEqual({ + path: '.', + framework: 'iOS', + variant: 'ios', + hasPostHog: true, + instrumentable: true, + }); + }); + + it('blocks an iOS project that has no PostHog SDK yet', () => { + const report = coerceReport({ + repoType: 'single', + projects: [ + { + path: '.', + framework: 'iOS', + targetId: 'ios', + hasPostHog: false, + }, + ], + }); + + expect(report.projects[0]).toEqual( + expect.objectContaining({ + variant: 'ios', + hasPostHog: false, + instrumentable: false, + reason: expect.stringMatching(/no posthog sdk/i), + }), + ); + }); + + it.each(['React Native', 'Expo', 'Flutter', 'Android'])( + 'blocks a native %s project misclassified as iOS', + (framework) => { + const report = coerceReport({ + repoType: 'single', + projects: [ + { + path: '.', + framework, + targetId: 'ios', + hasPostHog: true, + }, + ], + }); + + expect(report.projects[0]).toEqual( + expect.objectContaining({ + variant: null, + instrumentable: false, + reason: expect.stringMatching(/isn't supported/i), + }), + ); + }, + ); + it('blocks a supported project that has no PostHog SDK yet', () => { const report = coerceReport({ repoType: 'single', diff --git a/src/lib/programs/__tests__/source-maps-prompt.test.ts b/src/lib/programs/__tests__/source-maps-prompt.test.ts new file mode 100644 index 000000000..1d78449bb --- /dev/null +++ b/src/lib/programs/__tests__/source-maps-prompt.test.ts @@ -0,0 +1,38 @@ +import { buildSourceMapsUploadPrompt } from '@lib/programs/error-tracking-upload-source-maps/prompt'; + +const baseParams = { + displayName: 'Node.js', + variant: 'node' as const, + skillId: 'error-tracking-upload-source-maps-node', + projectId: 123, + host: 'https://us.i.posthog.com', + settingsUrl: 'https://us.posthog.com/settings/user-api-keys', + uiHost: 'https://us.posthog.com', +}; + +describe('buildSourceMapsUploadPrompt env file paths', () => { + it('scopes env tools to the selected monorepo project', () => { + const prompt = buildSourceMapsUploadPrompt({ + ...baseParams, + projectPath: 'backend', + }); + + expect(prompt).toContain( + "Project directory (relative to the wizard's working directory): backend", + ); + expect(prompt).toContain('pass `backend/.env`, not `.env`'); + }); + + it.each([undefined, '.'])( + 'keeps root-project env files at the wizard working directory (%s)', + (projectPath) => { + const prompt = buildSourceMapsUploadPrompt({ + ...baseParams, + projectPath, + }); + + expect(prompt).toContain('pass `.env`'); + expect(prompt).not.toContain('pass `./.env`'); + }, + ); +}); diff --git a/src/lib/programs/error-tracking-upload-source-maps/detect-agentic.ts b/src/lib/programs/error-tracking-upload-source-maps/detect-agentic.ts index e5aff10e2..d0ab3b9c6 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/detect-agentic.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/detect-agentic.ts @@ -46,12 +46,21 @@ export type DetectionReport = { /** * Variant precedence for the agentic picker (most specific first). The detector - * keeps the EARLIEST matching target, so this ordering is what makes a React app - * built with Vite resolve to `vite` (bundler-plugin upload) instead of the - * generic `react` posthog-cli path. Mirrors `pickJsVariant` in detect.ts: - * opinionated frameworks → bundlers → bare React → Node → generic web. + * keeps the EARLIEST matching target. Unsupported native targets are included + * as guards before iOS so React Native, Flutter, and Android projects with + * nested Xcode manifests are not misclassified as instrumentable iOS apps. + * JS ordering mirrors `pickJsVariant` in detect.ts: opinionated frameworks → + * bundlers → bare React → Node → generic web. */ +const NON_AUTOMATABLE_NATIVE_VARIANTS: readonly SkillVariant[] = [ + 'react-native', + 'flutter', + 'android', +]; + const VARIANT_PRECEDENCE: readonly SkillVariant[] = [ + ...NON_AUTOMATABLE_NATIVE_VARIANTS, + 'ios', 'nextjs', 'nuxt', 'angular', @@ -69,13 +78,14 @@ const precedenceRank = (v: SkillVariant): number => { }; /** - * Source-maps targets: the variants the wizard can automate, by id → name, - * ordered by VARIANT_PRECEDENCE so the detector's "earliest match wins" - * tie-break selects the most specific variant. Derived from AUTOMATABLE_VARIANTS - * so a newly-automatable variant is never silently dropped (unranked ones sort - * last). Exported for testing. + * Source-map detection targets, ordered by VARIANT_PRECEDENCE. Unsupported + * native variants remain in the target list so the detector can identify and + * block them instead of falling through to iOS or a JS target. */ -export const SOURCE_MAPS_TARGETS: DetectTarget[] = [...AUTOMATABLE_VARIANTS] +export const SOURCE_MAPS_TARGETS: DetectTarget[] = [ + ...NON_AUTOMATABLE_NATIVE_VARIANTS, + ...AUTOMATABLE_VARIANTS, +] .sort((a, b) => precedenceRank(a) - precedenceRank(b)) .map((v) => ({ id: v, name: VARIANT_DISPLAY_NAME[v] })); @@ -98,12 +108,20 @@ function classify( return { instrumentable: true }; } +function isAutomatableVariant(value: string | null): value is SkillVariant { + return value !== null && AUTOMATABLE_VARIANTS.includes(value as SkillVariant); +} + /** Map a generic detection report into source-maps projects. */ function toSourceMapsReport(report: AgenticDetectionReport): DetectionReport { return { repoType: report.repoType, projects: report.projects.map((p) => { - const variant = (p.targetId as SkillVariant | null) ?? null; + const variant = + isAutomatableVariant(p.targetId) && + !/\b(?:react[\s-]*native|expo|flutter|android)\b/i.test(p.framework) + ? p.targetId + : null; return { path: p.path, framework: p.framework, @@ -117,12 +135,15 @@ function toSourceMapsReport(report: AgenticDetectionReport): DetectionReport { /** * Validate the agent's raw JSON into a source-maps detection report. Exported - * for testing — clamps variants to the automatable set and classifies - * instrumentability. + * for testing — recognises detection-only native targets, then clamps them to + * non-instrumentable projects. */ export function coerceReport(parsed: unknown): DetectionReport { return toSourceMapsReport( - coerceAgenticReport(parsed, AUTOMATABLE_VARIANTS as readonly string[]), + coerceAgenticReport( + parsed, + SOURCE_MAPS_TARGETS.map((target) => target.id), + ), ); } diff --git a/src/lib/programs/error-tracking-upload-source-maps/detect.ts b/src/lib/programs/error-tracking-upload-source-maps/detect.ts index 2daf56974..ae767c0b3 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/detect.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/detect.ts @@ -52,10 +52,11 @@ const DISPLAY_NAME: Record = { /** * Variants the wizard can wire up source-map upload for automatically. The - * native variants (react-native, android, flutter, ios) are recognised but not - * yet automatable, so the agentic picker treats them as non-instrumentable. + * native variants (react-native, android, flutter) are recognised but not yet + * automatable, so the agentic picker treats them as non-instrumentable. */ export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [ + 'ios', 'web', 'nextjs', 'node', @@ -67,6 +68,13 @@ export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [ 'rollup', ]; +/** + * Variants the wizard pre-installs a machine-global `posthog-cli` for — their + * build shells out to it with no npx / local-dep fallback. JS variants stay out. + */ +export const VARIANTS_REQUIRING_POSTHOG_CLI: ReadonlySet = + new Set(['ios']); + const POSTHOG_SDKS = [ 'posthog-js', 'posthog-node', @@ -305,8 +313,8 @@ export function detectSourceMapsPrerequisites( const signals = collectSignals(installDir); const variant = selectVariant(signals); - // This program currently targets JS-like stacks only. Avoid selecting native - // platforms until dedicated skill variants are available. + // The legacy filesystem detector does not automate native platforms. The + // live source-map picker uses detectSourceMapsProjects instead. if ( variant && ['react-native', 'flutter', 'ios', 'android'].includes(variant) diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index ad009d255..f2d64a5fb 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -10,14 +10,46 @@ import { import { SOURCE_MAPS_ABORT_CASES, SOURCE_MAPS_CONTEXT_KEYS, + VARIANTS_REQUIRING_POSTHOG_CLI, type SkillVariant, } from './detect.js'; import { getContentBlocks } from './content/index.js'; import { getUI } from '@ui'; +import { installOrUpdatePostHogCli } from '@steps/install-cli-steering'; +import { analytics } from '@utils/analytics'; const REPORT_FILE = 'posthog-source-maps-report.md'; const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps'; +let postHogCliInstallAttempted = false; + +/** + * Pre-install posthog-cli for variants that need a machine-global copy + * (`VARIANTS_REQUIRING_POSTHOG_CLI`). The agent can't — warlock blocks + * `npm install -g` — so the wizard does it in-process. Warn, don't fail. + */ +function ensurePostHogCli(variant: SkillVariant): void { + if (postHogCliInstallAttempted) return; + postHogCliInstallAttempted = true; + + const result = installOrUpdatePostHogCli(); + if (!result.success) { + analytics.wizardCapture('source maps posthog-cli preinstall failed', { + variant, + error: String(result.error).slice(0, 500), + }); + analytics.captureException( + result.errorObject ?? + new Error(`posthog-cli pre-install failed: ${result.error}`), + { source: 'source_maps_cli_preinstall', variant }, + ); + getUI().log.warn( + `Could not pre-install posthog-cli (${result.error}). Your Xcode build ` + + `will fail to upload dSYMs until it's installed: npm install -g @posthog/cli@latest`, + ); + } +} + export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { command: 'upload-source-maps', description: 'Upload source maps to PostHog Error Tracking', @@ -74,6 +106,9 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { return SOURCE_MAPS_DETECTION_FAILED_PROMPT; } + if (VARIANTS_REQUIRING_POSTHOG_CLI.has(variant)) + ensurePostHogCli(variant); + const uiHost = ctx.host.appHost.replace(/\/$/, ''); return buildSourceMapsUploadPrompt({ diff --git a/src/lib/programs/error-tracking-upload-source-maps/prompt.ts b/src/lib/programs/error-tracking-upload-source-maps/prompt.ts index 74e6c380f..655b0c16e 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/prompt.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/prompt.ts @@ -5,7 +5,7 @@ export type SourceMapsUploadPromptParams = { displayName: string | undefined; variant: SkillVariant; skillId: string; - /** Project to instrument, relative to the repo root ("." or undefined = root). */ + /** Project to instrument, relative to the wizard working directory ("." or undefined = root). */ projectPath?: string; projectId: number; host: string; @@ -33,8 +33,33 @@ export function buildSourceMapsUploadPrompt( const platformLabel = displayName ?? variant; const inSubproject = projectPath != null && projectPath !== '.'; const projectLine = inSubproject - ? `- Project directory (relative to repo root): ${projectPath}` - : '- Project directory: the repo root'; + ? `- Project directory (relative to the wizard's working directory): ${projectPath}` + : "- Project directory: the wizard's working directory (even when it sits inside a larger git repo, this directory is the project root)"; + const envFilePathGuidance = inSubproject + ? `Tool filePaths are relative to the wizard's working directory, not the selected project directory. Treat the skill's env path as relative to the selected project and prefix it with \`${projectPath}/\` when calling the tools: for example, pass \`${projectPath}/.env\`, not \`.env\` (which would target the wizard's working directory).` + : `Tool filePaths are relative to the wizard's working directory. For an env file at this project root, pass \`.env\`; never prefix it with this directory's path inside an ancestor repository.`; + + const credentialSteps = `STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time") + Follow the skill's step. Wizard-specific: if it calls for a loader (e.g. + \`dotenv\`), install it SILENTLY — do NOT ask the user or call wizard_ask. + Skip this step entirely if the platform already auto-loads .env. + +STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file") + Use the wizard-tools MCP server. Reuse the env file the skill tells you to + pick — the prerequisite PostHog integration usually already wrote + POSTHOG_* vars to one, so seed your keys alongside them. + - First call check_env_keys on that file (returns present/absent, never + values — don't read the file directly). + - Env tool path rule: ${envFilePathGuidance} + - Then call set_env_values, passing the STEP 1 secretRef as a value + object, not a literal string: + values: { + "POSTHOG_CLI_API_KEY": { secretRef: "" }, + "POSTHOG_CLI_PROJECT_ID": "${projectId}", + "POSTHOG_CLI_HOST": "${uiHost}" + } + Variable names follow the skill's per-uploader conventions. The wizard + resolves the ref locally before writing, so you never see the key value.`; return `You are wiring up PostHog Error Tracking source map upload for this ${platformLabel} project. @@ -117,26 +142,7 @@ STEP 3 — Apply build-config changes. (skill: "Apply build-config changes") Make the bundler / build-config changes the skill's step instructs. The skill and its reference are the source of truth for this platform. -STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time") - Follow the skill's step. Wizard-specific: if it calls for a loader (e.g. - \`dotenv\`), install it SILENTLY — do NOT ask the user or call wizard_ask. - Skip this step entirely if the platform already auto-loads .env. - -STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file") - Use the wizard-tools MCP server. Reuse the env file the skill tells you to - pick — the prerequisite PostHog integration usually already wrote - POSTHOG_* vars to one, so seed your keys alongside them. - - First call check_env_keys on that file (returns present/absent, never - values — don't read the file directly). - - Then call set_env_values, passing the STEP 1 secretRef as a value - object, not a literal string: - values: { - "POSTHOG_CLI_API_KEY": { secretRef: "" }, - "POSTHOG_CLI_PROJECT_ID": "${projectId}", - "POSTHOG_CLI_HOST": "${host}" - } - Variable names follow the skill's per-uploader conventions. The wizard - resolves the ref locally before writing, so you never see the key value. +${credentialSteps} STEP 6 — Identify the build AND run commands. (skill: "Identify the build and run commands") Per the skill, resolve the production BUILD command and the RUN command @@ -174,13 +180,24 @@ STEP 8 — Offer to test the local setup. (skill: "Test the local setup") If "yes", follow the skill's "Test the local setup" step for the platform-appropriate affordance, the captureException shape, the placement, and the read-before-edit / always-revert rules. Then pause for - the user with wizard_ask, baking the EXACT build and run commands from - STEP 6 (and the exact button label / route) into the prompt as literal, - copy-pasteable steps. Separate each numbered step with \\n\\n so the TUI - renders them as distinct lines: + the user with wizard_ask, baking the build-and-run flow (and the exact + button label / route) into the prompt as literal, copy-pasteable + numbered steps: + - Steps 1-2: the production build, then launching the app and + triggering the test affordance. Source them from STEP 6 and the + skill's "Test the local setup" step — when the skill gives the + platform's test flow as verbatim steps (IDE-driven platforms like + Xcode do), use its wording and do NOT invent CLI build commands, + debugger steps, or relaunch steps it doesn't state. Quote CLI + commands verbatim. When build and run are one action (an IDE Run), + fold them into step 1 and make step 2 just triggering the affordance. + - The last step is always the Error Tracking check, exactly as in the + template. + Separate each numbered step with \\n\\n so the TUI renders them as + distinct lines: { id: "test-done", - prompt: "1) Run \`\` to upload source maps and build the app with the test affordance.\\n\\n2) Start the app with \`\`, then click the \\"\\" button (or hit \`\`).\\n\\n3) Open Error Tracking in PostHog (${uiHost}/project/${projectId}/error_tracking) and confirm the test error appears with a source-resolved stack trace pointing at real source files (not minified bundle paths).\\n\\nWhen you're done, select Continue and I'll revert the test code.", + prompt: "1) \\n\\n2) , then click the \\"\\" button (or hit \`\`).\\n\\n3) Open Error Tracking in PostHog (${uiHost}/project/${projectId}/error_tracking) and confirm the test error appears with a source-resolved stack trace pointing at real source files (not minified bundle paths).\\n\\nWhen you're done, select Continue and I'll revert the test code.", kind: "single", options: [{ label: "Continue (revert test code)", value: "continue" }] } diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index 5672ee092..cba3aa24e 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -387,6 +387,9 @@ export function evaluateAskCap( // Env file helpers // --------------------------------------------------------------------------- +export const ENV_FILE_PATH_DESCRIPTION = + 'Path to the .env file, relative to the wizard working directory. Pass ".env" for a file in that directory, or include the selected subproject path (for example, "packages/app/.env") for a nested project. Never prefix it with the wizard working directory\'s path inside an ancestor repository.'; + /** * Resolve filePath relative to workingDirectory, rejecting path traversal. */ @@ -457,6 +460,7 @@ export function mergeEnvValues( const updatedKeys = new Set(); for (const [key, value] of Object.entries(values)) { + // Preserve the existing `KEY=` prefix exactly; only swap the value. const regex = new RegExp(`^(\\s*${key}\\s*=).*$`, 'm'); if (regex.test(result)) { result = result.replace(regex, `$1${value}`); @@ -656,9 +660,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { 'check_env_keys', 'Check which environment variable keys are present or missing in a .env file. Never reveals values.', { - filePath: z - .string() - .describe('Path to the .env file, relative to the project root'), + filePath: z.string().describe(ENV_FILE_PATH_DESCRIPTION), keys: z .array(z.string()) .describe('Environment variable key names to check'), @@ -690,9 +692,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { 'set_env_values', 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage. Each value can be either a literal string or a secret reference of the form `{ "secretRef": "secret:..." }` returned by another tool (e.g. wizard_ask). Secret references are resolved locally — the actual value is written to the file but never returned to the agent.', { - filePath: z - .string() - .describe('Path to the .env file, relative to the project root'), + filePath: z.string().describe(ENV_FILE_PATH_DESCRIPTION), values: z .record( z.string(), @@ -762,10 +762,27 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { : ''; const content = mergeEnvValues(existing, resolvedValues); - // Ensure parent directory exists + // Env files belong in directories that already exist. Refusing to create + // parents catches the classic agent mistake of re-prefixing the wizard + // working directory with its ancestor-repo-relative location (e.g. + // "apps/web/.env" while already running in apps/web), which would + // otherwise silently nest a duplicate tree. const dir = path.dirname(resolved); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + analytics.wizardCapture('set_env_values parent dir missing', { + platform: process.platform, + }); + return { + content: [ + { + type: 'text' as const, + text: `Error: parent directory does not exist: "${path.dirname( + args.filePath, + )}". filePath is resolved against the wizard working directory — pass ".env" for a file there, or "/.env" for an existing nested project.`, + }, + ], + isError: true, + }; } fs.writeFileSync(resolved, content, 'utf8'); diff --git a/src/steps/install-cli-steering/index.ts b/src/steps/install-cli-steering/index.ts index cb6d5dd80..45e133219 100644 --- a/src/steps/install-cli-steering/index.ts +++ b/src/steps/install-cli-steering/index.ts @@ -66,6 +66,13 @@ export interface SteeringInstallResult { export interface CliInstallResult { success: boolean; error?: string; + /** + * The underlying failure as an Error, for callers that report to error + * tracking. Carries the real spawn error (with its stack) when npm couldn't + * launch; a synthesized Error for a non-zero exit. Set whenever success is + * false. + */ + errorObject?: Error; } const spawnOptions = { @@ -90,17 +97,20 @@ export function installOrUpdatePostHogCli(): CliInstallResult { return { success: false, error: `Failed to run npm: ${result.error.message}. Is Node.js installed?`, + errorObject: result.error, }; } if (result.status !== 0) { const detail = (result.stderr || result.stdout || '').trim(); + const message = + detail || + `npm install --global @posthog/cli@latest exited with status ${ + result.status ?? 'unknown' + }`; return { success: false, - error: - detail || - `npm install --global @posthog/cli@latest exited with status ${ - result.status ?? 'unknown' - }`, + error: message, + errorObject: new Error(message), }; } return { success: true }; diff --git a/src/ui/tui/screens/AuthScreen.tsx b/src/ui/tui/screens/AuthScreen.tsx index 7d848ebfa..7d9cb710b 100644 --- a/src/ui/tui/screens/AuthScreen.tsx +++ b/src/ui/tui/screens/AuthScreen.tsx @@ -172,8 +172,8 @@ export const AuthScreen = ({ store }: AuthScreenProps) => { )} - Press [C] to copy the link · - on a remote machine or devbox? Press{' '} + Press [C] to copy the link · on + a remote machine or devbox? Press{' '} [P] to paste the callback URL.