diff --git a/src/lib/app.tsx b/src/lib/app.tsx index fdd9742..375607a 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -121,6 +121,9 @@ export function App({ const { isRawModeSupported } = useStdin() const projectRef = useRef(detectProject(root)) const attemptRef = useRef(0) + // The key this run actually connected with. Downstream steps read this + // instead of the environment, which may hold a key for another workspace. + const apiKeyRef = useRef(null) const [dimensions, setDimensions] = useState({ rows: stdout?.rows ?? 24, @@ -331,6 +334,7 @@ export function App({ source: 'project' | 'cli' | 'browser' | 'pasted', location: string | null, ): void => { + apiKeyRef.current = apiKey setWorkspace(settledWorkspace) saveConnection(root, { workspace: settledWorkspace, @@ -429,12 +433,11 @@ export function App({ !cancelled && setBrowser((b) => ({ ...b, received: true })), }) if (cancelled) return - setWorkspace(result.workspace) addMessage({ tone: 'ok', text: `Connected ยท workspace ${result.workspace.name}`, }) - advanceAfterAuth() + settleOn(result.workspace, result.api_key, 'browser', '.env') } catch (error) { if (!cancelled) { setPhase({ @@ -471,9 +474,8 @@ export function App({ try { const result = await verifyAndSaveKey(root, apiKey) if (cancelled) return - setWorkspace(result.workspace) addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) - advanceAfterAuth() + settleOn(result.workspace, result.api_key, 'pasted', '.env') } catch (error) { if (cancelled) return const message = @@ -610,8 +612,11 @@ export function App({ if (phase.t !== 'analyze') return let cancelled = false const run = async (): Promise => { - const found = findExistingApiKey(root) - if (found == null) { + // Prefer the key this run connected with: re-reading here would pick up + // SEAM_API_KEY, which can belong to a different workspace than the one + // just chosen, and silently plan the integration against that one. + const apiKey = apiKeyRef.current ?? findExistingApiKey(root)?.api_key + if (apiKey == null) { addMessage({ tone: 'warn', text: "Couldn't find your Seam API key to plan the integration.", @@ -622,7 +627,7 @@ export function App({ let currentSession: WizardInferenceSession try { - currentSession = await exchangeWizardInferenceToken(found.api_key) + currentSession = await exchangeWizardInferenceToken(apiKey) } catch (error) { if (!cancelled) { addMessage({ diff --git a/src/lib/steps/authenticate.test.ts b/src/lib/steps/authenticate.test.ts new file mode 100644 index 0000000..2869166 --- /dev/null +++ b/src/lib/steps/authenticate.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) + +vi.mock('@seamapi/http', () => ({ + isSeamHttpApiError: () => false, + isSeamHttpUnauthorizedError: () => false, + SeamHttpInvalidTokenError: class extends Error {}, + SeamHttpWorkspaces: class { + get = get + client = { post: vi.fn() } + }, +})) + +const { verifyAndSaveKey } = await import('./authenticate.js') + +const workspace = { + workspace_id: 'workspace-1', + name: 'Acme', + is_sandbox: false, +} + +let dir = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'seam-wizard-')) + vi.clearAllMocks() +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +// The run has to carry the key it verified. Returning only the workspace left +// the caller to re-read SEAM_API_KEY, which may belong to another workspace. +test('verifyAndSaveKey returns the key it verified', async () => { + get.mockResolvedValue(workspace) + + const result = await verifyAndSaveKey(dir, 'seam_pasted_key') + + expect(result.workspace).toBe(workspace) + expect(result.api_key).toBe('seam_pasted_key') +}) + +test('verifyAndSaveKey returns the trimmed key it saved, not the raw input', async () => { + get.mockResolvedValue(workspace) + + const result = await verifyAndSaveKey(dir, ' seam_padded_key\n') + + expect(result.api_key).toBe('seam_padded_key') + expect(readFileSync(join(dir, '.env'), 'utf8')).toContain( + 'SEAM_API_KEY=seam_padded_key', + ) +}) diff --git a/src/lib/steps/authenticate.ts b/src/lib/steps/authenticate.ts index e192464..e8d807e 100644 --- a/src/lib/steps/authenticate.ts +++ b/src/lib/steps/authenticate.ts @@ -4,6 +4,7 @@ import { findExistingApiKey, saveProjectApiKey } from 'lib/env-file.js' export interface AuthResult { workspace: SeamWorkspace + api_key: string } export interface ExistingKeyResult { @@ -55,7 +56,7 @@ export async function verifyAndSaveKey( const trimmed = apiKey.trim() const workspace = await getWorkspaceForApiKey(trimmed) saveProjectApiKey(root, trimmed) - return { workspace } + return { workspace, api_key: trimmed } } export function saveVerifiedKey(root: string, apiKey: string): void { diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index 3dd19c1..d2307f7 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -14,6 +14,7 @@ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 export interface WebConnectResult { workspace: SeamWorkspace + api_key: string } // Progress callbacks so the Ink UI can render the handoff without any logging @@ -110,7 +111,7 @@ export async function connectViaWeb( const workspace = await getWorkspaceForApiKey(payload.api_key) saveProjectApiKey(root, payload.api_key) - return { workspace } + return { workspace, api_key: payload.api_key } } function respondJson(