Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const args = parseArgs(process.argv.slice(2));
Expand Down Expand Up @@ -68,6 +69,9 @@ async function main(): Promise<number> {
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(),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<token>] Store a long-lived DeepSeek auth token (CI)
deepcode cron <cmd> 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)
Expand Down
102 changes: 102 additions & 0 deletions apps/cli/src/setup-token.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
58 changes: 58 additions & 0 deletions apps/cli/src/setup-token.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<number> {
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 <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;
}
Loading