Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ca9f368
fix: scope init API keys to selected provider (#312)
404-Page-Found Sep 22, 2026
e62d7ca
fix: clear API key when changing provider (#312)
404-Page-Found Sep 22, 2026
69063a8
test: cover provider-scoped init API keys (#312)
404-Page-Found Sep 22, 2026
5e5f98a
test: cover provider-switch API key fallback (#312)
404-Page-Found Sep 22, 2026
b0c2169
fix: allow raw provider config provenance to be optional
404-Page-Found Sep 22, 2026
f3d4cb8
fix: keep init prompt wording and format provider key lookup
404-Page-Found Sep 22, 2026
8cfea72
fix: preserve init API key prompt and provenance types
404-Page-Found Sep 22, 2026
b7b5e53
test: update provider-switch key expectation
404-Page-Found Sep 22, 2026
847bc12
fix: preserve generic API key env during init reconfiguration
404-Page-Found Sep 22, 2026
ccdecbc
test: cover scoped init API key fallback behavior
404-Page-Found Sep 22, 2026
b644ce3
fix: validate and trim init API keys
404-Page-Found Sep 22, 2026
7f40694
test: cover malformed and padded init API keys
404-Page-Found Sep 22, 2026
bfa95a7
chore: expose prettier formatting diff in CI
404-Page-Found Sep 22, 2026
8fad086
style: match prettier formatting for init
404-Page-Found Sep 22, 2026
00564c5
chore: restore format check
404-Page-Found Sep 22, 2026
ef56235
fix: trim provider API key env fallback
404-Page-Found Sep 22, 2026
7af6652
test: cover trimmed provider API key env
404-Page-Found Sep 22, 2026
d3aa767
Merge branch 'main' into fix/312-provider-api-key-scope
404-Page-Found Sep 23, 2026
49a068f
fix(init): scope custom API keys to endpoint
404-Page-Found Sep 23, 2026
15aa0af
test(init): cover custom endpoint and empty API key fallback
404-Page-Found Sep 23, 2026
9c29b6f
fix: guard custom provider API key endpoint types
404-Page-Found Sep 23, 2026
cd946ed
test: cover invalid custom provider endpoints
404-Page-Found Sep 23, 2026
700dab9
fix: remove duplicate blank line in API key prompt test
404-Page-Found Sep 23, 2026
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
10 changes: 9 additions & 1 deletion src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,21 @@ function updateConfigField<K extends ConfigSetKey>(config: Config, key: K, value

function applyProviderChange(config: Config, provider: Config['provider']): Config {
const nextConfig = updateConfigField(config, 'provider', provider);
if (provider === CUSTOM_PROVIDER_KEY) {
if (config.provider === provider) {
return nextConfig;
}

if (provider === CUSTOM_PROVIDER_KEY) {
return {
...nextConfig,
apiKey: undefined,
Comment thread
404-Page-Found marked this conversation as resolved.
};
}

return {
...nextConfig,
baseUrl: undefined,
apiKey: undefined,
};
}

Expand Down
61 changes: 55 additions & 6 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
getProviderInfo,
fetchModels,
} from '../providers/index.js';
import { saveConfig, configExists, loadConfig } from '../config/store.js';
import { saveConfig, configExists, loadConfig, loadRawConfig } from '../config/store.js';
import type { Config } from '../types.js';
import { getAvailableTemplateVars } from '../llm/prompt.js';
import { installCommitHooks, uninstallCommitHooks } from '../git/hook.js';
Expand All @@ -30,6 +30,46 @@
};
}

export function getStoredApiKeyForProvider(
selectedProvider: string,
selectedBaseUrl: string | undefined,
storedConfig: Pick<Partial<Config>, 'provider' | 'apiKey' | 'baseUrl'> | null,

Check warning on line 36 in src/commands/init.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this union type with a type alias.

See more on https://sonarcloud.io/project/issues?id=404-PF_commit-echo&issues=AaDMwqyKiZtQKySHKKB0&open=AaDMwqyKiZtQKySHKKB0&pullRequest=321
): string | undefined {
if (storedConfig?.provider !== selectedProvider || typeof storedConfig.apiKey !== 'string') {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return undefined;
}

if (selectedProvider === CUSTOM_PROVIDER_KEY) {
if (typeof storedConfig.baseUrl !== 'string' || typeof selectedBaseUrl !== 'string') {
return undefined;
}

const storedUrl = normalizeBaseUrl(storedConfig.baseUrl);
const selectedUrl = normalizeBaseUrl(selectedBaseUrl);
if (storedUrl !== selectedUrl) {
return undefined;
}
}

const storedKey = storedConfig.apiKey.trim();
return storedKey || undefined;
}

export function getExistingApiKeyForProvider(
provider: string,
baseUrl: string | undefined,
storedConfig: Pick<Partial<Config>, 'provider' | 'apiKey' | 'baseUrl'> | null,
apiKeyEnv: string,
env: NodeJS.ProcessEnv = process.env,
): string {
return (
env['COMMIT_ECHO_API_KEY']?.trim() ||
getStoredApiKeyForProvider(provider, baseUrl, storedConfig) ||
env[apiKeyEnv]?.trim() ||
''
);
}

/**
* A template file takes precedence over inline templates at runtime, so drop
* inline values when a templatePath is set to keep the saved config consistent
Expand Down Expand Up @@ -146,11 +186,16 @@

async function promptApiKey(
provider: ProviderSetup,
existingConfig: Config | null,
storedConfig: Pick<Partial<Config>, 'provider' | 'apiKey' | 'baseUrl'> | null,
): Promise<string | undefined | null> {
if (!provider.needsApiKey) return undefined;

const existingKey = existingConfig?.apiKey ?? process.env[provider.apiKeyEnv] ?? '';
const existingKey = getExistingApiKeyForProvider(
provider.providerKey,
provider.baseUrl,
storedConfig,
provider.apiKeyEnv,
);
const keyResult = await text(buildApiKeyPrompt(existingKey, provider.apiKeyEnv));
if (isCancel(keyResult)) return null;
return keyResult || existingKey || '';
Expand Down Expand Up @@ -339,11 +384,14 @@
provider: ProviderSetup;
}

async function collectConfig(existingConfig: Config | null): Promise<CollectedSetup | null> {
async function collectConfig(
existingConfig: Config | null,
storedConfig: Pick<Partial<Config>, 'provider' | 'apiKey' | 'baseUrl'> | null,
): Promise<CollectedSetup | null> {
const provider = await promptProvider(existingConfig);
if (!provider) return null;

const apiKey = await promptApiKey(provider, existingConfig);
const apiKey = await promptApiKey(provider, storedConfig);
if (apiKey === null) return null;

const selectedModel = await promptModel(provider, apiKey, existingConfig);
Expand Down Expand Up @@ -379,6 +427,7 @@

const isReconfig = configExists();
const existingConfig = isReconfig ? await loadConfig().catch(() => null) : null;
const storedConfig = isReconfig ? await loadRawConfig().catch(() => null) : null;

if (isReconfig) {
const reconfirm = await confirm({
Expand All @@ -391,7 +440,7 @@
}
}

const setup = await collectConfig(existingConfig);
const setup = await collectConfig(existingConfig, storedConfig);
if (!setup) {
outro('Setup cancelled.');
return;
Expand Down
32 changes: 30 additions & 2 deletions tests/config-command.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from 'node:path';
import test from 'node:test';
import { promisify } from 'node:util';
import { maskApiKey } from '../dist/commands/config.js';
import { resolveApiKey } from '../dist/llm/client.js';
import { assertFriendlyCommandError, assertJsonCommandError, writeInvalidConfig } from './cli-error-helpers.mjs';
const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -358,7 +359,7 @@ test('config set rejects unknown provider keys and lists valid options', async (
});
});

test('config set clears stale baseUrl when switching away from custom provider', async () => {
test('config set clears stale baseUrl and API key when switching away from custom provider', async () => {
await withTempHome(async (homeDir) => {
writeConfig(homeDir, {
apiKey: 'sk-still-valid-for-next-provider',
Expand All @@ -371,7 +372,34 @@ test('config set clears stale baseUrl when switching away from custom provider',

assert.equal(config.provider, 'openai');
assert.equal(config.baseUrl, undefined);
assert.equal(config.apiKey, 'sk-still-valid-for-next-provider');
assert.equal(config.apiKey, undefined);
});
});

test('config set clears the API key when switching providers and uses the new provider env key', async () => {
await withTempHome(async (homeDir) => {
writeConfig(homeDir, {
provider: 'openai',
apiKey: 'sk-openai',
});

await runConfigWithArgs(homeDir, ['set', 'provider', 'anthropic']);
const config = readConfig(homeDir);

assert.equal(config.provider, 'anthropic');
assert.equal(config.apiKey, undefined);
Comment thread
404-Page-Found marked this conversation as resolved.

const original = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'sk-anthropic';
try {
assert.equal(resolveApiKey(config), 'sk-anthropic');
} finally {
if (original === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = original;
}
}
});
});

Expand Down
135 changes: 134 additions & 1 deletion tests/init-api-key-prompt.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { buildApiKeyPrompt } from '../dist/commands/init.js';
import { buildApiKeyPrompt, getExistingApiKeyForProvider, getStoredApiKeyForProvider } from '../dist/commands/init.js';

test('does not prefill an existing API key in the init prompt', () => {
const prompt = buildApiKeyPrompt('sk-live-secret', 'OPENAI_API_KEY');
Expand All @@ -16,3 +16,136 @@ test('leaves the API key prompt blank for new configs', () => {
assert.equal(prompt.placeholder, '');
assert.equal(Object.hasOwn(prompt, 'initialValue'), false);
});

test('reuses a trimmed stored API key only for the same provider', () => {
assert.equal(
getStoredApiKeyForProvider('openai', 'https://api.openai.com/v1', {
provider: 'openai',
apiKey: ' sk-openai ',
}),
'sk-openai',
);
assert.equal(
getStoredApiKeyForProvider('anthropic', 'https://api.anthropic.com/v1', {
provider: 'openai',
apiKey: 'sk-openai',
}),
undefined,
);
});

test('reuses a custom API key only for the same normalized endpoint', () => {
const storedConfig = {
provider: '__custom__',
baseUrl: 'https://api.example.com/v1',
apiKey: ' sk-custom ',
};

assert.equal(
getStoredApiKeyForProvider('__custom__', 'https://api.example.com/v1/', storedConfig),
'sk-custom',
);
assert.equal(
getStoredApiKeyForProvider('__custom__', 'https://other.example.com/v1', storedConfig),
undefined,
);
});

test('ignores a non-string stored API key for the selected provider', () => {
assert.equal(
getStoredApiKeyForProvider('openai', 'https://api.openai.com/v1', { provider: 'openai', apiKey: 123 }),
undefined,
);
});

test('does not reuse a custom API key when either endpoint is missing or malformed', () => {
assert.equal(
getStoredApiKeyForProvider('__custom__', 'https://api.example.com/v1', {
provider: '__custom__',
baseUrl: 123,
apiKey: 'sk-custom',
}),
undefined,
);
assert.equal(
getStoredApiKeyForProvider('__custom__', 'https://api.example.com/v1', {
provider: '__custom__',
apiKey: 'sk-custom',
}),
undefined,
);
assert.equal(
getStoredApiKeyForProvider('__custom__', undefined, {
provider: '__custom__',
baseUrl: 'https://api.example.com/v1',
apiKey: 'sk-custom',
}),
undefined,
);
});

Comment thread
404-Page-Found marked this conversation as resolved.
test('prefers the trimmed generic API key env override during init reconfiguration', () => {
const env = {
COMMIT_ECHO_API_KEY: ' sk-generic ',
OPENAI_API_KEY: 'sk-openai',
};

assert.equal(
getExistingApiKeyForProvider(
'openai',
'https://api.openai.com/v1',
{ provider: 'openai', apiKey: 'sk-stored' },
'OPENAI_API_KEY',
env,
),
'sk-generic',
);
});

test('falls back to the provider-specific API key env when no generic or matching stored key exists', () => {
const env = { OPENAI_API_KEY: ' sk-openai ' };

assert.equal(
getExistingApiKeyForProvider(
'openai',
'https://api.openai.com/v1',
{ provider: 'anthropic', apiKey: 'sk-anthropic' },
'OPENAI_API_KEY',
env,
),
'sk-openai',
);
assert.equal(
getExistingApiKeyForProvider('openai', 'https://api.openai.com/v1', null, 'OPENAI_API_KEY', {}),
'',
);
});

test('skips empty API-key candidates after trimming', () => {
assert.equal(
getExistingApiKeyForProvider(
'openai',
'https://api.openai.com/v1',
{ provider: 'openai', apiKey: ' sk-stored ' },
'OPENAI_API_KEY',
{
COMMIT_ECHO_API_KEY: ' ',
OPENAI_API_KEY: 'sk-openai',
},
),
'sk-stored',
);

assert.equal(
getExistingApiKeyForProvider(
'openai',
'https://api.openai.com/v1',
{ provider: 'openai', apiKey: ' ' },
'OPENAI_API_KEY',
{
OPENAI_API_KEY: ' sk-openai ',
},
),
'sk-openai',
);
});
Loading