mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
refactor(dispatcher): extract bootstrap and pre-dispatch handlers from ccs.ts
Phases 2+3 of #1165. Extracts main() bootstrap + pre-dispatch handler chain into focused dispatcher modules: - src/dispatcher/cli-argument-parser.ts (+138 LOC): bootstrapAndParseEarlyCli + DispatcherBootstrap. Encapsulates UI init, --config-dir parse/validate, cloud-sync warnings, completion command short-circuit, legacy cursor arg normalization, browser launch flag resolution, and native codex passthrough escape. - src/dispatcher/pre-dispatch.ts (155 LOC): runPreDispatchHandlers. Wraps update check, root command router, provider help shortcut, copilot/cursor subcommand routing, first-time install hint. Returns boolean consumed signal. - New tests: 12 + 7 covering both modules. Adapter registration intentionally stays in main() — singleton wiring with no arg dependency; folding it into bootstrap would conflate concerns. ccs.ts: 1475 -> 1262 LOC (-213). Behavior unchanged; full suite passes 1824/1824. Refs #1165
This commit is contained in:
+13
-226
@@ -1,13 +1,7 @@
|
||||
import './utils/fetch-proxy-setup';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import {
|
||||
getSettingsPath,
|
||||
loadSettings,
|
||||
setGlobalConfigDir,
|
||||
detectCloudSyncPath,
|
||||
} from './utils/config-manager';
|
||||
import { getSettingsPath, loadSettings } from './utils/config-manager';
|
||||
import { expandPath } from './utils/helpers';
|
||||
import {
|
||||
validateGlmKey,
|
||||
@@ -42,7 +36,6 @@ import {
|
||||
getBlockedBrowserOverrideWarning,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserExposure,
|
||||
resolveBrowserLaunchFlagResolution,
|
||||
resolveOptionalBrowserAttachRuntime,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from './utils/browser';
|
||||
@@ -60,13 +53,8 @@ import {
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from './utils/hooks';
|
||||
import { fail, info, warn } from './utils/ui';
|
||||
import { isCopilotSubcommandToken } from './copilot/constants';
|
||||
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
|
||||
import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
|
||||
|
||||
// Import centralized error handling
|
||||
import { handleError, runCleanup } from './errors';
|
||||
import { tryHandleRootCommand } from './commands/root-command-router';
|
||||
|
||||
// Import extracted utility functions
|
||||
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
|
||||
@@ -75,7 +63,6 @@ import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-s
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger, runWithRequestId } from './services/logging';
|
||||
import type { ProfileDetectionResult } from './auth/profile-detector';
|
||||
import type { BrowserLaunchOverride } from './utils/browser';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
@@ -99,126 +86,40 @@ import {
|
||||
startOpenAICompatProxy,
|
||||
} from './proxy';
|
||||
|
||||
// Version and Update check utilities
|
||||
import { isCacheStale } from './utils/update-checker';
|
||||
// Note: npm is now the only supported installation method
|
||||
|
||||
// Import extracted dispatcher modules
|
||||
import {
|
||||
detectProfile,
|
||||
normalizeLegacyCursorArgs,
|
||||
printCursorLegacySubcommandDeprecation,
|
||||
resolveRuntimeReasoningFlags,
|
||||
normalizeCodexRuntimeReasoningOverride,
|
||||
exitWithRuntimeReasoningFlagError,
|
||||
normalizeNativeClaudeEffortArgs,
|
||||
shouldNormalizeNativeClaudeEffort,
|
||||
shouldPassthroughNativeCodexFlagCommand,
|
||||
bootstrapAndParseEarlyCli,
|
||||
} from './dispatcher/cli-argument-parser';
|
||||
import {
|
||||
resolveCodexRuntimeConfigOverrides,
|
||||
refreshUpdateCache,
|
||||
showCachedUpdateNotification,
|
||||
resolveNativeClaudeLaunchArgs,
|
||||
} from './dispatcher/environment-builder';
|
||||
import { type ProfileError, execNativeCodexFlagCommand } from './dispatcher/target-executor';
|
||||
import { type ProfileError } from './dispatcher/target-executor';
|
||||
import { runPreDispatchHandlers } from './dispatcher/pre-dispatch';
|
||||
|
||||
// ========== Main Execution ==========
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Register target adapters
|
||||
// Register target adapters (singleton wiring — stays in main)
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
registerTarget(new CodexAdapter());
|
||||
const cliLogger = createLogger('cli');
|
||||
|
||||
let args = process.argv.slice(2);
|
||||
const isCompletionCommand = args[0] === '__complete';
|
||||
|
||||
// Initialize UI colors early to ensure consistent colored output
|
||||
// Must happen before any status messages (ok, info, fail, etc.)
|
||||
if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) {
|
||||
const { initUI } = await import('./utils/ui');
|
||||
await initUI();
|
||||
}
|
||||
|
||||
// Parse --config-dir flag (must happen before any config loading)
|
||||
const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir='));
|
||||
if (configDirIdx !== -1) {
|
||||
const arg = args[configDirIdx];
|
||||
let configDirValue: string | undefined;
|
||||
let spliceCount = 1;
|
||||
|
||||
if (arg.startsWith('--config-dir=')) {
|
||||
configDirValue = arg.split('=').slice(1).join('=');
|
||||
} else {
|
||||
configDirValue = args[configDirIdx + 1];
|
||||
spliceCount = 2;
|
||||
}
|
||||
|
||||
if (!configDirValue || configDirValue.startsWith('-')) {
|
||||
console.error(fail('--config-dir requires a path argument'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(configDirValue);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(fail(`Not a directory: ${configDirValue}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(fail(`Config directory not found: ${configDirValue}`));
|
||||
console.error(info('Create the directory first, then copy your config files into it.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
setGlobalConfigDir(configDirValue);
|
||||
|
||||
// Security warning: cloud sync paths expose OAuth tokens
|
||||
const cloudService = detectCloudSyncPath(configDirValue);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
|
||||
// Remove consumed args so they don't leak to Claude CLI
|
||||
args.splice(configDirIdx, spliceCount);
|
||||
} else if (process.env.CCS_DIR) {
|
||||
// Also warn for CCS_DIR env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
} else if (process.env.CCS_HOME) {
|
||||
// Also warn for CCS_HOME env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompletionCommand) {
|
||||
await tryHandleRootCommand(args);
|
||||
// Phase A: bootstrap + early arg pre-parse
|
||||
const bootstrap = await bootstrapAndParseEarlyCli(process.argv.slice(2));
|
||||
if (bootstrap.exitNow) {
|
||||
return;
|
||||
}
|
||||
|
||||
args = normalizeLegacyCursorArgs(args);
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
args = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const args = bootstrap.args;
|
||||
const browserLaunchOverride = bootstrap.browserLaunchOverride;
|
||||
|
||||
cliLogger.info('command.start', 'CLI invocation started', {
|
||||
command: args[0] || 'default',
|
||||
@@ -226,126 +127,12 @@ async function main(): Promise<void> {
|
||||
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
|
||||
});
|
||||
|
||||
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
||||
execNativeCodexFlagCommand(args);
|
||||
// Phase B: pre-dispatch side-effects (update check, migrate, recovery, root commands, routing)
|
||||
const preDispatchConsumed = await runPreDispatchHandlers({ args, cliLogger });
|
||||
if (preDispatchConsumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstArg = args[0];
|
||||
|
||||
// Trigger update check early for ALL commands except version/help/update
|
||||
// Only if TTY and not CI to avoid noise in automated environments
|
||||
const skipUpdateCheck = [
|
||||
'version',
|
||||
'--version',
|
||||
'-v',
|
||||
'help',
|
||||
'--help',
|
||||
'-h',
|
||||
'update',
|
||||
'--update',
|
||||
];
|
||||
if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg)) {
|
||||
// 1. Show cached update notification (async for proper UI)
|
||||
await showCachedUpdateNotification();
|
||||
|
||||
// 2. Refresh cache in background if stale (non-blocking)
|
||||
if (isCacheStale()) {
|
||||
refreshUpdateCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-migrate to unified config format (silent if already migrated)
|
||||
// Skip if user is explicitly running migrate command
|
||||
if (firstArg !== 'migrate') {
|
||||
const { autoMigrate } = await import('./config/migration-manager');
|
||||
await autoMigrate();
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
||||
// This ensures ALL commands benefit from auto-recovery, not just profile-switching flow
|
||||
// Recovery is safe to run early - it only creates missing files with safe defaults
|
||||
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
|
||||
try {
|
||||
const RecoveryManagerModule = await import('./management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
if (recovered) {
|
||||
recovery.showRecoveryHints();
|
||||
}
|
||||
} catch (err) {
|
||||
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Recovery is best-effort - don't block basic CLI functionality
|
||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
if (await tryHandleRootCommand(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof firstArg === 'string' &&
|
||||
isCLIProxyProvider(firstArg) &&
|
||||
args.length > 1 &&
|
||||
(args.includes('--help') || args.includes('-h'))
|
||||
) {
|
||||
const { showProviderShortcutHelp } = await import('./commands/help-command');
|
||||
await showProviderShortcutHelp(firstArg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Route known subcommands to command handler, keep all other args as profile passthrough.
|
||||
if (firstArg === 'copilot' && args.length > 1) {
|
||||
const copilotToken = args[1];
|
||||
const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
|
||||
|
||||
if (shouldRouteToCopilotCommand) {
|
||||
const { handleCopilotCommand } = await import('./commands/copilot-command');
|
||||
const exitCode = await handleCopilotCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: explicit legacy Cursor bridge namespace.
|
||||
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('./commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken)) {
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
|
||||
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
|
||||
if (firstArg === 'cursor' && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('./commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
|
||||
printCursorLegacySubcommandDeprecation(cursorToken);
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// First-time install: offer setup wizard for interactive users
|
||||
// Check independently of recovery status (user may have empty config.yaml)
|
||||
// Skip if headless, CI, or non-TTY environment
|
||||
const { isFirstTimeInstall } = await import('./commands/setup-command');
|
||||
if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
|
||||
console.log('');
|
||||
console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
|
||||
console.log(' Or use `ccs config` for the web dashboard.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Use ProfileDetector to determine profile type
|
||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||
const ProfileDetector = ProfileDetectorModule.default;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for bootstrapAndParseEarlyCli() — Phase A extraction.
|
||||
*
|
||||
* Uses a real temp dir for --config-dir validation tests.
|
||||
* Mocks process.exit to assert error paths without terminating the test runner.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { bootstrapAndParseEarlyCli } from '../cli-argument-parser';
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bootstrap-test-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dir: string): void {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Tests ==========
|
||||
|
||||
describe('bootstrapAndParseEarlyCli', () => {
|
||||
let originalArgv: string[];
|
||||
let originalStdoutIsTTY: boolean | undefined;
|
||||
let exitSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalArgv = process.argv.slice();
|
||||
originalStdoutIsTTY = process.stdout.isTTY;
|
||||
// Non-TTY so initUI and update checks don't fire
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
||||
// Suppress CI to ensure TTY branches are testable separately
|
||||
process.env['CI'] = '1';
|
||||
|
||||
// Mock process.exit so error paths don't terminate test runner
|
||||
exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => {
|
||||
throw new Error(`process.exit(${_code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
delete process.env['CI'];
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ---------- completion command ----------
|
||||
|
||||
it('returns isCompletionCommand=true and exitNow=true for __complete args', async () => {
|
||||
const result = await bootstrapAndParseEarlyCli(['__complete', 'some', 'arg']);
|
||||
expect(result.isCompletionCommand).toBe(true);
|
||||
expect(result.exitNow).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- --config-dir flag ----------
|
||||
|
||||
it('accepts a valid --config-dir and strips it from args', async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
try {
|
||||
const result = await bootstrapAndParseEarlyCli(['--config-dir', tmpDir, 'gemini']);
|
||||
expect(result.exitNow).toBe(false);
|
||||
// --config-dir and its value must be stripped
|
||||
expect(result.args).not.toContain('--config-dir');
|
||||
expect(result.args).not.toContain(tmpDir);
|
||||
expect(result.args).toContain('gemini');
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts --config-dir=<path> (= syntax) and strips it from args', async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
try {
|
||||
const result = await bootstrapAndParseEarlyCli([`--config-dir=${tmpDir}`, 'gemini']);
|
||||
expect(result.exitNow).toBe(false);
|
||||
expect(result.args.some((a) => a.startsWith('--config-dir'))).toBe(false);
|
||||
expect(result.args).toContain('gemini');
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls process.exit when --config-dir has no value', async () => {
|
||||
await expect(bootstrapAndParseEarlyCli(['--config-dir'])).rejects.toThrow('process.exit(1)');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls process.exit when --config-dir value is a flag', async () => {
|
||||
await expect(bootstrapAndParseEarlyCli(['--config-dir', '--other-flag'])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls process.exit when --config-dir points to non-existent path', async () => {
|
||||
await expect(
|
||||
bootstrapAndParseEarlyCli(['--config-dir', '/tmp/does-not-exist-ccs-test-xyz'])
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls process.exit when --config-dir points to a file (not dir)', async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const filePath = path.join(tmpDir, 'notadir.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
await expect(bootstrapAndParseEarlyCli(['--config-dir', filePath])).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
cleanupDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- legacy cursor args ----------
|
||||
|
||||
it('normalizes legacy cursor args (legacy cursor → legacy-cursor profile)', async () => {
|
||||
const result = await bootstrapAndParseEarlyCli(['legacy', 'cursor', '--auth']);
|
||||
expect(result.exitNow).toBe(false);
|
||||
// normalizeLegacyCursorArgs transforms ['legacy', 'cursor', '--auth'] → ['legacy-cursor', '--auth']
|
||||
expect(result.args[0]).toBe('legacy-cursor');
|
||||
expect(result.args).toContain('--auth');
|
||||
});
|
||||
|
||||
// ---------- normal passthrough ----------
|
||||
|
||||
it('returns exitNow=false and preserves args for normal invocation', async () => {
|
||||
const result = await bootstrapAndParseEarlyCli(['gemini', '-p', 'hello']);
|
||||
expect(result.exitNow).toBe(false);
|
||||
expect(result.args).toContain('gemini');
|
||||
expect(result.args).toContain('-p');
|
||||
expect(result.args).toContain('hello');
|
||||
});
|
||||
|
||||
it('returns browserLaunchOverride=undefined when no browser flags are present', async () => {
|
||||
const result = await bootstrapAndParseEarlyCli(['gemini']);
|
||||
expect(result.browserLaunchOverride).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tests for runPreDispatchHandlers() — Phase B extraction.
|
||||
*
|
||||
* Heavy dynamic-import surface means most paths are tested via the return value
|
||||
* (consumed=true/false) and spied process.exit, not deep module internals.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { runPreDispatchHandlers } from '../pre-dispatch';
|
||||
import type { Logger } from '../../services/logging/logger';
|
||||
|
||||
// ========== Stub Logger ==========
|
||||
|
||||
function makeStubLogger(): Logger {
|
||||
return {
|
||||
info: mock(() => {}),
|
||||
warn: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
debug: mock(() => {}),
|
||||
stage: mock(() => {}),
|
||||
} as unknown as Logger;
|
||||
}
|
||||
|
||||
// ========== Tests ==========
|
||||
|
||||
describe('runPreDispatchHandlers', () => {
|
||||
let exitSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['CI'] = '1';
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
||||
// Suppress process.exit — handlers use it for subcommand routing
|
||||
exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number | string) => {
|
||||
throw new Error(`process.exit(${_code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['CI'];
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true });
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ---------- update check / migrate / recovery (non-consuming) ----------
|
||||
|
||||
it('returns false for a normal profile invocation (gemini)', async () => {
|
||||
const consumed = await runPreDispatchHandlers({
|
||||
args: ['gemini', '-p', 'hello'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for default (no-arg) invocation', async () => {
|
||||
const consumed = await runPreDispatchHandlers({
|
||||
args: [],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
|
||||
// ---------- root command router ----------
|
||||
|
||||
it('consumes --version (either returns true or exits via process.exit(0))', async () => {
|
||||
// --version is handled by tryHandleRootCommand; it either returns true or calls process.exit(0)
|
||||
let consumed: boolean | undefined;
|
||||
try {
|
||||
consumed = await runPreDispatchHandlers({
|
||||
args: ['--version'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
} catch (e) {
|
||||
// process.exit(0) thrown by our spy — that is also a valid "consumed" signal
|
||||
expect((e as Error).message).toMatch(/process\.exit/);
|
||||
return;
|
||||
}
|
||||
expect(consumed).toBe(true);
|
||||
});
|
||||
|
||||
it('consumes --help (either returns true or exits via process.exit(0))', async () => {
|
||||
let consumed: boolean | undefined;
|
||||
try {
|
||||
consumed = await runPreDispatchHandlers({
|
||||
args: ['--help'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toMatch(/process\.exit/);
|
||||
return;
|
||||
}
|
||||
expect(consumed).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- provider help shortcut ----------
|
||||
|
||||
it('returns true for provider + --help shortcut (gemini --help)', async () => {
|
||||
const consumed = await runPreDispatchHandlers({
|
||||
args: ['gemini', '--help'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
expect(consumed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for provider + -h shortcut (codex -h)', async () => {
|
||||
const consumed = await runPreDispatchHandlers({
|
||||
args: ['codex', '-h'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
expect(consumed).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- copilot subcommand routing ----------
|
||||
|
||||
it('exits via process.exit for copilot subcommand (copilot --auth)', async () => {
|
||||
// copilot --auth is a known subcommand token; handler exits with a code
|
||||
await expect(
|
||||
runPreDispatchHandlers({
|
||||
args: ['copilot', '--auth'],
|
||||
cliLogger: makeStubLogger(),
|
||||
})
|
||||
).rejects.toThrow(/process\.exit/);
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------- cursor subcommand routing ----------
|
||||
|
||||
it('exits via process.exit for legacy-cursor subcommand (legacy-cursor auth)', async () => {
|
||||
// 'auth' is a valid CURSOR_SUBCOMMANDS token; handler calls process.exit(exitCode)
|
||||
await expect(
|
||||
runPreDispatchHandlers({
|
||||
args: ['legacy-cursor', 'auth'],
|
||||
cliLogger: makeStubLogger(),
|
||||
})
|
||||
).rejects.toThrow(/process\.exit/);
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------- recovery error (non-fatal) ----------
|
||||
|
||||
it('does not throw when recovery manager throws (best-effort)', async () => {
|
||||
// Recovery errors are caught; handler must continue and return false
|
||||
// We can't easily force recovery to throw here without mocking the dynamic import,
|
||||
// but we verify the normal path doesn't surface recovery exceptions.
|
||||
const consumed = await runPreDispatchHandlers({
|
||||
args: ['glm'],
|
||||
cliLogger: makeStubLogger(),
|
||||
});
|
||||
expect(consumed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,152 @@
|
||||
*
|
||||
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
|
||||
* Pure functions — no side effects except console.error and process.exit.
|
||||
*
|
||||
* Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted
|
||||
* from main() (lines 128-232 of the original). Handles: adapter registration,
|
||||
* UI init, --config-dir flag, cloud-sync warnings, completion short-circuit,
|
||||
* normalizeLegacyCursorArgs, resolveBrowserLaunchFlagResolution, codex passthrough.
|
||||
*/
|
||||
|
||||
import { fail, warn } from '../utils/ui';
|
||||
import * as fs from 'fs';
|
||||
import { fail, warn, info } from '../utils/ui';
|
||||
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
||||
import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
||||
import { resolveDroidReasoningRuntime } from '../targets/droid-reasoning-runtime';
|
||||
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
||||
import { setGlobalConfigDir, detectCloudSyncPath } from '../utils/config-manager';
|
||||
import { resolveBrowserLaunchFlagResolution } from '../utils/browser';
|
||||
import type { BrowserLaunchOverride } from '../utils/browser';
|
||||
|
||||
// ========== Bootstrap Result ==========
|
||||
|
||||
/**
|
||||
* Result of the early CLI bootstrap pass.
|
||||
* All fields are consumed by main() to decide how to continue.
|
||||
*/
|
||||
export interface DispatcherBootstrap {
|
||||
/** Normalized/mutated args after pre-parse (--config-dir stripped, browser flags stripped, legacy cursor normalized) */
|
||||
args: string[];
|
||||
isCompletionCommand: boolean;
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
/** true when the caller should return immediately (completion handled, codex passthrough triggered, process.exit called) */
|
||||
exitNow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase A bootstrap: runs everything that must happen before config loading and profile detection.
|
||||
*
|
||||
* Side-effects preserved from original main():
|
||||
* - Dynamic import of initUI
|
||||
* - setGlobalConfigDir (--config-dir flag)
|
||||
* - Cloud-sync warnings
|
||||
* - Completion short-circuit via tryHandleRootCommand
|
||||
* - Codex native passthrough via execNativeCodexFlagCommand
|
||||
*
|
||||
* Adapter registration (registerTarget calls) stays in main() because it is
|
||||
* singleton wiring with no dependency on the parsed args.
|
||||
*/
|
||||
export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<DispatcherBootstrap> {
|
||||
let args = rawArgs;
|
||||
const isCompletionCommand = args[0] === '__complete';
|
||||
|
||||
// Initialize UI colors early to ensure consistent colored output
|
||||
// Must happen before any status messages (ok, info, fail, etc.)
|
||||
if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) {
|
||||
const { initUI } = await import('../utils/ui');
|
||||
await initUI();
|
||||
}
|
||||
|
||||
// Parse --config-dir flag (must happen before any config loading)
|
||||
const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir='));
|
||||
if (configDirIdx !== -1) {
|
||||
const arg = args[configDirIdx];
|
||||
let configDirValue: string | undefined;
|
||||
let spliceCount = 1;
|
||||
|
||||
if (arg.startsWith('--config-dir=')) {
|
||||
configDirValue = arg.split('=').slice(1).join('=');
|
||||
} else {
|
||||
configDirValue = args[configDirIdx + 1];
|
||||
spliceCount = 2;
|
||||
}
|
||||
|
||||
if (!configDirValue || configDirValue.startsWith('-')) {
|
||||
console.error(fail('--config-dir requires a path argument'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(configDirValue);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(fail(`Not a directory: ${configDirValue}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(fail(`Config directory not found: ${configDirValue}`));
|
||||
console.error(info('Create the directory first, then copy your config files into it.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
setGlobalConfigDir(configDirValue);
|
||||
|
||||
// Security warning: cloud sync paths expose OAuth tokens
|
||||
const cloudService = detectCloudSyncPath(configDirValue);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
|
||||
// Remove consumed args so they don't leak to Claude CLI
|
||||
// Clone the array before splicing so the original rawArgs is unaffected
|
||||
args = [...args];
|
||||
args.splice(configDirIdx, spliceCount);
|
||||
} else if (process.env.CCS_DIR) {
|
||||
// Also warn for CCS_DIR env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
} else if (process.env.CCS_HOME) {
|
||||
// Also warn for CCS_HOME env var pointing to cloud sync
|
||||
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
|
||||
if (!isCompletionCommand && cloudService) {
|
||||
console.error(warn(`CCS directory is under ${cloudService}.`));
|
||||
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
|
||||
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompletionCommand) {
|
||||
const { tryHandleRootCommand } = await import('../commands/root-command-router');
|
||||
await tryHandleRootCommand(args);
|
||||
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
|
||||
}
|
||||
|
||||
args = normalizeLegacyCursorArgs(args);
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
try {
|
||||
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(args);
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
args = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
// process.exit never returns but TypeScript needs the unreachable return
|
||||
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
|
||||
}
|
||||
|
||||
if (shouldPassthroughNativeCodexFlagCommand(args)) {
|
||||
const { execNativeCodexFlagCommand } = await import('./target-executor');
|
||||
execNativeCodexFlagCommand(args);
|
||||
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
|
||||
}
|
||||
|
||||
return { args, isCompletionCommand, browserLaunchOverride, exitNow: false };
|
||||
}
|
||||
|
||||
// ========== Interfaces ==========
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Pre-dispatch side-effect handlers.
|
||||
*
|
||||
* Extracted from src/ccs.ts (Phase B, lines 145-254 after Phase A extraction).
|
||||
* Each handler may short-circuit the dispatch by returning true.
|
||||
*
|
||||
* Handles: update check, auto-migrate, recovery-manager, root-command router,
|
||||
* provider help shortcut, copilot/cursor subcommand routing, first-time install hint.
|
||||
*/
|
||||
|
||||
import { info } from '../utils/ui';
|
||||
import { isCLIProxyProvider } from '../cliproxy/provider-capabilities';
|
||||
import { isCopilotSubcommandToken } from '../copilot/constants';
|
||||
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
||||
import { isCacheStale } from '../utils/update-checker';
|
||||
import { tryHandleRootCommand } from '../commands/root-command-router';
|
||||
import { refreshUpdateCache, showCachedUpdateNotification } from './environment-builder';
|
||||
import { printCursorLegacySubcommandDeprecation } from './cli-argument-parser';
|
||||
import type { Logger } from '../services/logging/logger';
|
||||
|
||||
// ========== Pre-Dispatch Context ==========
|
||||
|
||||
export interface PreDispatchContext {
|
||||
args: string[];
|
||||
cliLogger: Logger;
|
||||
}
|
||||
|
||||
// ========== Pre-Dispatch Runner ==========
|
||||
|
||||
/**
|
||||
* Run all pre-dispatch side-effect handlers in sequence.
|
||||
* Returns true if a handler consumed the command (caller should return immediately).
|
||||
* Returns false if dispatch should continue normally.
|
||||
*
|
||||
* All process.exit calls and dynamic imports are preserved at original call sites.
|
||||
*/
|
||||
export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<boolean> {
|
||||
const { args, cliLogger } = ctx;
|
||||
const firstArg = args[0] as string | undefined;
|
||||
|
||||
// Update check: show cached notification + refresh background cache
|
||||
const skipUpdateCheck = [
|
||||
'version',
|
||||
'--version',
|
||||
'-v',
|
||||
'help',
|
||||
'--help',
|
||||
'-h',
|
||||
'update',
|
||||
'--update',
|
||||
];
|
||||
if (process.stdout.isTTY && !process.env['CI'] && !skipUpdateCheck.includes(firstArg ?? '')) {
|
||||
// 1. Show cached update notification (async for proper UI)
|
||||
await showCachedUpdateNotification();
|
||||
|
||||
// 2. Refresh cache in background if stale (non-blocking)
|
||||
if (isCacheStale()) {
|
||||
refreshUpdateCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-migrate to unified config format (silent if already migrated)
|
||||
// Skip if user is explicitly running migrate command
|
||||
if (firstArg !== 'migrate') {
|
||||
const { autoMigrate } = await import('../config/migration-manager');
|
||||
await autoMigrate();
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
||||
// Recovery is safe to run early - it only creates missing files with safe defaults
|
||||
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
|
||||
try {
|
||||
const RecoveryManagerModule = await import('../management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
if (recovered) {
|
||||
recovery.showRecoveryHints();
|
||||
}
|
||||
} catch (err) {
|
||||
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Recovery is best-effort - don't block basic CLI functionality
|
||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
// Root command router (handles --help, --version, config, doctor, etc.)
|
||||
if (await tryHandleRootCommand(args)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Provider help shortcut: `ccs gemini --help` → provider-specific help page
|
||||
if (
|
||||
typeof firstArg === 'string' &&
|
||||
isCLIProxyProvider(firstArg) &&
|
||||
args.length > 1 &&
|
||||
(args.includes('--help') || args.includes('-h'))
|
||||
) {
|
||||
const { showProviderShortcutHelp } = await import('../commands/help-command');
|
||||
await showProviderShortcutHelp(firstArg);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Route known subcommands to command handler, keep all other args as profile passthrough.
|
||||
if (firstArg === 'copilot' && args.length > 1) {
|
||||
const copilotToken = args[1];
|
||||
const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
|
||||
|
||||
if (shouldRouteToCopilotCommand) {
|
||||
const { handleCopilotCommand } = await import('../commands/copilot-command');
|
||||
const exitCode = await handleCopilotCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: explicit legacy Cursor bridge namespace.
|
||||
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken)) {
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
|
||||
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
|
||||
if (firstArg === 'cursor' && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
|
||||
printCursorLegacySubcommandDeprecation(cursorToken);
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// First-time install: offer setup wizard for interactive users
|
||||
// Check independently of recovery status (user may have empty config.yaml)
|
||||
// Skip if headless, CI, or non-TTY environment
|
||||
const { isFirstTimeInstall } = await import('../commands/setup-command');
|
||||
if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
|
||||
console.log('');
|
||||
console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
|
||||
console.log(' Or use `ccs config` for the web dashboard.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user