diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts index c0b4e00..63a354d 100644 --- a/src/commands/auth.test.ts +++ b/src/commands/auth.test.ts @@ -1224,3 +1224,90 @@ describe('createAuthCommand surface', () => { expect(err.exitCode).toBe(3); }); }); + +// --------------------------------------------------------------------------- +// runConfigure -- skipIfConfigured +// --------------------------------------------------------------------------- + +describe('runConfigure -- skipIfConfigured', () => { + it('skips the prompt and returns early when credentials already exist', async () => { + const { capture, deps } = makeCapture(); + // Write a saved key first. + writeProfile('default', { apiKey: 'sk-existing' }, { path: credentialsPath }); + const prompt = { secret: vi.fn(async () => 'sk-new') }; + const fetchImpl = vi.fn(); + + await runConfigure( + { profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true }, + { + ...deps, + credentialsPath, + prompt, + fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'], + }, + ); + + // Prompt must never have fired. + expect(prompt.secret).not.toHaveBeenCalled(); + // No network call -- we never validated or wrote a key. + expect(fetchImpl).not.toHaveBeenCalled(); + // The saved key must be untouched. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-existing'); + // Output indicates already_configured. + expect(capture.stdout.join('\n')).toContain('already configured'); + }); + + it('emits already_configured status in JSON mode', async () => { + const { capture, deps } = makeCapture(); + writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath }); + const fetchImpl = vi.fn(); + + await runConfigure( + { profile: 'default', output: 'json', debug: false, fromEnv: false, skipIfConfigured: true }, + { ...deps, credentialsPath, fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'] }, + ); + + expect(fetchImpl).not.toHaveBeenCalled(); + const parsed = JSON.parse(capture.stdout.join('')); + expect(parsed).toMatchObject({ profile: 'default', status: 'already_configured' }); + }); + + it('proceeds normally when no credentials exist and skipIfConfigured is true', async () => { + const { deps } = makeCapture(); + // No pre-existing profile -- skip has no effect, should fall through to prompt. + const prompt = { secret: vi.fn(async () => 'sk-new') }; + + await runConfigure( + { profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true }, + { ...deps, credentialsPath, prompt, fetchImpl: meOkFetch }, + ); + + expect(prompt.secret).toHaveBeenCalledTimes(1); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new'); + }); + + it('ignores skipIfConfigured when --from-env is set', async () => { + const { deps } = makeCapture(); + // Pre-existing key -- but fromEnv should override and write a new one. + writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath }); + + await runConfigure( + { + profile: 'default', + output: 'text', + debug: false, + fromEnv: true, + skipIfConfigured: true, + }, + { + ...deps, + env: { TESTSPRITE_API_KEY: 'sk-from-env' }, + credentialsPath, + fetchImpl: meOkFetch, + }, + ); + + // The env key must overwrite the saved key. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-from-env'); + }); +}); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 59cd43f..ad320ff 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -65,6 +65,17 @@ type CommonOptions = FactoryCommonOptions; interface ConfigureOptions extends CommonOptions { fromEnv: boolean; + /** + * When true and the active profile already has a saved API key, skip the + * interactive key prompt and proceed directly to the skill-install step. + * A CI-safe flag: lets `setup` run idempotently without prompting on + * machines that already have credentials (e.g. re-running setup to + * refresh the agent skill without re-entering the key). + * + * Ignored when an explicit `--api-key` or `--from-env` key source is + * provided -- those paths always overwrite, regardless of existing state. + */ + skipIfConfigured?: boolean; } const DEFAULT_API_URL = 'https://api.testsprite.com'; @@ -125,9 +136,23 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}): apiKey = env.TESTSPRITE_API_KEY?.trim(); if (!apiKey) throw validationError('TESTSPRITE_API_KEY', FROM_ENV_MISSING_KEY); } else { + // --skip-if-configured: when a non-empty API key is already saved for + // this profile, skip the interactive prompt and return early. The + // key is NOT re-validated via GET /me on this path -- the caller + // (runInit) only reaches this when no explicit key source was given, + // and the subsequent whoami call in runInit will surface an expired + // key to the user anyway. + if (opts.skipIfConfigured && existingProfile?.apiKey) { + out.print({ profile: opts.profile, apiUrl, status: 'already_configured' }, data => { + const d = data as { profile: string; apiUrl: string }; + return `Profile "${d.profile}" already configured. Endpoint: ${d.apiUrl}`; + }); + return; + } + const promptApi = deps.prompt ?? { secret: (q: string) => promptSecret(q) }; prelude(`Configuring profile "${opts.profile}".\n`); - // Only the API key is prompted — the endpoint defaults to prod (see above). + // Only the API key is prompted -- the endpoint defaults to prod (see above). apiKey = (await promptApi.secret('TestSprite API key: ')).trim(); if (!apiKey) throw new CLIError('No API key provided.', 5); } diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 617fd6f..c66be21 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, CLIError } from '../lib/errors.js'; import { resetDryRunBannerForTesting } from '../lib/client-factory.js'; +import { readProfile, writeProfile } from '../lib/credentials.js'; import type { MeResponse } from './auth.js'; import type { AgentFs } from './agent.js'; import type { InitDeps } from './init.js'; @@ -1006,3 +1007,99 @@ describe('runInit — telemetry attribution (X-CLI-Command)', () => { expect(initTagged).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// runInit -- skipIfConfigured +// --------------------------------------------------------------------------- + +describe('runInit -- skipIfConfigured', () => { + it('skips the API key prompt and reuses saved credentials when the profile exists', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + // Write a saved key before running setup. + writeProfile('default', { apiKey: 'sk-saved' }, { 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, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + prompt, + }); + + // The prompt must never have fired. + expect(prompt.secret).not.toHaveBeenCalled(); + // The saved key must be untouched. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-saved'); + // The summary must still be emitted. + const parsed = JSON.parse(captured.stdout.join('')) as { status: string }; + expect(parsed.status).toBe('initialized'); + }); + + it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + // No pre-existing credentials -- skip has no effect. + const fetchMock = makeOkFetch(); + const prompt = { secret: vi.fn(async () => 'sk-fresh') }; + + await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'text' }), { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: true, + prompt, + }); + + // With no saved key, the prompt should fire. + expect(prompt.secret).toHaveBeenCalledTimes(1); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-fresh'); + expect(captured.stdout.join('')).toContain('initialized'); + }); + + it('allows non-interactive (isTTY=false) when skipIfConfigured is true and credentials exist', async () => { + const { deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-ci' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + + // Must not throw exit 5 for "non-interactive mode, no key source". + await expect( + runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + }), + ).resolves.toBeUndefined(); + + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-ci'); + }); + + it('--api-key takes precedence over skipIfConfigured and overwrites the saved key', async () => { + const { deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + + await runInit( + makeBaseOpts({ apiKey: 'sk-new', skipIfConfigured: true, noAgent: true, output: 'text' }), + { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + }, + ); + + // Explicit --api-key must overwrite regardless of skipIfConfigured. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new'); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index c2d5678..d0db3b4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -90,6 +90,12 @@ interface InitOptions extends CommonOptions { force: boolean; dir?: string; yes: boolean; + /** + * When true and the active profile already has a saved API key, skip the + * interactive key prompt. Forwarded verbatim to runConfigure. Has no + * effect when --api-key or --from-env is also given. + */ + skipIfConfigured?: boolean; /** Set by the command action when both --agent and --no-agent appear in rawArgs. */ rawArgConflict?: boolean; } @@ -196,9 +202,16 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise, --from-env (reads TESTSPRITE_API_KEY), or run interactively.', @@ -208,7 +221,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise or --from-env.', @@ -223,7 +236,13 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise', 'Project root for the skill install (default: current directory)') - .option('-y, --yes', 'Non-interactive: accept all defaults, never prompt'); + .option('-y, --yes', 'Non-interactive: accept all defaults, never prompt') + .option( + '--skip-if-configured', + 'Skip the API key prompt when credentials already exist for this profile (CI-safe idempotent re-run)', + ); } /** Build {@link InitOptions} from raw Commander opts + globals. */ @@ -539,6 +568,7 @@ function buildSetupOptions( force: Boolean(cmdOpts.force), dir: cmdOpts.dir, yes: Boolean(cmdOpts.yes), + skipIfConfigured: Boolean(cmdOpts.skipIfConfigured), rawArgConflict, }; } diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 62efe5d..76198ad 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -112,18 +112,22 @@ exports[`--help snapshots > init 1`] = ` (deprecated) alias for \`setup\` Options: - --api-key API key to configure (skips the interactive prompt) - --from-env Read TESTSPRITE_API_KEY from the environment instead of - prompting (default: false) - --agent Coding-agent target to install: claude, antigravity, - cursor, cline, kiro, windsurf, copilot, codex (default: - claude) (default: "claude") - --no-agent Skip the agent skill install (configure credentials only) - --force Overwrite an existing skill file (a .bak backup is kept) - --dir Project root for the skill install (default: current - directory) - -y, --yes Non-interactive: accept all defaults, never prompt - -h, --help display help for command + --api-key API key to configure (skips the interactive prompt) + --from-env Read TESTSPRITE_API_KEY from the environment instead of + prompting (default: false) + --agent Coding-agent target to install: claude, antigravity, + cursor, cline, kiro, windsurf, copilot, codex (default: + claude) (default: "claude") + --no-agent Skip the agent skill install (configure credentials + only) + --force Overwrite an existing skill file (a .bak backup is + kept) + --dir Project root for the skill install (default: current + directory) + -y, --yes Non-interactive: accept all defaults, never prompt + --skip-if-configured Skip the API key prompt when credentials already exist + for this profile (CI-safe idempotent re-run) + -h, --help display help for command " `;