mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-17 00:24:57 +00:00
chore(merge): resolve conflicts with origin/dev
This commit is contained in:
@@ -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()` |
|
||||
|
||||
@@ -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` |
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"sourceDirectory": "src",
|
||||
"largeFileThresholdLoc": 350,
|
||||
"typeScriptFileCount": 338,
|
||||
"locInSrc": 65869,
|
||||
"processExitReferenceCount": 152,
|
||||
"synchronousFsApiReferenceCount": 842,
|
||||
"largeFileCountOver350Loc": 52
|
||||
}
|
||||
+28
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -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*|(?<![\\w$.]))(${SYNC_CALL_NAMES.join('|')})\\s*\\(`,
|
||||
'g'
|
||||
);
|
||||
const LEGACY_MARKER_REGEX =
|
||||
/(?:\blegacy\b|\bshim\b|backward compatibility|backwards compatibility|compatibility layer|deprecated.*re-export|re-export.*compatibility)/i;
|
||||
const REGEX_LITERAL_KEYWORDS = new Set([
|
||||
'return',
|
||||
'throw',
|
||||
'case',
|
||||
'else',
|
||||
'do',
|
||||
'delete',
|
||||
'void',
|
||||
'typeof',
|
||||
'instanceof',
|
||||
'in',
|
||||
'of',
|
||||
'yield',
|
||||
'await',
|
||||
'new',
|
||||
]);
|
||||
|
||||
function toPosixPath(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function relativePath(filePath) {
|
||||
return toPosixPath(path.relative(ROOT_DIR, filePath));
|
||||
}
|
||||
|
||||
function isSourceFile(filePath) {
|
||||
return SOURCE_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function isHotpath(filePath) {
|
||||
return HOTPATH_PATTERNS.some((pattern) => 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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -532,6 +532,14 @@ async function main(): Promise<void> {
|
||||
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 = [
|
||||
|
||||
@@ -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<CLIProxyProvider, ProviderCapabilities> = {
|
||||
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<string, CLIProxyProvider> = (() => {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Remot
|
||||
* @param files Array of auth files from remote API
|
||||
*/
|
||||
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
|
||||
const byProvider = new Map<string, RemoteAuthFile[]>();
|
||||
const byProvider = new Map<CLIProxyProvider, RemoteAuthFile[]>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Command Execution Contract
|
||||
*
|
||||
* Standardized lifecycle: parse -> validate -> execute -> render
|
||||
* for CLI command handlers.
|
||||
*/
|
||||
|
||||
export interface CommandExecutionContract<TParsedArgs, TExecutionResult> {
|
||||
parse(rawArgs: string[]): TParsedArgs;
|
||||
validate(parsedArgs: TParsedArgs): void | Promise<void>;
|
||||
execute(parsedArgs: TParsedArgs): Promise<TExecutionResult> | TExecutionResult;
|
||||
render(
|
||||
result: TExecutionResult,
|
||||
context: { rawArgs: string[]; parsedArgs: TParsedArgs }
|
||||
): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command through the standard lifecycle.
|
||||
*/
|
||||
export async function runCommandWithContract<TParsedArgs, TExecutionResult>(
|
||||
rawArgs: string[],
|
||||
contract: CommandExecutionContract<TParsedArgs, TExecutionResult>
|
||||
): 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 };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Cursor CLI Command
|
||||
*
|
||||
* Handles `ccs cursor <subcommand>` 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<number> {
|
||||
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 <subcommand>');
|
||||
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<number> {
|
||||
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<number> {
|
||||
// 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<number> {
|
||||
// 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<number> {
|
||||
// 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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <cmd>', '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)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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<ShellCompletionParsedArgs, ShellCompletionInstallResult> {
|
||||
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 <TAB> # See available profiles');
|
||||
console.log(' ccs auth <TAB> # See auth subcommands');
|
||||
console.log('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle shell completion command
|
||||
@@ -16,38 +82,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise<void
|
||||
console.log(header('Shell Completion Installer'));
|
||||
console.log('');
|
||||
|
||||
// Parse flags
|
||||
let targetShell: string | null = 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';
|
||||
|
||||
try {
|
||||
const installer = new ShellCompletionInstaller();
|
||||
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null, {
|
||||
force,
|
||||
});
|
||||
|
||||
if (result.alreadyInstalled && !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 <TAB> # See available profiles');
|
||||
console.log(' ccs auth <TAB> # 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}`));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>): 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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<CursorDaemonStatus> {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<CursorModel[]> {
|
||||
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<CursorModel[]> {
|
||||
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(' ');
|
||||
}
|
||||
@@ -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';
|
||||
+36
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function parseProjectDirectory(projectDir: string): Promise<RawUsag
|
||||
const projectPath = path.basename(projectDir).replace(/-/g, '/');
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(projectDir);
|
||||
const files = await fs.promises.readdir(projectDir);
|
||||
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
|
||||
|
||||
// Parse files sequentially within a project to avoid too many open handles
|
||||
|
||||
@@ -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<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidProvider(provider)) {
|
||||
if (!isCLIProxyProvider(provider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
try {
|
||||
const result = await stopDaemon();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user