diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 53d2332..653d97c 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -14,6 +14,7 @@ import { startRepl } from './repl.js'; import { runCronCommand, runSchedulerRun } from './scheduler.js'; import { runTrustCommand } from './trust-cmd.js'; import { runPluginsCommand, runSkillsCommand } from './list-cmd.js'; +import { runSetupToken } from './setup-token.js'; async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -68,6 +69,9 @@ async function main(): Promise { output: process.stdout, }); } + if (args.positional[0] === 'setup-token') { + return runSetupToken({ token: args.positional[1] }); + } if (args.positional[0] === 'plugins') { return runPluginsCommand(args.positional.slice(1), { cwd: process.cwd(), diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 242e167..14b163f 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -272,6 +272,7 @@ USAGE deepcode --continue Continue most recent session deepcode doctor Diagnostic checks deepcode upgrade Self-update (CLI; Mac client auto-updates) + deepcode setup-token [] Store a long-lived DeepSeek auth token (CI) deepcode cron Scheduled tasks: install/uninstall/list/status deepcode scheduler run Run due scheduled jobs (invoked by launchd) deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) diff --git a/apps/cli/src/setup-token.test.ts b/apps/cli/src/setup-token.test.ts new file mode 100644 index 0000000..f23184a --- /dev/null +++ b/apps/cli/src/setup-token.test.ts @@ -0,0 +1,102 @@ +import { CredentialsStore } from '@deepcode/core'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runSetupToken } from './setup-token.js'; + +function sink(): { stream: Writable; text: () => string } { + let buf = ''; + const stream = new Writable({ + write(c, _e, cb) { + buf += c.toString(); + cb(); + }, + }); + return { stream, text: () => buf }; +} + +/** A non-TTY readable carrying `data` (mimics a pipe). */ +function pipe(data: string): Readable & { isTTY?: boolean } { + const r = Readable.from([Buffer.from(data)]) as Readable & { isTTY?: boolean }; + r.isTTY = false; + return r; +} + +describe('runSetupToken', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-tok-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('stores a token passed as an argument', async () => { + const out = sink(); + const code = await runSetupToken({ + token: 'tok-abc123', + home, + output: out.stream, + env: {}, + forceFile: true, + }); + expect(code).toBe(0); + expect(out.text()).toMatch(/Stored DeepSeek auth token/); + expect((await new CredentialsStore({ home, forceFile: true }).load()).authToken).toBe( + 'tok-abc123', + ); + }); + + it('reads the token from $DEEPSEEK_AUTH_TOKEN', async () => { + await runSetupToken({ + home, + output: sink().stream, + env: { DEEPSEEK_AUTH_TOKEN: 'env-tok' }, + forceFile: true, + }); + expect((await new CredentialsStore({ home, forceFile: true }).load()).authToken).toBe( + 'env-tok', + ); + }); + + it('reads a piped token from stdin (non-TTY)', async () => { + await runSetupToken({ + home, + output: sink().stream, + env: {}, + stdin: pipe('piped-tok\n'), + forceFile: true, + }); + expect((await new CredentialsStore({ home, forceFile: true }).load()).authToken).toBe( + 'piped-tok', + ); + }); + + it('preserves an existing apiKey/baseURL when adding the token', async () => { + await new CredentialsStore({ home, forceFile: true }).save({ + apiKey: 'sk-keep', + baseURL: 'https://x/v1', + }); + await runSetupToken({ token: 't', home, output: sink().stream, env: {}, forceFile: true }); + const creds = await new CredentialsStore({ home, forceFile: true }).load(); + expect(creds.apiKey).toBe('sk-keep'); + expect(creds.authToken).toBe('t'); + expect(creds.baseURL).toBe('https://x/v1'); + }); + + it('errors with usage when no token is available', async () => { + const err = sink(); + const ttyStdin = Object.assign(Readable.from([]), { isTTY: true }); + const code = await runSetupToken({ + home, + errOutput: err.stream, + env: {}, + stdin: ttyStdin, + forceFile: true, + }); + expect(code).toBe(2); + expect(err.text()).toMatch(/Usage: deepcode setup-token/); + }); +}); diff --git a/apps/cli/src/setup-token.ts b/apps/cli/src/setup-token.ts new file mode 100644 index 0000000..ae32a32 --- /dev/null +++ b/apps/cli/src/setup-token.ts @@ -0,0 +1,58 @@ +// `deepcode setup-token` — provision a long-lived DeepSeek auth token for CI / +// headless use, persisted to the credential store (Keychain or chmod-600 file). +// Spec: docs/DEVELOPMENT_PLAN.md §3.4 +// +// DeepSeek has no OAuth device flow (unlike Claude Code's `setup-token`), so the +// token is supplied directly: as an argument, via $DEEPSEEK_AUTH_TOKEN, or piped +// on stdin (the CI-friendly path: `echo "$TOK" | deepcode setup-token`). + +import { CredentialsStore, redact } from '@deepcode/core'; +import type { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; + +export interface SetupTokenDeps { + /** Token from the CLI argument, if any. */ + token?: string; + home?: string; + output?: Writable; + errOutput?: Writable; + /** Stdin to read a piped token from (non-TTY only). Defaults to process.stdin. */ + stdin?: Readable & { isTTY?: boolean }; + /** Env lookup (injectable for tests). Defaults to process.env. */ + env?: NodeJS.ProcessEnv; + /** Bypass the macOS Keychain and write the chmod-600 file (tests). */ + forceFile?: boolean; +} + +async function readStdin(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +export async function runSetupToken(deps: SetupTokenDeps = {}): Promise { + const out = deps.output ?? process.stdout; + const err = deps.errOutput ?? process.stderr; + const env = deps.env ?? process.env; + const stdin = deps.stdin ?? process.stdin; + + let token = deps.token?.trim() || env.DEEPSEEK_AUTH_TOKEN?.trim(); + if (!token && stdin && !stdin.isTTY) { + token = (await readStdin(stdin)).trim(); + } + if (!token) { + err.write( + 'Usage: deepcode setup-token \n' + + ' or set $DEEPSEEK_AUTH_TOKEN, or pipe it: echo "$TOKEN" | deepcode setup-token\n', + ); + return 2; + } + + const store = new CredentialsStore({ home: deps.home, forceFile: deps.forceFile }); + const existing = await store.load(); + await store.save({ ...existing, authToken: token }); + out.write( + `✓ Stored DeepSeek auth token (${redact(token)}). It will be sent as the Bearer credential on future runs.\n`, + ); + return 0; +}