feat(oauth): add interactive mode prompt for VPS/headless environments (#462)

- Add promptOAuthModeChoice() for headless OAuth mode selection
- Add --port-forward flag to force port-forwarding mode
- Add conflict detection for --paste-callback + --port-forward
- Handle edge cases: Ctrl+C, invalid input, non-TTY stdin
- Update help docs with new flag

Closes #461
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-04 20:54:13 -05:00
committed by GitHub
4 changed files with 80 additions and 0 deletions
+2
View File
@@ -221,4 +221,6 @@ export interface OAuthOptions {
import?: boolean;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
portForward?: boolean;
}
+65
View File
@@ -58,6 +58,55 @@ async function promptAddAccount(): Promise<boolean> {
});
}
/**
* Prompt user to choose OAuth mode for headless environment
* Returns 'paste' for paste-callback mode or 'forward' for port-forwarding
*/
async function promptOAuthModeChoice(callbackPort: number | null): Promise<'paste' | 'forward'> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('');
console.log(info('Headless environment detected (SSH session)'));
console.log(' OAuth requires choosing a mode:');
console.log('');
console.log(' [1] Paste-callback (recommended for VPS)');
console.log(' Open URL in any browser, paste redirect URL back');
console.log('');
console.log(' [2] Port forwarding (advanced)');
if (callbackPort) {
console.log(` Requires: ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`);
} else {
console.log(' Requires SSH tunnel to callback port');
}
console.log('');
return new Promise<'paste' | 'forward'>((resolve) => {
let resolved = false;
// Handle Ctrl+C gracefully
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve('paste'); // Safe default on cancel
}
});
rl.question('[?] Which mode? (1/2): ', (answer) => {
const choice = answer.trim();
if (choice !== '1' && choice !== '2') {
console.log(info('Invalid choice, using paste-callback mode'));
}
resolved = true;
rl.close();
resolve(choice === '2' ? 'forward' : 'paste');
});
});
}
/**
* Prompt user for account nickname (required for kiro/ghcp)
* Returns null if user cancels
@@ -389,6 +438,22 @@ export async function triggerOAuth(
const headless = options.headless ?? isHeadlessEnvironment();
const isDeviceCodeFlow = callbackPort === null;
// Interactive mode selection for headless environments
// Skip if explicit mode flag provided or device code flow (no callback needed)
if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) {
// Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
// mode === 'forward' continues to existing port-forwarding flow below
}
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
+12
View File
@@ -316,7 +316,17 @@ export async function execClaudeWithCLIProxy(
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
const forceAuth = argsWithoutProxy.includes('--auth');
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
const portForward = argsWithoutProxy.includes('--port-forward');
const forceHeadless = argsWithoutProxy.includes('--headless');
// Validate conflicting flags
if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward'));
console.error(' --paste-callback: Manually paste OAuth redirect URL');
console.error(' --port-forward: Use SSH port forwarding for callback');
process.exit(1);
}
const forceLogout = argsWithoutProxy.includes('--logout');
const forceConfig = argsWithoutProxy.includes('--config');
const addAccount = argsWithoutProxy.includes('--add');
@@ -548,6 +558,7 @@ export async function execClaudeWithCLIProxy(
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
...(pasteCallback ? { pasteCallback: true } : {}),
...(portForward ? { portForward: true } : {}),
});
if (!authSuccess) {
throw new Error(`Authentication required for ${providerConfig.displayName}`);
@@ -991,6 +1002,7 @@ export async function execClaudeWithCLIProxy(
const ccsFlags = [
'--auth',
'--paste-callback',
'--port-forward',
'--headless',
'--logout',
'--config',
+1
View File
@@ -184,6 +184,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --port-forward', 'Force port-forwarding mode (skip prompt)'],
['ccs kiro --import', 'Import token from Kiro IDE'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],