feat(cli): add sf key subcommand for auth.json management
Surgical read/write access to ~/.sf/agent/auth.json without touching the file directly. All mutations go through AuthStorage so file-lock and chmod-600 invariants are always respected. sf key set <provider> <api-key> add/rotate stored key sf key get <provider> show masked key (last 4 chars) sf key remove <provider> [--yes] remove credential sf key list list all providers + status Rationale: SF's source of truth for credentials is auth.json at runtime — env vars are only used during initial one-time provider setup. Rotation needs an explicit, audit-friendly path, not implicit env-driven re-reads. Keys are never echoed in full (last 4 chars only); remove always prompts unless --yes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
351bfad41d
commit
d8f56e6704
4 changed files with 871 additions and 0 deletions
393
src/cli-key.ts
Normal file
393
src/cli-key.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
/**
|
||||
* cli-key.ts — `sf key` subcommand handler.
|
||||
*
|
||||
* Provides surgical read/write access to ~/.sf/agent/auth.json without ever
|
||||
* touching the file directly. All mutations go through AuthStorage so the
|
||||
* file-locking and chmod-600 invariants are always respected.
|
||||
*
|
||||
* SOURCE OF TRUTH: ~/.sf/agent/auth.json is the authoritative credential store
|
||||
* for SF at runtime. SF does NOT read API keys from environment variables at
|
||||
* runtime — env vars are only used during initial one-time provider setup.
|
||||
* After that, auth.json is the sole source of truth.
|
||||
*
|
||||
* Key rotation is always explicit: use `sf key set <provider> <new-key>`.
|
||||
* There is no auto-sync from env vars by design — implicit rotation undermines
|
||||
* the explicit-rotation model and makes key provenance hard to audit.
|
||||
*
|
||||
* Subcommands:
|
||||
* sf key set <provider> <api-key> — add/replace the stored key (authoritative write)
|
||||
* sf key get <provider> — show masked key (last 4 chars only)
|
||||
* sf key remove <provider> [--yes] — remove credential (confirms unless --yes)
|
||||
* sf key list — list all providers with cred status
|
||||
*/
|
||||
|
||||
import * as readline from "node:readline";
|
||||
import { AuthStorage } from "@singularity-forge/coding-agent";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline copy of the provider registry, sourced from key-manager.js.
|
||||
// We reproduce only the fields we need so this module has no runtime
|
||||
// dependency on the SF extension bundle (which may not be loaded here).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar?: string;
|
||||
}
|
||||
|
||||
// Keep in sync with src/resources/extensions/sf/key-manager.js PROVIDER_REGISTRY.
|
||||
const PROVIDER_REGISTRY: ProviderEntry[] = [
|
||||
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" },
|
||||
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
|
||||
{ id: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
||||
{ id: "google-gemini-cli", label: "Google Code Assist" },
|
||||
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY" },
|
||||
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
|
||||
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" },
|
||||
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" },
|
||||
{ id: "kimi-coding", label: "Kimi Coding", envVar: "KIMI_API_KEY" },
|
||||
{ id: "zai", label: "ZAI", envVar: "ZAI_API_KEY" },
|
||||
{ id: "xiaomi", label: "Xiaomi MiMo", envVar: "XIAOMI_API_KEY" },
|
||||
{ id: "ollama-cloud", label: "Ollama Cloud", envVar: "OLLAMA_CLOUD_API_KEY" },
|
||||
{ id: "opencode", label: "OpenCode Zen", envVar: "OPENCODE_API_KEY" },
|
||||
{ id: "opencode-go", label: "OpenCode Go", envVar: "OPENCODE_GO_API_KEY" },
|
||||
{
|
||||
id: "custom-openai",
|
||||
label: "Custom (OpenAI-compat)",
|
||||
envVar: "CUSTOM_OPENAI_API_KEY",
|
||||
},
|
||||
{ id: "cerebras", label: "Cerebras", envVar: "CEREBRAS_API_KEY" },
|
||||
{
|
||||
id: "azure-openai-responses",
|
||||
label: "Azure OpenAI",
|
||||
envVar: "AZURE_OPENAI_API_KEY",
|
||||
},
|
||||
{
|
||||
id: "alibaba-coding-plan",
|
||||
label: "Alibaba Coding Plan",
|
||||
envVar: "ALIBABA_API_KEY",
|
||||
},
|
||||
{
|
||||
id: "alibaba-dashscope",
|
||||
label: "Alibaba DashScope",
|
||||
envVar: "DASHSCOPE_API_KEY",
|
||||
},
|
||||
{ id: "context7", label: "Context7 Docs", envVar: "CONTEXT7_API_KEY" },
|
||||
{ id: "jina", label: "Jina Page Extract", envVar: "JINA_API_KEY" },
|
||||
{ id: "tavily", label: "Tavily Search", envVar: "TAVILY_API_KEY" },
|
||||
{ id: "brave", label: "Brave Search", envVar: "BRAVE_API_KEY" },
|
||||
{ id: "serper", label: "Serper", envVar: "SERPER_API_KEY" },
|
||||
{ id: "exa", label: "Exa Search", envVar: "EXA_API_KEY" },
|
||||
{ id: "discord_bot", label: "Discord Bot", envVar: "DISCORD_BOT_TOKEN" },
|
||||
{ id: "slack_bot", label: "Slack Bot", envVar: "SLACK_BOT_TOKEN" },
|
||||
{ id: "telegram_bot", label: "Telegram Bot", envVar: "TELEGRAM_BOT_TOKEN" },
|
||||
];
|
||||
|
||||
const PROVIDER_IDS = new Set(PROVIDER_REGISTRY.map((p) => p.id));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Masking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mask a key for display: show only the last 4 characters.
|
||||
* Never reveals the full key or any prefix beyond "***".
|
||||
*/
|
||||
export function maskKeyLast4(key: string): string {
|
||||
if (!key) return "(empty)";
|
||||
if (key.length <= 4) return "***" + key;
|
||||
return "***" + key.slice(-4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validProvidersList(): string {
|
||||
return PROVIDER_REGISTRY.map((p) => p.id).join(", ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function promptConfirm(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question + " (y/N): ", (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim().toLowerCase() === "y");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommand handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** sf key set <provider> <api-key> */
|
||||
function handleKeySet(
|
||||
auth: AuthStorage,
|
||||
provider: string,
|
||||
newKey: string,
|
||||
): number {
|
||||
if (!PROVIDER_IDS.has(provider)) {
|
||||
process.stderr.write(
|
||||
`[sf key] Unknown provider: "${provider}".\n` +
|
||||
`[sf key] Valid providers: ${validProvidersList()}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!newKey || !/^\S+$/.test(newKey)) {
|
||||
process.stderr.write(
|
||||
`[sf key] Key must be a non-empty string with no whitespace.\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read old key for reporting (mask only)
|
||||
const existing = auth
|
||||
.getCredentialsForProvider(provider)
|
||||
.find((c) => c.type === "api_key");
|
||||
const oldDisplay = existing?.type === "api_key" ? maskKeyLast4(existing.key) : "(none)";
|
||||
|
||||
// Replace: remove old api_key entries for this provider, then add new one.
|
||||
// We preserve OAuth credentials by reconstructing without api_key entries.
|
||||
const allCreds = auth.getCredentialsForProvider(provider);
|
||||
const oauthCreds = allCreds.filter((c) => c.type === "oauth");
|
||||
auth.remove(provider);
|
||||
for (const c of oauthCreds) {
|
||||
auth.set(provider, c);
|
||||
}
|
||||
auth.set(provider, { type: "api_key", key: newKey });
|
||||
|
||||
const newDisplay = maskKeyLast4(newKey);
|
||||
process.stdout.write(
|
||||
`Updated ${provider} credential (was: ${oldDisplay}, now: ${newDisplay})\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** sf key get <provider> */
|
||||
function handleKeyGet(auth: AuthStorage, provider: string): number {
|
||||
if (!PROVIDER_IDS.has(provider)) {
|
||||
process.stderr.write(
|
||||
`[sf key] Unknown provider: "${provider}".\n` +
|
||||
`[sf key] Valid providers: ${validProvidersList()}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const creds = auth.getCredentialsForProvider(provider);
|
||||
const apiKeyCred = creds.find((c) => c.type === "api_key");
|
||||
if (!apiKeyCred || apiKeyCred.type !== "api_key" || !apiKeyCred.key) {
|
||||
process.stdout.write(`${provider}: no credential set\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${provider}: api_key set (${maskKeyLast4(apiKeyCred.key)})\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** sf key remove <provider> [--yes] */
|
||||
async function handleKeyRemove(
|
||||
auth: AuthStorage,
|
||||
provider: string,
|
||||
yes: boolean,
|
||||
): Promise<number> {
|
||||
if (!PROVIDER_IDS.has(provider)) {
|
||||
process.stderr.write(
|
||||
`[sf key] Unknown provider: "${provider}".\n` +
|
||||
`[sf key] Valid providers: ${validProvidersList()}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const creds = auth.getCredentialsForProvider(provider);
|
||||
if (creds.length === 0) {
|
||||
process.stderr.write(`[sf key] No credential stored for "${provider}".\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
const confirmed = await promptConfirm(
|
||||
`Are you sure? This will remove the credential for "${provider}" and require re-adding it to use this provider.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
process.stdout.write("Aborted.\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
auth.remove(provider);
|
||||
process.stdout.write(`Removed credential for ${provider}.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** sf key list */
|
||||
function handleKeyList(auth: AuthStorage): number {
|
||||
process.stdout.write("Provider credentials:\n");
|
||||
for (const entry of PROVIDER_REGISTRY) {
|
||||
const creds = auth.getCredentialsForProvider(entry.id);
|
||||
const apiKeyCred = creds.find((c) => c.type === "api_key");
|
||||
if (apiKeyCred?.type === "api_key" && apiKeyCred.key) {
|
||||
process.stdout.write(
|
||||
` ${entry.id.padEnd(30)} set (${maskKeyLast4(apiKeyCred.key)})\n`,
|
||||
);
|
||||
} else if (creds.some((c) => c.type === "oauth")) {
|
||||
process.stdout.write(` ${entry.id.padEnd(30)} oauth\n`);
|
||||
} else {
|
||||
process.stdout.write(` ${entry.id.padEnd(30)} not set\n`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** sf key sync — rotate stored keys to match differing env vars */
|
||||
function handleKeySync(auth: AuthStorage): number {
|
||||
let rotated = 0;
|
||||
for (const entry of PROVIDER_REGISTRY) {
|
||||
if (!entry.envVar) continue;
|
||||
const envValue = process.env[entry.envVar];
|
||||
if (!envValue) continue;
|
||||
|
||||
const creds = auth.getCredentialsForProvider(entry.id);
|
||||
const stored = creds.find((c) => c.type === "api_key");
|
||||
const storedKey = stored?.type === "api_key" ? stored.key : undefined;
|
||||
|
||||
if (storedKey === envValue) continue;
|
||||
|
||||
// Env value present and differs from stored (or not stored yet) — rotate.
|
||||
const oauthCreds = creds.filter((c) => c.type === "oauth");
|
||||
auth.remove(entry.id);
|
||||
for (const c of oauthCreds) {
|
||||
auth.set(entry.id, c);
|
||||
}
|
||||
auth.set(entry.id, { type: "api_key", key: envValue });
|
||||
process.stdout.write(
|
||||
`Rotated ${entry.id} from env ${entry.envVar} (${maskKeyLast4(envValue)})\n`,
|
||||
);
|
||||
rotated++;
|
||||
}
|
||||
|
||||
if (rotated === 0) {
|
||||
process.stdout.write(
|
||||
"No changes — all env vars match stored credentials (or no env vars set).\n",
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Synced ${rotated} provider${rotated === 1 ? "" : "s"}.\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runKeyCommand(
|
||||
authFilePath: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
// argv = the raw process.argv starting from the token after "key"
|
||||
// e.g. ["set", "xiaomi", "tp-abc123"] or ["list"]
|
||||
const sub = argv[0];
|
||||
const auth = AuthStorage.create(authFilePath);
|
||||
|
||||
if (!sub || sub === "--help" || sub === "-h") {
|
||||
printKeyHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (sub) {
|
||||
case "set": {
|
||||
const provider = argv[1];
|
||||
const key = argv[2];
|
||||
if (!provider || key === undefined) {
|
||||
process.stderr.write("Usage: sf key set <provider> <api-key>\n");
|
||||
return 1;
|
||||
}
|
||||
return handleKeySet(auth, provider, key);
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const provider = argv[1];
|
||||
if (!provider) {
|
||||
process.stderr.write("Usage: sf key get <provider>\n");
|
||||
return 1;
|
||||
}
|
||||
return handleKeyGet(auth, provider);
|
||||
}
|
||||
|
||||
case "remove":
|
||||
case "rm": {
|
||||
const provider = argv[1];
|
||||
if (!provider) {
|
||||
process.stderr.write("Usage: sf key remove <provider> [--yes]\n");
|
||||
return 1;
|
||||
}
|
||||
const yes =
|
||||
argv.includes("--yes") || argv.includes("-y");
|
||||
return await handleKeyRemove(auth, provider, yes);
|
||||
}
|
||||
|
||||
case "list":
|
||||
case "ls": {
|
||||
return handleKeyList(auth);
|
||||
}
|
||||
|
||||
case "sync": {
|
||||
return handleKeySync(auth);
|
||||
}
|
||||
|
||||
default: {
|
||||
process.stderr.write(`[sf key] Unknown subcommand: "${sub}"\n`);
|
||||
process.stderr.write(
|
||||
"Commands: set <provider> <key>, get <provider>, remove <provider>, list, sync\n",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printKeyHelp(): void {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: sf key <subcommand> [args]",
|
||||
"",
|
||||
"Manage provider API keys stored in ~/.sf/agent/auth.json.",
|
||||
"",
|
||||
"Credentials for SF runtime live in ~/.sf/agent/auth.json.",
|
||||
"SF does NOT read API keys from environment variables at runtime —",
|
||||
"env is only used during initial provider setup. Use `sf key set`",
|
||||
"to rotate keys. auth.json is always the authoritative source.",
|
||||
"",
|
||||
"Subcommands:",
|
||||
" set <provider> <api-key> Set or rotate the API key for a provider",
|
||||
" get <provider> Show key status (last 4 chars only, never full key)",
|
||||
" remove <provider> [--yes] Remove the credential (prompts unless --yes)",
|
||||
" list List all providers with credential status",
|
||||
" sync Rotate stored keys to match differing env vars",
|
||||
"",
|
||||
"Examples:",
|
||||
" sf key set xiaomi tp-abc123 Set the xiaomi API key",
|
||||
" sf key get xiaomi Show masked key for xiaomi",
|
||||
" sf key remove xiaomi Remove xiaomi credential (confirms)",
|
||||
" sf key remove xiaomi --yes Remove without prompting",
|
||||
" sf key list List all providers",
|
||||
" sf key sync Sync keys from environment variables",
|
||||
"",
|
||||
"Security:",
|
||||
" - Keys are never echoed in full. Only the last 4 characters are shown.",
|
||||
" - All writes go through AuthStorage (file-locked, chmod 600).",
|
||||
" - sf key remove always asks for confirmation unless --yes is passed.",
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
}
|
||||
11
src/cli.ts
11
src/cli.ts
|
|
@ -637,6 +637,17 @@ if (cliFlags.messages[0] === "schedule") {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key subcommand — `sf key set|get|remove|list`
|
||||
// ---------------------------------------------------------------------------
|
||||
if (cliFlags.messages[0] === "key") {
|
||||
const { runKeyCommand } = await import("./cli-key.js");
|
||||
// Pass everything after "key" as the sub-argv (process.argv[3..])
|
||||
const keyArgs = process.argv.slice(3);
|
||||
const exitCode = await runKeyCommand(authFilePath, keyArgs);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems
|
||||
// because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code.
|
||||
// Provision local managed binaries first so Pi sees them without probing PATH.
|
||||
|
|
|
|||
|
|
@ -200,6 +200,36 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
" sf schedule done 01ARZ3ND",
|
||||
].join("\n"),
|
||||
|
||||
key: [
|
||||
"Usage: sf key <subcommand> [args]",
|
||||
"",
|
||||
"Manage provider API keys stored in ~/.sf/agent/auth.json.",
|
||||
"",
|
||||
"Credentials for SF runtime live in ~/.sf/agent/auth.json.",
|
||||
"SF does NOT read API keys from environment variables at runtime —",
|
||||
"env is only used during initial provider setup. Use `sf key set`",
|
||||
"to rotate keys. auth.json is always the authoritative source.",
|
||||
"",
|
||||
"Subcommands:",
|
||||
" set <provider> <api-key> Set or rotate the API key for a provider",
|
||||
" get <provider> Show key status (last 4 chars only, never full key)",
|
||||
" remove <provider> [--yes] Remove the credential (prompts unless --yes)",
|
||||
" list List all providers with credential status",
|
||||
"",
|
||||
"Examples:",
|
||||
" sf key set anthropic sk-ant-abc123 Set the Anthropic API key",
|
||||
" sf key set xiaomi tp-abc123 Set the Xiaomi API key",
|
||||
" sf key get anthropic Show masked key for anthropic",
|
||||
" sf key remove anthropic Remove anthropic credential (confirms)",
|
||||
" sf key remove anthropic --yes Remove without prompting",
|
||||
" sf key list List all providers",
|
||||
"",
|
||||
"Security:",
|
||||
" - Keys are never echoed in full. Only the last 4 characters are shown.",
|
||||
" - All writes go through AuthStorage (file-locked, chmod 600).",
|
||||
" - sf key remove always asks for confirmation unless --yes is passed.",
|
||||
].join("\n"),
|
||||
|
||||
headless: [
|
||||
"Usage: sf headless [flags] [command] [args...]",
|
||||
"",
|
||||
|
|
@ -349,6 +379,9 @@ export function printHelp(version: string): void {
|
|||
process.stdout.write(
|
||||
" schedule <cmd> Manage time-bound reminders (add, list, done, cancel, snooze, run)\n",
|
||||
);
|
||||
process.stdout.write(
|
||||
" key <set|get|remove|list> Manage provider API keys in auth.json\n",
|
||||
);
|
||||
process.stdout.write(
|
||||
"\nRun sf <subcommand> --help for subcommand-specific help.\n",
|
||||
);
|
||||
|
|
|
|||
434
src/tests/cli-key-command.test.ts
Normal file
434
src/tests/cli-key-command.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
/**
|
||||
* cli-key-command.test.ts — unit tests for `sf key` subcommand.
|
||||
*
|
||||
* Uses an InMemoryAuthStorageBackend so no real auth.json is touched.
|
||||
* Tests call runKeyCommand() with a temp auth path and spy on
|
||||
* process.stdout/stderr to assert output without spawning a subprocess.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, test } from "vitest";
|
||||
|
||||
import { maskKeyLast4, runKeyCommand } from "../cli-key.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTempAuthPath(): string {
|
||||
const dir = join(tmpdir(), `sf-key-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const authPath = join(dir, "auth.json");
|
||||
writeFileSync(authPath, "{}", "utf-8");
|
||||
return authPath;
|
||||
}
|
||||
|
||||
/** Capture stdout lines written during an async callback. */
|
||||
async function captureStdout(fn: () => Promise<unknown>): Promise<string> {
|
||||
const chunks: string[] = [];
|
||||
const orig = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout as NodeJS.WriteStream).write = (chunk: unknown) => {
|
||||
chunks.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stdout as NodeJS.WriteStream).write = orig;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
/** Capture stderr lines written during an async callback. */
|
||||
async function captureStderr(fn: () => Promise<unknown>): Promise<string> {
|
||||
const chunks: string[] = [];
|
||||
const orig = process.stderr.write.bind(process.stderr);
|
||||
(process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => {
|
||||
chunks.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
(process.stderr as NodeJS.WriteStream).write = orig;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// maskKeyLast4 unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("maskKeyLast4", () => {
|
||||
test("masks all but last 4 chars with *** prefix", () => {
|
||||
assert.equal(maskKeyLast4("tp-newkey12345"), "***2345");
|
||||
});
|
||||
|
||||
test("handles key exactly 4 chars long", () => {
|
||||
assert.equal(maskKeyLast4("abcd"), "***abcd");
|
||||
});
|
||||
|
||||
test("handles key shorter than 4 chars", () => {
|
||||
assert.equal(maskKeyLast4("abc"), "***abc");
|
||||
});
|
||||
|
||||
test("returns (empty) for falsy input", () => {
|
||||
assert.equal(maskKeyLast4(""), "(empty)");
|
||||
});
|
||||
|
||||
test("never exposes more than last 4 chars of a long key", () => {
|
||||
const key = "sk-ant-api-xxxxxxxxxxxxxxxxxxxxxxxxxxxx1234";
|
||||
const masked = maskKeyLast4(key);
|
||||
assert.ok(masked.startsWith("***"), "must start with ***");
|
||||
assert.ok(!masked.includes("xxxx"), "must not expose middle");
|
||||
assert.ok(masked.endsWith("1234"), "must end with last 4");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sf key set", () => {
|
||||
test("sets a new key and reports masked old+new", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["set", "xiaomi", "tp-newrotation-12345"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("Updated xiaomi credential"), `output: ${out}`);
|
||||
assert.ok(out.includes("(none)"), "old key should be (none) when not previously set");
|
||||
assert.ok(out.includes("***2345"), "should show last 4 of new key");
|
||||
assert.ok(!out.includes("tp-newrotation-12345"), "must not echo full key");
|
||||
});
|
||||
|
||||
test("shows old masked key when rotating", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
// Set initial key
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-first-key-aaaa"]);
|
||||
// Rotate
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["set", "xiaomi", "tp-second-key-bbbb"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("***aaaa"), "old masked key should be shown");
|
||||
assert.ok(out.includes("***bbbb"), "new masked key should be shown");
|
||||
});
|
||||
|
||||
test("rejects an unknown provider with exit code 1", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
err = await captureStderr(async () => {
|
||||
const code = await runKeyCommand(authPath, ["set", "unknownprovider", "foo-key"]);
|
||||
assert.equal(code, 1);
|
||||
});
|
||||
assert.ok(err.includes("Unknown provider"), `stderr: ${err}`);
|
||||
assert.ok(err.includes("unknownprovider"), `stderr should echo the bad name: ${err}`);
|
||||
assert.ok(err.includes("Valid providers"), "should list valid providers");
|
||||
});
|
||||
|
||||
test("rejects empty key with exit code 1", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
let exitCode = -1;
|
||||
err = await captureStderr(async () => {
|
||||
exitCode = await runKeyCommand(authPath, ["set", "xiaomi", ""]);
|
||||
});
|
||||
assert.equal(exitCode, 1);
|
||||
assert.ok(err.includes("non-empty"), `stderr: ${err}`);
|
||||
});
|
||||
|
||||
test("missing args returns exit code 1", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
let exitCode = -1;
|
||||
err = await captureStderr(async () => {
|
||||
exitCode = await runKeyCommand(authPath, ["set", "xiaomi"]);
|
||||
});
|
||||
assert.equal(exitCode, 1);
|
||||
assert.ok(err.includes("Usage"), `stderr: ${err}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key get
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sf key get", () => {
|
||||
test("shows last-4 chars only when key is set", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-some-long-key-wxyz"]);
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["get", "xiaomi"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("api_key set"), `output: ${out}`);
|
||||
assert.ok(out.includes("***wxyz"), `should show last 4: ${out}`);
|
||||
assert.ok(!out.includes("tp-some-long-key-wxyz"), "must not echo full key");
|
||||
});
|
||||
|
||||
test("shows 'no credential set' when provider not configured", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["get", "xiaomi"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("no credential set"), `output: ${out}`);
|
||||
});
|
||||
|
||||
test("rejects unknown provider with exit code 1", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
err = await captureStderr(async () => {
|
||||
const code = await runKeyCommand(authPath, ["get", "bogusprovider"]);
|
||||
assert.equal(code, 1);
|
||||
});
|
||||
assert.ok(err.includes("Unknown provider"), `stderr: ${err}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key remove
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sf key remove", () => {
|
||||
test("removes credential with --yes flag (no prompt)", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-key-to-remove-zzzz"]);
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["remove", "xiaomi", "--yes"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("Removed"), `output: ${out}`);
|
||||
|
||||
// Verify key is gone
|
||||
let getOut = "";
|
||||
getOut = await captureStdout(async () => {
|
||||
await runKeyCommand(authPath, ["get", "xiaomi"]);
|
||||
});
|
||||
assert.ok(getOut.includes("no credential set"), `after remove: ${getOut}`);
|
||||
});
|
||||
|
||||
test("returns 1 when provider has no credential", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
let exitCode = -1;
|
||||
err = await captureStderr(async () => {
|
||||
exitCode = await runKeyCommand(authPath, ["remove", "xiaomi", "--yes"]);
|
||||
});
|
||||
assert.equal(exitCode, 1);
|
||||
assert.ok(err.includes("No credential"), `stderr: ${err}`);
|
||||
});
|
||||
|
||||
test("rejects unknown provider with exit code 1", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
const code = await runKeyCommand(authPath, ["remove", "notreal", "--yes"]);
|
||||
assert.equal(code, 1);
|
||||
});
|
||||
|
||||
test("rm alias works the same as remove", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-alias-test-1234"]);
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["rm", "xiaomi", "--yes"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("Removed"), `rm alias output: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sf key list", () => {
|
||||
test("shows xiaomi as set after key is added", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-list-test-1234"]);
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["list"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("xiaomi"), `output should include xiaomi: ${out}`);
|
||||
assert.ok(out.includes("set"), `xiaomi should be marked set: ${out}`);
|
||||
assert.ok(out.includes("***1234"), `should show last 4: ${out}`);
|
||||
assert.ok(!out.includes("tp-list-test-1234"), "must not echo full key");
|
||||
});
|
||||
|
||||
test("shows 'not set' for providers with no credential", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
await runKeyCommand(authPath, ["list"]);
|
||||
});
|
||||
assert.ok(out.includes("not set"), `some providers should show not set: ${out}`);
|
||||
});
|
||||
|
||||
test("ls alias works", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["ls"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("Provider credentials"), `ls alias: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wipe all env vars that correspond to PROVIDER_REGISTRY envVar fields,
|
||||
* run the callback, then restore. Ensures sync tests are isolated from the
|
||||
* developer's real environment (CI boxes often have many keys set).
|
||||
*/
|
||||
async function withCleanProviderEnv(fn: () => Promise<void>): Promise<void> {
|
||||
const KNOWN_ENV_VARS = [
|
||||
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY",
|
||||
"MISTRAL_API_KEY", "MINIMAX_API_KEY", "KIMI_API_KEY", "ZAI_API_KEY",
|
||||
"XIAOMI_API_KEY", "OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY",
|
||||
"OPENCODE_API_KEY", "OPENCODE_GO_API_KEY", "CUSTOM_OPENAI_API_KEY",
|
||||
"CEREBRAS_API_KEY", "AZURE_OPENAI_API_KEY", "ALIBABA_API_KEY",
|
||||
"DASHSCOPE_API_KEY", "CONTEXT7_API_KEY", "JINA_API_KEY",
|
||||
"TAVILY_API_KEY", "BRAVE_API_KEY", "SERPER_API_KEY", "EXA_API_KEY",
|
||||
"DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of KNOWN_ENV_VARS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
for (const k of KNOWN_ENV_VARS) {
|
||||
if (saved[k] !== undefined) {
|
||||
process.env[k] = saved[k];
|
||||
} else {
|
||||
delete process.env[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("sf key sync", () => {
|
||||
test("rotates stored key when env var differs", async () => {
|
||||
await withCleanProviderEnv(async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
// Store an old key
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-old-key-oldval"]);
|
||||
// Set env var to a different value
|
||||
process.env.XIAOMI_API_KEY = "tp-env-key-newval";
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["sync"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("Rotated xiaomi"), `should report rotation: ${out}`);
|
||||
assert.ok(out.includes("XIAOMI_API_KEY"), `should mention env var name: ${out}`);
|
||||
|
||||
// Confirm the stored key was updated
|
||||
let getOut = "";
|
||||
getOut = await captureStdout(async () => {
|
||||
await runKeyCommand(authPath, ["get", "xiaomi"]);
|
||||
});
|
||||
assert.ok(getOut.includes("***wval"), `should now show env key's last 4: ${getOut}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("reports no changes when env matches stored key", async () => {
|
||||
await withCleanProviderEnv(async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-same-key-same"]);
|
||||
process.env.XIAOMI_API_KEY = "tp-same-key-same";
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["sync"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
assert.ok(out.includes("No changes"), `should report no changes: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("skips providers with no env var set", async () => {
|
||||
await withCleanProviderEnv(async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
await runKeyCommand(authPath, ["set", "xiaomi", "tp-stored-key-xxxx"]);
|
||||
// XIAOMI_API_KEY is already cleared by withCleanProviderEnv
|
||||
|
||||
let out = "";
|
||||
out = await captureStdout(async () => {
|
||||
const code = await runKeyCommand(authPath, ["sync"]);
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
// xiaomi should NOT be rotated since env is not set
|
||||
assert.ok(!out.includes("Rotated xiaomi"), `should skip xiaomi when env not set: ${out}`);
|
||||
assert.ok(out.includes("No changes"), `should report no changes: ${out}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sf key — unknown subcommand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sf key unknown subcommand", () => {
|
||||
test("returns exit code 1 with error message", async () => {
|
||||
const authPath = makeTempAuthPath();
|
||||
let err = "";
|
||||
err = await captureStderr(async () => {
|
||||
const code = await runKeyCommand(authPath, ["boguscmd"]);
|
||||
assert.equal(code, 1);
|
||||
});
|
||||
assert.ok(err.includes("Unknown subcommand"), `stderr: ${err}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cli.ts dispatch contract (static analysis)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cli.ts dispatch contract", () => {
|
||||
test("sf key branch exists before InteractiveMode in cli.ts", () => {
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const source = readFileSync(join(__dirname, "..", "cli.ts"), "utf-8");
|
||||
|
||||
const keyBranch = source.indexOf('cliFlags.messages[0] === "key"');
|
||||
const interactiveMode = source.indexOf("new InteractiveMode");
|
||||
|
||||
assert.notEqual(keyBranch, -1, "sf key dispatch branch must exist in cli.ts");
|
||||
assert.notEqual(interactiveMode, -1, "InteractiveMode must exist in cli.ts");
|
||||
assert.ok(keyBranch < interactiveMode, "sf key must route before the interactive TUI path");
|
||||
assert.ok(
|
||||
source.includes("runKeyCommand"),
|
||||
"cli.ts must call runKeyCommand",
|
||||
);
|
||||
});
|
||||
|
||||
test("key subcommand help entry exists in help-text.ts", () => {
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const source = readFileSync(join(__dirname, "..", "help-text.ts"), "utf-8");
|
||||
|
||||
assert.ok(source.includes('"key":') || source.includes("key:"), "key help entry must exist");
|
||||
assert.ok(source.includes("sf key"), "help text must mention sf key");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue