Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export function App({
const { isRawModeSupported } = useStdin()
const projectRef = useRef<ProjectInfo>(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<string | null>(null)

const [dimensions, setDimensions] = useState({
rows: stdout?.rows ?? 24,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -610,8 +612,11 @@ export function App({
if (phase.t !== 'analyze') return
let cancelled = false
const run = async (): Promise<void> => {
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.",
Expand All @@ -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({
Expand Down
58 changes: 58 additions & 0 deletions src/lib/steps/authenticate.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
3 changes: 2 additions & 1 deletion src/lib/steps/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { findExistingApiKey, saveProjectApiKey } from 'lib/env-file.js'

export interface AuthResult {
workspace: SeamWorkspace
api_key: string
}

export interface ExistingKeyResult {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/steps/connect-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading