Skip to content

Commit 1c300a6

Browse files
committed
codebuff login command for remote people that can't copy
1 parent 7056e72 commit 1c300a6

File tree

4 files changed

+113
-2
lines changed

4 files changed

+113
-2
lines changed

cli/src/components/login-modal.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
calculateResponsiveLayout,
1717
} from '../login/utils'
1818
import { useLoginStore } from '../state/login-store'
19-
import { copyTextToClipboard } from '../utils/clipboard'
19+
import { copyTextToClipboard, isRemoteSession } from '../utils/clipboard'
2020
import { logger } from '../utils/logger'
2121
import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system'
2222

@@ -437,6 +437,17 @@ export const LoginModal = ({
437437
Waiting for login...
438438
</span>
439439
</text>
440+
{isRemoteSession() && !isVerySmall && (
441+
<text style={{ wrapMode: 'word' }}>
442+
<span fg={theme.secondary}>
443+
Tip: Can't copy? Exit and run{' '}
444+
</span>
445+
<span fg={theme.primary}>codebuff login</span>
446+
<span fg={theme.secondary}>
447+
{' '}instead.
448+
</span>
449+
</text>
450+
)}
440451
</box>
441452
</box>
442453
)}

cli/src/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import React from 'react'
2020

2121
import { App } from './app'
2222
import { handlePublish } from './commands/publish'
23+
import { runPlainLogin } from './login/plain-login'
2324
import { initializeApp } from './init/init-app'
2425
import { getProjectRoot, setProjectRoot } from './project-files'
2526
import { initAnalytics, trackEvent } from './utils/analytics'
@@ -174,11 +175,18 @@ async function main(): Promise<void> {
174175
initialMode,
175176
} = parseArgs()
176177

178+
const isLoginCommand = process.argv[2] === 'login'
177179
const isPublishCommand = process.argv.includes('publish')
178180
const hasAgentOverride = Boolean(agent && agent.trim().length > 0)
179181

180182
await initializeApp({ cwd })
181183

184+
// Handle login command before rendering the app
185+
if (isLoginCommand) {
186+
await runPlainLogin()
187+
return
188+
}
189+
182190
// Show project picker only when user starts at the home directory or an ancestor
183191
const projectRoot = getProjectRoot()
184192
const homeDir = os.homedir()

cli/src/login/plain-login.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import open from 'open'
2+
import { cyan, green, red, yellow, bold } from 'picocolors'
3+
4+
import { WEBSITE_URL } from './constants'
5+
import { generateLoginUrl, pollLoginStatus } from './login-flow'
6+
import { generateFingerprintId } from './utils'
7+
import { saveUserCredentials } from '../utils/auth'
8+
import { logger } from '../utils/logger'
9+
10+
import type { User } from '../utils/auth'
11+
12+
/**
13+
* Plain-text login flow that runs outside the TUI.
14+
* Prints the login URL as plain text so the user can select and copy it
15+
* using normal terminal text selection (Cmd+C / Ctrl+Shift+C).
16+
*
17+
* This is the escape hatch for remote/SSH environments where the TUI's
18+
* clipboard and browser integration don't work.
19+
*/
20+
export async function runPlainLogin(): Promise<void> {
21+
const fingerprintId = generateFingerprintId()
22+
23+
console.log()
24+
console.log(bold('Codebuff Login'))
25+
console.log()
26+
console.log('Generating login URL...')
27+
28+
let loginData
29+
try {
30+
loginData = await generateLoginUrl(
31+
{ logger },
32+
{ baseUrl: WEBSITE_URL, fingerprintId },
33+
)
34+
} catch (error) {
35+
console.error(
36+
red(
37+
`Failed to generate login URL: ${
38+
error instanceof Error ? error.message : String(error)
39+
}`,
40+
),
41+
)
42+
process.exit(1)
43+
}
44+
45+
console.log()
46+
console.log('Open this URL in your browser to log in:')
47+
console.log()
48+
console.log(cyan(loginData.loginUrl))
49+
console.log()
50+
51+
// Try to open browser, silently ignore failure (expected on remote servers)
52+
try {
53+
await open(loginData.loginUrl)
54+
console.log(green('Browser opened. Waiting for login...'))
55+
} catch {
56+
console.log(yellow('Could not open browser — please open the URL above manually.'))
57+
}
58+
59+
console.log()
60+
console.log('Waiting for login...')
61+
62+
const sleep = (ms: number) =>
63+
new Promise<void>((resolve) => {
64+
setTimeout(resolve, ms)
65+
})
66+
67+
const result = await pollLoginStatus(
68+
{ sleep, logger },
69+
{
70+
baseUrl: WEBSITE_URL,
71+
fingerprintId,
72+
fingerprintHash: loginData.fingerprintHash,
73+
expiresAt: loginData.expiresAt,
74+
},
75+
)
76+
77+
if (result.status === 'success') {
78+
const user = result.user as User
79+
saveUserCredentials(user)
80+
console.log()
81+
console.log(green(`✓ Logged in as ${user.name} (${user.email})`))
82+
console.log()
83+
console.log('You can now run ' + cyan('codebuff') + ' to start.')
84+
process.exit(0)
85+
} else if (result.status === 'timeout') {
86+
console.error(red('Login timed out. Please try again.'))
87+
process.exit(1)
88+
} else {
89+
console.error(red('Login was aborted.'))
90+
process.exit(1)
91+
}
92+
}

cli/src/utils/clipboard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function clearClipboardMessage() {
132132
// because the client terminal handles clipboard. Format: ESC ] 52 ; c ; <base64> BEL
133133
// tmux/screen require passthrough wrapping to forward the sequence.
134134

135-
function isRemoteSession(): boolean {
135+
export function isRemoteSession(): boolean {
136136
const env = getCliEnv()
137137
return !!(env.SSH_CLIENT || env.SSH_TTY || env.SSH_CONNECTION)
138138
}

0 commit comments

Comments
 (0)