From 5afe68194998c206a1a9bc917c7ff6436777aeaa Mon Sep 17 00:00:00 2001 From: mai1015 Date: Wed, 28 Oct 2026 23:57:16 -0400 Subject: [PATCH 1/2] fix(setup): fail --skip-if-configured when the saved key is rejected With a saved key that has been revoked, `setup --skip-if-configured` reused the profile without validating it, then swallowed the 401/403 from the identity lookup and printed `status: "initialized"` with exit 0, so a CI onboarding step passed with credentials that cannot authenticate. The configure fast path relies on that lookup to surface an expired key; rethrow its auth error (exit 3, original message and nextAction) when the profile was reused. Freshly validated keys keep the display-only fallback. The existing skip-if-configured test used a malformed key that never reached /me; it now uses a valid-format key and asserts the lookup. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ src/commands/init.test.ts | 67 ++++++++++++++++++++++++++++++++++----- src/commands/init.ts | 9 ++++-- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e54360f..f7760143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for ## [Unreleased] +### Fixed + +- **`setup --skip-if-configured` rejects invalid saved credentials.** When the identity lookup rejects the saved API key with an authentication error (401/403), setup now exits 3 with the original error and recovery hint instead of reporting success. Valid saved keys still succeed without prompting. + ## [0.15.0] - 2026-10-17 ### Added diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index c5a51d20..644b502d 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -67,21 +67,21 @@ function makeOkFetch(): InitDeps['fetchImpl'] { ) as unknown as InitDeps['fetchImpl']; } -/** Mock fetch that returns 401 for any request (simulates bad key). */ -function makeAuthFailFetch(): InitDeps['fetchImpl'] { +/** Mock fetch that returns an auth failure for any request (simulates bad key). */ +function makeAuthFailFetch(status: 401 | 403 = 401): InitDeps['fetchImpl'] { return vi.fn( async () => new Response( JSON.stringify({ error: { - code: 'AUTH_INVALID', + code: status === 401 ? 'AUTH_INVALID' : 'AUTH_FORBIDDEN', message: 'Invalid API key', nextAction: 'Provide a valid key.', requestId: 'r-1', }, }), { - status: 401, + status, headers: { 'content-type': 'application/json' }, }, ), @@ -620,6 +620,10 @@ describe('runInit — happy path (interactive)', () => { const parsed = JSON.parse(captured.stdout.join('\n')) as Record; expect(parsed.status).toBe('initialized'); + expect(parsed.scopes).toEqual([]); + expect(parsed.env).toBe('production'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-user-json-test'); expect(captured.stderr.some(line => line.includes('setup identity lookup failed'))).toBe(true); }); @@ -2055,13 +2059,14 @@ describe('runInit -- skipIfConfigured', () => { const { captured, deps } = makeCapture(); const { fs: agentFs } = makeMemFs(); // Write a saved key before running setup. - writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath }); + writeProfile('default', { apiKey: 'sk-user-saved-key' }, { path: credentialsPath }); // Provide a mock fetch that accepts /me so runWhoami (identity banner) succeeds. const fetchMock = makeOkFetch(); const prompt = { secret: vi.fn(async () => 'sk-should-never-be-asked') }; await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { ...deps, + env: {}, credentialsPath, fetchImpl: fetchMock, fs: agentFs, @@ -2071,13 +2076,59 @@ describe('runInit -- skipIfConfigured', () => { // The prompt must never have fired. expect(prompt.secret).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/me$/), + expect.objectContaining({ method: 'GET' }), + ); // The saved key must be untouched. - expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-saved'); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-user-saved-key'); // The summary must still be emitted. - const parsed = JSON.parse(captured.stdout.join('')) as { status: string }; - expect(parsed.status).toBe('initialized'); + expect(JSON.parse(captured.stdout.join(''))).toMatchObject({ + status: 'initialized', + scopes: ME.scopes, + env: ME.env, + credentials: { persisted: true, source: 'profile' }, + }); }); + it.each([ + { status: 401, code: 'AUTH_INVALID' }, + { status: 403, code: 'AUTH_FORBIDDEN' }, + ] as const)( + 'surfaces HTTP $status from the saved-key identity lookup', + async ({ status, code }) => { + const { captured, deps } = makeCapture(); + const key = 'sk-user-revoked-key'; + writeProfile('default', { apiKey: key }, { path: credentialsPath }); + const fetchMock = makeAuthFailFetch(status); + const prompt = { secret: vi.fn(async () => 'sk-user-unused') }; + + await expect( + runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { + ...deps, + env: {}, + credentialsPath, + fetchImpl: fetchMock, + prompt, + isTTY: false, + }), + ).rejects.toMatchObject({ + code, + exitCode: 3, + message: 'Invalid API key', + nextAction: 'Provide a valid key.', + httpStatus: status, + requestId: 'r-1', + }); + + expect(captured.stdout).toEqual([]); + expect(prompt.secret).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe(key); + }, + ); + it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => { const { captured, deps } = makeCapture(); const { fs: agentFs } = makeMemFs(); diff --git a/src/commands/init.ts b/src/commands/init.ts index da79cc26..9e4afdcf 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -538,7 +538,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise Date: Thu, 29 Oct 2026 01:42:46 -0400 Subject: [PATCH 2/2] fix(setup): withhold the agent notice when the saved key is rejected Validate the reused saved profile before announcing an agent install, and reuse that lookup for the summary, so a rejected key exits 3 with nothing in front of the error envelope and no install is described. Co-Authored-By: Claude Opus 5.5 --- src/commands/init.test.ts | 167 ++++++++++++++++++++++++++++++++++++-- src/commands/init.ts | 21 ++++- 2 files changed, 181 insertions(+), 7 deletions(-) diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 644b502d..03eb2bb2 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -2093,25 +2093,37 @@ describe('runInit -- skipIfConfigured', () => { }); it.each([ - { status: 401, code: 'AUTH_INVALID' }, - { status: 403, code: 'AUTH_FORBIDDEN' }, + { status: 401, code: 'AUTH_INVALID', output: 'json', isTTY: false, detected: true }, + { status: 403, code: 'AUTH_FORBIDDEN', output: 'json', isTTY: false, detected: true }, + { status: 401, code: 'AUTH_INVALID', output: 'text', isTTY: false, detected: true }, + { status: 403, code: 'AUTH_FORBIDDEN', output: 'text', isTTY: false, detected: true }, + { status: 401, code: 'AUTH_INVALID', output: 'text', isTTY: true, detected: true }, + { status: 403, code: 'AUTH_FORBIDDEN', output: 'text', isTTY: true, detected: true }, + { status: 401, code: 'AUTH_INVALID', output: 'json', isTTY: false, detected: false }, + { status: 403, code: 'AUTH_FORBIDDEN', output: 'json', isTTY: false, detected: false }, ] as const)( - 'surfaces HTTP $status from the saved-key identity lookup', - async ({ status, code }) => { + 'surfaces HTTP $status without an agent notice ($output, TTY=$isTTY, detected=$detected)', + async ({ status, code, output, isTTY, detected }) => { const { captured, deps } = makeCapture(); + const { fs: agentFs, writeCalls, mkdirCalls } = makeMemFs(); const key = 'sk-user-revoked-key'; writeProfile('default', { apiKey: key }, { path: credentialsPath }); const fetchMock = makeAuthFailFetch(status); const prompt = { secret: vi.fn(async () => 'sk-user-unused') }; + const agentPrompt = vi.fn(async () => 'cursor'); await expect( - runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { + runInit(makeDetectOpts({ apiKey: undefined, skipIfConfigured: true, output }), { ...deps, env: {}, credentialsPath, fetchImpl: fetchMock, prompt, - isTTY: false, + agentPrompt, + isTTY, + cwd: CWD, + fs: agentFs, + detect: detectRepo(detected ? ['.cursor/rules/team-style.mdc'] : []), }), ).rejects.toMatchObject({ code, @@ -2123,12 +2135,155 @@ describe('runInit -- skipIfConfigured', () => { }); expect(captured.stdout).toEqual([]); + expect(captured.stderr.join('\n')).not.toContain('[info]'); + // runInit throws; index.ts writes the error envelope next. Its prefix must be empty. + expect(captured.stderr.join('')).toBe(''); expect(prompt.secret).not.toHaveBeenCalled(); + expect(agentPrompt).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(writeCalls).toEqual([]); + expect(mkdirCalls).toEqual([]); expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe(key); }, ); + it('propagates an interrupted saved-key lookup before any agent notice or confirmation', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs, writeCalls, mkdirCalls } = makeMemFs(); + const key = 'sk-user-saved-key'; + writeProfile('default', { apiKey: key }, { path: credentialsPath }); + const interruption = new InterruptError('SIGINT'); + const fetchImpl = vi.fn(async () => { + throw interruption; + }) as unknown as InitDeps['fetchImpl']; + const agentPrompt = vi.fn(async () => 'cursor'); + + await expect( + runInit(makeDetectOpts({ apiKey: undefined, skipIfConfigured: true }), { + ...deps, + env: {}, + credentialsPath, + fetchImpl, + agentPrompt, + isTTY: true, + cwd: CWD, + fs: agentFs, + detect: detectRepo(['.cursor/rules/team-style.mdc']), + }), + ).rejects.toBe(interruption); + + expect(captured.stderr.join('\n')).not.toContain('[info]'); + expect(agentPrompt).not.toHaveBeenCalled(); + expect(captured.stdout).toEqual([]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringMatching(/\/me$/), + expect.objectContaining({ method: 'GET' }), + ); + expect(writeCalls).toEqual([]); + expect(mkdirCalls).toEqual([]); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe(key); + }); + + it.each([ + { + files: ['.cursor/rules/team-style.mdc'], + notice: '[info] detected cursor (.cursor); installing skills for cursor', + }, + { + files: [], + notice: + '[info] no coding agent detected in this project; installing for claude. ' + + `Pass --agent to choose (${Object.keys(TARGETS).join(', ')}).`, + }, + ])('keeps the valid saved-key agent notice unchanged: $notice', async ({ files, notice }) => { + const { captured, deps } = makeCapture(); + const { fs: agentFs, writeCalls } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-user-saved-key' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + + await runInit(makeDetectOpts({ apiKey: undefined, skipIfConfigured: true, output: 'json' }), { + ...deps, + env: {}, + credentialsPath, + fetchImpl: fetchMock, + isTTY: false, + cwd: CWD, + fs: agentFs, + detect: detectRepo(files), + }); + + expect(captured.stderr).toEqual([notice]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(writeCalls.length).toBeGreaterThan(0); + expect(JSON.parse(captured.stdout.join(''))).toMatchObject({ + status: 'initialized', + email: ME.email, + credentials: { persisted: true, source: 'profile' }, + }); + }); + + it('keeps saved-key confirmation notices before the prompt and HTTP debug output', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-user-saved-key' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + const agentPrompt = vi.fn(async () => { + expect(captured.stderr).toEqual(['[info] detected cursor (.cursor)']); + return 'cursor'; + }); + + await runInit(makeDetectOpts({ apiKey: undefined, skipIfConfigured: true, debug: true }), { + ...deps, + env: {}, + credentialsPath, + fetchImpl: fetchMock, + agentPrompt, + isTTY: true, + cwd: CWD, + fs: agentFs, + detect: detectRepo(['.cursor/rules/team-style.mdc']), + }); + + expect(agentPrompt).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(captured.stderr.slice(0, 2)).toEqual([ + '[info] detected cursor (.cursor)', + '[info] installing skills for cursor', + ]); + expect(captured.stderr[2]).toMatch(/^\[debug /); + expect(captured.stdout.join('')).toContain('TestSprite initialized.'); + }); + + it('keeps the saved-key dry-run notice before the preview without looking up identity', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs, writeCalls, mkdirCalls } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-user-revoked-key' }, { path: credentialsPath }); + const fetchMock = makeAuthFailFetch(); + + await runInit( + makeDetectOpts({ apiKey: undefined, skipIfConfigured: true, dryRun: true, output: 'json' }), + { + ...deps, + env: {}, + credentialsPath, + fetchImpl: fetchMock, + isTTY: false, + cwd: CWD, + fs: agentFs, + detect: detectRepo(['.cursor/rules/team-style.mdc']), + }, + ); + + expect(captured.stderr[0]).toBe( + '[info] detected cursor (.cursor); installing skills for cursor', + ); + expect(captured.stderr[1]).toMatch(/^\[dry-run\]/); + expect(fetchMock).not.toHaveBeenCalled(); + expect(writeCalls).toEqual([]); + expect(mkdirCalls).toEqual([]); + }); + it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => { const { captured, deps } = makeCapture(); const { fs: agentFs } = makeMemFs(); diff --git a/src/commands/init.ts b/src/commands/init.ts index 9e4afdcf..900ff1ba 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -438,6 +438,24 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise | undefined; + const savedIdentityStderr: string[] = []; + if (resolution && skipWillApply && !opts.dryRun) { + savedIdentityLookup = runWhoami(opts, { + ...toAuthDeps(deps), + stderr: line => savedIdentityStderr.push(line), + }); + try { + await savedIdentityLookup; + } catch (err) { + if (err instanceof InterruptError) throw err; + if (err instanceof CLIError && err.exitCode === 3) throw err; + // Other failures are handled by the normal whoami catch below. + } + } + // Announced only once the guards above have passed — otherwise a `--yes` with // no key source printed "detected …" and then exited 5, describing an install // that was never going to happen. @@ -555,7 +573,8 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise