mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
Merge pull request #329 from kaitranntt/dev
feat(release): v7.20.0 - persist command and dashboard auth
This commit is contained in:
@@ -69,8 +69,18 @@ jobs:
|
||||
|
||||
# CLIProxy environment for model routing
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: http://localhost:8317
|
||||
REVIEW_MODEL: gemini-claude-opus-4-5-thinking
|
||||
ANTHROPIC_BASE_URL: http://localhost:8317
|
||||
ANTHROPIC_AUTH_TOKEN: ccs-internal-managed
|
||||
ANTHROPIC_MODEL: gemini-claude-opus-4-5-thinking
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-5-thinking
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5
|
||||
DISABLE_BUG_COMMAND: "1"
|
||||
DISABLE_ERROR_REPORTING: "1"
|
||||
DISABLE_TELEMETRY: "1"
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "64000"
|
||||
MAX_THINKING_TOKENS: "32000"
|
||||
|
||||
steps:
|
||||
- name: Clear Claude install locks
|
||||
@@ -148,15 +158,17 @@ jobs:
|
||||
> 🤖 Reviewed by `${{ env.REVIEW_MODEL }}`
|
||||
|
||||
## IMPORTANT: Posting the Review
|
||||
After completing your analysis, post the review as a PR comment using:
|
||||
```bash
|
||||
gh pr comment ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} --body "YOUR_REVIEW"
|
||||
```
|
||||
You may use heredocs, pipes, or --body-file as needed for multi-line content.
|
||||
After completing your analysis, post the review as a PR comment.
|
||||
|
||||
REQUIRED METHOD (use Write tool + --body-file):
|
||||
1. Write your review to /tmp/pr_review.md using the Write tool
|
||||
2. Post with: gh pr comment ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} --body-file /tmp/pr_review.md
|
||||
|
||||
DO NOT use inline --body with multiline content - it will fail pattern matching.
|
||||
|
||||
claude_args: |
|
||||
--model ${{ env.REVIEW_MODEL }}
|
||||
--allowedTools "Bash(gh pr comment *),Bash(gh pr diff *),Bash(gh pr view *),Bash(echo *),Bash(cat *),Read,Glob,Grep"
|
||||
--allowedTools "Bash(gh pr comment *),Bash(gh pr diff *),Bash(gh pr view *),Bash(echo *),Write,Read,Glob,Grep"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Add success reaction
|
||||
|
||||
@@ -98,7 +98,7 @@ bun run validate # Step 3: Final check (must pass)
|
||||
|
||||
1. **NO EMOJIS** - ASCII only: [OK], [!], [X], [i]
|
||||
2. **TTY-aware colors** - Respect NO_COLOR env var
|
||||
3. **Non-invasive** - NEVER modify `~/.claude/settings.json`
|
||||
3. **Non-invasive** - NEVER modify `~/.claude/settings.json` without explicit user request and confirmation (exception: `ccs persist` command)
|
||||
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
|
||||
5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below)
|
||||
6. **Idempotent** - All install operations safe to run multiple times
|
||||
@@ -116,6 +116,7 @@ bun run validate # Step 3: Final check (must pass)
|
||||
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
|
||||
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
|
||||
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
|
||||
| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` |
|
||||
| `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` |
|
||||
|
||||
**Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text.
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@kaitranntt/ccs",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"boxen": "^5.1.2",
|
||||
"chalk": "^4.1.2",
|
||||
"chokidar": "^3.6.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"get-port": "^5.1.1",
|
||||
"gradient-string": "^2.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -28,8 +29,11 @@
|
||||
"@semantic-release/npm": "^13.1.3",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/chokidar": "^2.1.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-rate-limit": "6.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/ws": "^8.5.10",
|
||||
@@ -357,6 +361,8 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/chokidar": ["@types/chokidar@2.1.7", "", { "dependencies": { "chokidar": "*" } }, "sha512-A7/MFHf6KF7peCzjEC1BBTF8jpmZTokb3vr/A0NxRGfwRLK3Ws+Hq6ugVn6cJIMfM6wkCak/aplWrxbTcu8oig=="],
|
||||
@@ -369,8 +375,12 @@
|
||||
|
||||
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
|
||||
|
||||
"@types/express-rate-limit": ["@types/express-rate-limit@6.0.0", "", { "dependencies": { "express-rate-limit": "*" } }, "sha512-nZxo3nwU20EkTl/f2eGdndQkDIJYwkXIX4S3Vrp2jMdSdFJ6AWtIda8gOz0wiMuOFoeH/UUlCAiacz3x3eWNFA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg=="],
|
||||
|
||||
"@types/express-session": ["@types/express-session@1.18.2", "", { "dependencies": { "@types/express": "*" } }, "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
@@ -459,6 +469,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA=="],
|
||||
|
||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
@@ -651,6 +663,10 @@
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
|
||||
"express-session": ["express-session@1.18.2", "", { "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", "debug": "2.6.9", "depd": "~2.0.0", "on-headers": "~1.1.0", "parseurl": "~1.3.3", "safe-buffer": "5.2.1", "uid-safe": "~2.1.5" } }, "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A=="],
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -783,6 +799,8 @@
|
||||
|
||||
"into-stream": ["into-stream@7.0.0", "", { "dependencies": { "from2": "^2.3.0", "p-is-promise": "^3.0.0" } }, "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw=="],
|
||||
|
||||
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
@@ -973,8 +991,12 @@
|
||||
|
||||
"nerf-dart": ["nerf-dart@1.0.0", "", {}, "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
||||
|
||||
"node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="],
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"normalize-package-data": ["normalize-package-data@8.0.0", "", { "dependencies": { "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ=="],
|
||||
@@ -993,6 +1015,8 @@
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
@@ -1071,6 +1095,8 @@
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"random-bytes": ["random-bytes@1.0.0", "", {}, "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
@@ -1239,6 +1265,8 @@
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"uid-safe": ["uid-safe@2.1.5", "", { "dependencies": { "random-bytes": "~1.0.0" } }, "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA=="],
|
||||
|
||||
"undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
@@ -1395,6 +1423,8 @@
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express-session/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -1875,6 +1905,8 @@
|
||||
|
||||
"env-ci/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
|
||||
"express-session/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful when running the dashboard on a network-accessible machine.
|
||||
|
||||
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
|
||||
|
||||
## Commands
|
||||
|
||||
### `ccs config auth setup`
|
||||
|
||||
Interactive wizard to configure dashboard login.
|
||||
|
||||
```bash
|
||||
$ ccs config auth setup
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Dashboard Auth Setup │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
[i] Configure username and password for dashboard access.
|
||||
Password will be hashed with bcrypt before storage.
|
||||
|
||||
Username
|
||||
Enter username: admin
|
||||
|
||||
Password
|
||||
Minimum 8 characters
|
||||
Enter password: ********
|
||||
Confirm password: ********
|
||||
|
||||
[i] Hashing password...
|
||||
|
||||
[OK] Dashboard authentication configured
|
||||
|
||||
[i] Settings saved to ~/.ccs/config.yaml
|
||||
[i] Username: admin
|
||||
[i] Session timeout: 24 hours
|
||||
|
||||
Start dashboard: ccs config
|
||||
Show status: ccs config auth show
|
||||
Disable auth: ccs config auth disable
|
||||
```
|
||||
|
||||
### `ccs config auth show`
|
||||
|
||||
Display current authentication status.
|
||||
|
||||
```bash
|
||||
$ ccs config auth show
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Dashboard Auth Status │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
Configuration
|
||||
[OK] Authentication: Enabled
|
||||
[OK] Username: admin
|
||||
[i] Session timeout: 24 hours
|
||||
|
||||
Commands
|
||||
ccs config auth setup Configure authentication
|
||||
ccs config auth disable Disable authentication
|
||||
ccs config Open dashboard
|
||||
```
|
||||
|
||||
### `ccs config auth disable`
|
||||
|
||||
Disable dashboard authentication with confirmation.
|
||||
|
||||
```bash
|
||||
$ ccs config auth disable
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Disable Dashboard Auth │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
[!] This will disable login protection for the dashboard.
|
||||
[i] Anyone with network access will be able to view the dashboard.
|
||||
|
||||
Disable authentication? [y/N]: y
|
||||
|
||||
[OK] Dashboard authentication disabled
|
||||
|
||||
[i] Credentials preserved - re-enable with: ccs config auth setup
|
||||
```
|
||||
|
||||
### `ccs config auth --help`
|
||||
|
||||
Display usage information.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Environment variables override `config.yaml` values:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CCS_DASHBOARD_AUTH_ENABLED` | Enable/disable auth (`true`/`false`) |
|
||||
| `CCS_DASHBOARD_USERNAME` | Username |
|
||||
| `CCS_DASHBOARD_PASSWORD_HASH` | Bcrypt password hash |
|
||||
|
||||
### Generating a Password Hash
|
||||
|
||||
Use bcrypt to generate a hash:
|
||||
|
||||
```bash
|
||||
# Using Node.js
|
||||
node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
|
||||
|
||||
# Using npx
|
||||
npx bcrypt-cli hash "your-password"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Dashboard Auth: Optional login protection for CCS dashboard
|
||||
# Generate password hash: npx bcrypt-cli hash "your-password"
|
||||
# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH
|
||||
dashboard_auth:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password_hash: "$2b$10$..."
|
||||
session_timeout_hours: 24
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage
|
||||
2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript)
|
||||
3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes)
|
||||
4. **File permissions**: Config file is created with 0o600 permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication not configured"
|
||||
|
||||
Run `ccs config auth setup` to configure credentials.
|
||||
|
||||
### Forgot password
|
||||
|
||||
Run `ccs config auth setup` again to set a new password.
|
||||
|
||||
### ENV override not working
|
||||
|
||||
Ensure the variable is exported:
|
||||
|
||||
```bash
|
||||
export CCS_DASHBOARD_AUTH_ENABLED=true
|
||||
export CCS_DASHBOARD_USERNAME=admin
|
||||
export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...'
|
||||
```
|
||||
|
||||
### Session expired immediately
|
||||
|
||||
Check `session_timeout_hours` in config. Default is 24 hours.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation
|
||||
- [Config Schema](https://ccs.kaitran.ca/reference/config-schema) - All config options
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.19.2",
|
||||
"version": "7.19.2-dev.5",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -81,11 +81,13 @@
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"boxen": "^5.1.2",
|
||||
"chalk": "^4.1.2",
|
||||
"chokidar": "^3.6.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"get-port": "^5.1.1",
|
||||
"gradient-string": "^2.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -104,8 +106,11 @@
|
||||
"@semantic-release/npm": "^13.1.3",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/chokidar": "^2.1.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-rate-limit": "6.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/ws": "^8.5.10",
|
||||
|
||||
@@ -437,6 +437,13 @@ async function main(): Promise<void> {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Special case: persist command (write profile env to ~/.claude/settings.json)
|
||||
if (firstArg === 'persist') {
|
||||
const { handlePersistCommand } = await import('./commands/persist-command');
|
||||
await handlePersistCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: setup command (first-time wizard)
|
||||
if (firstArg === 'setup' || firstArg === '--setup') {
|
||||
const { handleSetupCommand } = await import('./commands/setup-command');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Config Auth Disable Command
|
||||
*
|
||||
* Disable dashboard authentication with confirmation.
|
||||
*/
|
||||
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { initUI, header, ok, info, warn, dim } from '../../utils/ui';
|
||||
|
||||
/**
|
||||
* Handle disable command - disable auth with confirmation
|
||||
*/
|
||||
export async function handleDisable(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
console.log('');
|
||||
console.log(header('Disable Dashboard Auth'));
|
||||
console.log('');
|
||||
|
||||
const config = getDashboardAuthConfig();
|
||||
|
||||
// Check if already disabled
|
||||
if (!config.enabled) {
|
||||
console.log(info('Dashboard authentication is already disabled.'));
|
||||
console.log('');
|
||||
console.log(dim(' To enable: ccs config auth setup'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for ENV override
|
||||
if (process.env.CCS_DASHBOARD_AUTH_ENABLED) {
|
||||
console.log(warn('CCS_DASHBOARD_AUTH_ENABLED environment variable is set.'));
|
||||
console.log(info('Disabling in config.yaml, but ENV var will still override.'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Confirm before disabling
|
||||
console.log(warn('This will disable login protection for the dashboard.'));
|
||||
console.log(info('Anyone with network access will be able to view the dashboard.'));
|
||||
console.log('');
|
||||
|
||||
const confirmed = await InteractivePrompt.confirm('Disable authentication?', {
|
||||
default: false, // Safe default
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('');
|
||||
console.log(info('Cancelled. Authentication remains enabled.'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable auth
|
||||
const fullConfig = loadOrCreateUnifiedConfig();
|
||||
fullConfig.dashboard_auth = {
|
||||
enabled: false,
|
||||
username: fullConfig.dashboard_auth?.username ?? '',
|
||||
password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
|
||||
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(fullConfig);
|
||||
|
||||
console.log('');
|
||||
console.log(ok('Dashboard authentication disabled'));
|
||||
console.log('');
|
||||
console.log(info('Credentials preserved - re-enable with: ccs config auth setup'));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Config Auth Commands (Facade)
|
||||
*
|
||||
* CLI interface for managing dashboard authentication.
|
||||
* Commands: setup, show, disable
|
||||
*
|
||||
* Implementation follows auth-commands.ts pattern.
|
||||
*/
|
||||
|
||||
import { initUI, header, subheader, color, dim, fail } from '../../utils/ui';
|
||||
|
||||
// Import command handlers
|
||||
import { handleSetup } from './setup-command';
|
||||
import { handleShow } from './show-command';
|
||||
import { handleDisable } from './disable-command';
|
||||
|
||||
/**
|
||||
* Show help for config auth commands
|
||||
*/
|
||||
async function showHelp(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
console.log('');
|
||||
console.log(header('Dashboard Auth Management'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs config auth', 'command')} <command>`);
|
||||
console.log('');
|
||||
console.log(subheader('Commands'));
|
||||
console.log(` ${color('setup', 'command')} Configure username and password`);
|
||||
console.log(` ${color('show', 'command')} Display current auth status`);
|
||||
console.log(` ${color('disable', 'command')} Disable authentication`);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# Interactive setup wizard')}`);
|
||||
console.log(` ${color('ccs config auth setup', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Check current status')}`);
|
||||
console.log(` ${color('ccs config auth show', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Disable authentication')}`);
|
||||
console.log(` ${color('ccs config auth disable', 'command')}`);
|
||||
console.log('');
|
||||
console.log(subheader('Environment Variables'));
|
||||
console.log(' These override config.yaml values:');
|
||||
console.log(
|
||||
` ${color('CCS_DASHBOARD_AUTH_ENABLED', 'command')} Enable/disable (true/false)`
|
||||
);
|
||||
console.log(` ${color('CCS_DASHBOARD_USERNAME', 'command')} Username`);
|
||||
console.log(` ${color('CCS_DASHBOARD_PASSWORD_HASH', 'command')} Bcrypt hash`);
|
||||
console.log('');
|
||||
console.log(subheader('Documentation'));
|
||||
console.log(' https://ccs.kaitran.ca/features/dashboard-auth');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Route config auth command to appropriate handler
|
||||
*/
|
||||
export async function handleConfigAuthCommand(args: string[]): Promise<void> {
|
||||
// Default to help if no subcommand
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'setup':
|
||||
await handleSetup();
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
case 'status':
|
||||
await handleShow();
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
await handleDisable();
|
||||
break;
|
||||
|
||||
default:
|
||||
await initUI();
|
||||
console.log(fail(`Unknown command: ${command}`));
|
||||
console.log('');
|
||||
console.log('Run for help:');
|
||||
console.log(` ${color('ccs config auth --help', 'command')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type { ConfigAuthContext, AuthSetupResult, AuthStatusInfo } from './types';
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Config Auth Setup Command
|
||||
*
|
||||
* Interactive wizard for configuring dashboard authentication.
|
||||
* Prompts for username and password, hashes password with bcrypt,
|
||||
* and saves to config.yaml.
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui';
|
||||
import type { AuthSetupResult } from './types';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Validate username (non-empty, alphanumeric with underscores)
|
||||
*/
|
||||
function validateUsername(value: string): string | null {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Username cannot be empty';
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
return 'Username must start with letter, contain only letters/numbers/underscores/hyphens';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup command - interactive wizard
|
||||
*/
|
||||
export async function handleSetup(): Promise<AuthSetupResult> {
|
||||
await initUI();
|
||||
|
||||
console.log('');
|
||||
console.log(header('Dashboard Auth Setup'));
|
||||
console.log('');
|
||||
console.log(info('Configure username and password for dashboard access.'));
|
||||
console.log(dim(' Password will be hashed with bcrypt before storage.'));
|
||||
console.log('');
|
||||
|
||||
// Check for ENV overrides
|
||||
if (
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED ||
|
||||
process.env.CCS_DASHBOARD_USERNAME ||
|
||||
process.env.CCS_DASHBOARD_PASSWORD_HASH
|
||||
) {
|
||||
console.log(warn('Environment variables detected - they will override config values:'));
|
||||
if (process.env.CCS_DASHBOARD_AUTH_ENABLED) {
|
||||
console.log(` CCS_DASHBOARD_AUTH_ENABLED=${process.env.CCS_DASHBOARD_AUTH_ENABLED}`);
|
||||
}
|
||||
if (process.env.CCS_DASHBOARD_USERNAME) {
|
||||
console.log(` CCS_DASHBOARD_USERNAME=${process.env.CCS_DASHBOARD_USERNAME}`);
|
||||
}
|
||||
if (process.env.CCS_DASHBOARD_PASSWORD_HASH) {
|
||||
console.log(' CCS_DASHBOARD_PASSWORD_HASH=***');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
try {
|
||||
// Prompt for username
|
||||
console.log(subheader('Username'));
|
||||
const username = await InteractivePrompt.input('Enter username', {
|
||||
validate: validateUsername,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
|
||||
// Prompt for password
|
||||
console.log(subheader('Password'));
|
||||
console.log(dim(` Minimum ${MIN_PASSWORD_LENGTH} characters`));
|
||||
const password = await InteractivePrompt.password('Enter password');
|
||||
|
||||
// Validate password length
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
console.log('');
|
||||
console.log(fail(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`));
|
||||
return { success: false, error: 'Password too short' };
|
||||
}
|
||||
|
||||
// Confirm password
|
||||
const confirmPassword = await InteractivePrompt.password('Confirm password');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
console.log('');
|
||||
console.log(fail('Passwords do not match'));
|
||||
return { success: false, error: 'Password mismatch' };
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(info('Hashing password...'));
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
// Load existing config and update
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.dashboard_auth = {
|
||||
enabled: true,
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
|
||||
// Save config
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
console.log('');
|
||||
console.log(ok('Dashboard authentication configured'));
|
||||
console.log('');
|
||||
console.log(info('Settings saved to ~/.ccs/config.yaml'));
|
||||
console.log(info(`Username: ${username}`));
|
||||
console.log(info(`Session timeout: ${config.dashboard_auth.session_timeout_hours} hours`));
|
||||
console.log('');
|
||||
console.log(dim(' Start dashboard: ccs config'));
|
||||
console.log(dim(' Show status: ccs config auth show'));
|
||||
console.log(dim(' Disable auth: ccs config auth disable'));
|
||||
console.log('');
|
||||
|
||||
return { success: true, username };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.log('');
|
||||
console.log(fail(`Setup failed: ${err.message}`));
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Config Auth Show Command
|
||||
*
|
||||
* Display current dashboard authentication status.
|
||||
*/
|
||||
|
||||
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
|
||||
import { initUI, header, subheader, ok, info, warn, dim, color } from '../../utils/ui';
|
||||
import type { AuthStatusInfo } from './types';
|
||||
|
||||
/**
|
||||
* Get auth status info with ENV override detection
|
||||
*/
|
||||
function getAuthStatus(): AuthStatusInfo {
|
||||
const config = getDashboardAuthConfig();
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
configured: !!(config.username && config.password_hash),
|
||||
username: config.username,
|
||||
sessionTimeoutHours: config.session_timeout_hours ?? 24,
|
||||
envOverride: {
|
||||
enabled: process.env.CCS_DASHBOARD_AUTH_ENABLED !== undefined,
|
||||
username: process.env.CCS_DASHBOARD_USERNAME !== undefined,
|
||||
passwordHash: process.env.CCS_DASHBOARD_PASSWORD_HASH !== undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle show command - display current auth status
|
||||
*/
|
||||
export async function handleShow(): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
console.log('');
|
||||
console.log(header('Dashboard Auth Status'));
|
||||
console.log('');
|
||||
|
||||
const status = getAuthStatus();
|
||||
|
||||
// Status section
|
||||
console.log(subheader('Configuration'));
|
||||
|
||||
if (status.enabled) {
|
||||
console.log(ok('Authentication: Enabled'));
|
||||
} else {
|
||||
console.log(info('Authentication: Disabled'));
|
||||
}
|
||||
|
||||
if (status.configured) {
|
||||
console.log(ok(`Username: ${status.username}`));
|
||||
console.log(info(`Session timeout: ${status.sessionTimeoutHours} hours`));
|
||||
} else {
|
||||
console.log(warn('Not configured - run `ccs config auth setup`'));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ENV override section
|
||||
const hasEnvOverride =
|
||||
status.envOverride.enabled || status.envOverride.username || status.envOverride.passwordHash;
|
||||
|
||||
if (hasEnvOverride) {
|
||||
console.log(subheader('Environment Overrides'));
|
||||
console.log(warn('The following ENV vars override config.yaml:'));
|
||||
|
||||
if (status.envOverride.enabled) {
|
||||
const value = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
console.log(` ${color('CCS_DASHBOARD_AUTH_ENABLED', 'command')}=${value}`);
|
||||
}
|
||||
if (status.envOverride.username) {
|
||||
const value = process.env.CCS_DASHBOARD_USERNAME;
|
||||
console.log(` ${color('CCS_DASHBOARD_USERNAME', 'command')}=${value}`);
|
||||
}
|
||||
if (status.envOverride.passwordHash) {
|
||||
console.log(` ${color('CCS_DASHBOARD_PASSWORD_HASH', 'command')}=***`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Help section
|
||||
console.log(subheader('Commands'));
|
||||
console.log(dim(' ccs config auth setup Configure authentication'));
|
||||
console.log(dim(' ccs config auth disable Disable authentication'));
|
||||
console.log(dim(' ccs config Open dashboard'));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Config Auth Command Types
|
||||
*
|
||||
* Shared interfaces for dashboard authentication CLI commands.
|
||||
*/
|
||||
|
||||
export interface ConfigAuthContext {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthSetupResult {
|
||||
success: boolean;
|
||||
username?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthStatusInfo {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
username: string;
|
||||
sessionTimeoutHours: number;
|
||||
envOverride: {
|
||||
enabled: boolean;
|
||||
username: boolean;
|
||||
passwordHash: boolean;
|
||||
};
|
||||
}
|
||||
@@ -52,10 +52,16 @@ function parseArgs(args: string[]): ConfigOptions {
|
||||
*/
|
||||
function showHelp(): void {
|
||||
console.log('');
|
||||
console.log('Usage: ccs config [options]');
|
||||
console.log('Usage: ccs config [command] [options]');
|
||||
console.log('');
|
||||
console.log('Open web-based configuration dashboard');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(' auth Manage dashboard authentication');
|
||||
console.log(' auth setup Configure username and password');
|
||||
console.log(' auth show Display current auth status');
|
||||
console.log(' auth disable Disable authentication');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
||||
console.log(' --dev Development mode with Vite HMR');
|
||||
@@ -65,6 +71,7 @@ function showHelp(): void {
|
||||
console.log(' ccs config Auto-detect available port');
|
||||
console.log(' ccs config --port 3000 Use specific port');
|
||||
console.log(' ccs config --dev Development mode with hot reload');
|
||||
console.log(' ccs config auth setup Configure dashboard login');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -72,6 +79,13 @@ function showHelp(): void {
|
||||
* Handle config command
|
||||
*/
|
||||
export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||
// Route subcommands before dashboard launch
|
||||
if (args[0] === 'auth') {
|
||||
const { handleConfigAuthCommand } = await import('./config-auth');
|
||||
await handleConfigAuthCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
|
||||
@@ -230,7 +230,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs doctor', 'Run health check and diagnostics'],
|
||||
['ccs cleanup', 'Remove old CLIProxy logs'],
|
||||
['ccs config', 'Open web configuration dashboard'],
|
||||
['ccs config auth setup', 'Configure dashboard login'],
|
||||
['ccs config auth show', 'Show dashboard auth status'],
|
||||
['ccs config --port 3000', 'Use specific port'],
|
||||
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
|
||||
['ccs persist --list-backups', 'List available settings.json backups'],
|
||||
['ccs persist --restore', 'Restore settings.json from latest backup'],
|
||||
['ccs sync', 'Sync delegation commands and skills'],
|
||||
['ccs update', 'Update CCS to latest version'],
|
||||
['ccs update --force', 'Force reinstall current version'],
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Persist Command Handler
|
||||
*
|
||||
* Writes a profile's environment variables to ~/.claude/settings.json
|
||||
* for native Claude Code usage (IDEs, extensions, etc.).
|
||||
*
|
||||
* Supports all profile types: API, CLIProxy, Copilot.
|
||||
* Account-based profiles are not supported (use CLAUDE_CONFIG_DIR).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import ProfileDetector, {
|
||||
ProfileDetectionResult,
|
||||
loadSettingsFromFile,
|
||||
CLIPROXY_PROFILES,
|
||||
} from '../auth/profile-detector';
|
||||
import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
|
||||
interface PersistCommandArgs {
|
||||
profile?: string;
|
||||
yes?: boolean;
|
||||
listBackups?: boolean;
|
||||
restore?: string | boolean;
|
||||
}
|
||||
|
||||
interface ResolvedEnv {
|
||||
env: Record<string, string>;
|
||||
profileType: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/** Parse command line arguments */
|
||||
function parseArgs(args: string[]): PersistCommandArgs {
|
||||
const result: PersistCommandArgs = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--yes' || arg === '-y') {
|
||||
result.yes = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
// Will be handled in main function
|
||||
} else if (arg === '--list-backups') {
|
||||
result.listBackups = true;
|
||||
} else if (arg === '--restore') {
|
||||
// Check if next arg is a timestamp (not a flag)
|
||||
const nextArg = args[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
result.restore = nextArg;
|
||||
i++; // Skip next arg
|
||||
} else {
|
||||
result.restore = true; // Use latest
|
||||
}
|
||||
} else if (!arg.startsWith('-') && !result.profile) {
|
||||
result.profile = arg;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get Claude settings.json path */
|
||||
function getClaudeSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
/** Read existing Claude settings.json with validation */
|
||||
function readClaudeSettings(): Record<string, unknown> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
// Handle empty file (0 bytes)
|
||||
if (!content.trim()) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
// Validate parsed value is a plain object (not array, null, or primitive)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('settings.json must contain a JSON object, not an array or primitive');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Failed to parse settings.json: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings back to settings.json
|
||||
* Note: mode 0o600 only applies when creating a new file.
|
||||
* Existing file permissions are preserved (acceptable behavior).
|
||||
*/
|
||||
function writeClaudeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
// Security: Reject symlinks to prevent writing to unexpected locations
|
||||
if (isSymlink(settingsPath)) {
|
||||
throw new Error('settings.json is a symlink - refusing to write for security');
|
||||
}
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Maximum number of backups to keep (oldest are deleted) */
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/** Check if path is a symlink (security check) */
|
||||
function isSymlink(filePath: string): boolean {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath);
|
||||
return stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create backup of settings.json with proper permissions and rotation */
|
||||
function createBackup(): string {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
throw new Error('No settings.json to backup');
|
||||
}
|
||||
// Security: Reject symlinks to prevent writing to unexpected locations
|
||||
if (isSymlink(settingsPath)) {
|
||||
throw new Error('settings.json is a symlink - refusing to backup for security');
|
||||
}
|
||||
const now = new Date();
|
||||
const timestamp =
|
||||
now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0') +
|
||||
'_' +
|
||||
now.getHours().toString().padStart(2, '0') +
|
||||
now.getMinutes().toString().padStart(2, '0') +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
const backupPath = `${settingsPath}.backup.${timestamp}`;
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
// Security: Set restrictive permissions on backup (contains API keys)
|
||||
fs.chmodSync(backupPath, 0o600);
|
||||
// Cleanup: Rotate old backups (keep only MAX_BACKUPS)
|
||||
cleanupOldBackups();
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/** Remove old backups keeping only MAX_BACKUPS most recent */
|
||||
function cleanupOldBackups(): void {
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length > MAX_BACKUPS) {
|
||||
const toDelete = backups.slice(MAX_BACKUPS);
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
fs.unlinkSync(backup.path);
|
||||
} catch {
|
||||
// Ignore deletion errors (file may be locked or already deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupFile {
|
||||
path: string;
|
||||
timestamp: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/** Get all backup files sorted by date (newest first) */
|
||||
function getBackupFiles(): BackupFile[] {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => backupPattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(backupPattern);
|
||||
if (!match) return null;
|
||||
const timestamp = match[1];
|
||||
// Parse YYYYMMDD_HHMMSS
|
||||
const year = parseInt(timestamp.slice(0, 4));
|
||||
const month = parseInt(timestamp.slice(4, 6)) - 1;
|
||||
const day = parseInt(timestamp.slice(6, 8));
|
||||
const hour = parseInt(timestamp.slice(9, 11));
|
||||
const min = parseInt(timestamp.slice(11, 13));
|
||||
const sec = parseInt(timestamp.slice(13, 15));
|
||||
return {
|
||||
path: path.join(dir, f),
|
||||
timestamp,
|
||||
date: new Date(year, month, day, hour, min, sec),
|
||||
};
|
||||
})
|
||||
.filter((f): f is BackupFile => f !== null)
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Mask API key for display (show first 4 and last 4 chars) */
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Resolve env vars for a profile */
|
||||
async function resolveProfileEnvVars(
|
||||
profileName: string,
|
||||
profileResult: ProfileDetectionResult
|
||||
): Promise<ResolvedEnv> {
|
||||
switch (profileResult.type) {
|
||||
case 'settings': {
|
||||
// API profile - load from settings file
|
||||
let env: Record<string, string> = {};
|
||||
if (profileResult.env) {
|
||||
env = profileResult.env;
|
||||
} else if (profileResult.settingsPath) {
|
||||
env = loadSettingsFromFile(expandPath(profileResult.settingsPath));
|
||||
}
|
||||
if (Object.keys(env).length === 0) {
|
||||
throw new Error(`Profile '${profileName}' has no env vars configured`);
|
||||
}
|
||||
return { env, profileType: 'API' };
|
||||
}
|
||||
case 'cliproxy': {
|
||||
// CLIProxy profile - generate env vars
|
||||
const provider =
|
||||
profileResult.provider || (profileName as (typeof CLIPROXY_PROFILES)[number]);
|
||||
const port = profileResult.port || CLIPROXY_DEFAULT_PORT;
|
||||
const env = getEffectiveEnvVars(provider, port, profileResult.settingsPath) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return {
|
||||
env,
|
||||
profileType: 'CLIProxy',
|
||||
warning: 'CLIProxy must be running for this profile to work',
|
||||
};
|
||||
}
|
||||
case 'copilot': {
|
||||
// Copilot profile - generate env vars
|
||||
if (!profileResult.copilotConfig) {
|
||||
throw new Error('Copilot configuration not found');
|
||||
}
|
||||
const env = generateCopilotEnv(profileResult.copilotConfig);
|
||||
return {
|
||||
env,
|
||||
profileType: 'Copilot',
|
||||
warning: 'copilot-api daemon must be running for this profile to work',
|
||||
};
|
||||
}
|
||||
case 'account': {
|
||||
throw new Error(
|
||||
`Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n` +
|
||||
`Use 'ccs ${profileName}' to run with this profile instead.`
|
||||
);
|
||||
}
|
||||
case 'default': {
|
||||
throw new Error(
|
||||
'Default profile has no env vars to persist.\n' +
|
||||
'Specify a profile name: ccs persist <profile>'
|
||||
);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown profile type: ${profileResult.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle --list-backups flag */
|
||||
async function handleListBackups(): Promise<void> {
|
||||
await initUI();
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length === 0) {
|
||||
console.log(info('No backups found'));
|
||||
return;
|
||||
}
|
||||
console.log(header('Available Backups'));
|
||||
console.log('');
|
||||
backups.forEach((b, i) => {
|
||||
const dateStr = b.date.toLocaleString();
|
||||
const marker = i === 0 ? color(' (latest)', 'success') : '';
|
||||
console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`);
|
||||
});
|
||||
console.log('');
|
||||
console.log(dim('To restore: ccs persist --restore [timestamp]'));
|
||||
}
|
||||
|
||||
/** Handle --restore [timestamp] flag */
|
||||
async function handleRestore(timestamp: string | boolean, yes: boolean): Promise<void> {
|
||||
await initUI();
|
||||
const backups = getBackupFiles();
|
||||
if (backups.length === 0) {
|
||||
console.log(fail('No backups found'));
|
||||
process.exit(1);
|
||||
}
|
||||
// Find backup to restore
|
||||
let backup: BackupFile;
|
||||
if (timestamp === true) {
|
||||
// Use latest
|
||||
backup = backups[0];
|
||||
} else {
|
||||
const found = backups.find((b) => b.timestamp === timestamp);
|
||||
if (!found) {
|
||||
console.log(fail(`Backup not found: ${timestamp}`));
|
||||
console.log('');
|
||||
console.log('Available backups:');
|
||||
backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`));
|
||||
process.exit(1);
|
||||
}
|
||||
backup = found;
|
||||
}
|
||||
console.log(header('Restore Backup'));
|
||||
console.log('');
|
||||
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
|
||||
console.log(`Date: ${backup.date.toLocaleString()}`);
|
||||
console.log('');
|
||||
console.log(warn('This will replace ~/.claude/settings.json'));
|
||||
console.log('');
|
||||
if (!yes) {
|
||||
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
|
||||
if (!proceed) {
|
||||
console.log(info('Cancelled'));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
// Validate backup JSON integrity before restore
|
||||
try {
|
||||
const backupContent = fs.readFileSync(backup.path, 'utf8');
|
||||
const parsed: unknown = JSON.parse(backupContent);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
console.log(fail('Backup file is corrupted: not a valid JSON object'));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
// Security: Reject symlink backup files
|
||||
if (isSymlink(backup.path)) {
|
||||
console.log(fail('Backup file is a symlink - refusing to restore for security'));
|
||||
process.exit(1);
|
||||
}
|
||||
// Copy backup over settings.json
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
// Security: Reject symlink target
|
||||
if (isSymlink(settingsPath)) {
|
||||
console.log(fail('settings.json is a symlink - refusing to restore for security'));
|
||||
process.exit(1);
|
||||
}
|
||||
fs.copyFileSync(backup.path, settingsPath);
|
||||
console.log(ok(`Restored from backup: ${backup.timestamp}`));
|
||||
}
|
||||
|
||||
/** Show help for persist command */
|
||||
async function showHelp(): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CCS Persist Command'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs persist', 'command')} <profile> [options]`);
|
||||
console.log(` ${color('ccs persist', 'command')} --list-backups`);
|
||||
console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`);
|
||||
console.log('');
|
||||
console.log(subheader('Description'));
|
||||
console.log(" Writes a profile's environment variables directly to");
|
||||
console.log(' ~/.claude/settings.json for native Claude Code usage.');
|
||||
console.log('');
|
||||
console.log(' This allows Claude Code to use the profile without CCS,');
|
||||
console.log(' enabling compatibility with IDEs and extensions.');
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`);
|
||||
console.log(` ${color('--help, -h', 'command')} Show this help message`);
|
||||
console.log('');
|
||||
console.log(subheader('Backup Management'));
|
||||
console.log(` ${color('--list-backups', 'command')} List available backup files`);
|
||||
console.log(` ${color('--restore', 'command')} Restore from the most recent backup`);
|
||||
console.log(
|
||||
` ${color('--restore <ts>', 'command')} Restore from specific backup (e.g., 20260110_205324)`
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Supported Profile Types'));
|
||||
console.log(` ${color('API profiles', 'command')} glm, glmt, kimi, custom API profiles`);
|
||||
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
|
||||
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
|
||||
console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# Persist GLM profile')}`);
|
||||
console.log(` ${color('ccs persist glm', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist with auto-confirmation')}`);
|
||||
console.log(` ${color('ccs persist gemini --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# List all backups')}`);
|
||||
console.log(` ${color('ccs persist --list-backups', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Restore latest backup')}`);
|
||||
console.log(` ${color('ccs persist --restore', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Restore specific backup')}`);
|
||||
console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`);
|
||||
console.log('');
|
||||
console.log(subheader('Notes'));
|
||||
console.log(' [i] CLIProxy profiles require the proxy to be running.');
|
||||
console.log(' [i] Copilot profiles require copilot-api daemon.');
|
||||
console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/** Main persist command handler */
|
||||
export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
// Check for help first
|
||||
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
const parsedArgs = parseArgs(args);
|
||||
// Handle --list-backups
|
||||
if (parsedArgs.listBackups) {
|
||||
await handleListBackups();
|
||||
return;
|
||||
}
|
||||
// Handle --restore
|
||||
if (parsedArgs.restore) {
|
||||
await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false);
|
||||
return;
|
||||
}
|
||||
await initUI();
|
||||
if (!parsedArgs.profile) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(` ${color('ccs persist <profile>', 'command')}`);
|
||||
console.log('');
|
||||
console.log('Run for help:');
|
||||
console.log(` ${color('ccs persist --help', 'command')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Detect profile
|
||||
const detector = new ProfileDetector();
|
||||
let profileResult: ProfileDetectionResult;
|
||||
try {
|
||||
profileResult = detector.detectProfileType(parsedArgs.profile);
|
||||
} catch (error) {
|
||||
const err = error as Error & { availableProfiles?: string };
|
||||
console.log(fail(`Profile not found: ${parsedArgs.profile}`));
|
||||
console.log('');
|
||||
if (err.availableProfiles) {
|
||||
console.log(err.availableProfiles);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
// Resolve env vars
|
||||
let resolved: ResolvedEnv;
|
||||
try {
|
||||
resolved = await resolveProfileEnvVars(parsedArgs.profile, profileResult);
|
||||
} catch (error) {
|
||||
console.log(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
// Display what will be written
|
||||
console.log(header(`Persist Profile: ${parsedArgs.profile}`));
|
||||
console.log('');
|
||||
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
|
||||
console.log('');
|
||||
console.log('The following env vars will be written to ~/.claude/settings.json:');
|
||||
console.log('');
|
||||
// Display env vars (mask sensitive values)
|
||||
const envKeys = Object.keys(resolved.env);
|
||||
if (envKeys.length === 0) {
|
||||
console.log(fail('Profile has no environment variables to persist'));
|
||||
process.exit(1);
|
||||
}
|
||||
const maxKeyLen = Math.max(...envKeys.map((k) => k.length));
|
||||
for (const [key, value] of Object.entries(resolved.env)) {
|
||||
const paddedKey = key.padEnd(maxKeyLen + 2);
|
||||
const displayValue =
|
||||
key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET')
|
||||
? maskApiKey(value)
|
||||
: value;
|
||||
console.log(` ${color(paddedKey, 'command')} = ${displayValue}`);
|
||||
}
|
||||
console.log('');
|
||||
// Show warning if applicable
|
||||
if (resolved.warning) {
|
||||
console.log(warn(resolved.warning));
|
||||
console.log('');
|
||||
}
|
||||
// Warning about modification
|
||||
console.log(warn('This will modify ~/.claude/settings.json'));
|
||||
console.log(dim(' Existing hooks and other settings will be preserved.'));
|
||||
console.log('');
|
||||
// Check if settings.json exists for backup
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const settingsExist = fs.existsSync(settingsPath);
|
||||
// Track backup path for error recovery guidance
|
||||
let createdBackupPath: string | null = null;
|
||||
// Backup prompt (unless --yes)
|
||||
if (settingsExist) {
|
||||
let createBackupFlag: boolean = parsedArgs.yes === true; // Auto-backup with --yes
|
||||
if (!parsedArgs.yes) {
|
||||
createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', {
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
if (createBackupFlag) {
|
||||
try {
|
||||
createdBackupPath = createBackup();
|
||||
console.log(ok(`Backup created: ${createdBackupPath.replace(os.homedir(), '~')}`));
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.log(fail(`Failed to create backup: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Proceed confirmation (unless --yes)
|
||||
if (!parsedArgs.yes) {
|
||||
const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true });
|
||||
if (!proceed) {
|
||||
console.log(info('Cancelled'));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
// Read existing settings and merge
|
||||
const existingSettings = readClaudeSettings();
|
||||
// Validate existing env is an object (not array/primitive)
|
||||
const rawEnv = existingSettings.env;
|
||||
let existingEnv: Record<string, string> = {};
|
||||
if (rawEnv !== undefined && rawEnv !== null) {
|
||||
if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) {
|
||||
console.log(warn('Existing env in settings.json is not an object - it will be replaced'));
|
||||
} else {
|
||||
existingEnv = rawEnv as Record<string, string>;
|
||||
}
|
||||
}
|
||||
const mergedSettings = {
|
||||
...existingSettings,
|
||||
env: {
|
||||
...existingEnv,
|
||||
...resolved.env,
|
||||
},
|
||||
};
|
||||
// Write merged settings
|
||||
try {
|
||||
writeClaudeSettings(mergedSettings);
|
||||
} catch (error) {
|
||||
console.log(fail(`Failed to write settings: ${(error as Error).message}`));
|
||||
if (createdBackupPath) {
|
||||
console.log('');
|
||||
console.log(info(`A backup was created before this error:`));
|
||||
console.log(` ${createdBackupPath.replace(os.homedir(), '~')}`);
|
||||
console.log(dim(' To restore: ccs persist --restore'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('');
|
||||
console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`));
|
||||
console.log('');
|
||||
console.log(info('Claude Code will now use this profile by default.'));
|
||||
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
|
||||
console.log('');
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||
GlobalEnvConfig,
|
||||
DashboardAuthConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
@@ -242,6 +244,16 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
|
||||
},
|
||||
},
|
||||
// Dashboard auth config - disabled by default
|
||||
dashboard_auth: {
|
||||
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
|
||||
username: partial.dashboard_auth?.username ?? DEFAULT_DASHBOARD_AUTH_CONFIG.username,
|
||||
password_hash:
|
||||
partial.dashboard_auth?.password_hash ?? DEFAULT_DASHBOARD_AUTH_CONFIG.password_hash,
|
||||
session_timeout_hours:
|
||||
partial.dashboard_auth?.session_timeout_hours ??
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,6 +439,26 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Dashboard auth section (only if configured)
|
||||
if (config.dashboard_auth?.enabled) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Dashboard Auth: Optional login protection for CCS dashboard');
|
||||
lines.push('# Generate password hash: npx bcrypt-cli hash "your-password"');
|
||||
lines.push(
|
||||
'# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH'
|
||||
);
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump(
|
||||
{ dashboard_auth: config.dashboard_auth },
|
||||
{ indent: 2, lineWidth: -1, quotingType: '"' }
|
||||
)
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -575,3 +607,26 @@ export function getGlobalEnvConfig(): GlobalEnvConfig {
|
||||
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard_auth configuration with ENV var override.
|
||||
* Priority: ENV vars > config.yaml > defaults
|
||||
*/
|
||||
export function getDashboardAuthConfig(): DashboardAuthConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// ENV vars take precedence
|
||||
const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
const envUsername = process.env.CCS_DASHBOARD_USERNAME;
|
||||
const envPasswordHash = process.env.CCS_DASHBOARD_PASSWORD_HASH;
|
||||
|
||||
return {
|
||||
enabled:
|
||||
envEnabled !== undefined
|
||||
? envEnabled === 'true' || envEnabled === '1'
|
||||
: (config.dashboard_auth?.enabled ?? false),
|
||||
username: envUsername ?? config.dashboard_auth?.username ?? '',
|
||||
password_hash: envPasswordHash ?? config.dashboard_auth?.password_hash ?? '',
|
||||
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,6 +424,33 @@ export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
|
||||
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
||||
};
|
||||
|
||||
/**
|
||||
* Dashboard authentication configuration.
|
||||
* Optional login protection for CCS dashboard.
|
||||
* Disabled by default for backward compatibility.
|
||||
*/
|
||||
export interface DashboardAuthConfig {
|
||||
/** Enable dashboard authentication (default: false) */
|
||||
enabled: boolean;
|
||||
/** Username for dashboard login */
|
||||
username: string;
|
||||
/** Bcrypt-hashed password (use: npx bcrypt-cli hash 'password') */
|
||||
password_hash: string;
|
||||
/** Session timeout in hours (default: 24) */
|
||||
session_timeout_hours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default dashboard auth configuration.
|
||||
* Disabled by default - must be explicitly enabled.
|
||||
*/
|
||||
export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password_hash: '',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
@@ -451,6 +478,8 @@ export interface UnifiedConfig {
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
/** Quota management configuration (v7+) */
|
||||
quota_management?: QuotaManagementConfig;
|
||||
/** Dashboard authentication configuration (optional) */
|
||||
dashboard_auth?: DashboardAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,6 +569,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
|
||||
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import http from 'http';
|
||||
import path from 'path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { setupWebSocket } from './websocket';
|
||||
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
@@ -49,6 +50,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
}
|
||||
);
|
||||
|
||||
// Session middleware (for dashboard auth)
|
||||
app.use(createSessionMiddleware());
|
||||
|
||||
// Auth middleware (protects API routes when enabled)
|
||||
app.use(authMiddleware);
|
||||
|
||||
// REST API routes (modularized)
|
||||
const { apiRoutes } = await import('./routes/index');
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Dashboard Authentication Middleware
|
||||
* Session-based auth with httpOnly cookies for CCS dashboard.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import session from 'express-session';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Extend Express Request with session
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** Public paths that bypass auth (lowercase for case-insensitive matching) */
|
||||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health'];
|
||||
|
||||
/** Path to persistent session secret file */
|
||||
const SESSION_SECRET_PATH = path.join(os.homedir(), '.ccs', '.session-secret');
|
||||
|
||||
/**
|
||||
* Generate or retrieve persistent session secret.
|
||||
* Priority: ENV var > persisted file > generate new
|
||||
*/
|
||||
function getSessionSecret(): string {
|
||||
// 1. Check ENV var first
|
||||
if (process.env.CCS_SESSION_SECRET) {
|
||||
return process.env.CCS_SESSION_SECRET;
|
||||
}
|
||||
|
||||
// 2. Try to read persisted secret
|
||||
try {
|
||||
if (fs.existsSync(SESSION_SECRET_PATH)) {
|
||||
const secret = fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim();
|
||||
if (secret.length >= 32) {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors, generate new secret
|
||||
}
|
||||
|
||||
// 3. Generate and persist new random secret
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
const dir = path.dirname(SESSION_SECRET_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(SESSION_SECRET_PATH, newSecret, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
// Log warning - sessions won't persist across restarts
|
||||
console.warn('[!] Failed to persist session secret:', (err as Error).message);
|
||||
}
|
||||
|
||||
return newSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter for login attempts.
|
||||
* 5 attempts per 15 minutes per IP.
|
||||
*/
|
||||
export const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 attempts
|
||||
message: { error: 'Too many login attempts. Please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: () => !getDashboardAuthConfig().enabled,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create session middleware configured for CCS dashboard.
|
||||
*/
|
||||
export function createSessionMiddleware() {
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000;
|
||||
|
||||
return session({
|
||||
secret: getSessionSecret(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // Local CLI uses HTTP
|
||||
httpOnly: true,
|
||||
maxAge,
|
||||
sameSite: 'strict',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth middleware that protects all routes except public paths.
|
||||
* Only active when dashboard_auth.enabled = true.
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
|
||||
// Skip auth if disabled
|
||||
if (!authConfig.enabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Allow public paths (case-insensitive)
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (PUBLIC_PATHS.some((p) => pathLower.startsWith(p))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Allow static assets and SPA routes (non-API)
|
||||
if (!req.path.startsWith('/api/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check session
|
||||
if (req.session?.authenticated) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Unauthorized
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Dashboard Authentication Routes
|
||||
* Handles login, logout, session check, and setup status.
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
|
||||
import { loginRateLimiter } from '../middleware/auth-middleware';
|
||||
|
||||
/**
|
||||
* Timing-safe string comparison to prevent timing attacks.
|
||||
* Returns true if strings match, false otherwise.
|
||||
*/
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
// Still compare to avoid length-based timing leak
|
||||
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a));
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate user with username/password.
|
||||
* Rate limited: 5 attempts per 15 minutes.
|
||||
*/
|
||||
router.post('/login', loginRateLimiter, async (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
|
||||
// Check if auth is configured
|
||||
if (!authConfig.enabled || !authConfig.username || !authConfig.password_hash) {
|
||||
res.status(400).json({ error: 'Authentication not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate bcrypt hash format to prevent bcrypt.compare errors
|
||||
const isBcryptHash = /^\$2[aby]?\$\d{2}\$.{53}$/.test(authConfig.password_hash);
|
||||
if (!isBcryptHash) {
|
||||
res.status(500).json({ error: 'Invalid password hash format in config' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify credentials (timing-safe comparison for username)
|
||||
const usernameMatch = timingSafeEqual(username, authConfig.username);
|
||||
const passwordMatch = await bcrypt.compare(password, authConfig.password_hash);
|
||||
|
||||
if (!usernameMatch || !passwordMatch) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Regenerate session to prevent session fixation, then set auth
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: 'Session error' });
|
||||
return;
|
||||
}
|
||||
req.session.authenticated = true;
|
||||
req.session.username = username;
|
||||
res.json({ success: true, username });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Clear session and log out user.
|
||||
*/
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: 'Failed to logout' });
|
||||
return;
|
||||
}
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/check
|
||||
* Check if user is authenticated and if auth is required.
|
||||
*/
|
||||
router.get('/check', (req: Request, res: Response) => {
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
|
||||
res.json({
|
||||
authRequired: authConfig.enabled,
|
||||
authenticated: req.session?.authenticated ?? false,
|
||||
username: req.session?.username ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/setup
|
||||
* Check if authentication is properly configured.
|
||||
*/
|
||||
router.get('/setup', (_req: Request, res: Response) => {
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
|
||||
res.json({
|
||||
enabled: authConfig.enabled,
|
||||
configured: !!(authConfig.username && authConfig.password_hash),
|
||||
sessionTimeoutHours: authConfig.session_timeout_hours ?? 24,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||
import copilotRoutes from './copilot-routes';
|
||||
import miscRoutes from './misc-routes';
|
||||
import cliproxyServerRoutes from './proxy-routes';
|
||||
import authRoutes from './auth-routes';
|
||||
|
||||
// Create the main API router
|
||||
export const apiRoutes = Router();
|
||||
@@ -38,6 +39,9 @@ apiRoutes.use('/config', configRoutes);
|
||||
// ==================== Health Checks ====================
|
||||
apiRoutes.use('/health', healthRoutes);
|
||||
|
||||
// ==================== Dashboard Auth ====================
|
||||
apiRoutes.use('/auth', authRoutes);
|
||||
|
||||
// ==================== CLIProxy ====================
|
||||
// Variants, auth, accounts, stats, status, models, error logs
|
||||
apiRoutes.use('/cliproxy', variantRoutes);
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Persist Command Tests
|
||||
*
|
||||
* Tests for the `ccs persist` CLI command including:
|
||||
* - Argument parsing
|
||||
* - API key masking
|
||||
* - Settings merge logic
|
||||
* - Backup management
|
||||
* - Error handling
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Persist Command', () => {
|
||||
// =========================================================================
|
||||
// Argument Parsing Tests
|
||||
// =========================================================================
|
||||
describe('parseArgs', () => {
|
||||
/**
|
||||
* Simulates the argument parsing logic from persist-command.ts
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
profile: undefined,
|
||||
yes: false,
|
||||
listBackups: false,
|
||||
restore: undefined,
|
||||
};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--yes' || arg === '-y') {
|
||||
result.yes = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
// Will be handled in main function
|
||||
} else if (arg === '--list-backups') {
|
||||
result.listBackups = true;
|
||||
} else if (arg === '--restore') {
|
||||
// Check if next arg is a timestamp (not a flag)
|
||||
const nextArg = args[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
result.restore = nextArg;
|
||||
i++; // Skip next arg
|
||||
} else {
|
||||
result.restore = true; // Use latest
|
||||
}
|
||||
} else if (!arg.startsWith('-') && !result.profile) {
|
||||
result.profile = arg;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
it('parses profile name as first positional argument', () => {
|
||||
const result = parseArgs(['glm']);
|
||||
assert.strictEqual(result.profile, 'glm');
|
||||
});
|
||||
|
||||
it('parses --yes flag', () => {
|
||||
const result = parseArgs(['glm', '--yes']);
|
||||
assert.strictEqual(result.yes, true);
|
||||
assert.strictEqual(result.profile, 'glm');
|
||||
});
|
||||
|
||||
it('parses -y short flag', () => {
|
||||
const result = parseArgs(['gemini', '-y']);
|
||||
assert.strictEqual(result.yes, true);
|
||||
assert.strictEqual(result.profile, 'gemini');
|
||||
});
|
||||
|
||||
it('parses flags before profile name', () => {
|
||||
const result = parseArgs(['--yes', 'kimi']);
|
||||
assert.strictEqual(result.yes, true);
|
||||
assert.strictEqual(result.profile, 'kimi');
|
||||
});
|
||||
|
||||
it('handles no arguments', () => {
|
||||
const result = parseArgs([]);
|
||||
assert.strictEqual(result.profile, undefined);
|
||||
assert.strictEqual(result.yes, false);
|
||||
});
|
||||
|
||||
it('ignores unknown flags', () => {
|
||||
const result = parseArgs(['glm', '--unknown', '--yes']);
|
||||
assert.strictEqual(result.profile, 'glm');
|
||||
assert.strictEqual(result.yes, true);
|
||||
});
|
||||
|
||||
it('takes only first positional as profile', () => {
|
||||
const result = parseArgs(['first', 'second', 'third']);
|
||||
assert.strictEqual(result.profile, 'first');
|
||||
});
|
||||
|
||||
it('parses --list-backups flag', () => {
|
||||
const result = parseArgs(['--list-backups']);
|
||||
assert.strictEqual(result.listBackups, true);
|
||||
assert.strictEqual(result.profile, undefined);
|
||||
});
|
||||
|
||||
it('parses --restore flag without timestamp (use latest)', () => {
|
||||
const result = parseArgs(['--restore']);
|
||||
assert.strictEqual(result.restore, true);
|
||||
});
|
||||
|
||||
it('parses --restore flag with timestamp', () => {
|
||||
const result = parseArgs(['--restore', '20260110_205324']);
|
||||
assert.strictEqual(result.restore, '20260110_205324');
|
||||
});
|
||||
|
||||
it('parses --restore with --yes flag', () => {
|
||||
const result = parseArgs(['--restore', '--yes']);
|
||||
assert.strictEqual(result.restore, true);
|
||||
assert.strictEqual(result.yes, true);
|
||||
});
|
||||
|
||||
it('parses --restore with timestamp and --yes flag', () => {
|
||||
const result = parseArgs(['--restore', '20260110_205324', '--yes']);
|
||||
assert.strictEqual(result.restore, '20260110_205324');
|
||||
assert.strictEqual(result.yes, true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API Key Masking Tests
|
||||
// =========================================================================
|
||||
describe('maskApiKey', () => {
|
||||
/**
|
||||
* Simulates the maskApiKey function from persist-command.ts
|
||||
*/
|
||||
function maskApiKey(key) {
|
||||
if (key.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks keys showing first 4 and last 4 characters', () => {
|
||||
const result = maskApiKey('sk-1234567890abcdef');
|
||||
assert.strictEqual(result, 'sk-1...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for keys <= 12 characters', () => {
|
||||
assert.strictEqual(maskApiKey('123456789012'), '****');
|
||||
assert.strictEqual(maskApiKey('12345678901'), '****');
|
||||
assert.strictEqual(maskApiKey('short'), '****');
|
||||
assert.strictEqual(maskApiKey(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 13 character keys', () => {
|
||||
const result = maskApiKey('1234567890abc');
|
||||
assert.strictEqual(result, '1234...0abc');
|
||||
});
|
||||
|
||||
it('handles long API keys', () => {
|
||||
const longKey = 'api_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
const result = maskApiKey(longKey);
|
||||
assert.strictEqual(result, 'api_...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters', () => {
|
||||
const key = '!@#$%^&*()_+-=[]{}';
|
||||
const result = maskApiKey(key);
|
||||
assert.strictEqual(result, '!@#$...[]{}');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Settings Merge Tests
|
||||
// =========================================================================
|
||||
describe('Settings Merge Logic', () => {
|
||||
/**
|
||||
* Simulates the merge logic from persist-command.ts
|
||||
*/
|
||||
function mergeSettings(existing, newEnv) {
|
||||
const existingEnv = existing.env || {};
|
||||
return {
|
||||
...existing,
|
||||
env: {
|
||||
...existingEnv,
|
||||
...newEnv,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('merges env vars into empty settings', () => {
|
||||
const existing = {};
|
||||
const newEnv = { ANTHROPIC_BASE_URL: 'http://example.com' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.deepStrictEqual(result.env, newEnv);
|
||||
});
|
||||
|
||||
it('preserves existing hooks', () => {
|
||||
const existing = {
|
||||
hooks: { PreToolUse: [{ matcher: 'WebSearch' }] },
|
||||
};
|
||||
const newEnv = { ANTHROPIC_MODEL: 'test' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.deepStrictEqual(result.hooks, existing.hooks);
|
||||
});
|
||||
|
||||
it('preserves existing presets', () => {
|
||||
const existing = {
|
||||
presets: [{ name: 'test', default: 'model' }],
|
||||
};
|
||||
const newEnv = { ANTHROPIC_MODEL: 'test' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.deepStrictEqual(result.presets, existing.presets);
|
||||
});
|
||||
|
||||
it('preserves other settings like model and alwaysThinkingEnabled', () => {
|
||||
const existing = {
|
||||
model: 'opus',
|
||||
alwaysThinkingEnabled: true,
|
||||
};
|
||||
const newEnv = { ANTHROPIC_MODEL: 'test' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.strictEqual(result.model, 'opus');
|
||||
assert.strictEqual(result.alwaysThinkingEnabled, true);
|
||||
});
|
||||
|
||||
it('merges new env vars with existing env vars', () => {
|
||||
const existing = {
|
||||
env: { EXISTING_VAR: 'value' },
|
||||
};
|
||||
const newEnv = { NEW_VAR: 'new_value' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.strictEqual(result.env.EXISTING_VAR, 'value');
|
||||
assert.strictEqual(result.env.NEW_VAR, 'new_value');
|
||||
});
|
||||
|
||||
it('overwrites existing env vars with new values', () => {
|
||||
const existing = {
|
||||
env: { ANTHROPIC_MODEL: 'old_model' },
|
||||
};
|
||||
const newEnv = { ANTHROPIC_MODEL: 'new_model' };
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
assert.strictEqual(result.env.ANTHROPIC_MODEL, 'new_model');
|
||||
});
|
||||
|
||||
it('handles complex settings with multiple fields', () => {
|
||||
const existing = {
|
||||
hooks: { PreToolUse: [] },
|
||||
presets: [],
|
||||
model: 'sonnet',
|
||||
env: { OLD_VAR: 'old' },
|
||||
customField: 'custom',
|
||||
};
|
||||
const newEnv = {
|
||||
ANTHROPIC_BASE_URL: 'http://test.com',
|
||||
ANTHROPIC_MODEL: 'test',
|
||||
};
|
||||
const result = mergeSettings(existing, newEnv);
|
||||
|
||||
assert.deepStrictEqual(result.hooks, existing.hooks);
|
||||
assert.deepStrictEqual(result.presets, existing.presets);
|
||||
assert.strictEqual(result.model, 'sonnet');
|
||||
assert.strictEqual(result.customField, 'custom');
|
||||
assert.strictEqual(result.env.OLD_VAR, 'old');
|
||||
assert.strictEqual(result.env.ANTHROPIC_BASE_URL, 'http://test.com');
|
||||
assert.strictEqual(result.env.ANTHROPIC_MODEL, 'test');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Backup Timestamp Tests
|
||||
// =========================================================================
|
||||
describe('Backup Timestamp Format', () => {
|
||||
/**
|
||||
* Simulates the timestamp generation from persist-command.ts
|
||||
*/
|
||||
function generateTimestamp(date) {
|
||||
return (
|
||||
date.getFullYear().toString() +
|
||||
(date.getMonth() + 1).toString().padStart(2, '0') +
|
||||
date.getDate().toString().padStart(2, '0') +
|
||||
'_' +
|
||||
date.getHours().toString().padStart(2, '0') +
|
||||
date.getMinutes().toString().padStart(2, '0') +
|
||||
date.getSeconds().toString().padStart(2, '0')
|
||||
);
|
||||
}
|
||||
|
||||
it('generates correct format YYYYMMDD_HHMMSS', () => {
|
||||
const date = new Date(2025, 0, 15, 10, 30, 45); // Jan 15, 2025 10:30:45
|
||||
const result = generateTimestamp(date);
|
||||
assert.strictEqual(result, '20250115_103045');
|
||||
});
|
||||
|
||||
it('pads single digit months', () => {
|
||||
const date = new Date(2025, 0, 1, 0, 0, 0); // Jan 1
|
||||
const result = generateTimestamp(date);
|
||||
assert.match(result, /^202501/);
|
||||
});
|
||||
|
||||
it('pads single digit days', () => {
|
||||
const date = new Date(2025, 11, 5, 0, 0, 0); // Dec 5
|
||||
const result = generateTimestamp(date);
|
||||
assert.match(result, /^20251205/);
|
||||
});
|
||||
|
||||
it('pads single digit hours, minutes, seconds', () => {
|
||||
const date = new Date(2025, 5, 15, 1, 2, 3);
|
||||
const result = generateTimestamp(date);
|
||||
assert.strictEqual(result, '20250615_010203');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Profile Type Detection Tests
|
||||
// =========================================================================
|
||||
describe('Profile Type Messages', () => {
|
||||
const profileTypes = {
|
||||
settings: 'API',
|
||||
cliproxy: 'CLIProxy',
|
||||
copilot: 'Copilot',
|
||||
account: 'Account (not supported)',
|
||||
default: 'Default (not supported)',
|
||||
};
|
||||
|
||||
it('maps settings type to API', () => {
|
||||
assert.strictEqual(profileTypes.settings, 'API');
|
||||
});
|
||||
|
||||
it('maps cliproxy type to CLIProxy', () => {
|
||||
assert.strictEqual(profileTypes.cliproxy, 'CLIProxy');
|
||||
});
|
||||
|
||||
it('maps copilot type to Copilot', () => {
|
||||
assert.strictEqual(profileTypes.copilot, 'Copilot');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Sensitive Key Detection Tests
|
||||
// =========================================================================
|
||||
describe('Sensitive Key Detection', () => {
|
||||
/**
|
||||
* Simulates the logic to detect sensitive keys for masking
|
||||
*/
|
||||
function isSensitiveKey(key) {
|
||||
return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET');
|
||||
}
|
||||
|
||||
it('detects TOKEN in key name', () => {
|
||||
assert.strictEqual(isSensitiveKey('ANTHROPIC_AUTH_TOKEN'), true);
|
||||
assert.strictEqual(isSensitiveKey('ACCESS_TOKEN'), true);
|
||||
});
|
||||
|
||||
it('detects KEY in key name', () => {
|
||||
assert.strictEqual(isSensitiveKey('API_KEY'), true);
|
||||
assert.strictEqual(isSensitiveKey('ANTHROPIC_API_KEY'), true);
|
||||
});
|
||||
|
||||
it('detects SECRET in key name', () => {
|
||||
assert.strictEqual(isSensitiveKey('CLIENT_SECRET'), true);
|
||||
assert.strictEqual(isSensitiveKey('SECRET_KEY'), true);
|
||||
});
|
||||
|
||||
it('does not flag non-sensitive keys', () => {
|
||||
assert.strictEqual(isSensitiveKey('ANTHROPIC_BASE_URL'), false);
|
||||
assert.strictEqual(isSensitiveKey('ANTHROPIC_MODEL'), false);
|
||||
assert.strictEqual(isSensitiveKey('DISABLE_TELEMETRY'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Help Text Coverage Tests
|
||||
// =========================================================================
|
||||
describe('Help Text Coverage', () => {
|
||||
const expectedOptions = ['--yes', '-y', '--help', '-h'];
|
||||
const expectedProfileTypes = ['API profiles', 'CLIProxy', 'Copilot', 'Account-based'];
|
||||
|
||||
it('documents all CLI options', () => {
|
||||
expectedOptions.forEach((option) => {
|
||||
assert(typeof option === 'string', `Option ${option} should be a string`);
|
||||
assert(option.startsWith('-'), `Option ${option} should start with -`);
|
||||
});
|
||||
});
|
||||
|
||||
it('documents all profile types', () => {
|
||||
expectedProfileTypes.forEach((type) => {
|
||||
assert(typeof type === 'string', `Profile type ${type} should be documented`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Error Message Tests
|
||||
// =========================================================================
|
||||
describe('Error Messages', () => {
|
||||
it('account profile error message mentions CLAUDE_CONFIG_DIR', () => {
|
||||
const errorMessage =
|
||||
"Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n" +
|
||||
"Use 'ccs profileName' to run with this profile instead.";
|
||||
assert(errorMessage.includes('CLAUDE_CONFIG_DIR'));
|
||||
assert(errorMessage.includes('ccs profileName'));
|
||||
});
|
||||
|
||||
it('no env vars error message includes profile name placeholder', () => {
|
||||
const profileName = 'test';
|
||||
const errorMessage = `Profile '${profileName}' has no env vars configured`;
|
||||
assert(errorMessage.includes(profileName));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Backup File Parsing Tests
|
||||
// =========================================================================
|
||||
describe('Backup File Parsing', () => {
|
||||
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
||||
|
||||
/**
|
||||
* Simulates parsing a backup filename into a BackupFile object
|
||||
*/
|
||||
function parseBackupFile(filename, dir) {
|
||||
const match = filename.match(backupPattern);
|
||||
if (!match) return null;
|
||||
const timestamp = match[1];
|
||||
// Parse YYYYMMDD_HHMMSS
|
||||
const year = parseInt(timestamp.slice(0, 4));
|
||||
const month = parseInt(timestamp.slice(4, 6)) - 1;
|
||||
const day = parseInt(timestamp.slice(6, 8));
|
||||
const hour = parseInt(timestamp.slice(9, 11));
|
||||
const min = parseInt(timestamp.slice(11, 13));
|
||||
const sec = parseInt(timestamp.slice(13, 15));
|
||||
return {
|
||||
path: dir + '/' + filename,
|
||||
timestamp,
|
||||
date: new Date(year, month, day, hour, min, sec),
|
||||
};
|
||||
}
|
||||
|
||||
it('matches valid backup filename pattern', () => {
|
||||
assert(backupPattern.test('settings.json.backup.20260110_205324'));
|
||||
assert(backupPattern.test('settings.json.backup.20250101_000000'));
|
||||
});
|
||||
|
||||
it('rejects invalid backup filenames', () => {
|
||||
assert(!backupPattern.test('settings.json'));
|
||||
assert(!backupPattern.test('settings.json.backup'));
|
||||
assert(!backupPattern.test('settings.json.backup.invalid'));
|
||||
assert(!backupPattern.test('settings.json.backup.20260110'));
|
||||
assert(!backupPattern.test('other.json.backup.20260110_205324'));
|
||||
});
|
||||
|
||||
it('parses backup file correctly', () => {
|
||||
const result = parseBackupFile('settings.json.backup.20260110_205324', '/home/user/.claude');
|
||||
assert.strictEqual(result.timestamp, '20260110_205324');
|
||||
assert.strictEqual(result.path, '/home/user/.claude/settings.json.backup.20260110_205324');
|
||||
assert.strictEqual(result.date.getFullYear(), 2026);
|
||||
assert.strictEqual(result.date.getMonth(), 0); // January
|
||||
assert.strictEqual(result.date.getDate(), 10);
|
||||
assert.strictEqual(result.date.getHours(), 20);
|
||||
assert.strictEqual(result.date.getMinutes(), 53);
|
||||
assert.strictEqual(result.date.getSeconds(), 24);
|
||||
});
|
||||
|
||||
it('returns null for non-matching files', () => {
|
||||
const result = parseBackupFile('settings.json', '/home/user/.claude');
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('sorts backup files by date descending (newest first)', () => {
|
||||
const files = [
|
||||
{ timestamp: '20260109_100000', date: new Date(2026, 0, 9, 10, 0, 0) },
|
||||
{ timestamp: '20260110_205324', date: new Date(2026, 0, 10, 20, 53, 24) },
|
||||
{ timestamp: '20260110_100000', date: new Date(2026, 0, 10, 10, 0, 0) },
|
||||
];
|
||||
const sorted = files.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
assert.strictEqual(sorted[0].timestamp, '20260110_205324');
|
||||
assert.strictEqual(sorted[1].timestamp, '20260110_100000');
|
||||
assert.strictEqual(sorted[2].timestamp, '20260109_100000');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Backup Restore Logic Tests
|
||||
// =========================================================================
|
||||
describe('Backup Restore Logic', () => {
|
||||
it('selects first backup when restore=true (latest)', () => {
|
||||
const backups = [
|
||||
{ timestamp: '20260110_205324' },
|
||||
{ timestamp: '20260110_100000' },
|
||||
];
|
||||
const restore = true;
|
||||
const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore);
|
||||
assert.strictEqual(selected.timestamp, '20260110_205324');
|
||||
});
|
||||
|
||||
it('selects specific backup when restore is a timestamp', () => {
|
||||
const backups = [
|
||||
{ timestamp: '20260110_205324' },
|
||||
{ timestamp: '20260110_100000' },
|
||||
];
|
||||
const restore = '20260110_100000';
|
||||
const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore);
|
||||
assert.strictEqual(selected.timestamp, '20260110_100000');
|
||||
});
|
||||
|
||||
it('returns undefined when timestamp not found', () => {
|
||||
const backups = [
|
||||
{ timestamp: '20260110_205324' },
|
||||
{ timestamp: '20260110_100000' },
|
||||
];
|
||||
const restore = '20260101_000000';
|
||||
const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore);
|
||||
assert.strictEqual(selected, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Auth Middleware Tests
|
||||
* Tests for dashboard authentication middleware and routes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
// Mock the config loader
|
||||
const mockAuthConfig = {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password_hash: '',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
|
||||
mock.module('../../src/config/unified-config-loader', () => ({
|
||||
getDashboardAuthConfig: () => ({ ...mockAuthConfig }),
|
||||
}));
|
||||
|
||||
describe('Dashboard Auth', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to default disabled state
|
||||
mockAuthConfig.enabled = false;
|
||||
mockAuthConfig.username = '';
|
||||
mockAuthConfig.password_hash = '';
|
||||
});
|
||||
|
||||
describe('getDashboardAuthConfig', () => {
|
||||
it('returns disabled by default', async () => {
|
||||
const { getDashboardAuthConfig } = await import(
|
||||
'../../src/config/unified-config-loader'
|
||||
);
|
||||
const config = getDashboardAuthConfig();
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 24 hour default session timeout', async () => {
|
||||
const { getDashboardAuthConfig } = await import(
|
||||
'../../src/config/unified-config-loader'
|
||||
);
|
||||
const config = getDashboardAuthConfig();
|
||||
expect(config.session_timeout_hours).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bcrypt password hashing', () => {
|
||||
it('generates valid bcrypt hash', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
expect(hash).toMatch(/^\$2[aby]\$\d{2}\$.{53}$/);
|
||||
});
|
||||
|
||||
it('verifies correct password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
const isValid = await bcrypt.compare(password, hash);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects incorrect password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
const isValid = await bcrypt.compare('wrongpassword', hash);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('timing-safe comparison for wrong password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
// bcrypt.compare should take similar time for wrong vs right password
|
||||
// This is a basic check that the function works
|
||||
const start1 = performance.now();
|
||||
await bcrypt.compare('wrongpassword', hash);
|
||||
const time1 = performance.now() - start1;
|
||||
|
||||
const start2 = performance.now();
|
||||
await bcrypt.compare(password, hash);
|
||||
const time2 = performance.now() - start2;
|
||||
|
||||
// Both should complete (timing comparison is handled by bcrypt internally)
|
||||
expect(time1).toBeGreaterThan(0);
|
||||
expect(time2).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public paths', () => {
|
||||
const PUBLIC_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/check',
|
||||
'/api/auth/setup',
|
||||
'/api/health',
|
||||
];
|
||||
|
||||
it('identifies public paths correctly', () => {
|
||||
const isPublicPath = (path: string) =>
|
||||
PUBLIC_PATHS.some((p) => path.toLowerCase().startsWith(p));
|
||||
|
||||
expect(isPublicPath('/api/auth/login')).toBe(true);
|
||||
expect(isPublicPath('/api/auth/check')).toBe(true);
|
||||
expect(isPublicPath('/api/auth/setup')).toBe(true);
|
||||
expect(isPublicPath('/api/health')).toBe(true);
|
||||
expect(isPublicPath('/api/profiles')).toBe(false);
|
||||
expect(isPublicPath('/api/config')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles case-insensitive paths', () => {
|
||||
const isPublicPath = (path: string) =>
|
||||
PUBLIC_PATHS.some((p) => path.toLowerCase().startsWith(p));
|
||||
|
||||
expect(isPublicPath('/API/AUTH/LOGIN')).toBe(true);
|
||||
expect(isPublicPath('/Api/Health')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session configuration', () => {
|
||||
it('session timeout converts to milliseconds correctly', () => {
|
||||
const hours = 24;
|
||||
const maxAge = hours * 60 * 60 * 1000;
|
||||
|
||||
expect(maxAge).toBe(86400000); // 24 hours in ms
|
||||
});
|
||||
|
||||
it('custom session timeout works', () => {
|
||||
const hours = 8;
|
||||
const maxAge = hours * 60 * 60 * 1000;
|
||||
|
||||
expect(maxAge).toBe(28800000); // 8 hours in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth flow logic', () => {
|
||||
it('bypasses auth when disabled', () => {
|
||||
mockAuthConfig.enabled = false;
|
||||
const shouldSkip = !mockAuthConfig.enabled;
|
||||
expect(shouldSkip).toBe(true);
|
||||
});
|
||||
|
||||
it('requires auth when enabled', () => {
|
||||
mockAuthConfig.enabled = true;
|
||||
mockAuthConfig.username = 'admin';
|
||||
mockAuthConfig.password_hash = '$2b$10$test';
|
||||
|
||||
const shouldSkip = !mockAuthConfig.enabled;
|
||||
expect(shouldSkip).toBe(false);
|
||||
});
|
||||
|
||||
it('validates username match', () => {
|
||||
mockAuthConfig.username = 'admin';
|
||||
const usernameMatch = 'admin' === mockAuthConfig.username;
|
||||
expect(usernameMatch).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong username', () => {
|
||||
mockAuthConfig.username = 'admin';
|
||||
const usernameMatch = 'wrong' === mockAuthConfig.username;
|
||||
expect(usernameMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting config', () => {
|
||||
it('rate limit window is 15 minutes', () => {
|
||||
const windowMs = 15 * 60 * 1000;
|
||||
expect(windowMs).toBe(900000);
|
||||
});
|
||||
|
||||
it('max attempts is 5', () => {
|
||||
const maxAttempts = 5;
|
||||
expect(maxAttempts).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
|
||||
+103
-19
@@ -1,14 +1,18 @@
|
||||
import { lazy } from 'react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { ThemeProvider } from '@/components/layout/theme-provider';
|
||||
import { PrivacyProvider } from '@/contexts/privacy-context';
|
||||
import { AuthProvider } from '@/contexts/auth-context';
|
||||
import { RequireAuth } from '@/components/auth/require-auth';
|
||||
import { Layout } from '@/components/layout/layout';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// Eager load: HomePage (initial route)
|
||||
// Eager load: HomePage (initial route) + LoginPage (auth flow)
|
||||
import { HomePage } from '@/pages';
|
||||
import { LoginPage } from '@/pages/login';
|
||||
|
||||
// Lazy load: heavy pages with charts or complex dependencies
|
||||
const AnalyticsPage = lazy(() =>
|
||||
@@ -31,28 +35,108 @@ const SettingsPage = lazy(() =>
|
||||
const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage })));
|
||||
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
|
||||
|
||||
// Loading fallback for lazy components
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||
<PrivacyProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/providers" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||
<Route path="/copilot" element={<CopilotPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/shared" element={<SharedPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public route: Login page */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes: wrapped with RequireAuth */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/analytics"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AnalyticsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/providers"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ApiPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cliproxy"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<CliproxyPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cliproxy/control-panel"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<CliproxyControlPanelPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/copilot"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<CopilotPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AccountsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/health"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<HealthPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shared"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SharedPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* RequireAuth - Protected route wrapper component
|
||||
* Redirects to login page if auth is required but user is not authenticated.
|
||||
*/
|
||||
|
||||
import { Navigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export function RequireAuth() {
|
||||
const { authRequired, isAuthenticated, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If auth not required, allow access
|
||||
if (!authRequired) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to login
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Authenticated, render children
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* User Menu - Header component showing username and logout button
|
||||
* Only renders when auth is enabled and user is authenticated.
|
||||
*/
|
||||
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { User, LogOut } from 'lucide-react';
|
||||
|
||||
export function UserMenu() {
|
||||
const { authRequired, isAuthenticated, username, logout } = useAuth();
|
||||
|
||||
// Only show when auth is enabled and user is logged in
|
||||
if (!authRequired || !isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{username}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleLogout} className="gap-2 text-destructive">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ClaudeKitBadge } from '@/components/shared/claudekit-badge';
|
||||
import { SponsorButton } from '@/components/shared/sponsor-button';
|
||||
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
|
||||
import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
|
||||
import { UserMenu } from '@/components/auth/user-menu';
|
||||
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||
import { useDeviceCode } from '@/hooks/use-device-code';
|
||||
|
||||
@@ -44,6 +45,7 @@ export function Layout() {
|
||||
<GitHubLink />
|
||||
<PrivacyToggle />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Auth Context - Dashboard authentication state management
|
||||
* Provides auth status and login/logout functions globally.
|
||||
*/
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { checkAuth, login as apiLogin, logout as apiLogout } from '@/lib/auth-api';
|
||||
|
||||
interface AuthContextValue {
|
||||
/** Whether authentication is required for this dashboard */
|
||||
authRequired: boolean;
|
||||
/** Whether user is currently authenticated */
|
||||
isAuthenticated: boolean;
|
||||
/** Username of authenticated user */
|
||||
username: string | null;
|
||||
/** Whether auth check is in progress */
|
||||
loading: boolean;
|
||||
/** Login with credentials */
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** Logout current session */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
.then((res) => {
|
||||
setAuthRequired(res.authRequired);
|
||||
setIsAuthenticated(res.authenticated);
|
||||
setUsername(res.username);
|
||||
})
|
||||
.catch(() => {
|
||||
// If check fails, assume no auth required (backward compat)
|
||||
setAuthRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (user: string, password: string) => {
|
||||
const res = await apiLogin(user, password);
|
||||
setIsAuthenticated(true);
|
||||
setUsername(res.username);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await apiLogout();
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
authRequired,
|
||||
isAuthenticated,
|
||||
username,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
}),
|
||||
[authRequired, isAuthenticated, username, loading, login, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -69,7 +69,8 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
const ws = new WebSocket(`ws://${window.location.host}`);
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Auth API Client
|
||||
* Handles authentication-related API calls.
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api/auth';
|
||||
|
||||
export interface AuthCheckResponse {
|
||||
authRequired: boolean;
|
||||
authenticated: boolean;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface AuthSetupResponse {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
sessionTimeoutHours: number;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
username: string;
|
||||
}
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Include cookies for session
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || res.statusText);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Check authentication status */
|
||||
export function checkAuth(): Promise<AuthCheckResponse> {
|
||||
return request('/check');
|
||||
}
|
||||
|
||||
/** Check auth setup status */
|
||||
export function getAuthSetup(): Promise<AuthSetupResponse> {
|
||||
return request('/setup');
|
||||
}
|
||||
|
||||
/** Login with username/password */
|
||||
export function login(username: string, password: string): Promise<LoginResponse> {
|
||||
return request('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Logout current session */
|
||||
export function logout(): Promise<{ success: boolean }> {
|
||||
return request('/logout', { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Login Page - Dashboard authentication form
|
||||
* Uses shadcn/ui Card and Input components.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertCircle, Loader2, Lock } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login, authRequired, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get redirect destination (default to home)
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
|
||||
|
||||
// Redirect if already authenticated or auth not required (via useEffect to avoid render side effects)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated || !authRequired) {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, authRequired, navigate, from]);
|
||||
|
||||
// Show nothing while redirecting
|
||||
if (isAuthenticated || !authRequired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">CCS Dashboard</CardTitle>
|
||||
<CardDescription>Enter your credentials to access the dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user