Skip to content

Commit 2708a40

Browse files
authored
fix(init): skip API key prompt when credentials already exist (TestSprite#234)
Add --skip-if-configured to testsprite setup (and the deprecated init alias). When the active profile already has a saved API key and no explicit key source (--api-key or --from-env) is given, the interactive prompt is skipped and the existing key is reused. Motivation: re-running setup to refresh the agent skill -- a common pattern in dotfiles, onboarding scripts, and CI bootstraps -- currently always re-prompts for the key, even when credentials were already configured. The flag makes setup idempotent for the credential step. Behaviour: - runConfigure short-circuits with status:already_configured when skipIfConfigured is true and existingProfile.apiKey is present. - runInit relaxes the non-interactive guard and the --output json guard when skipWillApply is true, so the flag works in CI (isTTY=false) and JSON mode without requiring a separate --api-key. - --api-key always overwrites, regardless of --skip-if-configured. - --from-env always overwrites, regardless of --skip-if-configured. - --dry-run is unaffected (no network, no writes; preview only). New tests (8 cases across auth.test.ts and init.test.ts): - skips prompt and returns early when credentials exist (text + JSON) - proceeds to prompt when no credentials exist - allows isTTY=false CI runs when skip applies - --api-key overwrites despite skip flag - --from-env overwrites despite skip flag Closes TestSprite#206
1 parent 5fe0b00 commit 2708a40

5 files changed

Lines changed: 261 additions & 18 deletions

File tree

src/commands/auth.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,3 +1281,90 @@ describe('createAuthCommand surface', () => {
12811281
expect(err.exitCode).toBe(3);
12821282
});
12831283
});
1284+
1285+
// ---------------------------------------------------------------------------
1286+
// runConfigure -- skipIfConfigured
1287+
// ---------------------------------------------------------------------------
1288+
1289+
describe('runConfigure -- skipIfConfigured', () => {
1290+
it('skips the prompt and returns early when credentials already exist', async () => {
1291+
const { capture, deps } = makeCapture();
1292+
// Write a saved key first.
1293+
writeProfile('default', { apiKey: 'sk-existing' }, { path: credentialsPath });
1294+
const prompt = { secret: vi.fn(async () => 'sk-new') };
1295+
const fetchImpl = vi.fn();
1296+
1297+
await runConfigure(
1298+
{ profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true },
1299+
{
1300+
...deps,
1301+
credentialsPath,
1302+
prompt,
1303+
fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'],
1304+
},
1305+
);
1306+
1307+
// Prompt must never have fired.
1308+
expect(prompt.secret).not.toHaveBeenCalled();
1309+
// No network call -- we never validated or wrote a key.
1310+
expect(fetchImpl).not.toHaveBeenCalled();
1311+
// The saved key must be untouched.
1312+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-existing');
1313+
// Output indicates already_configured.
1314+
expect(capture.stdout.join('\n')).toContain('already configured');
1315+
});
1316+
1317+
it('emits already_configured status in JSON mode', async () => {
1318+
const { capture, deps } = makeCapture();
1319+
writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath });
1320+
const fetchImpl = vi.fn();
1321+
1322+
await runConfigure(
1323+
{ profile: 'default', output: 'json', debug: false, fromEnv: false, skipIfConfigured: true },
1324+
{ ...deps, credentialsPath, fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'] },
1325+
);
1326+
1327+
expect(fetchImpl).not.toHaveBeenCalled();
1328+
const parsed = JSON.parse(capture.stdout.join(''));
1329+
expect(parsed).toMatchObject({ profile: 'default', status: 'already_configured' });
1330+
});
1331+
1332+
it('proceeds normally when no credentials exist and skipIfConfigured is true', async () => {
1333+
const { deps } = makeCapture();
1334+
// No pre-existing profile -- skip has no effect, should fall through to prompt.
1335+
const prompt = { secret: vi.fn(async () => 'sk-new') };
1336+
1337+
await runConfigure(
1338+
{ profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true },
1339+
{ ...deps, credentialsPath, prompt, fetchImpl: meOkFetch },
1340+
);
1341+
1342+
expect(prompt.secret).toHaveBeenCalledTimes(1);
1343+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new');
1344+
});
1345+
1346+
it('ignores skipIfConfigured when --from-env is set', async () => {
1347+
const { deps } = makeCapture();
1348+
// Pre-existing key -- but fromEnv should override and write a new one.
1349+
writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath });
1350+
1351+
await runConfigure(
1352+
{
1353+
profile: 'default',
1354+
output: 'text',
1355+
debug: false,
1356+
fromEnv: true,
1357+
skipIfConfigured: true,
1358+
},
1359+
{
1360+
...deps,
1361+
env: { TESTSPRITE_API_KEY: 'sk-from-env' },
1362+
credentialsPath,
1363+
fetchImpl: meOkFetch,
1364+
},
1365+
);
1366+
1367+
// The env key must overwrite the saved key.
1368+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-from-env');
1369+
});
1370+
});

src/commands/auth.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ type CommonOptions = FactoryCommonOptions;
7171

7272
interface ConfigureOptions extends CommonOptions {
7373
fromEnv: boolean;
74+
/**
75+
* When true and the active profile already has a saved API key, skip the
76+
* interactive key prompt and proceed directly to the skill-install step.
77+
* A CI-safe flag: lets `setup` run idempotently without prompting on
78+
* machines that already have credentials (e.g. re-running setup to
79+
* refresh the agent skill without re-entering the key).
80+
*
81+
* Ignored when an explicit `--api-key` or `--from-env` key source is
82+
* provided -- those paths always overwrite, regardless of existing state.
83+
*/
84+
skipIfConfigured?: boolean;
7485
}
7586

7687
const DEFAULT_API_URL = 'https://api.testsprite.com';
@@ -131,9 +142,23 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
131142
apiKey = env.TESTSPRITE_API_KEY?.trim();
132143
if (!apiKey) throw validationError('TESTSPRITE_API_KEY', FROM_ENV_MISSING_KEY);
133144
} else {
145+
// --skip-if-configured: when a non-empty API key is already saved for
146+
// this profile, skip the interactive prompt and return early. The
147+
// key is NOT re-validated via GET /me on this path -- the caller
148+
// (runInit) only reaches this when no explicit key source was given,
149+
// and the subsequent whoami call in runInit will surface an expired
150+
// key to the user anyway.
151+
if (opts.skipIfConfigured && existingProfile?.apiKey) {
152+
out.print({ profile: opts.profile, apiUrl, status: 'already_configured' }, data => {
153+
const d = data as { profile: string; apiUrl: string };
154+
return `Profile "${d.profile}" already configured. Endpoint: ${d.apiUrl}`;
155+
});
156+
return;
157+
}
158+
134159
const promptApi = deps.prompt ?? { secret: (q: string) => promptSecret(q) };
135160
prelude(`Configuring profile "${opts.profile}".\n`);
136-
// Only the API key is prompted the endpoint defaults to prod (see above).
161+
// Only the API key is prompted -- the endpoint defaults to prod (see above).
137162
apiKey = (await promptApi.secret('TestSprite API key: ')).trim();
138163
if (!apiKey) throw new CLIError('No API key provided.', 5);
139164
}

src/commands/init.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import path from 'node:path';
99
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1010
import { ApiError, CLIError } from '../lib/errors.js';
1111
import { resetDryRunBannerForTesting } from '../lib/client-factory.js';
12+
import { readProfile, writeProfile } from '../lib/credentials.js';
1213
import type { MeResponse } from './auth.js';
1314
import type { AgentFs } from './agent.js';
1415
import type { InitDeps } from './init.js';
@@ -1045,3 +1046,99 @@ describe('runInit — telemetry attribution (X-CLI-Command)', () => {
10451046
expect(initTagged).toHaveLength(1);
10461047
});
10471048
});
1049+
1050+
// ---------------------------------------------------------------------------
1051+
// runInit -- skipIfConfigured
1052+
// ---------------------------------------------------------------------------
1053+
1054+
describe('runInit -- skipIfConfigured', () => {
1055+
it('skips the API key prompt and reuses saved credentials when the profile exists', async () => {
1056+
const { captured, deps } = makeCapture();
1057+
const { fs: agentFs } = makeMemFs();
1058+
// Write a saved key before running setup.
1059+
writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath });
1060+
// Provide a mock fetch that accepts /me so runWhoami (identity banner) succeeds.
1061+
const fetchMock = makeOkFetch();
1062+
const prompt = { secret: vi.fn(async () => 'sk-should-never-be-asked') };
1063+
1064+
await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), {
1065+
...deps,
1066+
credentialsPath,
1067+
fetchImpl: fetchMock,
1068+
fs: agentFs,
1069+
isTTY: false,
1070+
prompt,
1071+
});
1072+
1073+
// The prompt must never have fired.
1074+
expect(prompt.secret).not.toHaveBeenCalled();
1075+
// The saved key must be untouched.
1076+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-saved');
1077+
// The summary must still be emitted.
1078+
const parsed = JSON.parse(captured.stdout.join('')) as { status: string };
1079+
expect(parsed.status).toBe('initialized');
1080+
});
1081+
1082+
it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => {
1083+
const { captured, deps } = makeCapture();
1084+
const { fs: agentFs } = makeMemFs();
1085+
// No pre-existing credentials -- skip has no effect.
1086+
const fetchMock = makeOkFetch();
1087+
const prompt = { secret: vi.fn(async () => 'sk-fresh') };
1088+
1089+
await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'text' }), {
1090+
...deps,
1091+
credentialsPath,
1092+
fetchImpl: fetchMock,
1093+
fs: agentFs,
1094+
isTTY: true,
1095+
prompt,
1096+
});
1097+
1098+
// With no saved key, the prompt should fire.
1099+
expect(prompt.secret).toHaveBeenCalledTimes(1);
1100+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-fresh');
1101+
expect(captured.stdout.join('')).toContain('initialized');
1102+
});
1103+
1104+
it('allows non-interactive (isTTY=false) when skipIfConfigured is true and credentials exist', async () => {
1105+
const { deps } = makeCapture();
1106+
const { fs: agentFs } = makeMemFs();
1107+
writeProfile('default', { apiKey: 'sk-ci' }, { path: credentialsPath });
1108+
const fetchMock = makeOkFetch();
1109+
1110+
// Must not throw exit 5 for "non-interactive mode, no key source".
1111+
await expect(
1112+
runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), {
1113+
...deps,
1114+
credentialsPath,
1115+
fetchImpl: fetchMock,
1116+
fs: agentFs,
1117+
isTTY: false,
1118+
}),
1119+
).resolves.toBeUndefined();
1120+
1121+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-ci');
1122+
});
1123+
1124+
it('--api-key takes precedence over skipIfConfigured and overwrites the saved key', async () => {
1125+
const { deps } = makeCapture();
1126+
const { fs: agentFs } = makeMemFs();
1127+
writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath });
1128+
const fetchMock = makeOkFetch();
1129+
1130+
await runInit(
1131+
makeBaseOpts({ apiKey: 'sk-new', skipIfConfigured: true, noAgent: true, output: 'text' }),
1132+
{
1133+
...deps,
1134+
credentialsPath,
1135+
fetchImpl: fetchMock,
1136+
fs: agentFs,
1137+
isTTY: false,
1138+
},
1139+
);
1140+
1141+
// Explicit --api-key must overwrite regardless of skipIfConfigured.
1142+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new');
1143+
});
1144+
});

src/commands/init.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ interface InitOptions extends CommonOptions {
9090
force: boolean;
9191
dir?: string;
9292
yes: boolean;
93+
/**
94+
* When true and the active profile already has a saved API key, skip the
95+
* interactive key prompt. Forwarded verbatim to runConfigure. Has no
96+
* effect when --api-key or --from-env is also given.
97+
*/
98+
skipIfConfigured?: boolean;
9399
/** Set by the command action when both --agent and --no-agent appear in rawArgs. */
94100
rawArgConflict?: boolean;
95101
}
@@ -196,9 +202,16 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
196202
// -------------------------------------------------------------------------
197203
const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
198204
const hasKeySource = Boolean(opts.apiKey) || opts.fromEnv;
205+
// --skip-if-configured counts as a key source when a saved key already exists:
206+
// runConfigure will short-circuit before prompting, so no TTY is needed.
207+
const credentialsPath = deps.credentialsPath;
208+
const savedKey = credentialsPath
209+
? readProfile(opts.profile, { path: credentialsPath })?.apiKey
210+
: readProfile(opts.profile)?.apiKey;
211+
const skipWillApply = Boolean(opts.skipIfConfigured) && Boolean(savedKey) && !hasKeySource;
199212
// Non-interactive guard: no TTY + no key source → exit 5. Skipped under
200213
// --dry-run, which is documented to work without credentials or network.
201-
if (!isTTY && !hasKeySource && !opts.dryRun) {
214+
if (!isTTY && !hasKeySource && !skipWillApply && !opts.dryRun) {
202215
throw new CLIError(
203216
'No API key available in non-interactive mode. ' +
204217
'Pass --api-key <key>, --from-env (reads TESTSPRITE_API_KEY), or run interactively.',
@@ -208,7 +221,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
208221
// JSON-output guard: an interactive secret prompt writes to stdout and would
209222
// corrupt init's single-JSON-object output contract. In --output json mode
210223
// require a non-interactive key source. Skipped under --dry-run (never prompts).
211-
if (opts.output === 'json' && !hasKeySource && !opts.dryRun) {
224+
if (opts.output === 'json' && !hasKeySource && !skipWillApply && !opts.dryRun) {
212225
throw new CLIError(
213226
'Interactive API-key prompt is unavailable in --output json mode (it would corrupt JSON stdout). ' +
214227
'Pass --api-key <key> or --from-env.',
@@ -223,7 +236,13 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
223236
stderrFn('[dry-run] no writes or network calls — preview only');
224237
stderrFn(
225238
`[dry-run] would configure profile="${opts.profile}" (key source: ${
226-
opts.apiKey ? 'flag' : opts.fromEnv ? 'env' : 'prompt'
239+
opts.apiKey
240+
? 'flag'
241+
: opts.fromEnv
242+
? 'env'
243+
: opts.skipIfConfigured
244+
? 'skip-if-configured'
245+
: 'prompt'
227246
})`,
228247
);
229248

@@ -265,7 +284,12 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
265284
// force fromEnv=false so runConfigure uses the injected key (toAuthDeps wires it
266285
// as the prompt) instead of reading TESTSPRITE_API_KEY from the environment (codex).
267286
await runConfigure(
268-
{ ...opts, fromEnv: opts.apiKey ? false : opts.fromEnv },
287+
{
288+
...opts,
289+
fromEnv: opts.apiKey ? false : opts.fromEnv,
290+
// --api-key always overwrites; skip only applies when no explicit key source was given.
291+
skipIfConfigured: opts.apiKey ? false : opts.skipIfConfigured,
292+
},
269293
// commandTag:'init' tags ONLY this configure-validate GET /me with
270294
// `X-CLI-Command: init` → counted as cli.initialized. The whoami banner call
271295
// below builds deps WITHOUT a tag, so init emits exactly one cli.initialized.
@@ -479,6 +503,7 @@ interface SetupCmdOpts {
479503
force?: boolean;
480504
dir?: string;
481505
yes?: boolean;
506+
skipIfConfigured?: boolean;
482507
}
483508

484509
/** Attach the onboarding flags shared by `setup` and the `init` alias. */
@@ -502,7 +527,11 @@ function addSetupOptions(
502527
.option('--no-agent', 'Skip the agent skill install (configure credentials only)')
503528
.option('--force', 'Overwrite an existing skill file (a .bak backup is kept)')
504529
.option('--dir <path>', 'Project root for the skill install (default: current directory)')
505-
.option('-y, --yes', 'Non-interactive: accept all defaults, never prompt');
530+
.option('-y, --yes', 'Non-interactive: accept all defaults, never prompt')
531+
.option(
532+
'--skip-if-configured',
533+
'Skip the API key prompt when credentials already exist for this profile (CI-safe idempotent re-run)',
534+
);
506535
}
507536

508537
/** Build {@link InitOptions} from raw Commander opts + globals. */
@@ -543,6 +572,7 @@ function buildSetupOptions(
543572
force: Boolean(cmdOpts.force),
544573
dir: cmdOpts.dir,
545574
yes: Boolean(cmdOpts.yes),
575+
skipIfConfigured: Boolean(cmdOpts.skipIfConfigured),
546576
rawArgConflict,
547577
};
548578
}

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,22 @@ exports[`--help snapshots > init 1`] = `
112112
(deprecated) alias for \`setup\`
113113
114114
Options:
115-
--api-key <key> API key to configure (skips the interactive prompt)
116-
--from-env Read TESTSPRITE_API_KEY from the environment instead of
117-
prompting (default: false)
118-
--agent <target> Coding-agent target to install: claude, antigravity,
119-
cursor, cline, kiro, windsurf, copilot, codex (default:
120-
claude) (default: "claude")
121-
--no-agent Skip the agent skill install (configure credentials only)
122-
--force Overwrite an existing skill file (a .bak backup is kept)
123-
--dir <path> Project root for the skill install (default: current
124-
directory)
125-
-y, --yes Non-interactive: accept all defaults, never prompt
126-
-h, --help display help for command
115+
--api-key <key> API key to configure (skips the interactive prompt)
116+
--from-env Read TESTSPRITE_API_KEY from the environment instead of
117+
prompting (default: false)
118+
--agent <target> Coding-agent target to install: claude, antigravity,
119+
cursor, cline, kiro, windsurf, copilot, codex (default:
120+
claude) (default: "claude")
121+
--no-agent Skip the agent skill install (configure credentials
122+
only)
123+
--force Overwrite an existing skill file (a .bak backup is
124+
kept)
125+
--dir <path> Project root for the skill install (default: current
126+
directory)
127+
-y, --yes Non-interactive: accept all defaults, never prompt
128+
--skip-if-configured Skip the API key prompt when credentials already exist
129+
for this profile (CI-safe idempotent re-run)
130+
-h, --help display help for command
127131
"
128132
`;
129133

0 commit comments

Comments
 (0)