Skip to content

Commit a31c3e1

Browse files
committed
feat(cli): add browser-based login via device-code flow
Adds a `codetime login` command so users can authorize a machine by signing in through their browser instead of copying the upload token from the dashboard by hand. The CLI calls /v3/agent/cli/link/start, opens <remote>/cli/auth?code=… (or just prints it, for SSH/headless hosts), then polls /poll until the user approves it in a signed-in tab. The upload token is never typed and never travels in a URL; it is resolved server-side at approval time.
1 parent 55cf272 commit a31c3e1

4 files changed

Lines changed: 288 additions & 7 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@ import { DEFAULT_API_URL, DEFAULT_BACKFILL_BATCH_BYTES, DEFAULT_BACKFILL_BATCH_S
3535
import { isPlainObject, numberOption, stringOption, valuesOption } from './lib/fields.js'
3636
import { countDirectoryEntries, listJsonlFiles, pathExists, readJsonIfExists } from './lib/fs.js'
3737
import { logError } from './lib/logger.js'
38+
import { isHeadless, openBrowser, sleep } from './lib/login.js'
3839
import { ProgressBar } from './lib/progress.js'
3940
import {
4041
deleteMachine,
4142
deleteRollupsBySource,
4243
listMachines,
44+
pollCliLink,
4345
postRollupBatch,
4446
renameMachine,
4547
resolveRemote,
48+
startCliLink,
4649
} from './lib/remote.js'
4750
import { BACKFILL_STATE_SCHEMA_VERSION } from './lib/types.js'
4851

@@ -172,9 +175,18 @@ function createCli(ctx: RunContext, registry: AdapterRegistry) {
172175
.option('--force', 'Force full re-import: clear watermark and re-process all files')
173176
.action((action, options) => backfillCommand({ ...normalizeOptions(options), action }, ctx, registry))
174177

175-
// `token` is the only path to set credentials — the agent CLI reuses
178+
// Browser login (device-code flow): opens `<remote>/cli/auth?code=…`,
179+
// polls until the user approves it there, then writes the upload token
180+
// to config — the one-click alternative to `token set`. Works over SSH
181+
// too: open the printed URL on any device. See lib/login.ts + remote.ts.
182+
cli.command('login', 'Authorize this machine by signing in through your browser')
183+
.option('--remote <url>', 'Override API base URL for this login')
184+
.option('--no-browser', 'Print the login URL instead of opening a browser')
185+
.action(options => loginCommand(normalizeOptions(options), ctx))
186+
187+
// `token` is the manual alternative to `login`: the agent CLI reuses
176188
// the user's existing upload_token (visible in the codetime
177-
// dashboard's Settings page), so there is no device-flow login.
189+
// dashboard's Settings page).
178190
// token set <value> write to ~/.codetime/config.json
179191
// token show print masked token + remoteUrl
180192
// token clear remove only the token (keep remoteUrl)
@@ -1320,6 +1332,91 @@ function maskToken(token: string): string {
13201332
return `${token.slice(0, 3)}${token.slice(-4)}`
13211333
}
13221334

1335+
// Hard ceiling on how long we keep polling, independent of the server's
1336+
// advertised expiry, so a misbehaving server can't pin the CLI forever.
1337+
const LOGIN_MAX_WAIT_MS = 15 * 60 * 1000
1338+
1339+
async function loginCommand(options: ParsedArgs, ctx: RunContext): Promise<number> {
1340+
const home = resolveHome(options, ctx)
1341+
const remoteOverride = stringOption(options.remote) || stringOption(options['api-url'])
1342+
const existing = readConfig(home)
1343+
const baseUrl = (remoteOverride
1344+
|| ctx.env.CODETIME_API_URL
1345+
|| existing.remoteUrl
1346+
|| DEFAULT_API_URL).replace(/\/$/, '')
1347+
1348+
const remote = resolveRemote({
1349+
apiUrl: baseUrl,
1350+
env: ctx.env,
1351+
fetch: ctx.fetch,
1352+
homeOverride: home,
1353+
})
1354+
if (!remote) {
1355+
write(ctx.stderr, 'No fetch implementation available.\n')
1356+
return 1
1357+
}
1358+
1359+
let link
1360+
try {
1361+
link = await startCliLink(remote)
1362+
}
1363+
catch (error) {
1364+
write(ctx.stderr, `${(error as Error).message}\n`)
1365+
return 1
1366+
}
1367+
1368+
// Build the browser URL from the CLI's own base URL rather than the
1369+
// server-advertised verificationUri, so it stays correct when the API
1370+
// host and the web origin differ (e.g. local dev on a non-default port).
1371+
const authUrl = `${baseUrl}/cli/auth?code=${encodeURIComponent(link.userCode)}`
1372+
const noBrowser = options.browser === false
1373+
const headless = isHeadless(ctx)
1374+
1375+
write(ctx.stdout, `\nTo sign in, visit:\n\n ${authUrl}\n\nand confirm this code: ${link.userCode}\n\n`)
1376+
if (!noBrowser && !headless) {
1377+
openBrowser(ctx, authUrl)
1378+
}
1379+
write(ctx.stdout, 'Waiting for authorization…\n')
1380+
1381+
const intervalMs = Math.max(1, link.interval || 4) * 1000
1382+
const deadline = Date.now() + Math.min(LOGIN_MAX_WAIT_MS, Math.max(1, link.expiresIn || 600) * 1000)
1383+
while (Date.now() < deadline) {
1384+
let poll
1385+
try {
1386+
poll = await pollCliLink(remote, link.deviceCode)
1387+
}
1388+
catch (error) {
1389+
// Transient network blip — wait and keep polling until the deadline.
1390+
void error
1391+
await sleep(intervalMs)
1392+
continue
1393+
}
1394+
if (poll.status === 'pending') {
1395+
await sleep(intervalMs)
1396+
continue
1397+
}
1398+
if (poll.status === 'expired') {
1399+
write(ctx.stderr, 'Login code expired before it was approved. Re-run `codetime login`.\n')
1400+
return 1
1401+
}
1402+
// Approved.
1403+
writeConfig({
1404+
...existing,
1405+
token: poll.token,
1406+
...(poll.userId == null ? {} : { userId: String(poll.userId) }),
1407+
// Persist the host only when explicitly overridden, matching
1408+
// `token set` so a default-host login never clobbers an earlier
1409+
// --remote choice.
1410+
...(remoteOverride ? { remoteUrl: remoteOverride } : {}),
1411+
}, home)
1412+
write(ctx.stdout, `Logged in. Token saved (${maskToken(poll.token)}).\n`)
1413+
return 0
1414+
}
1415+
1416+
write(ctx.stderr, 'Timed out waiting for authorization. Re-run `codetime login`.\n')
1417+
return 1
1418+
}
1419+
13231420
async function tokenCommand(
13241421
action: string | undefined,
13251422
value: string | undefined,
@@ -1440,19 +1537,22 @@ Usage:
14401537
codetime install [--target codex,claude,opencode,pi] [--all] [--dry-run] [--force] [--home <path>]
14411538
codetime hook --agent <name>
14421539
codetime backfill discover|plan|import|verify --source codex|claude-code|opencode|pi|all --dry-run [--json] [--batch-size <count>]
1540+
codetime login [--no-browser] [--remote <url>]
14431541
codetime token set <token>
14441542
codetime token show
14451543
codetime token clear
14461544
14471545
Setup:
1448-
Copy your upload token from https://codetime.dev/dashboard/settings,
1449-
then run: codetime token set <token>
1546+
Run: codetime login (signs in through your browser)
1547+
Or copy your upload token from https://codetime.dev/dashboard/settings
1548+
and run: codetime token set <token>
14501549
14511550
Commands:
14521551
detect Show supported local targets and install status.
14531552
install Install integration files into detected or requested targets.
14541553
hook Read agent hook JSON from stdin and report a throttled event.
14551554
backfill Discover local history and create metadata-only import plans.
1555+
login Authorize this machine by signing in through your browser.
14561556
token Set, show, or clear the persisted API token.
14571557
machine List your machines (read-only).
14581558
version Print CLI version.

packages/cli/src/lib/login.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Helpers for the browser-based `codetime login` (device-code flow). The
2+
// orchestration — start, open browser, poll, persist — lives in
3+
// loginCommand (cli.ts); this module holds the side-effecting bits
4+
// (launching a browser, detecting headless hosts) plus a small sleep.
5+
// The server side is codetime-web-v3/server/routes/v3/agent/cli/link/.
6+
import type { RunContext } from './types.js'
7+
8+
export function sleep(ms: number): Promise<void> {
9+
return new Promise(resolve => setTimeout(resolve, ms))
10+
}
11+
12+
// Best-effort launch of the user's default browser. Failures are
13+
// swallowed: `codetime login` always prints the URL too, so a missing
14+
// opener (headless box, locked-down container) just means the user
15+
// opens the printed link themselves.
16+
export function openBrowser(ctx: RunContext, url: string): void {
17+
const { command, args } = browserCommand(ctx.env.CODETIME_OS ?? process.platform, url)
18+
try {
19+
const child = ctx.spawn(command, args, { detached: true, stdio: 'ignore' })
20+
child.unref?.()
21+
}
22+
catch {
23+
// Ignored — the printed URL is the fallback.
24+
}
25+
}
26+
27+
function browserCommand(platform: string, url: string): { command: string, args: string[] } {
28+
if (platform === 'darwin') {
29+
return { command: 'open', args: [url] }
30+
}
31+
if (platform === 'win32') {
32+
// `start` is a cmd builtin; the empty "" is the window title cmd
33+
// expects before the URL, otherwise a quoted URL becomes the title.
34+
return { command: 'cmd', args: ['/c', 'start', '', url] }
35+
}
36+
return { command: 'xdg-open', args: [url] }
37+
}
38+
39+
// A headless host (CI, SSH session without X) can't open a browser; the
40+
// caller skips the auto-open and tells the user to open the URL manually.
41+
// Device-code login still works there — the user opens the URL on any
42+
// other device — which is the main reason this flow exists.
43+
export function isHeadless(ctx: RunContext): boolean {
44+
if (ctx.env.CODETIME_NO_BROWSER) {
45+
return true
46+
}
47+
if (ctx.env.SSH_CONNECTION || ctx.env.SSH_TTY) {
48+
return true
49+
}
50+
if (process.platform === 'linux') {
51+
return !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY
52+
}
53+
return false
54+
}

packages/cli/src/lib/remote.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,49 @@ export async function deleteRollupsBySource(
119119
return Number(body.deleted) || 0
120120
}
121121

122-
// Device-link helpers (startCliLink/pollCliLink) were removed when the
123-
// CLI moved to reusing the user's upload_token via `codetime token
124-
// set <token>`. The server no longer exposes `/v3/agent/cli/link/*`.
122+
// ── Device-code login ──
123+
// Browser-based `codetime login`: /start mints a code pair, the user
124+
// approves it in a signed-in browser tab, and /poll returns the token
125+
// once approved. The server side lives in
126+
// codetime-web-v3/server/routes/v3/agent/cli/link/.
127+
128+
export interface CliLinkStart {
129+
deviceCode: string
130+
userCode: string
131+
verificationUri: string
132+
verificationUriComplete: string
133+
interval: number
134+
expiresIn: number
135+
}
136+
137+
export type CliLinkPoll
138+
= | { status: 'pending' }
139+
| { status: 'approved', token: string, userId?: number }
140+
| { status: 'expired' }
141+
142+
export async function startCliLink(remote: RemoteOptions): Promise<CliLinkStart> {
143+
const response = await remote.fetchImpl(joinUrl(remote.baseUrl, '/v3/agent/cli/link/start'), {
144+
method: 'POST',
145+
headers: buildHeaders(),
146+
body: '{}',
147+
})
148+
if (!response.ok) {
149+
throw new Error(`Failed to start login: ${response.status}`)
150+
}
151+
return await response.json() as CliLinkStart
152+
}
153+
154+
export async function pollCliLink(remote: RemoteOptions, deviceCode: string): Promise<CliLinkPoll> {
155+
const response = await remote.fetchImpl(joinUrl(remote.baseUrl, '/v3/agent/cli/link/poll'), {
156+
method: 'POST',
157+
headers: buildHeaders(),
158+
body: JSON.stringify({ deviceCode }),
159+
})
160+
if (!response.ok) {
161+
throw new Error(`Login poll failed: ${response.status}`)
162+
}
163+
return await response.json() as CliLinkPoll
164+
}
125165

126166
export interface MachineRow {
127167
id: string

packages/cli/test/cli.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,3 +1488,90 @@ test('XDG_DATA_HOME relocates OpenCode backfill source candidates', async () =>
14881488
`expected XDG_DATA_HOME path among ${JSON.stringify(candidatePaths)}`,
14891489
)
14901490
})
1491+
1492+
// Mock the device-code endpoints: /start hands back a fixed code pair,
1493+
// /poll replays the supplied statuses in order (repeating the last).
1494+
function linkFetch(pollStatuses: Array<Record<string, unknown>>) {
1495+
const requests: Array<{ url: string, body: string }> = []
1496+
let pollIdx = 0
1497+
const impl = (async (url: string, init?: { body?: string }) => {
1498+
const u = String(url)
1499+
requests.push({ url: u, body: typeof init?.body === 'string' ? init.body : '' })
1500+
if (u.endsWith('/v3/agent/cli/link/start')) {
1501+
return Response.json({
1502+
deviceCode: 'device-secret-1',
1503+
userCode: 'WXYZ2345',
1504+
verificationUri: 'https://codetime.dev/cli/auth',
1505+
verificationUriComplete: 'https://codetime.dev/cli/auth?code=WXYZ2345',
1506+
interval: 1,
1507+
expiresIn: 600,
1508+
})
1509+
}
1510+
if (u.endsWith('/v3/agent/cli/link/poll')) {
1511+
const status = pollStatuses[Math.min(pollIdx, pollStatuses.length - 1)]
1512+
pollIdx += 1
1513+
return Response.json(status)
1514+
}
1515+
throw new Error(`unexpected url ${u}`)
1516+
}) as unknown as RunContext['fetch']
1517+
return { impl, requests }
1518+
}
1519+
1520+
test('login polls the device-code endpoint and saves the approved token', async () => {
1521+
const home = await mkdtemp(path.join(tmpdir(), 'codetime-'))
1522+
let output = ''
1523+
const { impl, requests } = linkFetch([{ status: 'approved', token: 'upload_abc123', userId: 7 }])
1524+
1525+
const exitCode = await run(['login', '--no-browser', '--home', home], testContext({
1526+
env: { HOME: home },
1527+
fetch: impl,
1528+
stdout: { write: (text: string) => {
1529+
output += text
1530+
} },
1531+
}))
1532+
1533+
assert.equal(exitCode, 0)
1534+
// The printed URL + code let an SSH user open it on another device.
1535+
assert.match(output, /\/cli\/auth\?code=WXYZ2345/)
1536+
assert.match(output, /WXYZ2345/)
1537+
// start is POSTed before any poll.
1538+
assert.ok(requests[0].url.endsWith('/v3/agent/cli/link/start'))
1539+
assert.ok(requests.some(r => r.url.endsWith('/v3/agent/cli/link/poll') && r.body.includes('device-secret-1')))
1540+
1541+
const config = JSON.parse(await readFile(path.join(home, '.codetime', 'config.json'), 'utf8'))
1542+
assert.equal(config.token, 'upload_abc123')
1543+
assert.equal(config.userId, '7')
1544+
})
1545+
1546+
test('login keeps polling while pending, then saves on approval', async () => {
1547+
const home = await mkdtemp(path.join(tmpdir(), 'codetime-'))
1548+
const { impl } = linkFetch([
1549+
{ status: 'pending' },
1550+
{ status: 'approved', token: 'tok_2', userId: 9 },
1551+
])
1552+
1553+
const exitCode = await run(['login', '--no-browser', '--home', home], testContext({
1554+
env: { HOME: home },
1555+
fetch: impl,
1556+
stdout: { write: () => {} },
1557+
}))
1558+
1559+
assert.equal(exitCode, 0)
1560+
const config = JSON.parse(await readFile(path.join(home, '.codetime', 'config.json'), 'utf8'))
1561+
assert.equal(config.token, 'tok_2')
1562+
})
1563+
1564+
test('login fails and writes no token when the code expires', async () => {
1565+
const home = await mkdtemp(path.join(tmpdir(), 'codetime-'))
1566+
const { impl } = linkFetch([{ status: 'expired' }])
1567+
1568+
const exitCode = await run(['login', '--no-browser', '--home', home], testContext({
1569+
env: { HOME: home },
1570+
fetch: impl,
1571+
stdout: { write: () => {} },
1572+
stderr: { write: () => {} },
1573+
}))
1574+
1575+
assert.equal(exitCode, 1)
1576+
await assert.rejects(readFile(path.join(home, '.codetime', 'config.json'), 'utf8'), { code: 'ENOENT' })
1577+
})

0 commit comments

Comments
 (0)