Skip to content

Commit 9ce0f2a

Browse files
committed
fix(init): skip API key prompt when credentials already exist
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 #206
1 parent 60d55e4 commit 9ce0f2a

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
@@ -1224,3 +1224,90 @@ describe('createAuthCommand surface', () => {
12241224
expect(err.exitCode).toBe(3);
12251225
});
12261226
});
1227+
1228+
// ---------------------------------------------------------------------------
1229+
// runConfigure -- skipIfConfigured
1230+
// ---------------------------------------------------------------------------
1231+
1232+
describe('runConfigure -- skipIfConfigured', () => {
1233+
it('skips the prompt and returns early when credentials already exist', async () => {
1234+
const { capture, deps } = makeCapture();
1235+
// Write a saved key first.
1236+
writeProfile('default', { apiKey: 'sk-existing' }, { path: credentialsPath });
1237+
const prompt = { secret: vi.fn(async () => 'sk-new') };
1238+
const fetchImpl = vi.fn();
1239+
1240+
await runConfigure(
1241+
{ profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true },
1242+
{
1243+
...deps,
1244+
credentialsPath,
1245+
prompt,
1246+
fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'],
1247+
},
1248+
);
1249+
1250+
// Prompt must never have fired.
1251+
expect(prompt.secret).not.toHaveBeenCalled();
1252+
// No network call -- we never validated or wrote a key.
1253+
expect(fetchImpl).not.toHaveBeenCalled();
1254+
// The saved key must be untouched.
1255+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-existing');
1256+
// Output indicates already_configured.
1257+
expect(capture.stdout.join('\n')).toContain('already configured');
1258+
});
1259+
1260+
it('emits already_configured status in JSON mode', async () => {
1261+
const { capture, deps } = makeCapture();
1262+
writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath });
1263+
const fetchImpl = vi.fn();
1264+
1265+
await runConfigure(
1266+
{ profile: 'default', output: 'json', debug: false, fromEnv: false, skipIfConfigured: true },
1267+
{ ...deps, credentialsPath, fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'] },
1268+
);
1269+
1270+
expect(fetchImpl).not.toHaveBeenCalled();
1271+
const parsed = JSON.parse(capture.stdout.join(''));
1272+
expect(parsed).toMatchObject({ profile: 'default', status: 'already_configured' });
1273+
});
1274+
1275+
it('proceeds normally when no credentials exist and skipIfConfigured is true', async () => {
1276+
const { deps } = makeCapture();
1277+
// No pre-existing profile -- skip has no effect, should fall through to prompt.
1278+
const prompt = { secret: vi.fn(async () => 'sk-new') };
1279+
1280+
await runConfigure(
1281+
{ profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true },
1282+
{ ...deps, credentialsPath, prompt, fetchImpl: meOkFetch },
1283+
);
1284+
1285+
expect(prompt.secret).toHaveBeenCalledTimes(1);
1286+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new');
1287+
});
1288+
1289+
it('ignores skipIfConfigured when --from-env is set', async () => {
1290+
const { deps } = makeCapture();
1291+
// Pre-existing key -- but fromEnv should override and write a new one.
1292+
writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath });
1293+
1294+
await runConfigure(
1295+
{
1296+
profile: 'default',
1297+
output: 'text',
1298+
debug: false,
1299+
fromEnv: true,
1300+
skipIfConfigured: true,
1301+
},
1302+
{
1303+
...deps,
1304+
env: { TESTSPRITE_API_KEY: 'sk-from-env' },
1305+
credentialsPath,
1306+
fetchImpl: meOkFetch,
1307+
},
1308+
);
1309+
1310+
// The env key must overwrite the saved key.
1311+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-from-env');
1312+
});
1313+
});

src/commands/auth.ts

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

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

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

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';
@@ -1006,3 +1007,99 @@ describe('runInit — telemetry attribution (X-CLI-Command)', () => {
10061007
expect(initTagged).toHaveLength(1);
10071008
});
10081009
});
1010+
1011+
// ---------------------------------------------------------------------------
1012+
// runInit -- skipIfConfigured
1013+
// ---------------------------------------------------------------------------
1014+
1015+
describe('runInit -- skipIfConfigured', () => {
1016+
it('skips the API key prompt and reuses saved credentials when the profile exists', async () => {
1017+
const { captured, deps } = makeCapture();
1018+
const { fs: agentFs } = makeMemFs();
1019+
// Write a saved key before running setup.
1020+
writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath });
1021+
// Provide a mock fetch that accepts /me so runWhoami (identity banner) succeeds.
1022+
const fetchMock = makeOkFetch();
1023+
const prompt = { secret: vi.fn(async () => 'sk-should-never-be-asked') };
1024+
1025+
await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), {
1026+
...deps,
1027+
credentialsPath,
1028+
fetchImpl: fetchMock,
1029+
fs: agentFs,
1030+
isTTY: false,
1031+
prompt,
1032+
});
1033+
1034+
// The prompt must never have fired.
1035+
expect(prompt.secret).not.toHaveBeenCalled();
1036+
// The saved key must be untouched.
1037+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-saved');
1038+
// The summary must still be emitted.
1039+
const parsed = JSON.parse(captured.stdout.join('')) as { status: string };
1040+
expect(parsed.status).toBe('initialized');
1041+
});
1042+
1043+
it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => {
1044+
const { captured, deps } = makeCapture();
1045+
const { fs: agentFs } = makeMemFs();
1046+
// No pre-existing credentials -- skip has no effect.
1047+
const fetchMock = makeOkFetch();
1048+
const prompt = { secret: vi.fn(async () => 'sk-fresh') };
1049+
1050+
await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'text' }), {
1051+
...deps,
1052+
credentialsPath,
1053+
fetchImpl: fetchMock,
1054+
fs: agentFs,
1055+
isTTY: true,
1056+
prompt,
1057+
});
1058+
1059+
// With no saved key, the prompt should fire.
1060+
expect(prompt.secret).toHaveBeenCalledTimes(1);
1061+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-fresh');
1062+
expect(captured.stdout.join('')).toContain('initialized');
1063+
});
1064+
1065+
it('allows non-interactive (isTTY=false) when skipIfConfigured is true and credentials exist', async () => {
1066+
const { deps } = makeCapture();
1067+
const { fs: agentFs } = makeMemFs();
1068+
writeProfile('default', { apiKey: 'sk-ci' }, { path: credentialsPath });
1069+
const fetchMock = makeOkFetch();
1070+
1071+
// Must not throw exit 5 for "non-interactive mode, no key source".
1072+
await expect(
1073+
runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), {
1074+
...deps,
1075+
credentialsPath,
1076+
fetchImpl: fetchMock,
1077+
fs: agentFs,
1078+
isTTY: false,
1079+
}),
1080+
).resolves.toBeUndefined();
1081+
1082+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-ci');
1083+
});
1084+
1085+
it('--api-key takes precedence over skipIfConfigured and overwrites the saved key', async () => {
1086+
const { deps } = makeCapture();
1087+
const { fs: agentFs } = makeMemFs();
1088+
writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath });
1089+
const fetchMock = makeOkFetch();
1090+
1091+
await runInit(
1092+
makeBaseOpts({ apiKey: 'sk-new', skipIfConfigured: true, noAgent: true, output: 'text' }),
1093+
{
1094+
...deps,
1095+
credentialsPath,
1096+
fetchImpl: fetchMock,
1097+
fs: agentFs,
1098+
isTTY: false,
1099+
},
1100+
);
1101+
1102+
// Explicit --api-key must overwrite regardless of skipIfConfigured.
1103+
expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new');
1104+
});
1105+
});

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.
@@ -475,6 +499,7 @@ interface SetupCmdOpts {
475499
force?: boolean;
476500
dir?: string;
477501
yes?: boolean;
502+
skipIfConfigured?: boolean;
478503
}
479504

480505
/** Attach the onboarding flags shared by `setup` and the `init` alias. */
@@ -498,7 +523,11 @@ function addSetupOptions(
498523
.option('--no-agent', 'Skip the agent skill install (configure credentials only)')
499524
.option('--force', 'Overwrite an existing skill file (a .bak backup is kept)')
500525
.option('--dir <path>', 'Project root for the skill install (default: current directory)')
501-
.option('-y, --yes', 'Non-interactive: accept all defaults, never prompt');
526+
.option('-y, --yes', 'Non-interactive: accept all defaults, never prompt')
527+
.option(
528+
'--skip-if-configured',
529+
'Skip the API key prompt when credentials already exist for this profile (CI-safe idempotent re-run)',
530+
);
502531
}
503532

504533
/** Build {@link InitOptions} from raw Commander opts + globals. */
@@ -539,6 +568,7 @@ function buildSetupOptions(
539568
force: Boolean(cmdOpts.force),
540569
dir: cmdOpts.dir,
541570
yes: Boolean(cmdOpts.yes),
571+
skipIfConfigured: Boolean(cmdOpts.skipIfConfigured),
542572
rawArgConflict,
543573
};
544574
}

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)