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
87 changes: 87 additions & 0 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
27 changes: 26 additions & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
97 changes: 97 additions & 0 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
40 changes: 35 additions & 5 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -196,9 +202,16 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// -------------------------------------------------------------------------
const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
const hasKeySource = Boolean(opts.apiKey) || opts.fromEnv;
// --skip-if-configured counts as a key source when a saved key already exists:
// runConfigure will short-circuit before prompting, so no TTY is needed.
const credentialsPath = deps.credentialsPath;
const savedKey = credentialsPath
? readProfile(opts.profile, { path: credentialsPath })?.apiKey
: readProfile(opts.profile)?.apiKey;
const skipWillApply = Boolean(opts.skipIfConfigured) && Boolean(savedKey) && !hasKeySource;
// Non-interactive guard: no TTY + no key source → exit 5. Skipped under
// --dry-run, which is documented to work without credentials or network.
if (!isTTY && !hasKeySource && !opts.dryRun) {
if (!isTTY && !hasKeySource && !skipWillApply && !opts.dryRun) {
Comment on lines +207 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect surrounding context.
git ls-files src/commands/init.ts
wc -l src/commands/init.ts
sed -n '160,260p' src/commands/init.ts

# Find definitions/usages for readProfile, dry-run handling, and skip-if-configured.
rg -n "function readProfile|const readProfile|readProfile\(" src -S
rg -n "dryRun|skipIfConfigured|resolveReportedEndpoint|credentialsPath" src/commands/init.ts src -S

Repository: TestSprite/testsprite-cli

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect readProfile implementation and its error behavior.
sed -n '1,260p' src/lib/credentials.ts

# Show the init dry-run block with a bit more context around the early credential read.
sed -n '200,290p' src/commands/init.ts

Repository: TestSprite/testsprite-cli

Length of output: 10442


Avoid reading credentials before the dry-run branch.
readProfile() can throw on unreadable credentials, so this eager lookup can make --dry-run fail before emitting its preview. Resolve savedKey only when !opts.dryRun and --skip-if-configured needs it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/init.ts` around lines 207 - 214, Move the `savedKey` lookup
using `readProfile()` behind the non-dry-run guard, and only perform it when
`opts.skipIfConfigured` is enabled and needed for `skipWillApply`; ensure
`--dry-run` does not read credentials before its preview branch or trigger
credential read errors.

throw new CLIError(
'No API key available in non-interactive mode. ' +
'Pass --api-key <key>, --from-env (reads TESTSPRITE_API_KEY), or run interactively.',
Expand All @@ -208,7 +221,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// JSON-output guard: an interactive secret prompt writes to stdout and would
// corrupt init's single-JSON-object output contract. In --output json mode
// require a non-interactive key source. Skipped under --dry-run (never prompts).
if (opts.output === 'json' && !hasKeySource && !opts.dryRun) {
if (opts.output === 'json' && !hasKeySource && !skipWillApply && !opts.dryRun) {
throw new CLIError(
'Interactive API-key prompt is unavailable in --output json mode (it would corrupt JSON stdout). ' +
'Pass --api-key <key> or --from-env.',
Expand All @@ -223,7 +236,13 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
stderrFn('[dry-run] no writes or network calls — preview only');
stderrFn(
`[dry-run] would configure profile="${opts.profile}" (key source: ${
opts.apiKey ? 'flag' : opts.fromEnv ? 'env' : 'prompt'
opts.apiKey
? 'flag'
: opts.fromEnv
? 'env'
: opts.skipIfConfigured
? 'skip-if-configured'
: 'prompt'
Comment on lines +239 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the dry-run key-source preview conditional.

With --skip-if-configured but no saved key, this reports skip-if-configured, while a real run will prompt. Describe it as conditional (for example, “saved credentials if available; otherwise prompt”) rather than as the selected source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/init.ts` around lines 239 - 245, The key-source preview logic in
the init command incorrectly treats skipIfConfigured as unconditional. Update
the expression near opts.apiKey/opts.fromEnv/opts.skipIfConfigured so that this
case describes the conditional behavior—use saved credentials if available,
otherwise prompt—instead of reporting skip-if-configured as the selected source.

})`,
);

Expand Down Expand Up @@ -265,7 +284,12 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// force fromEnv=false so runConfigure uses the injected key (toAuthDeps wires it
// as the prompt) instead of reading TESTSPRITE_API_KEY from the environment (codex).
await runConfigure(
{ ...opts, fromEnv: opts.apiKey ? false : opts.fromEnv },
{
...opts,
fromEnv: opts.apiKey ? false : opts.fromEnv,
// --api-key always overwrites; skip only applies when no explicit key source was given.
skipIfConfigured: opts.apiKey ? false : opts.skipIfConfigured,
},
// commandTag:'init' tags ONLY this configure-validate GET /me with
// `X-CLI-Command: init` → counted as cli.initialized. The whoami banner call
// below builds deps WITHOUT a tag, so init emits exactly one cli.initialized.
Expand Down Expand Up @@ -475,6 +499,7 @@ interface SetupCmdOpts {
force?: boolean;
dir?: string;
yes?: boolean;
skipIfConfigured?: boolean;
}

/** Attach the onboarding flags shared by `setup` and the `init` alias. */
Expand All @@ -498,7 +523,11 @@ function addSetupOptions(
.option('--no-agent', 'Skip the agent skill install (configure credentials only)')
.option('--force', 'Overwrite an existing skill file (a .bak backup is kept)')
.option('--dir <path>', '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)',
);
Comment on lines +526 to +530

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the new public flag before release.

Update DOCUMENTATION.md with --skip-if-configured behavior, including profile resolution, non-interactive/JSON output, and when exit code 5 still applies. As per path instructions, this flag should follow the existing --from-env documentation patterns and document its JSON/scriptable behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/init.ts` around lines 526 - 530, Document the new
--skip-if-configured flag in DOCUMENTATION.md, following the existing --from-env
option patterns. Describe profile resolution, skipping credential prompts only
when credentials already exist, behavior with --yes and JSON output for
scriptable use, and the conditions under which exit code 5 still applies.

Source: Path instructions

}

/** Build {@link InitOptions} from raw Commander opts + globals. */
Expand Down Expand Up @@ -539,6 +568,7 @@ function buildSetupOptions(
force: Boolean(cmdOpts.force),
dir: cmdOpts.dir,
yes: Boolean(cmdOpts.yes),
skipIfConfigured: Boolean(cmdOpts.skipIfConfigured),
rawArgConflict,
};
}
Expand Down
28 changes: 16 additions & 12 deletions test/__snapshots__/help.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,22 @@ exports[`--help snapshots > init 1`] = `
(deprecated) alias for \`setup\`

Options:
--api-key <key> API key to configure (skips the interactive prompt)
--from-env Read TESTSPRITE_API_KEY from the environment instead of
prompting (default: false)
--agent <target> 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 <path> 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 <key> API key to configure (skips the interactive prompt)
--from-env Read TESTSPRITE_API_KEY from the environment instead of
prompting (default: false)
--agent <target> 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 <path> 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
"
`;

Expand Down
Loading