@@ -35,14 +35,17 @@ import { DEFAULT_API_URL, DEFAULT_BACKFILL_BATCH_BYTES, DEFAULT_BACKFILL_BATCH_S
3535import { isPlainObject , numberOption , stringOption , valuesOption } from './lib/fields.js'
3636import { countDirectoryEntries , listJsonlFiles , pathExists , readJsonIfExists } from './lib/fs.js'
3737import { logError } from './lib/logger.js'
38+ import { isHeadless , openBrowser , sleep } from './lib/login.js'
3839import { ProgressBar } from './lib/progress.js'
3940import {
4041 deleteMachine ,
4142 deleteRollupsBySource ,
4243 listMachines ,
44+ pollCliLink ,
4345 postRollupBatch ,
4446 renameMachine ,
4547 resolveRemote ,
48+ startCliLink ,
4649} from './lib/remote.js'
4750import { 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+
13231420async 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
14471545Setup:
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
14511550Commands:
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.
0 commit comments