mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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:
1 parent
2486b3f59e
commit
7a1b4d6f20
4 files changed
+80
No files matched your search
@@ -221,4 +221,6 @@ export interface OAuthOptions {
|
|||||||
import?: boolean;
|
import?: boolean;
|
||||||
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
|
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
|
||||||
pasteCallback?: boolean;
|
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)
|
* Prompt user for account nickname (required for kiro/ghcp)
|
||||||
* Returns null if user cancels
|
* Returns null if user cancels
|
||||||
@@ -389,6 +438,22 @@ export async function triggerOAuth(
|
|||||||
const headless = options.headless ?? isHeadlessEnvironment();
|
const headless = options.headless ?? isHeadlessEnvironment();
|
||||||
const isDeviceCodeFlow = callbackPort === null;
|
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) {
|
if (existingAccounts.length > 0 && !add) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -316,7 +316,17 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
||||||
const forceAuth = argsWithoutProxy.includes('--auth');
|
const forceAuth = argsWithoutProxy.includes('--auth');
|
||||||
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
|
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
|
||||||
|
const portForward = argsWithoutProxy.includes('--port-forward');
|
||||||
const forceHeadless = argsWithoutProxy.includes('--headless');
|
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 forceLogout = argsWithoutProxy.includes('--logout');
|
||||||
const forceConfig = argsWithoutProxy.includes('--config');
|
const forceConfig = argsWithoutProxy.includes('--config');
|
||||||
const addAccount = argsWithoutProxy.includes('--add');
|
const addAccount = argsWithoutProxy.includes('--add');
|
||||||
@@ -548,6 +558,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
...(setNickname ? { nickname: setNickname } : {}),
|
...(setNickname ? { nickname: setNickname } : {}),
|
||||||
...(noIncognito ? { noIncognito: true } : {}),
|
...(noIncognito ? { noIncognito: true } : {}),
|
||||||
...(pasteCallback ? { pasteCallback: true } : {}),
|
...(pasteCallback ? { pasteCallback: true } : {}),
|
||||||
|
...(portForward ? { portForward: true } : {}),
|
||||||
});
|
});
|
||||||
if (!authSuccess) {
|
if (!authSuccess) {
|
||||||
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
||||||
@@ -991,6 +1002,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const ccsFlags = [
|
const ccsFlags = [
|
||||||
'--auth',
|
'--auth',
|
||||||
'--paste-callback',
|
'--paste-callback',
|
||||||
|
'--port-forward',
|
||||||
'--headless',
|
'--headless',
|
||||||
'--logout',
|
'--logout',
|
||||||
'--config',
|
'--config',
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
],
|
],
|
||||||
['ccs <provider> --logout', 'Clear authentication'],
|
['ccs <provider> --logout', 'Clear authentication'],
|
||||||
['ccs <provider> --headless', 'Headless auth (for SSH)'],
|
['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 --import', 'Import token from Kiro IDE'],
|
||||||
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
|
||||||
['ccs codex "explain code"', 'Use with prompt'],
|
['ccs codex "explain code"', 'Use with prompt'],
|
||||||
|
|||||||
Reference in new issue
Block a user