diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index b86eda41..a065aa26 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -33,9 +33,16 @@ export interface ParsedCliArgs { version: boolean; /** True when --help / -h was passed */ help: boolean; + /** Sub-command dispatched before the TUI starts (currently only "login"). */ + command?: "login"; + /** Parsed options for the `login` sub-command, present when `command === "login"`. */ + login?: { apiKey?: string; show: boolean; help: boolean }; } const EPILOG = [ + "Commands:", + " login Save your DeepSeek API key to ~/.deepcode/settings.json", + "", "Configuration:", " ~/.deepcode/settings.json User-level API key, model, base URL", " ./.deepcode/settings.json Project-level settings", @@ -125,6 +132,55 @@ async function configureYargs(argv?: string[]) { return yargsInstance; } +/** + * Parse `deepcode login` options. Only a small, fixed set of flags is + * accepted; anything else is rejected with a usage hint and `process.exit(1)`. + * + * Supported: + * --api-key | -k | --api-key= Non-interactive key + * --show Echo the key while typing + * -h | --help Show login help + */ +function parseLoginArgs(args: string[]): ParsedCliArgs { + let apiKey: string | undefined; + let show = false; + let help = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "-h" || arg === "--help") { + help = true; + } else if (arg === "--show") { + show = true; + } else if (arg === "--api-key" || arg === "-k") { + const value = args[i + 1]; + if (value === undefined || value.startsWith("-")) { + writeStderrLine(`${arg} requires a value.`); + writeStderrLine("Run `deepcode login --help` for usage."); + process.exit(1); + } + apiKey = value; + i++; + } else if (arg.startsWith("--api-key=")) { + apiKey = arg.slice("--api-key=".length); + } else { + writeStderrLine(`Unknown option: ${arg}`); + writeStderrLine("Run `deepcode login --help` for usage."); + process.exit(1); + } + } + + return { + prompt: undefined, + resume: undefined, + version: false, + help: false, + command: "login", + login: { apiKey: apiKey?.trim() || undefined, show, help }, + }; +} + /** * Parse CLI arguments with validation. * @@ -133,6 +189,14 @@ async function configureYargs(argv?: string[]) { * valid `ParsedCliArgs` or terminates the process. */ export async function parseArguments(argv?: string[]): Promise { + const rawArgv = argv ?? hideBin(process.argv); + + // `login` is dispatched before yargs so it never enters the TUI parser + // (which is strict and would reject unknown sub-commands). + if (rawArgv[0] === "login") { + return parseLoginArgs(rawArgv.slice(1)); + } + const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => { writeStderrLine(msg || _err?.message || "Unknown error"); yargs.showHelp(); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80b11f08..cb70ef54 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -7,6 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; import { parseArguments } from "./cli-args"; +import { runLogin, printLoginHelp } from "./commands/login"; import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers"; import { getPackageJson } from "./utils/package"; import { CLI_VERSION } from "./generated/git-commit"; @@ -23,6 +24,18 @@ async function main(): Promise { process.exit(0); } + // `login` is a standalone sub-command: it never starts the TUI and works + // without a TTY (so keys can be piped), so dispatch it before the + // interactive-terminal check and Windows shell configuration below. + if (parsed.command === "login") { + if (parsed.login?.help) { + printLoginHelp(); + process.exit(0); + } + await runLogin({ apiKey: parsed.login?.apiKey, show: parsed.login?.show ?? false }); + process.exit(0); + } + // Configure Windows shell AFTER --version/--help handling. // On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1). // If called before argument parsing, --help and --version would fail on those machines. diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts new file mode 100644 index 00000000..3888c45d --- /dev/null +++ b/packages/cli/src/commands/login.ts @@ -0,0 +1,189 @@ +/** + * `deepcode login` — interactively save your DeepSeek API key to + * ~/.deepcode/settings.json, writing a ready-to-use default config. + * + * This is a plain command-line script (no Ink/TUI): it reads the key from a + * hidden prompt, merges it with the existing user settings, and writes the + * result back. Run before the TUI is started in `cli.tsx`. + */ + +import * as os from "node:os"; +import { createInterface } from "node:readline"; +import { + buildLoginSettings, + readSettings, + writeSettings, + getUserSettingsPath, + DEFAULT_MODEL, + DEFAULT_BASE_URL, +} from "@vegamo/deepcode-core"; +import { writeStdout, writeStdoutLine, writeStderrLine } from "../utils/stdio-helpers"; + +export interface LoginOptions { + /** Non-interactive API key; skips the prompt entirely (for CI/scripts). */ + apiKey?: string; + /** Echo the key as it is typed instead of masking each char with `*`. */ + show: boolean; +} + +/** Render an absolute path with the home directory collapsed to `~`. */ +function formatHomePath(filePath: string): string { + const home = os.homedir(); + return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath; +} + +/** Toggle stdin raw mode, tolerating non-TTY streams where it is unavailable. */ +function setStdinRawMode(mode: boolean): void { + const stdin = process.stdin as NodeJS.ReadStream & { + setRawMode?: (mode: boolean) => void; + }; + stdin.setRawMode?.(mode); +} + +/** + * Read a single line from stdin with no echo, masking each typed character + * with `*`. Handles Backspace, Ctrl+U (clear line) and Ctrl+C (cancel). + * Falls back to a plain readline prompt when stdin is not a TTY (e.g. piped), + * where echo is naturally absent. + */ +function readHiddenLine(prompt: string): Promise { + return new Promise((resolve) => { + const stdin = process.stdin; + const stdout = process.stdout; + + if (!stdin.isTTY) { + // Piped stdin: don't echo the prompt (there is no one to read it), + // just consume one line. + const rl = createInterface({ input: stdin }); + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }); + rl.on("SIGINT", () => { + rl.close(); + resolve(undefined); + }); + return; + } + + stdout.write(prompt); + setStdinRawMode(true); + let value = ""; + + const onData = (buffer: Buffer): void => { + for (const byte of buffer) { + // Ctrl+C → cancel + if (byte === 0x03) { + cleanup(); + stdout.write("\r\n"); + resolve(undefined); + return; + } + // Enter → submit + if (byte === 0x0d || byte === 0x0a) { + cleanup(); + stdout.write("\r\n"); + resolve(value); + return; + } + // Backspace / Ctrl+H + if (byte === 0x7f || byte === 0x08) { + if (value.length > 0) { + value = value.slice(0, -1); + stdout.write("\b \b"); + } + continue; + } + // Ctrl+U → clear the whole line + if (byte === 0x15) { + while (value.length > 0) { + value = value.slice(0, -1); + stdout.write("\b \b"); + } + continue; + } + // Printable ASCII + if (byte >= 0x20 && byte <= 0x7e) { + value += String.fromCharCode(byte); + stdout.write("*"); + } + } + }; + + const cleanup = (): void => { + stdin.removeListener("data", onData); + setStdinRawMode(false); + }; + + stdin.on("data", onData); + }); +} + +/** Read a single line with normal echo (for `--show` or piped stdin). */ +function readVisibleLine(prompt: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }); + rl.on("SIGINT", () => { + rl.close(); + resolve(undefined); + }); + }); +} + +async function promptForApiKey(show: boolean): Promise { + writeStdoutLine("Deep Code — Login"); + writeStdoutLine(`Your API key is stored in ${formatHomePath(getUserSettingsPath())}`); + return show ? readVisibleLine("API key: ") : readHiddenLine("API key (hidden): "); +} + +export async function runLogin(opts: LoginOptions): Promise { + let apiKey = opts.apiKey?.trim(); + + if (!apiKey) { + const entered = await promptForApiKey(opts.show); + apiKey = entered?.trim(); + } + + if (!apiKey) { + writeStderrLine("Login cancelled: no API key provided."); + process.exitCode = 1; + return; + } + + const existing = readSettings(); + const next = buildLoginSettings(existing, apiKey); + writeSettings(next); + + writeStdoutLine(`✓ API key saved to ${formatHomePath(getUserSettingsPath())}`); + writeStdoutLine(` model: ${next.env?.MODEL ?? DEFAULT_MODEL}`); + writeStdoutLine(` base URL: ${next.env?.BASE_URL ?? DEFAULT_BASE_URL}`); + writeStdoutLine(` Run \`deepcode\` to start.`); +} + +export function printLoginHelp(): void { + const settingsPath = formatHomePath(getUserSettingsPath()); + writeStdout(`Usage: deepcode login [options] + +Save your DeepSeek API key to ${settingsPath}. + +Interactively prompts for the API key (hidden by default) and writes a +ready-to-use default config: model=deepseek-v4-pro, base URL=api.deepseek.com, +thinking enabled, reasoning effort=max. Existing custom fields are preserved. + +Options: + --api-key Provide the key non-interactively (no prompt, for CI/scripts) + -k Alias for --api-key + --show Show the key as you type it instead of masking with * + -h, --help Show this help + +Examples: + deepcode login Prompt for the key (hidden) + deepcode login --show Prompt and show the key while typing + deepcode login --api-key sk-xxx Write the key without prompting + echo sk-xxx | deepcode login Pipe the key via stdin +`); +} diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index fe90eeed..ee52140f 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -209,3 +209,95 @@ test("parseArguments exits on invalid --resume session ID", async () => { assert.ok(exitSpy.calls.length >= 1); }); }); + +// ── parseArguments: login sub-command ───────────────────────────────────────── + +test("parseArguments detects the login sub-command", async () => { + const r = await parseArguments(["login"]); + assert.equal(r.command, "login"); + assert.equal(r.login?.apiKey, undefined); + assert.equal(r.login?.show, false); + assert.equal(r.login?.help, false); + // login must not be confused with a normal launch + assert.equal(r.prompt, undefined); + assert.equal(r.resume, undefined); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +test("parseArguments reads --api-key for login", async () => { + const r = await parseArguments(["login", "--api-key", "sk-abc"]); + assert.equal(r.command, "login"); + assert.equal(r.login?.apiKey, "sk-abc"); +}); + +test("parseArguments reads -k alias for login", async () => { + const r = await parseArguments(["login", "-k", "sk-abc"]); + assert.equal(r.login?.apiKey, "sk-abc"); +}); + +test("parseArguments reads --api-key=value form for login", async () => { + const r = await parseArguments(["login", "--api-key=sk-xyz"]); + assert.equal(r.login?.apiKey, "sk-xyz"); +}); + +test("parseArguments trims the login api key", async () => { + const r = await parseArguments(["login", "--api-key", " sk-abc "]); + assert.equal(r.login?.apiKey, "sk-abc"); +}); + +test("parseArguments reads --show for login", async () => { + const r = await parseArguments(["login", "--show"]); + assert.equal(r.login?.show, true); +}); + +test("parseArguments reads --help / -h for login", async () => { + const r1 = await parseArguments(["login", "--help"]); + assert.equal(r1.command, "login"); + assert.equal(r1.login?.help, true); + + const r2 = await parseArguments(["login", "-h"]); + assert.equal(r2.login?.help, true); +}); + +test("parseArguments combines login flags", async () => { + const r = await parseArguments(["login", "--show", "--api-key", "sk-abc"]); + assert.equal(r.login?.apiKey, "sk-abc"); + assert.equal(r.login?.show, true); +}); + +test("parseArguments exits on login --api-key without a value", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["login", "--api-key"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on unknown login option", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["login", "--bogus"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +// ── parseArguments: login does not interfere with normal usage ──────────────── + +test("parseArguments still parses -p when first arg is not login", async () => { + const r = await parseArguments(["-p", "hello"]); + assert.equal(r.command, undefined); + assert.equal(r.prompt, "hello"); +}); + +test("parseArguments does not treat login as a prompt when passed via -p", async () => { + const r = await parseArguments(["-p", "login"]); + assert.equal(r.command, undefined); + assert.equal(r.prompt, "login"); +}); diff --git a/packages/cli/src/tests/login.test.ts b/packages/cli/src/tests/login.test.ts new file mode 100644 index 00000000..eed0a048 --- /dev/null +++ b/packages/cli/src/tests/login.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { runLogin } from "../commands/login"; + +/** Redirect $HOME to a temp dir and silence stdout while fn runs. */ +async function withTempHome(fn: () => Promise): Promise { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "dc-login-")); + const previousHome = process.env.HOME; + const previousExitCode = process.exitCode; + const origStdoutWrite = process.stdout.write.bind(process.stdout); + + process.env.HOME = tmpHome; + // runLogin prints a confirmation; swallow it during tests + process.stdout.write = (() => true) as never; + try { + return await fn(); + } finally { + process.env.HOME = previousHome; + process.exitCode = previousExitCode; + process.stdout.write = origStdoutWrite; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } +} + +function readSettings(homeDir: string): Record { + const settingsPath = path.join(homeDir, ".deepcode", "settings.json"); + return JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record; +} + +test("runLogin --api-key writes a ready-to-use settings file", async () => { + await withTempHome(async () => { + await runLogin({ apiKey: "sk-test-123", show: false }); + + const written = readSettings(process.env.HOME!); + const env = written.env as Record; + assert.equal(env.API_KEY, "sk-test-123"); + assert.equal(env.MODEL, "deepseek-v4-pro"); + assert.equal(env.BASE_URL, "https://api.deepseek.com"); + assert.equal(written.thinkingEnabled, true); + assert.equal(written.reasoningEffort, "max"); + }); +}); + +test("runLogin preserves existing custom fields when updating the key", async () => { + await withTempHome(async () => { + // seed an existing settings file with custom fields + const settingsDir = path.join(process.env.HOME!, ".deepcode"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify({ env: { MODEL: "deepseek-v3.2" }, thinkingEnabled: false }) + ); + + await runLogin({ apiKey: "sk-new", show: false }); + + const written = readSettings(process.env.HOME!); + const env = written.env as Record; + assert.equal(env.API_KEY, "sk-new"); // updated + assert.equal(env.MODEL, "deepseek-v3.2"); // preserved + assert.equal(written.thinkingEnabled, false); // preserved + assert.equal(env.BASE_URL, "https://api.deepseek.com"); // filled in + assert.equal(written.reasoningEffort, "max"); // filled in + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 832d2444..33bf9715 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export { writeModelConfigSelection, applyModelConfigSelection, modelConfigKey, + buildLoginSettings, getUserSettingsPath, getProjectSettingsPath, DEFAULT_MODEL, diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..796291c0 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -675,3 +675,39 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res process.env ); } + +/** + * Build the settings object to persist after `deepcode login`. + * + * Merges the user's existing settings with a ready-to-use default template + * (MODEL=deepseek-v4-pro, BASE_URL=https://api.deepseek.com, thinkingEnabled=true, + * reasoningEffort="max") so a fresh login works out of the box, while preserving + * any fields the user has already customized. The API key is always written to + * `env.API_KEY`, overriding any previous value. Missing defaults are filled in, + * existing non-empty values are left untouched. + */ +export function buildLoginSettings( + existing: DeepcodingSettings | null | undefined, + apiKey: string +): DeepcodingSettings { + const trimmedKey = apiKey.trim(); + const base: DeepcodingSettings = { ...(existing ?? {}) }; + const env: DeepcodingEnv = { ...(base.env ?? {}) }; + + if (!trimString(env.MODEL)) { + env.MODEL = DEFAULT_MODEL; + } + if (!trimString(env.BASE_URL)) { + env.BASE_URL = DEFAULT_BASE_URL; + } + env.API_KEY = trimmedKey; + + const result: DeepcodingSettings = { ...base, env }; + if (base.thinkingEnabled === undefined) { + result.thinkingEnabled = true; + } + if (base.reasoningEffort === undefined) { + result.reasoningEffort = "max"; + } + return result; +} diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index ceddc43e..f825fde4 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -7,7 +7,13 @@ import { type NotifyContext, type NotifySpawn, } from "../common/notify"; -import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; +import { + applyModelConfigSelection, + buildLoginSettings, + resolveSettings, + resolveSettingsSources, + type DeepcodingSettings, +} from "../settings"; const TEST_PROCESS_ENV = {}; @@ -612,3 +618,49 @@ test( assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); } ); + +// ── buildLoginSettings ──────────────────────────────────────────────────────── + +test("buildLoginSettings writes the full default template for fresh settings", () => { + const result = buildLoginSettings(null, " sk-new "); + + assert.equal(result.env?.API_KEY, "sk-new"); // trimmed + assert.equal(result.env?.MODEL, "deepseek-v4-pro"); + assert.equal(result.env?.BASE_URL, "https://api.deepseek.com"); + assert.equal(result.thinkingEnabled, true); + assert.equal(result.reasoningEffort, "max"); +}); + +test("buildLoginSettings preserves existing customized fields", () => { + const existing: DeepcodingSettings = { + env: { MODEL: "deepseek-v3.2", BASE_URL: "https://custom.example.com" }, + model: "my-model", + temperature: 0.5, + thinkingEnabled: false, + reasoningEffort: "high", + permissions: { allow: ["network"] }, + }; + + const result = buildLoginSettings(existing, "sk-new"); + + assert.equal(result.env?.API_KEY, "sk-new"); // updated + assert.equal(result.env?.MODEL, "deepseek-v3.2"); // preserved + assert.equal(result.env?.BASE_URL, "https://custom.example.com"); // preserved + assert.equal(result.model, "my-model"); // preserved + assert.equal(result.temperature, 0.5); // preserved + assert.equal(result.thinkingEnabled, false); // preserved + assert.equal(result.reasoningEffort, "high"); // preserved + assert.deepEqual(result.permissions, { allow: ["network"] }); // preserved +}); + +test("buildLoginSettings fills missing defaults in partial existing settings", () => { + const existing = { env: { API_KEY: "sk-old" } }; // missing MODEL/BASE_URL/thinking + + const result = buildLoginSettings(existing, "sk-new"); + + assert.equal(result.env?.API_KEY, "sk-new"); // overridden + assert.equal(result.env?.MODEL, "deepseek-v4-pro"); // filled + assert.equal(result.env?.BASE_URL, "https://api.deepseek.com"); // filled + assert.equal(result.thinkingEnabled, true); // filled + assert.equal(result.reasoningEffort, "max"); // filled +});