diff --git a/src/sync/auth.ts b/src/sync/auth.ts
index fa912322..ff60a713 100644
--- a/src/sync/auth.ts
+++ b/src/sync/auth.ts
@@ -141,6 +141,35 @@ export interface CallbackResult {
port: number
}
+/**
+ * Themed HTML for the OAuth callback landing page.
+ *
+ * Matches the CodeBurn dashboard theme (dash/src/index.css): warm paper
+ * surfaces, ink text, forest-green accent. Everything is inline — this page
+ * is served once from a throwaway localhost server, so it must not depend
+ * on network fonts, external CSS, or dashboard assets.
+ */
+export function renderCallbackPage(ok: boolean, title: string, message: string): string {
+ const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta)
+ const mark = ok ? '✓' : '✕' // ✓ / ✕
+ return `
+
+
+
+
+CodeBurn — ${title}
+
+
+
+ ${mark}
+ ${title}
+ ${message}
+ CodeBurn • local sync login
+
+
+`
+}
+
export function startCallbackServer(
expectedState: string,
timeoutMs: number = 300_000, // 5 minutes
@@ -179,8 +208,8 @@ export function startCallbackServer(
// Connection: close on every response — the callback server is
// single-purpose and must never leave pooled keep-alive sockets behind.
if (error) {
- res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' })
- res.end('Login failed
You can close this tab.
')
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
+ res.end(renderCallbackPage(false, 'Login failed', 'The identity provider rejected the login. You can close this tab and retry from the terminal.'))
clearTimeout(timer)
shutdown()
reject(new AuthError(`IdP returned error: ${error}`))
@@ -199,8 +228,8 @@ export function startCallbackServer(
return
}
- res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' })
- res.end('✓ Login successful
You can close this tab.
')
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
+ res.end(renderCallbackPage(true, 'Login successful', 'CodeBurn sync is now authenticated. You can close this tab and return to the terminal.'))
clearTimeout(timer)
shutdown()
resolve({ code, port: resolvedPort })
diff --git a/src/sync/cli.ts b/src/sync/cli.ts
index 9136dbfd..174b616c 100644
--- a/src/sync/cli.ts
+++ b/src/sync/cli.ts
@@ -7,8 +7,9 @@
import type { Command } from 'commander'
import { randomBytes } from 'crypto'
-import { fetchDiscoveryDoc } from './discovery.js'
+import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js'
import {
+ AuthError,
fetchOidcConfig,
generatePkce,
buildAuthUrl,
@@ -33,100 +34,120 @@ export function registerSyncCommands(program: Command): void {
.command('setup ')
.description('Configure sync with a remote endpoint (one-time)')
.action(async (url: string) => {
- const baseUrl = url.replace(/\/$/, '')
- process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
-
- // 1. Fetch codeburn discovery doc
- const discovery = await fetchDiscoveryDoc(baseUrl)
- process.stderr.write(` Issuer: ${discovery.issuer}\n`)
- process.stderr.write(` Client: ${discovery.client_id}\n`)
-
- // 2. Fetch OIDC configuration from the issuer
- const oidc = await fetchOidcConfig(discovery.issuer)
- process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
-
- // 3. Resolve scopes
- const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
-
- // 4. Generate PKCE
- const pkce = generatePkce()
- const state = randomBytes(16).toString('hex')
-
- // 5. Start callback server — await the actually-bound port (port
- // fallback means it may not be the first in CALLBACK_PORTS)
- const { promise: callbackPromise, ready } = startCallbackServer(state)
- const port = await ready
- const redirectUri = `http://127.0.0.1:${port}/callback`
-
- // 6. Build auth URL and open browser
- const authUrl = buildAuthUrl({
- authorization_endpoint: oidc.authorization_endpoint,
- client_id: discovery.client_id,
- redirect_uri: redirectUri,
- scopes,
- state,
- pkce,
- })
-
- process.stderr.write(`\nOpening browser for login...\n`)
- process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
-
- // Open browser (best-effort, platform-specific).
- // execFileSync with args array — authUrl comes from the remote discovery
- // doc so it must never be shell-interpolated. Scheme is also validated.
try {
- if (!/^https:\/\//.test(authUrl)) {
- throw new Error('auth URL must be https')
+ const baseUrl = url.replace(/\/$/, '')
+ process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
+
+ // 1. Fetch codeburn discovery doc
+ const discovery = await fetchDiscoveryDoc(baseUrl)
+ process.stderr.write(` Issuer: ${discovery.issuer}\n`)
+ process.stderr.write(` Client: ${discovery.client_id}\n`)
+
+ // 2. Fetch OIDC configuration from the issuer
+ const oidc = await fetchOidcConfig(discovery.issuer)
+ process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
+
+ // 3. Resolve scopes
+ const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
+
+ // 4. Generate PKCE
+ const pkce = generatePkce()
+ const state = randomBytes(16).toString('hex')
+
+ // 5. Start callback server — await the actually-bound port (port
+ // fallback means it may not be the first in CALLBACK_PORTS)
+ const { promise: callbackPromise, ready } = startCallbackServer(state)
+ // If all ports are in use, BOTH `ready` and `callbackPromise` reject.
+ // Mark callbackPromise as handled so the second rejection can't crash
+ // the process as an unhandled rejection while we're throwing from
+ // `await ready`. The later `await callbackPromise` still throws.
+ callbackPromise.catch(() => {})
+ const port = await ready
+ const redirectUri = `http://127.0.0.1:${port}/callback`
+
+ // 6. Build auth URL and open browser
+ const authUrl = buildAuthUrl({
+ authorization_endpoint: oidc.authorization_endpoint,
+ client_id: discovery.client_id,
+ redirect_uri: redirectUri,
+ scopes,
+ state,
+ pkce,
+ })
+
+ process.stderr.write(`\nOpening browser for login...\n`)
+ process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
+
+ // Open browser (best-effort, platform-specific).
+ // execFileSync with args array — authUrl comes from the remote discovery
+ // doc so it must never be shell-interpolated. Scheme is also validated.
+ try {
+ if (!/^https:\/\//.test(authUrl)) {
+ throw new Error('auth URL must be https')
+ }
+ const { execFileSync } = await import('child_process')
+ if (process.platform === 'darwin') {
+ execFileSync('open', [authUrl], { stdio: 'ignore' })
+ } else if (process.platform === 'win32') {
+ // Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command
+ // separator, so the auth URL's query string gets truncated at the
+ // first parameter and the IdP sees a bare /authorize request.
+ // rundll32's FileProtocolHandler receives the URL as a plain
+ // process argument with no shell parsing, so `&` survives intact.
+ execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' })
+ } else {
+ execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
+ }
+ } catch {
+ // Browser open failed — user sees the URL above
}
- const { execFileSync } = await import('child_process')
- if (process.platform === 'darwin') {
- execFileSync('open', [authUrl], { stdio: 'ignore' })
- } else if (process.platform === 'win32') {
- // `start` is a cmd builtin; empty first arg is the window title
- execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' })
- } else {
- execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
+
+ // 7. Wait for callback
+ process.stderr.write(`Waiting for login (5 min timeout)...\n`)
+ const callback = await callbackPromise
+
+ // 8. Exchange code for tokens
+ const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
+ const tokens = await exchangeCode(
+ oidc.token_endpoint,
+ callback.code,
+ pkce.code_verifier,
+ tokenRedirectUri,
+ discovery.client_id,
+ )
+
+ if (!tokens.refresh_token) {
+ process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
}
- } catch {
- // Browser open failed — user sees the URL above
- }
- // 7. Wait for callback
- process.stderr.write(`Waiting for login (5 min timeout)...\n`)
- const callback = await callbackPromise
-
- // 8. Exchange code for tokens
- const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
- const tokens = await exchangeCode(
- oidc.token_endpoint,
- callback.code,
- pkce.code_verifier,
- tokenRedirectUri,
- discovery.client_id,
- )
-
- if (!tokens.refresh_token) {
- process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
- }
+ // 9. Store credentials
+ const store = createCredentialStore()
+ if (tokens.refresh_token) {
+ store.store(tokens.refresh_token)
+ }
- // 9. Store credentials
- const store = createCredentialStore()
- if (tokens.refresh_token) {
- store.store(tokens.refresh_token)
- }
+ // 10. Write config
+ writeSyncConfig({
+ baseUrl,
+ clientId: discovery.client_id,
+ tracesPath: discovery.traces_path,
+ issuer: discovery.issuer,
+ })
- // 10. Write config
- writeSyncConfig({
- baseUrl,
- clientId: discovery.client_id,
- tracesPath: discovery.traces_path,
- issuer: discovery.issuer,
- })
-
- process.stderr.write(`\n✓ Sync configured successfully.\n`)
- process.stderr.write(` Endpoint: ${baseUrl}\n`)
- process.stderr.write(` Token stored in: ${store.method()}\n`)
- process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
+ process.stderr.write(`\n✓ Sync configured successfully.\n`)
+ process.stderr.write(` Endpoint: ${baseUrl}\n`)
+ process.stderr.write(` Token stored in: ${store.method()}\n`)
+ process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
+ } catch (err) {
+ // Known errors (login timeout, port exhaustion, discovery failures)
+ // get a clean one-line message instead of a raw Node crash dump.
+ if (err instanceof AuthError || err instanceof DiscoveryError) {
+ process.stderr.write(`\nError: ${err.message}\n`)
+ } else {
+ process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`)
+ }
+ process.exit(1)
+ }
})
// --- status ---