diff --git a/CLAUDE.md b/CLAUDE.md index a8aaa564..715f18d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` | | `ccs config --help` | `src/commands/config-command.ts` → `showHelp()` | | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | +| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | diff --git a/docs/hardening-debt-burndown.md b/docs/hardening-debt-burndown.md new file mode 100644 index 00000000..9a10e352 --- /dev/null +++ b/docs/hardening-debt-burndown.md @@ -0,0 +1,45 @@ +# Hardening Debt Burndown Tracker + +Last Updated: 2026-02-12 +Owner: Stream D (`#542`) + +## Scope + +Maintainability hardening groundwork with low-risk changes: + +- Inventory legacy shims/compatibility markers +- Inventory sync filesystem usage, especially runtime hotpaths +- Incrementally migrate hotpath sync I/O to async I/O with tests + +## How to Measure + +Run: + +```bash +bun run report:hardening +``` + +Generated artifacts: + +- `docs/reports/hardening-inventory.json` +- `docs/reports/hardening-inventory.md` + +## Kickoff Baseline (Issue #542 Stream D) + +The current baseline is sourced from `docs/reports/hardening-inventory.json` after running `bun run report:hardening`. +Baseline captured: `2026-02-12`. + +| Metric | Baseline | +|---|---:| +| Sync fs occurrences (all) | 835 | +| Sync fs files affected (all) | 100 | +| Sync fs occurrences (runtime hotpaths) | 724 | +| Sync fs files affected (runtime hotpaths) | 89 | +| Legacy shim markers | 131 | +| Legacy shim files affected | 56 | + +## Initial Async I/O Migration Log + +| Date | Area | Change | Safety Notes | +|---|---|---|---| +| 2026-02-12 | `src/web-server/jsonl-parser.ts` | Migrated `parseProjectDirectory()` directory listing from sync `readdirSync` to async `fs.promises.readdir` | Existing behavior kept (same filtering/fallback); covered by `tests/unit/jsonl-parser.test.ts` | diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json new file mode 100644 index 00000000..5afe7f98 --- /dev/null +++ b/docs/metrics/maintainability-baseline.json @@ -0,0 +1,9 @@ +{ + "sourceDirectory": "src", + "largeFileThresholdLoc": 350, + "typeScriptFileCount": 338, + "locInSrc": 65869, + "processExitReferenceCount": 152, + "synchronousFsApiReferenceCount": 842, + "largeFileCountOver350Loc": 52 +} diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7f257d11..07598a41 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-02-04 +Last Updated: 2026-02-12 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -39,6 +39,15 @@ All major modularization work is complete. The codebase evolved from monolithic ## Current Status +### Maintainability Hardening Kickoff + +- Issue owner: Stream D for **#542** +- Automated inventory command: `bun run report:hardening` +- Generated report artifacts: + - `docs/reports/hardening-inventory.json` + - `docs/reports/hardening-inventory.md` +- Debt burndown tracker: [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) + ### Remaining Large Files (Acceptable) **CLI** (complex core logic): @@ -191,6 +200,23 @@ All criteria achieved: - [x] Clear domain boundaries - [x] Consistent naming conventions +## Maintainability Gate (Issue #539 Foundation) + +- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Generate or refresh baseline: + - `bun run maintainability:baseline` + - `npm run maintainability:baseline` +- Run regression check gate: + - `bun run maintainability:check` + - `npm run maintainability:check` + +The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. + +The check mode supports a maintainability regression gate that blocks increases in: +- `process.exit` references +- synchronous fs API references +- TypeScript files over 350 LOC + --- ## Related Documentation @@ -198,4 +224,5 @@ All criteria achieved: - [Codebase Summary](./codebase-summary.md) - Current structure - [Code Standards](./code-standards.md) - Patterns and conventions - [System Architecture](./system-architecture.md) - Architecture diagrams +- [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking - [CLAUDE.md](../CLAUDE.md) - AI development guidance diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json new file mode 100644 index 00000000..2777c900 --- /dev/null +++ b/docs/reports/hardening-inventory.json @@ -0,0 +1,470 @@ +{ + "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", + "syncFs": { + "totalOccurrences": 835, + "filesAffected": 100, + "hotpathOccurrences": 724, + "hotpathFilesAffected": 89, + "topHotpathFiles": [ + { + "file": "src/management/shared-manager.ts", + "count": 60, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-symlink-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readlinkSync", + "renameSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/shell-completion.ts", + "count": 23, + "calls": [ + "appendFileSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/settings-routes.ts", + "count": 23, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-dir-installer.ts", + "count": 21, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "renameSync", + "rmSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/binary/version-cache.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/recovery-manager.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "renameSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/cliproxy-stats-routes.ts", + "count": 20, + "calls": [ + "closeSync", + "existsSync", + "fstatSync", + "mkdirSync", + "openSync", + "readdirSync", + "readFileSync", + "readSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/misc-routes.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/persist-routes.ts", + "count": 17, + "calls": [ + "closeSync", + "copyFileSync", + "existsSync", + "lstatSync", + "openSync", + "readdirSync", + "readSync", + "renameSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + } + ], + "topFilesOverall": [ + { + "file": "src/management/shared-manager.ts", + "count": 60, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/config/migration-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmdirSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-symlink-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readlinkSync", + "renameSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/shell-completion.ts", + "count": 23, + "calls": [ + "appendFileSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/settings-routes.ts", + "count": 23, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-dir-installer.ts", + "count": 21, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "renameSync", + "rmSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/binary/version-cache.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/copilot/copilot-package-manager.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "rmSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/recovery-manager.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "renameSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/cliproxy-stats-routes.ts", + "count": 20, + "calls": [ + "closeSync", + "existsSync", + "fstatSync", + "mkdirSync", + "openSync", + "readdirSync", + "readFileSync", + "readSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + } + ] + }, + "legacyShim": { + "totalMarkers": 131, + "filesAffected": 56, + "topFiles": [ + { + "file": "src/utils/config-manager.ts", + "count": 13, + "calls": [], + "markers": [ + "* Get config file path (legacy JSON path)", + "* Precedence: --config-dir flag > CCS_DIR env > CCS_HOME env (legacy, appends .ccs) > ~/.ccs default", + "* Read and parse config (legacy compatibility)", + "* Returns Config with profiles from unified config.yaml or legacy config.json.", + "* Returns config.yaml in unified mode, config.json in legacy mode.", + "* then falls back to config.json for backward compatibility.", + "// Convert unified cliproxy variants to legacy format", + "// Convert unified profiles to legacy format for compatibility", + "// If not found in unified config, try legacy config.json as fallback", + "// Legacy config is invalid JSON - that's OK in unified mode", + "// Legacy mode - read from config.json only", + "// Legacy mode: read config.json", + "// Merge legacy profiles into available list (avoid duplicates)" + ] + }, + { + "file": "src/auth/profile-detector.ts", + "count": 11, + "calls": [], + "markers": [ + "* - Legacy JSON format (config.json, profiles.json) as fallback", + "* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]", + "* 3. Settings-based profiles (config.profiles section) [legacy]", + "* 4. Account-based profiles (profiles.json) [legacy]", + "* Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility.", + "// Check if account-based default exists (legacy)", + "// Fall back to legacy config", + "// Fall back to legacy config display", + "// Fall through to legacy if not found in unified config", + "// Priority 3: Check settings-based profiles (glm, kimi) - LEGACY FALLBACK", + "// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK" + ] + }, + { + "file": "src/config/unified-config-loader.ts", + "count": 9, + "calls": [], + "markers": [ + "* 'json' if only legacy config exists,", + "* Check if legacy config.json exists", + "* Get path to legacy config.json", + "* Provides fallback to legacy JSON format for backward compatibility.", + "// Legacy field for backwards compatibility", + "// Legacy fields (deprecated)", + "// Legacy fields (keep for backwards compatibility during read)", + "partial.websearch?.gemini?.enabled ?? // Legacy fallback", + "partial.websearch?.gemini?.timeout ?? // Legacy fallback" + ] + }, + { + "file": "src/commands/setup-command.ts", + "count": 7, + "calls": [], + "markers": [ + "* 3. Legacy config.json profiles (GLM, Kimi)", + "* 4. Legacy profiles.json accounts", + "// Also check legacy config.json for existing profiles", + "// Has legacy accounts - NOT first time", + "// Has legacy profiles - NOT first time", + "// Legacy config exists but is invalid - ignore and continue", + "// Legacy profiles exists but is invalid - ignore and continue" + ] + }, + { + "file": "src/management/checks/config-check.ts", + "count": 6, + "calls": [], + "markers": [ + "* - Prefers config.yaml (v2) over config.json (legacy)", + "// Fallback to config.json (legacy format)", + "// Inform if legacy config.json also exists (purely informational, not a check)", + "console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`);", + "console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`);", + "info: 'Valid (legacy)'," + ] + }, + { + "file": "src/web-server/routes/account-routes.ts", + "count": 6, + "calls": [], + "markers": [ + "* Uses ProfileRegistry to read from both legacy (profiles.json)", + "// Add legacy profiles first", + "// Delete from appropriate config (unified and/or legacy)", + "// Get default from unified config first, fallback to legacy", + "// Get profiles from both legacy and unified config (same logic as CLI)", + "// Use unified config if in unified mode, otherwise use legacy" + ] + }, + { + "file": "src/config/migration-manager.ts", + "count": 5, + "calls": [], + "markers": [ + "* Check if there are legacy profiles that haven't been migrated to config.yaml.", + "* Handles migration from legacy JSON config (v1) to unified YAML config (v2).", + "`Profile \"${name}\" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})`", + "console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS'));", + "console.log(infoBox('Migration failed - using legacy config', 'WARNING'));" + ] + }, + { + "file": "src/api/services/profile-writer.ts", + "count": 4, + "calls": [], + "markers": [ + "* Supports both unified YAML config and legacy JSON config.", + "/** Create settings.json file for API profile (legacy format) */", + "/** Remove API profile from legacy config */", + "/** Update config.json with new API profile (legacy format) */" + ] + }, + { + "file": "src/cliproxy/quota-fetcher-gemini-cli.ts", + "count": 4, + "calls": [], + "markers": [ + "// Legacy pattern: gemini-email.json", + "// Must match account AND be gemini type (or legacy gemini- prefix)", + "// Try exact legacy match first", + "`gemini-${sanitizedId}.json`, // Legacy format" + ] + }, + { + "file": "src/auth/profile-registry.ts", + "count": 3, + "calls": [], + "markers": [ + "* Get all profiles merged from both legacy and unified config.", + "* Get resolved default profile from unified config first, fallback to legacy.", + "// Start with legacy profiles" + ] + } + ], + "explicitShimFiles": [ + "src/cliproxy/openai-compat-manager.ts" + ] + } +} diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md new file mode 100644 index 00000000..69a86f89 --- /dev/null +++ b/docs/reports/hardening-inventory.md @@ -0,0 +1,48 @@ +# Hardening Inventory Report + +Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` + +## Summary + +| Metric | Value | +|---|---:| +| Sync fs occurrences (all) | 835 | +| Sync fs files affected (all) | 100 | +| Sync fs occurrences (runtime hotpaths) | 724 | +| Sync fs files affected (runtime hotpaths) | 89 | +| Legacy shim markers | 131 | +| Legacy shim files affected | 56 | + +## Top Runtime Hotpath Sync fs Files + +| File | Sync Calls | API Names | +|---|---:|---| +| `src/management/shared-manager.ts` | 60 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync | +| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | +| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync | +| `src/web-server/routes/settings-routes.ts` | 23 | copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync | +| `src/utils/claude-dir-installer.ts` | 21 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync | +| `src/cliproxy/binary/version-cache.ts` | 20 | existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync | +| `src/management/recovery-manager.ts` | 20 | copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync | +| `src/web-server/routes/cliproxy-stats-routes.ts` | 20 | closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync | +| `src/web-server/routes/misc-routes.ts` | 20 | copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync | +| `src/web-server/routes/persist-routes.ts` | 17 | closeSync, copyFileSync, existsSync, lstatSync, openSync, readdirSync, readSync, renameSync, unlinkSync, writeFileSync | + +## Top Legacy Shim Marker Files + +| File | Marker Count | +|---|---:| +| `src/utils/config-manager.ts` | 13 | +| `src/auth/profile-detector.ts` | 11 | +| `src/config/unified-config-loader.ts` | 9 | +| `src/commands/setup-command.ts` | 7 | +| `src/management/checks/config-check.ts` | 6 | +| `src/web-server/routes/account-routes.ts` | 6 | +| `src/config/migration-manager.ts` | 5 | +| `src/api/services/profile-writer.ts` | 4 | +| `src/cliproxy/quota-fetcher-gemini-cli.ts` | 4 | +| `src/auth/profile-registry.ts` | 3 | + +## Explicit Shim/Re-export Files + +- `src/cliproxy/openai-compat-manager.ts` diff --git a/package.json b/package.json index 4b1dcab9..0400f00a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.43.0-dev.2", + "version": "7.43.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -64,8 +64,10 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", + "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run maintainability:check && bun run test:all", "verify:bundle": "node scripts/verify-bundle.js", + "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", @@ -73,6 +75,7 @@ "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", + "report:hardening": "node scripts/hardening-inventory.js", "dev": "bun run build:server && bun dist/ccs.js config --dev", "dev:symlink": "bash scripts/dev-symlink.sh", "dev:unlink": "bash scripts/dev-symlink.sh --restore", diff --git a/scripts/hardening-inventory.js b/scripts/hardening-inventory.js new file mode 100644 index 00000000..549fe35a --- /dev/null +++ b/scripts/hardening-inventory.js @@ -0,0 +1,526 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const REPORT_DIR = path.join(ROOT_DIR, 'docs', 'reports'); +const JSON_REPORT_PATH = path.join(REPORT_DIR, 'hardening-inventory.json'); +const MD_REPORT_PATH = path.join(REPORT_DIR, 'hardening-inventory.md'); + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); +const HOTPATH_PATTERNS = [ + /^src\/web-server\//, + /^src\/commands\//, + /^src\/cliproxy\//, + /^src\/management\//, + /^src\/auth\//, + /^src\/delegation\//, + /^src\/utils\//, + /^src\/ccs\.ts$/, +]; + +const SYNC_CALL_NAMES = [ + 'accessSync', + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'cpSync', + 'existsSync', + 'fstatSync', + 'fsyncSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'opendirSync', + 'readFileSync', + 'readdirSync', + 'readlinkSync', + 'readSync', + 'readvSync', + 'realpathSync', + 'renameSync', + 'rmSync', + 'rmdirSync', + 'statSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]; +const SYNC_CALL_CAPTURE_REGEX = new RegExp( + `(?:\\bfs(?:\\s*\\?\\.)?\\s*\\.\\s*|(? pattern.test(filePath)); +} + +function walkFiles(dirPath) { + const output = []; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + output.push(...walkFiles(fullPath)); + continue; + } + + if (entry.isFile() && isSourceFile(fullPath)) { + output.push(fullPath); + } + } + + return output; +} + +function uniqueSorted(values) { + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); +} + +function sortByCountDesc(items) { + return [...items].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return a.file.localeCompare(b.file); + }); +} + +function summarize(items, limit = 10) { + return sortByCountDesc(items) + .slice(0, limit) + .map((item) => ({ + file: item.file, + count: item.count, + calls: uniqueSorted(item.calls || []), + markers: uniqueSorted(item.markers || []), + })); +} + +function isRegexLiteralStart(previousSignificantChar, previousIdentifier) { + return ( + previousSignificantChar === '' || + '([{:;,=!?+-*%^&|~<>'.includes(previousSignificantChar) || + REGEX_LITERAL_KEYWORDS.has(previousIdentifier) + ); +} + +function stripComments(sourceText) { + let output = ''; + let index = 0; + let inLineComment = false; + let inBlockComment = false; + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateLiteral = false; + let inRegexLiteral = false; + let inRegexCharClass = false; + let previousSignificantChar = ''; + let previousIdentifier = ''; + + while (index < sourceText.length) { + const current = sourceText[index]; + const next = sourceText[index + 1]; + + if (inLineComment) { + if (current === '\n' || current === '\r') { + inLineComment = false; + output += current; + } else { + output += ' '; + } + index += 1; + continue; + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + output += ' '; + index += 2; + inBlockComment = false; + continue; + } + + output += current === '\n' || current === '\r' ? current : ' '; + index += 1; + continue; + } + + if (inRegexLiteral) { + if (current === '\n' || current === '\r') { + output += current; + index += 1; + inRegexLiteral = false; + inRegexCharClass = false; + continue; + } + + output += ' '; + + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + + if (!inRegexCharClass && current === '[') { + inRegexCharClass = true; + index += 1; + continue; + } + + if (inRegexCharClass && current === ']') { + inRegexCharClass = false; + index += 1; + continue; + } + + if (!inRegexCharClass && current === '/') { + index += 1; + while (index < sourceText.length && /[a-z]/i.test(sourceText[index])) { + output += ' '; + index += 1; + } + inRegexLiteral = false; + previousSignificantChar = 'r'; + previousIdentifier = ''; + continue; + } + + index += 1; + continue; + } + + if (inSingleQuote) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === "'") { + inSingleQuote = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (inDoubleQuote) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === '"') { + inDoubleQuote = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (inTemplateLiteral) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === '`') { + inTemplateLiteral = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (current === '/' && next === '/') { + output += ' '; + index += 2; + inLineComment = true; + continue; + } + + if (current === '/' && next === '*') { + output += ' '; + index += 2; + inBlockComment = true; + continue; + } + + if (current === '/' && isRegexLiteralStart(previousSignificantChar, previousIdentifier)) { + output += ' '; + index += 1; + inRegexLiteral = true; + inRegexCharClass = false; + continue; + } + + if (/[A-Za-z_$]/.test(current)) { + let tokenEnd = index + 1; + while (tokenEnd < sourceText.length && /[A-Za-z0-9_$]/.test(sourceText[tokenEnd])) { + tokenEnd += 1; + } + const token = sourceText.slice(index, tokenEnd); + output += token; + previousSignificantChar = 'i'; + previousIdentifier = token; + index = tokenEnd; + continue; + } + + if (current === "'") { + inSingleQuote = true; + output += ' '; + index += 1; + continue; + } + + if (current === '"') { + inDoubleQuote = true; + output += ' '; + index += 1; + continue; + } + + if (current === '`') { + inTemplateLiteral = true; + output += ' '; + index += 1; + continue; + } + + output += current; + if (!/\s/.test(current)) { + previousSignificantChar = current; + previousIdentifier = ''; + } + index += 1; + } + + return output; +} + +function collectSyncCallSites(sourceText) { + const sanitizedSource = stripComments(sourceText); + const captureRegex = new RegExp(SYNC_CALL_CAPTURE_REGEX.source, SYNC_CALL_CAPTURE_REGEX.flags); + const calls = []; + + for (const match of sanitizedSource.matchAll(captureRegex)) { + calls.push(match[1]); + } + + return { + count: calls.length, + calls, + }; +} + +function buildReport() { + const files = walkFiles(SRC_DIR); + const syncEntries = []; + const legacyEntries = []; + + for (const fullPath of files) { + const file = relativePath(fullPath); + const sourceText = fs.readFileSync(fullPath, 'utf8'); + const lines = sourceText.split(/\r?\n/); + const { count: syncCount, calls: syncCalls } = collectSyncCallSites(sourceText); + let legacyCount = 0; + const legacyMarkers = []; + + for (const line of lines) { + if (LEGACY_MARKER_REGEX.test(line)) { + legacyCount += 1; + const normalized = line.trim(); + if (normalized.length > 0) { + legacyMarkers.push(normalized); + } + } + } + + if (syncCount > 0) { + syncEntries.push({ + file, + count: syncCount, + calls: syncCalls, + hotpath: isHotpath(file), + }); + } + + if (legacyCount > 0) { + legacyEntries.push({ + file, + count: legacyCount, + markers: legacyMarkers, + }); + } + } + + const syncHotpathEntries = syncEntries.filter((entry) => entry.hotpath); + const totalSyncCount = syncEntries.reduce((acc, entry) => acc + entry.count, 0); + const totalSyncHotpathCount = syncHotpathEntries.reduce((acc, entry) => acc + entry.count, 0); + const totalLegacyMarkers = legacyEntries.reduce((acc, entry) => acc + entry.count, 0); + + return { + scope: 'src/**/*.{ts,tsx,js,jsx,mjs,cjs}', + syncFs: { + totalOccurrences: totalSyncCount, + filesAffected: syncEntries.length, + hotpathOccurrences: totalSyncHotpathCount, + hotpathFilesAffected: syncHotpathEntries.length, + topHotpathFiles: summarize(syncHotpathEntries), + topFilesOverall: summarize(syncEntries), + }, + legacyShim: { + totalMarkers: totalLegacyMarkers, + filesAffected: legacyEntries.length, + topFiles: summarize(legacyEntries), + explicitShimFiles: uniqueSorted( + legacyEntries + .map((entry) => entry.file) + .filter((file) => /shim|re-export|compat/i.test(path.basename(file))) + ), + }, + }; +} + +function renderMarkdown(report) { + const lines = []; + + lines.push('# Hardening Inventory Report'); + lines.push(''); + lines.push(`Scope: \`${report.scope}\``); + lines.push(''); + lines.push('## Summary'); + lines.push(''); + lines.push('| Metric | Value |'); + lines.push('|---|---:|'); + lines.push(`| Sync fs occurrences (all) | ${report.syncFs.totalOccurrences} |`); + lines.push(`| Sync fs files affected (all) | ${report.syncFs.filesAffected} |`); + lines.push(`| Sync fs occurrences (runtime hotpaths) | ${report.syncFs.hotpathOccurrences} |`); + lines.push(`| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |`); + lines.push(`| Legacy shim markers | ${report.legacyShim.totalMarkers} |`); + lines.push(`| Legacy shim files affected | ${report.legacyShim.filesAffected} |`); + lines.push(''); + + lines.push('## Top Runtime Hotpath Sync fs Files'); + lines.push(''); + lines.push('| File | Sync Calls | API Names |'); + lines.push('|---|---:|---|'); + + for (const item of report.syncFs.topHotpathFiles) { + lines.push(`| \`${item.file}\` | ${item.count} | ${item.calls.join(', ')} |`); + } + + if (report.syncFs.topHotpathFiles.length === 0) { + lines.push('| _none_ | 0 | - |'); + } + + lines.push(''); + lines.push('## Top Legacy Shim Marker Files'); + lines.push(''); + lines.push('| File | Marker Count |'); + lines.push('|---|---:|'); + + for (const item of report.legacyShim.topFiles) { + lines.push(`| \`${item.file}\` | ${item.count} |`); + } + + if (report.legacyShim.topFiles.length === 0) { + lines.push('| _none_ | 0 |'); + } + + lines.push(''); + lines.push('## Explicit Shim/Re-export Files'); + lines.push(''); + for (const file of report.legacyShim.explicitShimFiles) { + lines.push(`- \`${file}\``); + } + if (report.legacyShim.explicitShimFiles.length === 0) { + lines.push('- _none_'); + } + + lines.push(''); + return lines.join('\n'); +} + +function main() { + const report = buildReport(); + + fs.mkdirSync(REPORT_DIR, { recursive: true }); + fs.writeFileSync(JSON_REPORT_PATH, JSON.stringify(report, null, 2) + '\n', 'utf8'); + fs.writeFileSync(MD_REPORT_PATH, renderMarkdown(report), 'utf8'); + + const relJson = relativePath(JSON_REPORT_PATH); + const relMd = relativePath(MD_REPORT_PATH); + + console.log(`[hardening-inventory] generatedAt=${new Date().toISOString()}`); + console.log( + `[hardening-inventory] sync-fs total=${report.syncFs.totalOccurrences}, hotpath=${report.syncFs.hotpathOccurrences}` + ); + console.log( + `[hardening-inventory] legacy markers total=${report.legacyShim.totalMarkers}, files=${report.legacyShim.filesAffected}` + ); + console.log(`[hardening-inventory] wrote ${relJson}`); + console.log(`[hardening-inventory] wrote ${relMd}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildReport, + collectSyncCallSites, + renderMarkdown, + stripComments, +}; diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js new file mode 100644 index 00000000..0216b841 --- /dev/null +++ b/scripts/maintainability-baseline.js @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(PROJECT_ROOT, 'src'); +const DEFAULT_BASELINE_PATH = path.join( + PROJECT_ROOT, + 'docs', + 'metrics', + 'maintainability-baseline.json' +); + +const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.cts', '.mts']); +const LARGE_FILE_THRESHOLD_LOC = 350; + +const FS_SYNC_APIS = [ + 'accessSync', + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'cpSync', + 'existsSync', + 'fchmodSync', + 'fchownSync', + 'fdatasyncSync', + 'fstatSync', + 'fsyncSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'lutimesSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'opendirSync', + 'readFileSync', + 'readdirSync', + 'readlinkSync', + 'readSync', + 'realpathSync', + 'renameSync', + 'rmSync', + 'rmdirSync', + 'statSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]; + +const PROCESS_EXIT_PATTERN = /\bprocess\s*\.\s*exit\b/g; +const FS_SYNC_PATTERN = new RegExp(`\\b(?:${FS_SYNC_APIS.join('|')})\\b`, 'g'); + +function printUsage() { + console.log( + [ + 'Usage:', + ' node scripts/maintainability-baseline.js', + ' node scripts/maintainability-baseline.js --out [path]', + ' node scripts/maintainability-baseline.js --check [path]', + '', + 'Defaults:', + ` baseline path: ${path.relative(PROJECT_ROOT, DEFAULT_BASELINE_PATH)}`, + ].join('\n') + ); +} + +function parseArgs(argv) { + const options = { + outPath: null, + checkPath: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + + if (arg === '--out' || arg === '--write') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.outPath = nextArg; + index += 1; + } else { + options.outPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + if (arg === '--check') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.checkPath = nextArg; + index += 1; + } else { + options.checkPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function collectTrackedFilesFromGit() { + let output; + try { + output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + throw new Error( + 'Unable to enumerate tracked files via git. Run this command from a git checkout with git installed.' + ); + } + + if (!output) { + return []; + } + + return output + .split('\0') + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map(relativePath => path.resolve(PROJECT_ROOT, relativePath)) + .filter(filePath => { + const relativeToSrc = path.relative(SRC_DIR, filePath); + if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) { + return false; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + throw new Error(`Tracked path is not a file: ${path.relative(PROJECT_ROOT, filePath)}`); + } + + return true; + }); +} + +function collectFilesInSrc() { + return collectTrackedFilesFromGit(); +} + +function countLines(content) { + if (content.length === 0) { + return 0; + } + + const lineBreakMatches = content.match(/\r\n|\n|\r/g); + const lineBreakCount = lineBreakMatches ? lineBreakMatches.length : 0; + const endsWithLineBreak = content.endsWith('\n') || content.endsWith('\r'); + + return endsWithLineBreak ? lineBreakCount : lineBreakCount + 1; +} + +function countMatches(content, pattern) { + const matches = content.match(pattern); + return matches ? matches.length : 0; +} + +function collectMetrics() { + if (!fs.existsSync(SRC_DIR)) { + throw new Error(`Directory not found: ${SRC_DIR}`); + } + + const files = collectFilesInSrc(); + + let typeScriptFileCount = 0; + let locInSrc = 0; + let processExitReferenceCount = 0; + let synchronousFsApiReferenceCount = 0; + let largeFileCountOver350Loc = 0; + + for (const filePath of files) { + const content = fs.readFileSync(filePath, 'utf8'); + const loc = countLines(content); + const extension = path.extname(filePath).toLowerCase(); + const isTypeScriptFile = TYPESCRIPT_EXTENSIONS.has(extension); + + locInSrc += loc; + processExitReferenceCount += countMatches(content, PROCESS_EXIT_PATTERN); + synchronousFsApiReferenceCount += countMatches(content, FS_SYNC_PATTERN); + + if (isTypeScriptFile) { + typeScriptFileCount += 1; + if (loc > LARGE_FILE_THRESHOLD_LOC) { + largeFileCountOver350Loc += 1; + } + } + } + + return { + sourceDirectory: 'src', + largeFileThresholdLoc: LARGE_FILE_THRESHOLD_LOC, + typeScriptFileCount, + locInSrc, + processExitReferenceCount, + synchronousFsApiReferenceCount, + largeFileCountOver350Loc, + }; +} + +function writeMetrics(outPath, metrics) { + const resolvedOutPath = path.resolve(process.cwd(), outPath); + fs.mkdirSync(path.dirname(resolvedOutPath), { recursive: true }); + fs.writeFileSync(resolvedOutPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8'); +} + +function runCheck(checkPath, currentMetrics) { + const resolvedCheckPath = path.resolve(process.cwd(), checkPath); + const baselineContent = fs.readFileSync(resolvedCheckPath, 'utf8'); + const baselineMetrics = JSON.parse(baselineContent); + + if (baselineMetrics.sourceDirectory !== currentMetrics.sourceDirectory) { + throw new Error( + `Baseline sourceDirectory mismatch: expected "${currentMetrics.sourceDirectory}", got "${baselineMetrics.sourceDirectory}"` + ); + } + + if (baselineMetrics.largeFileThresholdLoc !== currentMetrics.largeFileThresholdLoc) { + throw new Error( + `Baseline largeFileThresholdLoc mismatch: expected ${currentMetrics.largeFileThresholdLoc}, got ${baselineMetrics.largeFileThresholdLoc}` + ); + } + + const gatedKeys = [ + 'processExitReferenceCount', + 'synchronousFsApiReferenceCount', + 'largeFileCountOver350Loc', + ]; + + const violations = []; + for (const key of gatedKeys) { + if (typeof baselineMetrics[key] !== 'number') { + throw new Error(`Baseline is missing numeric metric: ${key}`); + } + + if (currentMetrics[key] > baselineMetrics[key]) { + violations.push({ + metric: key, + baseline: baselineMetrics[key], + current: currentMetrics[key], + }); + } + } + + return { + gate: 'maintainability-baseline', + baselinePath: path.relative(PROJECT_ROOT, resolvedCheckPath), + passed: violations.length === 0, + comparedMetrics: gatedKeys, + baseline: { + typeScriptFileCount: baselineMetrics.typeScriptFileCount, + locInSrc: baselineMetrics.locInSrc, + processExitReferenceCount: baselineMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: baselineMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: baselineMetrics.largeFileCountOver350Loc, + }, + current: { + typeScriptFileCount: currentMetrics.typeScriptFileCount, + locInSrc: currentMetrics.locInSrc, + processExitReferenceCount: currentMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: currentMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: currentMetrics.largeFileCountOver350Loc, + }, + violations, + }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const metrics = collectMetrics(); + + if (options.outPath) { + writeMetrics(options.outPath, metrics); + } + + if (options.checkPath) { + const checkResult = runCheck(options.checkPath, metrics); + console.log(JSON.stringify(checkResult, null, 2)); + if (!checkResult.passed) { + process.exit(1); + } + return; + } + + console.log(JSON.stringify(metrics, null, 2)); +} + +main(); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 2c402d18..0d10bad3 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -22,21 +22,14 @@ import { } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; +import type { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; /** CLIProxy profile names (OAuth-based, zero config) */ -export const CLIPROXY_PROFILES = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - 'claude', -] as const; -export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; +export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS; +export type CLIProxyProfileName = CLIProxyProvider; export interface ProfileDetectionResult { type: ProfileType; @@ -250,11 +243,11 @@ class ProfileDetector { } // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config - if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) { + if (isCLIProxyProvider(profileName)) { return { type: 'cliproxy', name: profileName, - provider: profileName as CLIProxyProfileName, + provider: profileName, }; } diff --git a/src/ccs.ts b/src/ccs.ts index f5cb7486..f8b018cf 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -532,6 +532,14 @@ async function main(): Promise { return; } + // Special case: cursor command (Cursor IDE integration) + // All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode + if (firstArg === 'cursor') { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts new file mode 100644 index 00000000..bb377629 --- /dev/null +++ b/src/cliproxy/provider-capabilities.ts @@ -0,0 +1,113 @@ +import type { CLIProxyProvider } from './types'; + +export type OAuthFlowType = 'authorization_code' | 'device_code'; + +export interface ProviderCapabilities { + displayName: string; + oauthFlow: OAuthFlowType; + callbackPort: number | null; + /** + * Alternative provider names used by CLIProxyAPI or stats endpoints. + * These aliases normalize external names to canonical CCS provider IDs. + */ + aliases: readonly string[]; +} + +export const PROVIDER_CAPABILITIES: Record = { + gemini: { + displayName: 'Google Gemini', + oauthFlow: 'authorization_code', + callbackPort: 8085, + aliases: ['gemini-cli'], + }, + codex: { + displayName: 'Codex', + oauthFlow: 'authorization_code', + callbackPort: 1455, + aliases: [], + }, + agy: { + displayName: 'AntiGravity', + oauthFlow: 'authorization_code', + callbackPort: 51121, + aliases: ['antigravity'], + }, + qwen: { + displayName: 'Qwen', + oauthFlow: 'device_code', + callbackPort: null, + aliases: [], + }, + iflow: { + displayName: 'iFlow', + oauthFlow: 'authorization_code', + callbackPort: 11451, + aliases: [], + }, + kiro: { + displayName: 'Kiro (AWS)', + oauthFlow: 'authorization_code', + callbackPort: 9876, + aliases: ['codewhisperer'], + }, + ghcp: { + displayName: 'GitHub Copilot (OAuth)', + oauthFlow: 'device_code', + callbackPort: null, + aliases: ['github-copilot', 'copilot'], + }, + claude: { + displayName: 'Claude', + oauthFlow: 'authorization_code', + callbackPort: 54545, + aliases: ['anthropic'], + }, +}; + +export const CLIPROXY_PROVIDER_IDS = Object.freeze( + Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] +); + +const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); + +const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { + const entries: Array<[string, CLIProxyProvider]> = []; + for (const provider of CLIPROXY_PROVIDER_IDS) { + entries.push([provider, provider]); + for (const alias of PROVIDER_CAPABILITIES[provider].aliases) { + entries.push([alias.toLowerCase(), provider]); + } + } + return new Map(entries); +})(); + +export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { + return PROVIDER_ID_SET.has(provider as CLIProxyProvider); +} + +export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { + return PROVIDER_CAPABILITIES[provider]; +} + +export function getProviderDisplayName(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].displayName; +} + +export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] { + return CLIPROXY_PROVIDER_IDS.filter( + (provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType + ); +} + +export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType { + return PROVIDER_CAPABILITIES[provider].oauthFlow; +} + +export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null { + return PROVIDER_CAPABILITIES[provider].callbackPort; +} + +export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { + const normalized = providerName.toLowerCase(); + return PROVIDER_ALIAS_MAP.get(normalized) ?? null; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 5de9218e..aaedf04e 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -9,6 +9,8 @@ import { buildManagementHeaders, ProxyTarget, } from './proxy-target-resolver'; +import { getProviderDisplayName, mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; /** Timeout for remote fetch requests (ms) */ const REMOTE_FETCH_TIMEOUT_MS = 5000; @@ -43,32 +45,6 @@ export interface RemoteAuthStatus { source: 'remote'; } -/** Map CLIProxyAPI provider names to CCS internal names */ -const PROVIDER_MAP: Record = { - gemini: 'gemini', - 'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - kiro: 'kiro', - codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro - ghcp: 'ghcp', - 'github-copilot': 'ghcp', - copilot: 'ghcp', -}; - -/** Display names for providers */ -const PROVIDER_DISPLAY_NAMES: Record = { - gemini: 'Google Gemini', - agy: 'AntiGravity', - codex: 'Codex', - qwen: 'Qwen', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', -}; - /** * Fetch auth status from remote CLIProxyAPI * @throws Error if remote is unreachable or returns error @@ -124,10 +100,10 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise(); + const byProvider = new Map(); for (const file of files) { - const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + const provider = mapExternalProviderName(file.provider); if (!provider) { // Unknown provider, skip (could add logging in debug mode) continue; @@ -154,7 +130,7 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { result.push({ provider, - displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + displayName: getProviderDisplayName(provider), authenticated: activeFiles.length > 0, tokenFiles: providerFiles.length, accounts, diff --git a/src/commands/command-execution-contract.ts b/src/commands/command-execution-contract.ts new file mode 100644 index 00000000..3fff0098 --- /dev/null +++ b/src/commands/command-execution-contract.ts @@ -0,0 +1,30 @@ +/** + * Command Execution Contract + * + * Standardized lifecycle: parse -> validate -> execute -> render + * for CLI command handlers. + */ + +export interface CommandExecutionContract { + parse(rawArgs: string[]): TParsedArgs; + validate(parsedArgs: TParsedArgs): void | Promise; + execute(parsedArgs: TParsedArgs): Promise | TExecutionResult; + render( + result: TExecutionResult, + context: { rawArgs: string[]; parsedArgs: TParsedArgs } + ): Promise | void; +} + +/** + * Run a command through the standard lifecycle. + */ +export async function runCommandWithContract( + rawArgs: string[], + contract: CommandExecutionContract +): Promise<{ parsedArgs: TParsedArgs; result: TExecutionResult }> { + const parsedArgs = contract.parse(rawArgs); + await contract.validate(parsedArgs); + const result = await contract.execute(parsedArgs); + await contract.render(result, { rawArgs, parsedArgs }); + return { parsedArgs, result }; +} diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts new file mode 100644 index 00000000..ad6ce59a --- /dev/null +++ b/src/commands/cursor-command.ts @@ -0,0 +1,264 @@ +/** + * Cursor CLI Command + * + * Handles `ccs cursor ` commands. + */ + +import { + autoDetectTokens, + saveCredentials, + checkAuthStatus, + startDaemon, + stopDaemon, + getDaemonStatus, + getAvailableModels, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, +} from '../cursor'; +import { ok, fail, info, color } from '../utils/ui'; + +// Temporary default config until #521 adds cursor to unified config +const DEFAULT_CURSOR_CONFIG = { + port: DEFAULT_CURSOR_PORT, + model: DEFAULT_CURSOR_MODEL, +}; + +/** Valid cursor subcommands — imported by ccs.ts for routing */ +export const CURSOR_SUBCOMMANDS = [ + 'auth', + 'status', + 'models', + 'start', + 'stop', + 'help', + '--help', + '-h', +] as const; + +/** + * Handle cursor subcommand. + */ +export async function handleCursorCommand(args: string[]): Promise { + const subcommand = args[0]; + + switch (subcommand) { + case 'auth': + return handleAuth(); + case 'status': + return handleStatus(); + case 'models': + return handleModels(); + case 'start': + return handleStart(); + case 'stop': + return handleStop(); + case undefined: + case 'help': + case '--help': + case '-h': + return handleHelp(); + default: + console.error(fail(`Unknown subcommand: ${subcommand}`)); + console.error(''); + void handleHelp(); // Print help but keep exit code 1 + return 1; + } +} + +/** + * Show help for cursor commands. + */ +function handleHelp(): number { + console.log('Cursor IDE Integration'); + console.log(''); + console.log('Usage: ccs cursor '); + console.log(''); + console.log('Subcommands:'); + console.log(' auth Import Cursor IDE authentication token'); + console.log(' status Show authentication and daemon status'); + console.log(' models List available models'); + console.log(' start Start cursor daemon'); + console.log(' stop Stop cursor daemon'); + console.log(' help Show this help message'); + console.log(''); + console.log('Quick start:'); + console.log(' 1. ccs cursor auth # Import Cursor IDE token'); + console.log(' 2. ccs cursor start # Start daemon'); + console.log(' 3. Use cursor models # Via daemon on configured port'); + console.log(''); + console.log('Or use the web UI: ccs config → Cursor tab'); + console.log(''); + return 0; +} + +/** + * Handle auth subcommand. + */ +async function handleAuth(): Promise { + console.log(info('Importing Cursor IDE authentication...')); + console.log(''); + + // Try auto-detection first + console.log(info('Attempting auto-detection...')); + const autoResult = autoDetectTokens(); + + if (autoResult.found && autoResult.accessToken && autoResult.machineId) { + saveCredentials({ + accessToken: autoResult.accessToken, + machineId: autoResult.machineId, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + console.log(ok('Auto-detected Cursor credentials')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Start daemon: ccs cursor start'); + console.log(' 2. Check status: ccs cursor status'); + return 0; + } + + // Fall back to manual import + console.log(''); + if (autoResult.error) { + console.log(`Auto-detection failed: ${autoResult.error}`); + } else { + console.log('Auto-detection failed. Please provide credentials manually.'); + } + console.log(''); + console.log('To find your Cursor credentials:'); + console.log(' 1. Open Cursor IDE'); + console.log(' 2. Check application data directory'); + console.log(' 3. Look for access token and machine ID'); + console.log(''); + + // For now, just show instructions + // Manual import flow will be implemented when needed + console.error(fail('Manual import not yet implemented')); + console.error(''); + console.error('Use auto-detection for now or wait for manual import feature.'); + + return 1; +} + +/** + * Handle status subcommand. + */ +async function handleStatus(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + console.log('Cursor IDE Status'); + console.log('─────────────────'); + console.log(''); + + // Auth status + const authIcon = authStatus.authenticated ? color('[OK]', 'success') : color('[X]', 'error'); + const authText = authStatus.authenticated ? 'Authenticated' : 'Not authenticated'; + console.log(`Authentication: ${authIcon} ${authText}`); + + if (authStatus.authenticated && authStatus.tokenAge !== undefined) { + console.log(` Token age: ${authStatus.tokenAge} hours`); + } + + // Daemon status + const daemonIcon = daemonStatus.running ? color('[OK]', 'success') : color('[X]', 'error'); + const daemonText = daemonStatus.running ? 'Running' : 'Not running'; + console.log(`Daemon: ${daemonIcon} ${daemonText}`); + + if (daemonStatus.pid) { + console.log(` PID: ${daemonStatus.pid}`); + } + + console.log(''); + console.log('Configuration:'); + console.log(` Port: ${cursorConfig.port}`); + console.log(` Model: ${cursorConfig.model}`); + + console.log(''); + + // Show next steps if not fully configured + if (!authStatus.authenticated || !daemonStatus.running) { + console.log('Next steps:'); + if (!authStatus.authenticated) { + console.log(' - Auth: ccs cursor auth'); + } + if (!daemonStatus.running) { + console.log(' - Start: ccs cursor start'); + } + } + + return 0; +} + +/** + * Handle models subcommand. + */ +async function handleModels(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + console.log('Available Cursor Models'); + console.log('───────────────────────'); + console.log(''); + + const models = await getAvailableModels(cursorConfig.port); + + for (const model of models) { + const current = model.id === cursorConfig.model ? ' [CURRENT]' : ''; + const defaultMark = model.isDefault ? ' (default)' : ''; + console.log(` ${model.id}${current}${defaultMark}`); + console.log(` Provider: ${model.provider}`); + } + + console.log(''); + console.log('To change model: ccs config (Cursor section)'); + + return 0; +} + +/** + * Handle start subcommand. + */ +async function handleStart(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + // Check auth first + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated) { + console.error(fail('Not authenticated. Run: ccs cursor auth')); + return 1; + } + + console.log(info(`Starting cursor daemon on port ${cursorConfig.port}...`)); + + const result = await startDaemon(cursorConfig); + + if (result.success) { + console.log(ok(`Daemon started (PID: ${result.pid})`)); + return 0; + } else { + console.error(fail(result.error || 'Failed to start daemon')); + return 1; + } +} + +/** + * Handle stop subcommand. + */ +async function handleStop(): Promise { + console.log(info('Stopping cursor daemon...')); + + const result = await stopDaemon(); + + if (result.success) { + console.log(ok('Daemon stopped')); + return 0; + } else { + console.error(fail(result.error || 'Failed to stop daemon')); + return 1; + } +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 50fc685b..16cd06f6 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -220,6 +220,25 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ] ); + // ═══════════════════════════════════════════════════════════════════════════ + // MAJOR SECTION 5: Cursor IDE Integration + // ═══════════════════════════════════════════════════════════════════════════ + printMajorSection( + 'Cursor IDE Integration', + [ + 'Use Cursor IDE with Claude Code via cursor proxy daemon', + 'Auto-detects token from Cursor installation', + ], + [ + ['ccs cursor ', 'Use Cursor IDE integration'], + ['ccs cursor auth', 'Import Cursor token'], + ['ccs cursor status', 'Show connection status'], + ['ccs cursor models', 'List available models'], + ['ccs cursor start', 'Start proxy daemon'], + ['ccs cursor stop', 'Stop proxy daemon'], + ] + ); + // ═══════════════════════════════════════════════════════════════════════════ // SUB-SECTIONS (simpler styling) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index f01de47c..65fe4c58 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -5,6 +5,72 @@ */ import { initUI, header, ok, fail, color } from '../utils/ui'; +import { + runCommandWithContract, + type CommandExecutionContract, +} from './command-execution-contract'; + +type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null; + +interface ShellCompletionParsedArgs { + targetShell: ShellTarget; + force: boolean; +} + +interface ShellCompletionInstallResult { + success: boolean; + alreadyInstalled?: boolean; + message?: string; + reload?: string; +} + +interface ShellCompletionInstallerLike { + install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult; +} + +export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs { + let targetShell: ShellTarget = null; + const force = args.includes('--force') || args.includes('-f'); + + if (args.includes('--bash')) targetShell = 'bash'; + else if (args.includes('--zsh')) targetShell = 'zsh'; + else if (args.includes('--fish')) targetShell = 'fish'; + else if (args.includes('--powershell')) targetShell = 'powershell'; + + return { targetShell, force }; +} + +export function createShellCompletionCommandContract( + installer: ShellCompletionInstallerLike +): CommandExecutionContract { + return { + parse: parseShellCompletionArgs, + validate: () => { + // No validation at this stage to preserve existing behavior exactly. + }, + execute: (parsed) => installer.install(parsed.targetShell, { force: parsed.force }), + render: (result, context) => { + if (result.alreadyInstalled && !context.parsedArgs.force) { + console.log(ok('Shell completion already installed')); + console.log(` Use ${color('--force', 'warning')} to reinstall`); + console.log(''); + return; + } + + console.log(ok('Shell completion installed successfully!')); + console.log(''); + console.log(result.message); + console.log(''); + console.log(color('To activate:', 'info')); + console.log(` ${result.reload}`); + console.log(''); + console.log(color('Then test:', 'info')); + console.log(' ccs # See available profiles'); + console.log(' ccs auth # See auth subcommands'); + console.log(''); + }, + }; +} /** * Handle shell completion command @@ -16,38 +82,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise # See available profiles'); - console.log(' ccs auth # See auth subcommands'); - console.log(''); + const contract = createShellCompletionCommandContract(installer); + await runCommandWithContract(args, contract); } catch (error) { const err = error as Error; console.error(fail(`Error: ${err.message}`)); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 7e53f4c3..8705d63a 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -11,6 +11,8 @@ export const RESERVED_PROFILE_NAMES = [ 'iflow', // Copilot API (GitHub Copilot proxy) 'copilot', + // Cursor IDE (Cursor proxy daemon) + 'cursor', // CLI commands and special names 'default', 'config', diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 0fecb6ff..0824597d 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -15,6 +15,7 @@ import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, + DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, @@ -26,6 +27,7 @@ import { DashboardAuthConfig, ImageAnalysisConfig, CLIPROXY_SUPPORTED_PROVIDERS, + CursorConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -313,6 +315,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, }, + // Cursor config - disabled by default, merge with defaults + cursor: { + enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }, // Global env - injected into all non-Claude subscription profiles global_env: { enabled: partial.global_env?.enabled ?? true, @@ -599,6 +608,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Cursor section (Cursor IDE proxy daemon) + if (config.cursor) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Cursor: Cursor IDE proxy daemon'); + lines.push('# Enables Cursor IDE integration via local proxy daemon.'); + lines.push('#'); + lines.push('# enabled: Enable/disable Cursor integration (default: false)'); + lines.push('# port: Port for cursor proxy daemon (default: 20129)'); + lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)'); + lines.push('# ghost_mode: Disable telemetry for privacy (default: true)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Global env section if (config.global_env) { lines.push('# ----------------------------------------------------------------------------'); @@ -940,3 +966,12 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } + +/** + * Get cursor configuration. + * Returns defaults if not configured. + */ +export function getCursorConfig(): CursorConfig { + const config = loadOrCreateUnifiedConfig(); + return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index f4c4eb44..a9faf6d6 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -291,6 +291,21 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Cursor IDE integration configuration. + * Enables Cursor IDE usage via cursor proxy daemon. + */ +export interface CursorConfig { + /** Enable Cursor integration (default: false) */ + enabled: boolean; + /** Port for cursor proxy daemon (default: 20129) */ + port: number; + /** Auto-start daemon when CCS starts (default: false) */ + auto_start: boolean; + /** Enable ghost mode to disable telemetry (default: true) */ + ghost_mode: boolean; +} + /** * Remote proxy configuration. * Connect to a remote CLIProxyAPI instance instead of spawning local binary. @@ -670,6 +685,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** Cursor IDE configuration (Cursor proxy daemon) */ + cursor?: CursorConfig; /** CLIProxy server configuration for remote/local mode */ cliproxy_server?: CliproxyServerConfig; /** Quota management configuration (v7+) */ @@ -697,6 +714,17 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default Cursor configuration. + * Disabled by default, ghost mode enabled for privacy. + */ +export const DEFAULT_CURSOR_CONFIG: CursorConfig = { + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, +}; + /** * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. @@ -769,6 +797,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + cursor: { ...DEFAULT_CURSOR_CONFIG }, cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts new file mode 100644 index 00000000..99fef7b2 --- /dev/null +++ b/src/cursor/cursor-daemon.ts @@ -0,0 +1,320 @@ +/** + * Cursor Daemon Manager + * + * Manages the cursor daemon lifecycle (start/stop/status). + * Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import type { CursorConfig, CursorDaemonStatus } from './types'; +import { getCcsDir } from '../utils/config-manager'; + +/** + * Get Cursor directory path. + */ +function getCursorDir(): string { + return path.join(getCcsDir(), 'cursor'); +} + +/** + * Get PID file path. + * Computed at runtime to respect CCS_HOME changes (e.g., in tests). + */ +function getPidFilePath(): string { + return path.join(getCursorDir(), 'daemon.pid'); +} + +/** + * Check if cursor daemon is running on the specified port. + * Uses 127.0.0.1 instead of localhost for more reliable local connections. + */ +export async function isDaemonRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/health', + method: 'GET', + timeout: 3000, + }, + (res) => { + res.resume(); // Drain response body + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Get daemon status. + */ +export async function getDaemonStatus(port: number): Promise { + const running = await isDaemonRunning(port); + const pid = getPidFromFile(); + + return { + running, + port, + pid: running ? (pid ?? undefined) : undefined, + }; +} + +/** + * Read PID from file. + */ +export function getPidFromFile(): number | null { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + const content = fs.readFileSync(pidFile, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Write PID to file. + */ +export function writePidToFile(pid: number): void { + const pidFile = getPidFilePath(); + try { + const dir = path.dirname(pidFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +/** + * Remove PID file. + */ +export function removePidFile(): void { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + } catch { + // Ignore errors + } +} + +/** + * Start the cursor daemon. + * + * @param config Cursor configuration + * @returns Promise that resolves when daemon is ready + */ +export async function startDaemon( + config: CursorConfig +): Promise<{ success: boolean; pid?: number; error?: string }> { + // Check if already running + if (await isDaemonRunning(config.port)) { + return { success: true, pid: getPidFromFile() ?? undefined }; + } + + // Validate port before interpolation (prevents injection) + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) { + return { success: false, error: `Invalid port: ${config.port}` }; + } + + // For now, create a simple structure that will be filled in later + // The actual server implementation will be added in a separate task + return new Promise((resolve) => { + let proc: ChildProcess; + let resolved = false; + + const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => { + if (resolved) return; + resolved = true; + if (checkTimeout) clearTimeout(checkTimeout); + if (!result.success) removePidFile(); + resolve(result); + }; + + let checkTimeout: NodeJS.Timeout | null = null; + + try { + // Spawn a placeholder Node.js process + // TODO: Replace with actual CursorExecutor-based server + const args = [ + '-e', + ` + const http = require('http'); + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200); + res.end('OK'); + } else if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [] })); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + server.listen(${config.port}, '127.0.0.1'); + `, + ]; + + // Append --ccs-daemon marker for PID validation in stopDaemon + proc = spawn(process.execPath, [...args, '--ccs-daemon'], { + stdio: 'ignore', + detached: true, + }); + + // Unref so parent can exit + proc.unref(); + + if (proc.pid) { + writePidToFile(proc.pid); + } + + // Wait for daemon to be ready (poll for up to 30 seconds) + let attempts = 0; + const maxAttempts = 30; + const pollHealth = async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + safeResolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + // Kill orphaned process + if (proc.pid) { + try { + process.kill(proc.pid, 'SIGTERM'); + } catch { + /* already dead */ + } + } + safeResolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } else { + checkTimeout = setTimeout(pollHealth, 1000); + } + }; + checkTimeout = setTimeout(pollHealth, 1000); + + proc.on('error', (err) => { + safeResolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + + proc.on('exit', (code, signal) => { + if (code === null) { + safeResolve({ + success: false, + error: `Daemon process was killed by signal ${signal}`, + }); + } else if (code === 0) { + safeResolve({ + success: false, + error: 'Daemon process exited unexpectedly with code 0', + }); + } else { + safeResolve({ + success: false, + error: `Daemon process exited with code ${code}`, + }); + } + }); + } catch (err) { + safeResolve({ + success: false, + error: `Failed to spawn daemon: ${(err as Error).message}`, + }); + } + }); +} + +/** + * Stop the cursor daemon. + */ +export async function stopDaemon(): Promise<{ success: boolean; error?: string }> { + const pid = getPidFromFile(); + + if (!pid) { + // No PID file — daemon is not running or was already stopped + return { success: true }; + } + + try { + // Verify the PID belongs to our daemon before signaling + try { + const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + if (!cmdline.includes('--ccs-daemon')) { + // PID was reused by an unrelated process + removePidFile(); + return { success: true }; + } + } catch { + // /proc not available (macOS/Windows) or process gone — proceed with kill + } + + // Send SIGTERM to the process + process.kill(pid, 'SIGTERM'); + + // Wait for process to exit (up to 5 seconds) + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + // Check if process still exists (kill(pid, 0) throws if not) + process.kill(pid, 0); + attempts++; + } catch { + // Process no longer exists + break; + } + } + + // Escalate to SIGKILL only if SIGTERM wait loop exhausted + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already dead — good + } + } + + removePidFile(); + return { success: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process doesn't exist + removePidFile(); + return { success: true }; + } + return { + success: false, + error: `Failed to stop daemon: ${error.message}`, + }; + } +} diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts new file mode 100644 index 00000000..cc74cb1e --- /dev/null +++ b/src/cursor/cursor-models.ts @@ -0,0 +1,189 @@ +/** + * Cursor Model Catalog + * + * Manages available models from Cursor IDE. + * Based on Cursor's supported models catalog. + */ + +import * as http from 'http'; +import type { CursorModel } from './types'; +import { isDaemonRunning } from './cursor-daemon'; + +/** Default daemon port */ +export const DEFAULT_CURSOR_PORT = 4242; + +/** Default model ID */ +export const DEFAULT_CURSOR_MODEL = 'gpt-4.1'; + +/** + * Default models available through Cursor IDE. + * Used as fallback when daemon is not reachable. + * Source: Cursor IDE supported models (Feb 2025) + */ +export const DEFAULT_CURSOR_MODELS: CursorModel[] = [ + // Anthropic Models + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: 'anthropic', + }, + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + }, + { + id: 'claude-opus-4', + name: 'Claude Opus 4', + provider: 'anthropic', + }, + + // OpenAI Models + { + id: 'gpt-4.1', + name: 'GPT-4.1', + provider: 'openai', + isDefault: true, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + provider: 'openai', + }, + { + id: 'o3-mini', + name: 'O3 Mini', + provider: 'openai', + }, + + // Google Models + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: 'google', + }, + + // Cursor Custom Models + { + id: 'cursor-small', + name: 'Cursor Small', + provider: 'cursor', + }, +]; + +/** + * Fetch available models from running cursor daemon. + * + * @param port The port cursor daemon is running on + * @returns List of available models + */ +export async function fetchModelsFromDaemon(port: number): Promise { + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (models: CursorModel[]) => { + if (resolved) return; + resolved = true; + resolve(models); + }; + + const req = http.request( + { + // Use 127.0.0.1 instead of localhost for more reliable local connections + hostname: '127.0.0.1', + port, + path: '/v1/models', + method: 'GET', + timeout: 5000, + }, + (res) => { + const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + if (data.length > MAX_BODY_SIZE) { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + + res.on('end', () => { + try { + const response = JSON.parse(data) as { data?: Array<{ id: string }> }; + if (response.data && Array.isArray(response.data)) { + const models: CursorModel[] = response.data.map((m) => ({ + id: m.id, + name: formatModelName(m.id), + provider: detectProvider(m.id), + isDefault: m.id === DEFAULT_CURSOR_MODEL, + })); + safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); + } else { + safeResolve(DEFAULT_CURSOR_MODELS); + } + } catch { + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + } + ); + + req.on('error', () => { + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + * Checks daemon health first to avoid 5s timeout when daemon is not running. + */ +export async function getAvailableModels(port: number): Promise { + if (!(await isDaemonRunning(port))) { + return DEFAULT_CURSOR_MODELS; + } + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + * Uses gpt-4.1 as it's commonly available. + */ +export function getDefaultModel(): string { + return DEFAULT_CURSOR_MODEL; +} + +/** + * Detect provider from model ID. + */ +export function detectProvider(modelId: string): string { + if (modelId.includes('claude')) return 'anthropic'; + if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai'; + if (modelId.includes('gemini')) return 'google'; + if (modelId.includes('cursor')) return 'cursor'; + return 'unknown'; +} + +/** + * Format model ID to human-readable name. + */ +export function formatModelName(modelId: string): string { + // Find model in catalog for metadata + const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId); + if (model) { + return model.name; + } + + // Fallback: convert kebab-case to title case + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts new file mode 100644 index 00000000..3647a612 --- /dev/null +++ b/src/cursor/index.ts @@ -0,0 +1,37 @@ +/** + * Cursor Module Index + * + * Central exports for Cursor IDE integration. + */ + +// Types +export * from './types'; + +// Auth +export { autoDetectTokens, saveCredentials, loadCredentials, checkAuthStatus } from './cursor-auth'; + +// Daemon +export { + isDaemonRunning, + getDaemonStatus, + startDaemon, + stopDaemon, + getPidFromFile, + writePidToFile, + removePidFile, +} from './cursor-daemon'; + +// Models +export { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, + detectProvider, + formatModelName, +} from './cursor-models'; + +// Executor +export { CursorExecutor } from './cursor-executor'; diff --git a/src/cursor/types.ts b/src/cursor/types.ts index 9418fe70..c16150cc 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -1,9 +1,18 @@ /** * Cursor IDE Type Definitions * - * TypeScript interfaces for the Cursor auth module. + * TypeScript interfaces for the Cursor module. */ +/** + * Cursor daemon configuration. + * Temporary interface until #521 adds cursor to unified config. + */ +export interface CursorConfig { + port: number; + model: string; +} + /** * Cursor authentication credentials */ @@ -49,3 +58,29 @@ export interface AutoDetectResult { /** Error message (if detection failed) */ error?: string; } + +/** + * Cursor daemon/process status + */ +export interface CursorDaemonStatus { + /** Whether daemon is running */ + running: boolean; + /** Port number daemon is listening on */ + port: number; + /** Process ID (if available) */ + pid?: number; +} + +/** + * Cursor AI model + */ +export interface CursorModel { + /** Model ID */ + id: string; + /** Display name */ + name: string; + /** Provider (e.g., 'openai', 'anthropic') */ + provider: string; + /** Whether this is the default model */ + isDefault?: boolean; +} diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index cb8aa98a..85d0caf6 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -146,7 +146,7 @@ export async function parseProjectDirectory(projectDir: string): Promise f.endsWith('.jsonl')); // Parse files sequentially within a project to avoid too many open handles diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 2e8b4e84..20362ff6 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -17,28 +17,20 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; const router = Router(); const registry = new ProfileRegistry(); -/** Valid CLIProxy providers - derived from canonical CLIPROXY_PROFILES */ -const VALID_PROVIDERS: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; - -/** Check if provider is valid */ -function isValidProvider(provider: string): provider is CLIProxyProvider { - return VALID_PROVIDERS.includes(provider as CLIProxyProvider); -} - /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { const colonIndex = key.indexOf(':'); if (colonIndex === -1) return null; - const provider = key.slice(0, colonIndex) as CLIProxyProvider; + const provider = key.slice(0, colonIndex); const accountId = key.slice(colonIndex + 1); - if (!isValidProvider(provider) || !accountId) return null; + if (!isCLIProxyProvider(provider) || !accountId) return null; return { provider, accountId }; } @@ -239,7 +231,7 @@ router.post('/bulk-pause', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -276,7 +268,7 @@ router.post('/bulk-resume', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -313,7 +305,7 @@ router.post('/solo', async (req: Request, res: Response): Promise => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts new file mode 100644 index 00000000..5f7d6a27 --- /dev/null +++ b/src/web-server/routes/cursor-routes.ts @@ -0,0 +1,181 @@ +/** + * Cursor Routes - Cursor IDE integration via cursor proxy daemon + */ + +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + checkAuthStatus, + autoDetectTokens, + saveCredentials, + validateToken, +} from '../../cursor/cursor-auth'; +import { getCursorConfig } from '../../config/unified-config-loader'; +import cursorSettingsRoutes from './cursor-settings-routes'; + +const router = Router(); + +// Mount settings sub-routes +router.use('/settings', cursorSettingsRoutes); + +/** + * Get daemon status + * TODO: Implement in cursor-executor.ts (#520) + */ +async function getDaemonStatus(port: number): Promise<{ running: boolean; port?: number }> { + // Stub - will be implemented in #520 + return { running: false, port }; +} + +/** + * Get available models + * TODO: Implement in cursor-executor.ts (#520) + */ +async function getAvailableModels(): Promise { + // Stub - will be implemented in #520 + return []; // TODO: populated by cursor-models.ts (#520) +} + +/** + * Start daemon + * TODO: Implement in cursor-executor.ts (#520) + */ +async function startDaemon( + port: number, + ghostMode: boolean +): Promise<{ success: boolean; message: string }> { + // Stub - will be implemented in #520 + return { + success: false, + message: `Daemon start not implemented (port: ${port}, ghost: ${ghostMode})`, + }; +} + +/** + * Stop daemon + * TODO: Implement in cursor-executor.ts (#520) + */ +async function stopDaemon(): Promise<{ success: boolean; message: string }> { + // Stub - will be implemented in #520 + return { success: false, message: 'Daemon stop not implemented' }; +} + +/** + * GET /api/cursor/status - Get Cursor status (auth + daemon) + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + res.json({ + enabled: cursorConfig.enabled, + authenticated: authStatus.authenticated, + daemon_running: daemonStatus.running, + port: cursorConfig.port, + auto_start: cursorConfig.auto_start, + ghost_mode: cursorConfig.ghost_mode, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/import - Import Cursor token manually + */ +router.post('/auth/import', async (req: Request, res: Response): Promise => { + try { + const { accessToken, machineId } = req.body; + if (!accessToken || !machineId) { + res.status(400).json({ error: 'Missing accessToken or machineId' }); + return; + } + + // Validate token format + if (!validateToken(accessToken, machineId)) { + res.status(400).json({ error: 'Invalid token or machine ID format' }); + return; + } + + // Save credentials + saveCredentials({ + accessToken, + machineId, + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token imported successfully' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/auto-detect - Auto-detect token from SQLite + */ +router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { + try { + const result = autoDetectTokens(); + + if (!result.found || !result.accessToken || !result.machineId) { + res.status(404).json({ error: result.error ?? 'Token not found' }); + return; + } + + // Save credentials + saveCredentials({ + accessToken: result.accessToken, + machineId: result.machineId, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token auto-detected and imported' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/models - List available models + */ +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + const models = await getAvailableModels(); + res.json({ models }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/daemon/start - Start cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/start', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const result = await startDaemon(cursorConfig.port, cursorConfig.ghost_mode); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/daemon/stop - Stop cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopDaemon(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts new file mode 100644 index 00000000..685b643c --- /dev/null +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -0,0 +1,170 @@ +/** + * Cursor Settings Routes - Settings editor and raw settings for Cursor IDE + */ + +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + getCursorConfig, +} from '../../config/unified-config-loader'; + +const router = Router(); + +/** + * GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode) + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const cursorConfig = getCursorConfig(); + res.json(cursorConfig); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings - Update cursor config + */ +router.put('/', (req: Request, res: Response): void => { + try { + const updates = req.body; + + // Reject non-object bodies + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + res.status(400).json({ error: 'Request body must be a JSON object' }); + return; + } + + // Validate input types + if ('port' in updates) { + if (typeof updates.port !== 'number' || !Number.isInteger(updates.port)) { + res.status(400).json({ error: 'port must be an integer' }); + return; + } + if (updates.port < 1 || updates.port > 65535) { + res.status(400).json({ error: 'port must be between 1 and 65535' }); + return; + } + } + if ('enabled' in updates && typeof updates.enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + if ('auto_start' in updates && typeof updates.auto_start !== 'boolean') { + res.status(400).json({ error: 'auto_start must be a boolean' }); + return; + } + if ('ghost_mode' in updates && typeof updates.ghost_mode !== 'boolean') { + res.status(400).json({ error: 'ghost_mode must be a boolean' }); + return; + } + + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + // Only known fields are merged — unknown properties are ignored + config.cursor = { + enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: + updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: + updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }; + + saveUnifiedConfig(config); + res.json({ success: true, cursor: config.cursor }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/settings/raw - Get raw cursor.settings.json + * Returns the raw JSON content for editing in the code editor + */ +router.get('/raw', (_req: Request, res: Response): void => { + try { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const cursorConfig = getCursorConfig(); + + // If file doesn't exist, return default structure + if (!fs.existsSync(settingsPath)) { + // Create settings structure matching Cursor pattern + // Use 127.0.0.1 instead of localhost for more reliable local connections + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + res.json({ + settings: defaultSettings, + mtime: Date.now(), + path: settingsPath, + exists: false, + }); + return; + } + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + const stat = fs.statSync(settingsPath); + + res.json({ + settings, + mtime: stat.mtimeMs, + path: settingsPath, + exists: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings/raw - Save raw cursor.settings.json + * Saves the raw JSON content from the code editor + */ +router.put('/raw', (req: Request, res: Response): void => { + try { + const { settings, expectedMtime } = req.body; + + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + res.status(400).json({ error: 'settings must be a JSON object' }); + return; + } + + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + + // Check for conflict if file exists and expectedMtime provided + if (fs.existsSync(settingsPath) && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { + res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); + return; + } + } + + // Write settings file atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + // TODO: Sync raw settings back to unified config when cursor-daemon is integrated (#520) + + const stat = fs.statSync(settingsPath); + res.json({ success: true, mtime: stat.mtimeMs }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index c488a9c9..f9517635 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -20,6 +20,7 @@ import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; +import cursorRoutes from './cursor-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -63,6 +64,9 @@ apiRoutes.use('/websearch', websearchRoutes); // ==================== Copilot ==================== apiRoutes.use('/copilot', copilotRoutes); +// ==================== Cursor ==================== +apiRoutes.use('/cursor', cursorRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts new file mode 100644 index 00000000..2e32cb27 --- /dev/null +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getProviderDisplayName, + getProvidersByOAuthFlow, + isCLIProxyProvider, + mapExternalProviderName, +} from '../../../src/cliproxy/provider-capabilities'; + +describe('provider-capabilities', () => { + it('keeps canonical provider IDs backward-compatible', () => { + expect(CLIPROXY_PROVIDER_IDS).toEqual([ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', + ]); + }); + + it('validates provider IDs', () => { + expect(isCLIProxyProvider('gemini')).toBe(true); + expect(isCLIProxyProvider('ghcp')).toBe(true); + expect(isCLIProxyProvider('not-a-provider')).toBe(false); + expect(isCLIProxyProvider('Gemini')).toBe(false); + }); + + it('returns providers by OAuth flow capability', () => { + expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'ghcp']); + expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ + 'gemini', + 'codex', + 'agy', + 'iflow', + 'kiro', + 'claude', + ]); + }); + + it('maps external provider aliases to canonical IDs', () => { + expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); + expect(mapExternalProviderName('antigravity')).toBe('agy'); + expect(mapExternalProviderName('codewhisperer')).toBe('kiro'); + expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); + expect(mapExternalProviderName('copilot')).toBe('ghcp'); + expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName('unknown-provider')).toBeNull(); + }); + + it('exposes callback port and display name capabilities', () => { + expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(getProviderDisplayName('agy')).toBe('AntiGravity'); + }); +}); diff --git a/tests/unit/commands/command-execution-contract.test.ts b/tests/unit/commands/command-execution-contract.test.ts new file mode 100644 index 00000000..e9cd4eb6 --- /dev/null +++ b/tests/unit/commands/command-execution-contract.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'bun:test'; +import { runCommandWithContract } from '../../../src/commands/command-execution-contract'; + +describe('runCommandWithContract', () => { + it('runs parse -> validate -> execute -> render in order', async () => { + const lifecycle: string[] = []; + + const result = await runCommandWithContract(['--flag'], { + parse: (rawArgs) => { + lifecycle.push('parse'); + expect(rawArgs).toEqual(['--flag']); + return { parsed: true, value: rawArgs[0] }; + }, + validate: (parsedArgs) => { + lifecycle.push('validate'); + expect(parsedArgs).toEqual({ parsed: true, value: '--flag' }); + }, + execute: async (parsedArgs) => { + lifecycle.push('execute'); + return { output: parsedArgs.value.toUpperCase() }; + }, + render: (executionResult, context) => { + lifecycle.push('render'); + expect(executionResult).toEqual({ output: '--FLAG' }); + expect(context.rawArgs).toEqual(['--flag']); + expect(context.parsedArgs).toEqual({ parsed: true, value: '--flag' }); + }, + }); + + expect(lifecycle).toEqual(['parse', 'validate', 'execute', 'render']); + expect(result.parsedArgs).toEqual({ parsed: true, value: '--flag' }); + expect(result.result).toEqual({ output: '--FLAG' }); + }); + + it('short-circuits after validate failure', async () => { + const lifecycle: string[] = []; + + const promise = runCommandWithContract([], { + parse: () => { + lifecycle.push('parse'); + return { valid: false }; + }, + validate: () => { + lifecycle.push('validate'); + throw new Error('validation failed'); + }, + execute: () => { + lifecycle.push('execute'); + return { ok: true }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + await expect(promise).rejects.toThrow('validation failed'); + expect(lifecycle).toEqual(['parse', 'validate']); + }); + + it('awaits async validate before execute and render', async () => { + const lifecycle: string[] = []; + + const result = await runCommandWithContract(['--flag'], { + parse: (rawArgs) => { + lifecycle.push('parse'); + return { parsed: true, value: rawArgs[0] }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:end'); + }, + execute: (parsedArgs) => { + lifecycle.push('execute'); + return { output: parsedArgs.value.toUpperCase() }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:end', 'execute', 'render']); + expect(result.result).toEqual({ output: '--FLAG' }); + }); + + it('short-circuits after async validate failure', async () => { + const lifecycle: string[] = []; + + const promise = runCommandWithContract([], { + parse: () => { + lifecycle.push('parse'); + return { valid: false }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:reject'); + throw new Error('async validation failed'); + }, + execute: () => { + lifecycle.push('execute'); + return { ok: true }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + await expect(promise).rejects.toThrow('async validation failed'); + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:reject']); + }); +}); diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts new file mode 100644 index 00000000..f9c93a53 --- /dev/null +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, mock } from 'bun:test'; + +type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null; + +interface InstallResult { + success: boolean; + alreadyInstalled?: boolean; + message?: string; + reload?: string; +} + +interface InstallCall { + shell: ShellTarget; + options: { force: boolean }; +} + +const installCalls: InstallCall[] = []; +let installResult: InstallResult = { + success: true, + message: 'Added to ~/.zshrc', + reload: 'source ~/.zshrc', +}; +let installError: Error | null = null; + +mock.module('../../../src/utils/shell-completion', () => ({ + ShellCompletionInstaller: class { + install(shell: ShellTarget, options: { force: boolean }): InstallResult { + installCalls.push({ shell, options }); + if (installError) { + throw installError; + } + return installResult; + } + }, +})); + +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ''); +} + +let handleShellCompletionCommand: (args: string[]) => Promise; +let parseShellCompletionArgs: (args: string[]) => { targetShell: ShellTarget; force: boolean }; +let originalConsoleLog: typeof console.log; +let originalConsoleError: typeof console.error; +let originalProcessExit: typeof process.exit; +let logLines: string[] = []; +let errorLines: string[] = []; + +beforeAll(async () => { + const mod = await import('../../../src/commands/shell-completion-command'); + handleShellCompletionCommand = mod.handleShellCompletionCommand; + parseShellCompletionArgs = mod.parseShellCompletionArgs; +}); + +beforeEach(() => { + installCalls.length = 0; + installError = null; + installResult = { + success: true, + message: 'Added to ~/.zshrc', + reload: 'source ~/.zshrc', + }; + + logLines = []; + errorLines = []; + + originalConsoleLog = console.log; + originalConsoleError = console.error; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorLines.push(args.map(String).join(' ')); + }; + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; +}); + +afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exit = originalProcessExit; +}); + +describe('shell-completion command', () => { + it('parses shell flags and force flag', () => { + const parsed = parseShellCompletionArgs(['--zsh', '--force']); + expect(parsed).toEqual({ targetShell: 'zsh', force: true }); + }); + + it('preserves existing priority when multiple shell flags are present', () => { + const parsed = parseShellCompletionArgs(['--zsh', '--bash']); + expect(parsed).toEqual({ targetShell: 'bash', force: false }); + }); + + it('executes installer with parsed args and renders success output', async () => { + await handleShellCompletionCommand(['--zsh', '--force']); + + expect(installCalls).toHaveLength(1); + expect(installCalls[0]).toEqual({ + shell: 'zsh', + options: { force: true }, + }); + + expect(logLines.some((line) => line.includes('Shell completion installed successfully!'))).toBe( + true + ); + expect(logLines.some((line) => line.includes('source ~/.zshrc'))).toBe(true); + }); + + it('renders already-installed output without forcing reinstall', async () => { + installResult = { + success: true, + alreadyInstalled: true, + message: 'Updated completion files', + reload: 'source ~/.zshrc', + }; + + await handleShellCompletionCommand(['--zsh']); + + const plainLogLines = logLines.map(stripAnsi); + expect(plainLogLines.some((line) => line.includes('Shell completion already installed'))).toBe( + true + ); + expect(plainLogLines.some((line) => line.includes('Use --force to reinstall'))).toBe(true); + expect(plainLogLines.some((line) => line.includes('installed successfully!'))).toBe(false); + }); + + it('prints usage and exits with code 1 on installer error', async () => { + installError = new Error('boom'); + + await expect(handleShellCompletionCommand([])).rejects.toThrow('process.exit(1)'); + const plainErrorLines = errorLines.map(stripAnsi); + expect(plainErrorLines.some((line) => line.includes('Error: boom'))).toBe(true); + expect(plainErrorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe(true); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts new file mode 100644 index 00000000..e6480b9d --- /dev/null +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for Cursor daemon module + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + getPidFromFile, + writePidToFile, + removePidFile, + isDaemonRunning, + getDaemonStatus, + stopDaemon, + startDaemon, +} from '../../../src/cursor/cursor-daemon'; +import { getCcsDir } from '../../../src/utils/config-manager'; +import { handleCursorCommand } from '../../../src/commands/cursor-command'; + +// Test isolation +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-test-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Cleanup temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +// Use getCcsDir() for consistent path resolution with production code +const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); + +describe('getPidFromFile', () => { + it('returns null when no PID file exists', () => { + expect(getPidFromFile()).toBeNull(); + }); + + it('returns PID when valid PID file exists', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), '12345'); + + expect(getPidFromFile()).toBe(12345); + }); + + it('returns null when PID file contains invalid content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), 'not-a-number'); + + expect(getPidFromFile()).toBeNull(); + }); + + it('trims whitespace from PID file content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), ' 42 \n'); + + expect(getPidFromFile()).toBe(42); + }); +}); + +describe('writePidToFile', () => { + it('creates PID file with correct content', () => { + writePidToFile(12345); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('12345'); + }); + + it('creates cursor directory if it does not exist', () => { + const dir = getTestCursorDir(); + expect(fs.existsSync(dir)).toBe(false); + + writePidToFile(999); + + expect(fs.existsSync(dir)).toBe(true); + }); + + it('overwrites existing PID file', () => { + writePidToFile(111); + writePidToFile(222); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('222'); + }); +}); + +describe('removePidFile', () => { + it('removes existing PID file', () => { + writePidToFile(12345); + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + + removePidFile(); + + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('does not throw when PID file does not exist', () => { + expect(() => removePidFile()).not.toThrow(); + }); +}); + +describe('startDaemon', () => { + it('rejects invalid port (0)', async () => { + const result = await startDaemon({ port: 0, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects invalid port (65536)', async () => { + const result = await startDaemon({ port: 65536, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects non-integer port', async () => { + const result = await startDaemon({ port: 3.14, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it( + 'starts and stops daemon successfully', + async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, model: 'test' }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + + // Verify health + const running = await isDaemonRunning(port); + expect(running).toBe(true); + + // Stop + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); + + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, + 35000 + ); +}); + +describe('isDaemonRunning', () => { + it('returns false when no daemon is running on port', async () => { + // Use a port that should not have anything running + const result = await isDaemonRunning(19999); + expect(result).toBe(false); + }); +}); + +describe('getDaemonStatus', () => { + it('returns status with running=false when no daemon running', async () => { + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); + + it('returns status with pid when PID file exists but daemon not running', async () => { + writePidToFile(99999); + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); +}); + +describe('stopDaemon', () => { + it('returns success when no PID file exists', async () => { + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('returns success when PID refers to non-existent process', async () => { + // Write a PID that doesn't exist + writePidToFile(999999); + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + + // PID file should be removed + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(false); + }); +}); + +describe('handleCursorCommand', () => { + it('returns exit code 1 for unknown subcommand', async () => { + const exitCode = await handleCursorCommand(['nonexistent']); + expect(exitCode).toBe(1); + }); +}); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts new file mode 100644 index 00000000..f998d2b6 --- /dev/null +++ b/tests/unit/cursor/cursor-models.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for Cursor models module + */ + +import { describe, it, expect } from 'bun:test'; +import { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + getDefaultModel, + detectProvider, + formatModelName, + fetchModelsFromDaemon, +} from '../../../src/cursor/cursor-models'; + +describe('DEFAULT_CURSOR_MODELS', () => { + it('contains models from multiple providers', () => { + const providers = new Set(DEFAULT_CURSOR_MODELS.map((m) => m.provider)); + expect(providers.has('anthropic')).toBe(true); + expect(providers.has('openai')).toBe(true); + expect(providers.has('google')).toBe(true); + }); + + it('has exactly one default model', () => { + const defaults = DEFAULT_CURSOR_MODELS.filter((m) => m.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].id).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('DEFAULT_CURSOR_PORT', () => { + it('is 4242', () => { + expect(DEFAULT_CURSOR_PORT).toBe(4242); + }); +}); + +describe('DEFAULT_CURSOR_MODEL', () => { + it('is gpt-4.1', () => { + expect(DEFAULT_CURSOR_MODEL).toBe('gpt-4.1'); + }); +}); + +describe('getDefaultModel', () => { + it('returns the default model constant', () => { + expect(getDefaultModel()).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('detectProvider', () => { + it('detects anthropic models', () => { + expect(detectProvider('claude-sonnet-4')).toBe('anthropic'); + expect(detectProvider('claude-opus-4')).toBe('anthropic'); + }); + + it('detects openai models', () => { + expect(detectProvider('gpt-4.1')).toBe('openai'); + expect(detectProvider('gpt-5-mini')).toBe('openai'); + expect(detectProvider('o3-mini')).toBe('openai'); + }); + + it('detects o1 and o4 models as openai', () => { + expect(detectProvider('o1')).toBe('openai'); + expect(detectProvider('o1-preview')).toBe('openai'); + expect(detectProvider('o4-mini')).toBe('openai'); + }); + + it('detects google models', () => { + expect(detectProvider('gemini-2.5-pro')).toBe('google'); + }); + + it('detects cursor models', () => { + expect(detectProvider('cursor-small')).toBe('cursor'); + }); + + it('defaults to unknown for unrecognized models', () => { + expect(detectProvider('unknown-model')).toBe('unknown'); + }); +}); + +describe('formatModelName', () => { + it('returns catalog name for known models', () => { + expect(formatModelName('claude-sonnet-4')).toBe('Claude Sonnet 4'); + expect(formatModelName('gpt-4.1')).toBe('GPT-4.1'); + }); + + it('converts kebab-case to title case for unknown models', () => { + expect(formatModelName('my-custom-model')).toBe('My Custom Model'); + }); +}); + +describe('fetchModelsFromDaemon', () => { + it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => { + // Use a port that nothing is listening on + const unreachablePort = 9999; + const models = await fetchModelsFromDaemon(unreachablePort); + + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + }); +}); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index 5fa00305..a4b6a9e2 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -2,7 +2,7 @@ * Unit tests for JSONL Parser */ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { describe, expect, test, beforeEach, afterEach, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -254,6 +254,19 @@ describe('parseProjectDirectory', () => { const entries = await parseProjectDirectory('/nonexistent/dir'); expect(entries.length).toBe(0); }); + + test('returns empty array when directory read fails', async () => { + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + const readdirSpy = spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('EACCES')); + + try { + const entries = await parseProjectDirectory('/protected/dir'); + expect(entries).toEqual([]); + } finally { + existsSyncSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); }); describe('findProjectDirectories', () => { diff --git a/tests/unit/scripts/hardening-inventory.test.ts b/tests/unit/scripts/hardening-inventory.test.ts new file mode 100644 index 00000000..b5f02281 --- /dev/null +++ b/tests/unit/scripts/hardening-inventory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; + +const { collectSyncCallSites } = require('../../../scripts/hardening-inventory.js'); + +describe('hardening-inventory sync call scanning', () => { + test('ignores sync-call names inside regex literals after else', () => { + const source = [ + 'if (enabled) {', + ' run();', + '} else /fs\\.readFileSync\\(/.test("pattern");', + ].join('\n'); + + const result = collectSyncCallSites(source); + expect(result.count).toBe(0); + }); + + test('ignores sync-call names inside regex literals after do', () => { + const source = 'do /fs\\.writeFileSync\\(/.test("pattern"); while (false);'; + const result = collectSyncCallSites(source); + + expect(result.count).toBe(0); + }); + + test('still counts real sync fs call sites', () => { + const source = [ + 'if (enabled) {', + ' run();', + '} else /fs\\.readFileSync\\(/.test("pattern");', + 'fs.readFileSync("file.txt", "utf8");', + ].join('\n'); + + const result = collectSyncCallSites(source); + expect(result.count).toBe(1); + expect(result.calls).toEqual(['readFileSync']); + }); +}); diff --git a/tests/unit/web-server/cursor-settings-routes.test.ts b/tests/unit/web-server/cursor-settings-routes.test.ts new file mode 100644 index 00000000..13a05dfa --- /dev/null +++ b/tests/unit/web-server/cursor-settings-routes.test.ts @@ -0,0 +1,300 @@ +/** + * Cursor Settings Routes Tests + * Tests for Cursor configuration API endpoints. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Setup test environment BEFORE any imports +const TEST_CCS_DIR = path.join(os.tmpdir(), `ccs-test-cursor-settings-${Date.now()}`); +process.env.CCS_HOME = TEST_CCS_DIR; + +// Import after setting env var +import type { CursorConfig } from '../../../src/config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { getCcsDir } from '../../../src/utils/config-manager'; + +describe('Cursor Settings Routes Logic', () => { + beforeEach(() => { + // Ensure test directory exists + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(TEST_CCS_DIR)) { + fs.rmSync(TEST_CCS_DIR, { recursive: true, force: true }); + } + }); + + describe('PUT /settings validation logic', () => { + it('validates null body', () => { + const updates = null; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates non-object body', () => { + const updates = 'string'; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates array body', () => { + const updates = [1, 2, 3]; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates valid object', () => { + const updates = { port: 4000 }; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(true); + }); + + it('validates integer port', () => { + const port = 4000; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(true); + }); + + it('rejects non-integer port', () => { + const port = 3.14; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(false); + }); + + it('validates port range (valid)', () => { + const port = 3000; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(true); + }); + + it('validates port range (below)', () => { + const port = 0; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates port range (above)', () => { + const port = 65536; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates boolean auto_start', () => { + const auto_start = true; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean auto_start', () => { + const auto_start = 'yes'; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(false); + }); + + it('validates boolean ghost_mode', () => { + const ghost_mode = false; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean ghost_mode', () => { + const ghost_mode = 1; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(false); + }); + }); + + describe('PUT /settings whitelist merge pattern', () => { + it('merges known fields only (ignores unknown)', () => { + const config = loadOrCreateUnifiedConfig(); + const updates = { + port: 5000, + malicious_key: 'should be ignored', + another_unknown: true, + }; + + // Simulate the whitelist merge from the route + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(5000); + expect(cursorConfig).not.toHaveProperty('malicious_key'); + expect(cursorConfig).not.toHaveProperty('another_unknown'); + }); + + it('updates port only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { port: 4000 }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(4000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates auto_start only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { auto_start: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(true); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates ghost_mode only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { ghost_mode: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(true); + }); + }); + + describe('GET /settings/raw logic', () => { + it('returns defaults when file does not exist', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(false); + + const config = loadOrCreateUnifiedConfig(); + const cursorPort = config.cursor?.port ?? 3000; + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorPort}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + expect(defaultSettings.env.ANTHROPIC_BASE_URL).toContain('http://127.0.0.1:'); + expect(defaultSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + }); + + it('reads existing file', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000', + ANTHROPIC_AUTH_TOKEN: 'test-token', + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(testSettings, null, 2)); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(true); + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const parsed = JSON.parse(content); + + expect(parsed).toEqual(testSettings); + }); + }); + + describe('PUT /settings/raw validation logic', () => { + it('validates missing settings field', () => { + const body: { expectedMtime: number; settings?: unknown } = { expectedMtime: Date.now() }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates non-object settings', () => { + const body = { settings: 'not an object' }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates valid settings', () => { + const body = { settings: { env: { test: 'value' } } }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(true); + }); + + it('writes settings file atomically', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:5000', + ANTHROPIC_AUTH_TOKEN: 'new-token', + }, + }; + + // Simulate atomic write + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(testSettings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const exists = fs.existsSync(settingsPath); + expect(exists).toBe(true); + + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(written).toEqual(testSettings); + }); + + it('detects mtime conflict', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs - 5000; // 5 seconds in the past + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(true); + }); + + it('allows write when mtime matches', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs; + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(false); + }); + }); +});