mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
feat(oauth): add interactive mode prompt for VPS/headless environments
- 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user