mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
Merge pull request #147 from kaitranntt/dev
feat: remote proxy mode, error log viewer, and CI improvements
This commit is contained in:
@@ -6,13 +6,15 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Skip if commit message contains [skip ci] or is a release commit
|
||||
# Skip if commit message contains [skip ci]
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -43,88 +45,59 @@ jobs:
|
||||
- name: Validate (typecheck + lint + tests)
|
||||
run: bun run validate
|
||||
|
||||
- name: Bump dev version
|
||||
id: bump
|
||||
run: |
|
||||
CURRENT=$(cat VERSION)
|
||||
PKG_NAME=$(jq -r '.name' package.json)
|
||||
|
||||
# Extract base version
|
||||
if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then
|
||||
BASE="${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "Invalid version format: $CURRENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find highest published dev version for this base
|
||||
LATEST_DEV=$(npm view "${PKG_NAME}" versions --json 2>/dev/null | \
|
||||
jq -r '.[]' | \
|
||||
grep "^${BASE}-dev\." | \
|
||||
sed "s/${BASE}-dev\.//" | \
|
||||
sort -n | \
|
||||
tail -1)
|
||||
|
||||
if [[ -z "$LATEST_DEV" ]]; then
|
||||
NEW_DEV=1
|
||||
else
|
||||
NEW_DEV=$((LATEST_DEV + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${BASE}-dev.${NEW_DEV}"
|
||||
|
||||
echo "current=$CURRENT" >> $GITHUB_OUTPUT
|
||||
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Bumping: $CURRENT -> $NEW_VERSION"
|
||||
|
||||
- name: Update version files
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump.outputs.new }}"
|
||||
|
||||
# Update VERSION file
|
||||
echo "$NEW_VERSION" > VERSION
|
||||
|
||||
# Update package.json
|
||||
jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
# Update installers
|
||||
sed -i "s/^VERSION=.*/VERSION=\"$NEW_VERSION\"/" installers/install.sh
|
||||
sed -i "s/^\$Version = .*/\$Version = \"$NEW_VERSION\"/" installers/install.ps1
|
||||
|
||||
- name: Publish to npm
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
HUSKY: 0
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --tag dev
|
||||
run: |
|
||||
OUTPUT=$(bunx semantic-release 2>&1) || true
|
||||
echo "$OUTPUT"
|
||||
if echo "$OUTPUT" | grep -q "Published release"; then
|
||||
echo "released=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "released=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify Discord
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "DISCORD_WEBHOOK_URL not set, skipping"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/send-discord-release.cjs dev "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
- name: Tag resolved issues
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump.outputs.new }}"
|
||||
# Get version from package.json (updated by semantic-release)
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
|
||||
# Find commits since last dev release (look for version bump commits)
|
||||
LAST_RELEASE_COMMIT=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1)
|
||||
|
||||
if [[ -n "$LAST_RELEASE_COMMIT" ]]; then
|
||||
RANGE="${LAST_RELEASE_COMMIT}..HEAD"
|
||||
else
|
||||
RANGE="HEAD~20..HEAD"
|
||||
fi
|
||||
# Find commits since last release
|
||||
LAST_RELEASE=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1)
|
||||
RANGE="${LAST_RELEASE:-HEAD~20}..HEAD"
|
||||
|
||||
echo "Checking commits in range: $RANGE"
|
||||
|
||||
# Extract issue numbers from commits
|
||||
# Extract issue numbers
|
||||
ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \
|
||||
grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
|
||||
grep -oE "#[0-9]+" | sort -u || true)
|
||||
|
||||
if [[ -z "$ISSUES" ]]; then
|
||||
echo "No linked issues found in commits"
|
||||
echo "No linked issues found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create labels if they don't exist
|
||||
# Create label if needed
|
||||
gh label create "released-dev" \
|
||||
--color "1d76db" \
|
||||
--description "Fix available in @dev npm channel" \
|
||||
@@ -133,27 +106,13 @@ jobs:
|
||||
for ISSUE in $ISSUES; do
|
||||
NUM=${ISSUE#\#}
|
||||
|
||||
# Skip if already tagged (avoid spam)
|
||||
# Skip if already tagged
|
||||
if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then
|
||||
echo "Issue #$NUM already has released-dev label, skipping"
|
||||
echo "Issue #$NUM already tagged, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Tagging issue #$NUM as released-dev"
|
||||
|
||||
# Add comment
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body ":test_tube: Available in \`$NEW_VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true
|
||||
|
||||
# Add released-dev label
|
||||
echo "Tagging issue #$NUM"
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true
|
||||
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
|
||||
done
|
||||
|
||||
- name: Commit version bump
|
||||
env:
|
||||
HUSKY: 0
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add VERSION package.json installers/install.sh installers/install.ps1
|
||||
git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]"
|
||||
git push origin dev
|
||||
|
||||
@@ -45,10 +45,29 @@ jobs:
|
||||
run: bun run validate
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
HUSKY: 0
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bunx semantic-release
|
||||
run: |
|
||||
OUTPUT=$(bunx semantic-release 2>&1) || true
|
||||
echo "$OUTPUT"
|
||||
if echo "$OUTPUT" | grep -q "Published release"; then
|
||||
echo "released=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "released=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify Discord
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||
echo "DISCORD_WEBHOOK_URL not set, skipping"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Semantic Release Configuration
|
||||
*
|
||||
* Branch-aware config:
|
||||
* - dev branch: Uses dev release configuration (prerelease)
|
||||
* - main branch: Uses production release configuration
|
||||
*/
|
||||
|
||||
const currentBranch =
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
process.env.GIT_BRANCH ||
|
||||
(process.env.GITHUB_REF && process.env.GITHUB_REF.replace('refs/heads/', '')) ||
|
||||
require('child_process').execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
|
||||
|
||||
console.error(`[semantic-release config] Branch: ${currentBranch}`);
|
||||
|
||||
// Shared plugin config
|
||||
const commitAnalyzer = [
|
||||
'@semantic-release/commit-analyzer',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
releaseRules: [
|
||||
{ type: 'docs', scope: 'README', release: 'patch' },
|
||||
{ type: 'refactor', release: 'patch' },
|
||||
{ type: 'style', release: 'patch' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const releaseNotesGenerator = [
|
||||
'@semantic-release/release-notes-generator',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
presetConfig: {
|
||||
types: [
|
||||
{ type: 'feat', section: 'Features' },
|
||||
{ type: 'fix', section: 'Bug Fixes' },
|
||||
{ type: 'docs', section: 'Documentation' },
|
||||
{ type: 'style', section: 'Styles' },
|
||||
{ type: 'refactor', section: 'Code Refactoring' },
|
||||
{ type: 'perf', section: 'Performance Improvements' },
|
||||
{ type: 'test', section: 'Tests' },
|
||||
{ type: 'build', section: 'Build System' },
|
||||
{ type: 'ci', section: 'CI' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Dev release configuration
|
||||
const devConfig = {
|
||||
branches: [
|
||||
'main', // Required even in dev config
|
||||
{
|
||||
name: 'dev',
|
||||
prerelease: 'dev',
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
commitAnalyzer,
|
||||
releaseNotesGenerator,
|
||||
[
|
||||
'@semantic-release/changelog',
|
||||
{
|
||||
changelogFile: 'CHANGELOG.md',
|
||||
},
|
||||
],
|
||||
'@semantic-release/npm',
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
prerelease: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
// Production release configuration
|
||||
const productionConfig = {
|
||||
branches: ['main'],
|
||||
plugins: [
|
||||
commitAnalyzer,
|
||||
releaseNotesGenerator,
|
||||
[
|
||||
'@semantic-release/changelog',
|
||||
{
|
||||
changelogFile: 'CHANGELOG.md',
|
||||
},
|
||||
],
|
||||
'@semantic-release/npm',
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
successComment:
|
||||
':tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@latest)](https://www.npmjs.com/package/@kaitranntt/ccs)\n- [GitHub release](${releases[0].url})',
|
||||
releasedLabels: ['released'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const config = currentBranch === 'dev' ? devConfig : productionConfig;
|
||||
|
||||
console.error(`[semantic-release config] Using ${currentBranch === 'dev' ? 'DEV' : 'PRODUCTION'} config`);
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
["@semantic-release/changelog", {
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}],
|
||||
"./scripts/sync-version-plugin.cjs",
|
||||
["@semantic-release/npm", {
|
||||
"npmPublish": true
|
||||
}],
|
||||
["@semantic-release/git", {
|
||||
"assets": ["CHANGELOG.md", "package.json", "VERSION", "installers/install.sh", "installers/install.ps1"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}],
|
||||
["@semantic-release/github", {
|
||||
"successComment": ":tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@${nextRelease.channel || 'latest'})](https://www.npmjs.com/package/ccs-claude-code-switcher)\n- [GitHub release](${releases.filter(r => r.name === 'GitHub release').map(r => r.url)[0] || url})",
|
||||
"failComment": false,
|
||||
"releasedLabels": ["released"]
|
||||
}]
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,75 @@
|
||||
## [6.6.0-dev.4](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.3...v6.6.0-dev.4) (2025-12-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **profiles:** prevent env var inheritance for settings-based profiles ([903bc10](https://github.com/kaitranntt/ccs/commit/903bc10fea11694474f772356f301b8e4b37298e))
|
||||
|
||||
## [6.6.0-dev.3](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.2...v6.6.0-dev.3) (2025-12-19)
|
||||
|
||||
### Features
|
||||
|
||||
* **cliproxy:** add getRemoteEnvVars for remote proxy mode ([f4a50d0](https://github.com/kaitranntt/ccs/commit/f4a50d006c1f6bd284fe743f9a322540763e1848))
|
||||
* **cliproxy:** add proxy config resolver with CLI flag support ([68a93f0](https://github.com/kaitranntt/ccs/commit/68a93f0500f396ebcc65cc133c1a444ae5a0f220))
|
||||
* **cliproxy:** add remote proxy client for health checks ([30d564c](https://github.com/kaitranntt/ccs/commit/30d564cda66a54c2ac12788559624cb0736cdeb3))
|
||||
* **cliproxy:** integrate remote proxy mode in executor ([bd1ff2f](https://github.com/kaitranntt/ccs/commit/bd1ff2f059d01d4371b2230d4902bc5ab210055e))
|
||||
* **config:** add proxy configuration types and schema ([eff2e2d](https://github.com/kaitranntt/ccs/commit/eff2e2d29f3f227c05103c252823fb9e040b6e49))
|
||||
* **config:** add proxy section to unified config loader ([1971744](https://github.com/kaitranntt/ccs/commit/197174441f6eeca5e3c98e88af43d91ee081f734))
|
||||
* **ui:** add Proxy settings tab to dashboard ([9a9ef98](https://github.com/kaitranntt/ccs/commit/9a9ef98542bb766087b711fc39e928e347ad9b86))
|
||||
* **web-server:** add proxy configuration API routes ([8decdfb](https://github.com/kaitranntt/ccs/commit/8decdfb515075b772970de7c85b34c31baf93754))
|
||||
|
||||
### Documentation
|
||||
|
||||
* **cliproxy:** add remote proxy documentation ([196422c](https://github.com/kaitranntt/ccs/commit/196422cee1f7410d385581f2a28df3faa87d68e3))
|
||||
|
||||
### Styles
|
||||
|
||||
* **ui:** use sidebar accent colors for proxy update button ([eeb6913](https://github.com/kaitranntt/ccs/commit/eeb6913d96fe1a9a0d8721627a07c7f772b67b88))
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* rename proxy to cliproxy_server and update API routes ([8d8d4c2](https://github.com/kaitranntt/ccs/commit/8d8d4c248ad890413d5c4e7e72f9f2a16305f74f))
|
||||
|
||||
## [6.6.0-dev.2](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.1...v6.6.0-dev.2) (2025-12-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** remove sync-version.js that depends on deleted VERSION file ([18729c9](https://github.com/kaitranntt/ccs/commit/18729c9983ecd1f9d857b0de2753e99c675c624a))
|
||||
|
||||
## [6.6.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.5.0...v6.6.0-dev.1) (2025-12-19)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Native shell installers (curl/irm) no longer work.
|
||||
Use `npm install -g @kaitranntt/ccs` instead.
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add Discord notifications for releases ([ee76d66](https://github.com/kaitranntt/ccs/commit/ee76d663aec59a86a236156dbc163d0d291c0446))
|
||||
* **ci:** add semantic-release for dev branch with rich Discord notifications ([0f590c8](https://github.com/kaitranntt/ccs/commit/0f590c80d689c39cea7c94937ed398941dddb533))
|
||||
* **cleanup:** add age-based error log cleanup ([45207b4](https://github.com/kaitranntt/ccs/commit/45207b4e7f92c09d7464dd5c954718254ddfd43a))
|
||||
* **cliproxy:** set WRITABLE_PATH for log storage in ~/.ccs/cliproxy/ ([6b9396f](https://github.com/kaitranntt/ccs/commit/6b9396fbc6d464bc3e3d6d3bb639e70fe5306074))
|
||||
* **dashboard:** add error log viewer for CLIProxy diagnostics ([5b3d565](https://github.com/kaitranntt/ccs/commit/5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb)), closes [#132](https://github.com/kaitranntt/ccs/issues/132)
|
||||
* **global-env:** add global environment variables injection for third-party profiles ([5d34326](https://github.com/kaitranntt/ccs/commit/5d343260c7307c2d7ac8da92eb5f94c7f764d08c))
|
||||
* **ui:** add absolute path copy for error logs ([5d4f49e](https://github.com/kaitranntt/ccs/commit/5d4f49e4bb6f9748efa89e96c342dfae3e35d02b))
|
||||
* **ui:** add Stop and Restart buttons to ProxyStatusWidget ([c9ad0b0](https://github.com/kaitranntt/ccs/commit/c9ad0b077934ae8418d4e97b9b02a09044ff898b))
|
||||
* **ui:** add version sync timestamp to ProxyStatusWidget ([d43079b](https://github.com/kaitranntt/ccs/commit/d43079b72414d7b841a35a934ea39a91527f4172))
|
||||
* **ui:** redesign error logs monitor with split view layout ([8f47b87](https://github.com/kaitranntt/ccs/commit/8f47b8775f2c2493c05ee2be861ca3f8667cfc0e))
|
||||
* **ui:** show CLIProxyAPI update availability in dashboard ([96762a9](https://github.com/kaitranntt/ccs/commit/96762a9f6ee096570b2fe6136a4431e6ce1d1a47))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** remove deprecated installer references from dev-release workflow ([4b969b6](https://github.com/kaitranntt/ccs/commit/4b969b6870aae6b5859b9a1be0cf98b9d537ce00))
|
||||
* **cliproxy:** prevent misleading update message when proxy is running ([2adc272](https://github.com/kaitranntt/ccs/commit/2adc272f278b1d80d160ad4d6e1f35e3b61cb156)), closes [#143](https://github.com/kaitranntt/ccs/issues/143)
|
||||
* **error-logs-monitor:** properly handle status loading state ([1ef625e](https://github.com/kaitranntt/ccs/commit/1ef625ee863c517a5fbba21f16cf991bb77be7d7))
|
||||
|
||||
### Styles
|
||||
|
||||
* **ui:** widen cliproxy sidebar from w-64 to w-80 ([248d970](https://github.com/kaitranntt/ccs/commit/248d970cba8671b7c20dc99f8d1a70e4fe113605))
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove deprecated native shell installers ([126cffc](https://github.com/kaitranntt/ccs/commit/126cffc6dcf434abeee883a4109d3705cdb92a67))
|
||||
|
||||
# [6.5.0](https://github.com/kaitranntt/ccs/compare/v6.4.0...v6.5.0) (2025-12-18)
|
||||
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
|
||||
| OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) |
|
||||
| Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) |
|
||||
| API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) |
|
||||
| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) |
|
||||
| CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
|
||||
| Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) |
|
||||
| Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@semantic-release/npm": "^13.1.3",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/chokidar": "^2.1.7",
|
||||
"@types/express": "^4.17.21",
|
||||
@@ -31,6 +35,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
@@ -43,13 +48,13 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
|
||||
"@actions/core": ["@actions/core@2.0.1", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.0" } }, "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg=="],
|
||||
|
||||
"@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="],
|
||||
"@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="],
|
||||
|
||||
"@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
"@actions/http-client": ["@actions/http-client@3.0.0", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.28.5" } }, "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ=="],
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
"@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
@@ -305,7 +310,7 @@
|
||||
|
||||
"@semantic-release/github": ["@semantic-release/github@12.0.2", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-qyqLS+aSGH1SfXIooBKjs7mvrv0deg8v+jemegfJg1kq6ji+GJV8CO08VJDEsvjp3O8XJmTTIAjjZbMzagzsdw=="],
|
||||
|
||||
"@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="],
|
||||
"@semantic-release/npm": ["@semantic-release/npm@13.1.3", "", { "dependencies": { "@actions/core": "^2.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-q7zreY8n9V0FIP1Cbu63D+lXtRAVAIWb30MH5U3TdrfXt6r2MIrWCY0whAImN53qNvSGp0Zt07U95K+Qp9GpEg=="],
|
||||
|
||||
"@semantic-release/release-notes-generator": ["@semantic-release/release-notes-generator@14.1.0", "", { "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA=="],
|
||||
|
||||
@@ -529,7 +534,7 @@
|
||||
|
||||
"conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="],
|
||||
|
||||
"conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="],
|
||||
"conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA=="],
|
||||
|
||||
"conventional-changelog-writer": ["conventional-changelog-writer@8.2.0", "", { "dependencies": { "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw=="],
|
||||
|
||||
@@ -705,7 +710,7 @@
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
"get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="],
|
||||
|
||||
"git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="],
|
||||
|
||||
@@ -799,7 +804,7 @@
|
||||
|
||||
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
@@ -927,7 +932,7 @@
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="],
|
||||
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
@@ -1019,7 +1024,7 @@
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
@@ -1075,7 +1080,7 @@
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="],
|
||||
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
|
||||
"read-pkg": ["read-pkg@10.0.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.2.0", "unicorn-magic": "^0.3.0" } }, "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A=="],
|
||||
|
||||
@@ -1309,6 +1314,8 @@
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@commitlint/config-conventional/conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="],
|
||||
|
||||
"@commitlint/config-validator/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -1345,10 +1352,6 @@
|
||||
|
||||
"@semantic-release/npm/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||
@@ -1377,9 +1380,7 @@
|
||||
|
||||
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"conventional-changelog-writer/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
"cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="],
|
||||
|
||||
@@ -1389,6 +1390,8 @@
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"express/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=="],
|
||||
@@ -1399,6 +1402,8 @@
|
||||
|
||||
"from2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="],
|
||||
|
||||
"git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -1741,6 +1746,8 @@
|
||||
|
||||
"p-filter/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||
|
||||
"parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -1749,9 +1756,9 @@
|
||||
|
||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="],
|
||||
"read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="],
|
||||
|
||||
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
"read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"read-pkg/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="],
|
||||
|
||||
@@ -1759,12 +1766,18 @@
|
||||
|
||||
"semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="],
|
||||
|
||||
"semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="],
|
||||
|
||||
"semantic-release/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"semantic-release/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="],
|
||||
|
||||
"semantic-release/read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="],
|
||||
|
||||
"semantic-release/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -1797,8 +1810,12 @@
|
||||
|
||||
"through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"yargs-unparser/is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
|
||||
|
||||
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@commitlint/parse/conventional-commits-parser/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="],
|
||||
|
||||
"@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
|
||||
@@ -1823,8 +1840,6 @@
|
||||
|
||||
"@semantic-release/npm/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"@semantic-release/npm/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"@semantic-release/npm/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"@semantic-release/npm/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
@@ -1833,10 +1848,6 @@
|
||||
|
||||
"@semantic-release/npm/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"@types/chokidar/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
@@ -1893,7 +1904,11 @@
|
||||
|
||||
"pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="],
|
||||
|
||||
"read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
"read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="],
|
||||
|
||||
"read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
|
||||
|
||||
"semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="],
|
||||
|
||||
@@ -1903,8 +1918,6 @@
|
||||
|
||||
"semantic-release/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"semantic-release/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"semantic-release/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"semantic-release/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
@@ -1913,6 +1926,8 @@
|
||||
|
||||
"semantic-release/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"semantic-release/read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="],
|
||||
|
||||
"semantic-release/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"semantic-release/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
@@ -1951,12 +1966,6 @@
|
||||
|
||||
"@semantic-release/npm/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="],
|
||||
|
||||
"env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"env-ci/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
@@ -1971,6 +1980,12 @@
|
||||
|
||||
"pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||
|
||||
"read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm/@actions/core/@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm/@actions/core/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
|
||||
"semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
@@ -1991,16 +2006,18 @@
|
||||
|
||||
"@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
||||
|
||||
"pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="],
|
||||
|
||||
"read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"semantic-release/@semantic-release/npm/@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"@commitlint/top-level/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||
|
||||
"@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
# CCS Installation Script (v4.5.0) - Windows PowerShell - DEPRECATED
|
||||
# DEPRECATED: This installer is deprecated. Use npm instead.
|
||||
# Bootstrap-based: Installs lightweight shell wrappers (LEGACY)
|
||||
# Requires: Node.js 14+ (npm recommended)
|
||||
# https://github.com/kaitranntt/ccs
|
||||
|
||||
param(
|
||||
[string]$InstallDir = "$env:USERPROFILE\.ccs"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Deprecation Notice ---
|
||||
Write-Host ""
|
||||
Write-Host "=======================================================================" -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " [!] DEPRECATION NOTICE " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " Native shell installers are deprecated and will be removed " -ForegroundColor Yellow
|
||||
Write-Host " in a future version. Please use npm installation instead: " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " npm install -g @kaitranntt/ccs " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " Proceeding with legacy install (auto-runs npm if available)... " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host "=======================================================================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# --- Auto-redirect to npm installation ---
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
Write-Host "[i] Node.js detected, using npm installation (recommended)..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
npm install -g "@kaitranntt/ccs"
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "[OK] CCS installed via npm successfully!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Quick start:"
|
||||
Write-Host " ccs # Use Claude (default)"
|
||||
Write-Host " ccs glm # Use GLM"
|
||||
Write-Host " ccs --help # Show all commands"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "[!] npm installation failed. Falling back to legacy install..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
} else {
|
||||
Write-Host "[!] npm not found. Falling back to legacy install..." -ForegroundColor Yellow
|
||||
Write-Host "[!] Install Node.js from https://nodejs.org for the recommended method." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Continue with legacy PowerShell installation...
|
||||
|
||||
# Configuration
|
||||
$CcsDir = "$env:USERPROFILE\.ccs"
|
||||
$ClaudeDir = "$env:USERPROFILE\.claude"
|
||||
$GlmModel = "glm-4.6"
|
||||
$KimiModel = "kimi-for-coding"
|
||||
|
||||
# Detect if running from git repository or standalone
|
||||
$ScriptDir = if ($MyInvocation.MyCommand.Path) {
|
||||
Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
} else {
|
||||
# Running via irm | iex (in-memory, no file path)
|
||||
$null
|
||||
}
|
||||
|
||||
$InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (Test-Path "$ScriptDir\..\lib\ccs.ps1"))) {
|
||||
"git"
|
||||
} else {
|
||||
"standalone"
|
||||
}
|
||||
|
||||
# Version configuration
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (irm | iex)
|
||||
# For git installations, VERSION file is read if available
|
||||
$CcsVersion = "6.5.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if ($ScriptDir) {
|
||||
$VersionFile = if (Test-Path "$ScriptDir\VERSION") {
|
||||
"$ScriptDir\VERSION"
|
||||
} elseif (Test-Path "$ScriptDir\..\VERSION") {
|
||||
"$ScriptDir\..\VERSION"
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
|
||||
if ($VersionFile -and (Test-Path $VersionFile)) {
|
||||
$CcsVersion = (Get-Content $VersionFile -Raw).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
# --- Color/Format Functions ---
|
||||
function Write-Critical {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "╔═════════════════════════════════════════════╗" -ForegroundColor Red
|
||||
Write-Host "║ ACTION REQUIRED ║" -ForegroundColor Red
|
||||
Write-Host "╚═════════════════════════════════════════════╝" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host $Message -ForegroundColor Red
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Write-WarningMsg {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "[!] WARNING" -ForegroundColor Yellow
|
||||
Write-Host $Message -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Message)
|
||||
Write-Host "[OK] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[i] $Message"
|
||||
}
|
||||
|
||||
function Write-Section {
|
||||
param([string]$Title)
|
||||
Write-Host ""
|
||||
Write-Host "===== $Title =====" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- Node.js Detection (v4.5) ---
|
||||
function Test-NodeJs {
|
||||
$MIN_VERSION = 14
|
||||
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-WarningMsg @"
|
||||
Node.js not found
|
||||
|
||||
CCS v4.5+ requires Node.js 14+ to run.
|
||||
The bootstrap scripts will check and install the npm package on first use.
|
||||
|
||||
Install Node.js: https://nodejs.org (LTS recommended)
|
||||
|
||||
Installation will continue, but 'ccs' will not work until Node.js is installed.
|
||||
"@
|
||||
return $false
|
||||
}
|
||||
|
||||
$nodeVersion = (node -v) -replace 'v', ''
|
||||
$nodeMajor = [int]($nodeVersion -split '\.')[0]
|
||||
if ($nodeMajor -lt $MIN_VERSION) {
|
||||
Write-WarningMsg @"
|
||||
Node.js 14+ required (found: $(node -v))
|
||||
|
||||
CCS v4.5+ requires Node.js 14 or newer.
|
||||
Upgrade from: https://nodejs.org
|
||||
|
||||
Installation will continue, but 'ccs' may not work correctly.
|
||||
"@
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Success "Node.js $(node -v) detected"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Helper Functions
|
||||
|
||||
function Detect-CurrentProvider {
|
||||
$SettingsFile = "$ClaudeDir\settings.json"
|
||||
if (-not (Test-Path $SettingsFile)) {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
try {
|
||||
$Content = Get-Content $SettingsFile -Raw
|
||||
if ($Content -match "api\.kimi\.com|kimi-for-coding") {
|
||||
return "kimi"
|
||||
} elseif ($Content -match "api\.z\.ai|glm-4") {
|
||||
return "glm"
|
||||
} elseif ($Content -match "ANTHROPIC_BASE_URL" -and $Content -notmatch "api\.z\.ai|api\.kimi\.com") {
|
||||
return "custom"
|
||||
} else {
|
||||
return "claude"
|
||||
}
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function New-GlmTemplate {
|
||||
$Template = @{
|
||||
env = @{
|
||||
ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"
|
||||
ANTHROPIC_AUTH_TOKEN = "YOUR_GLM_API_KEY_HERE"
|
||||
ANTHROPIC_MODEL = $GlmModel
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL = $GlmModel
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL = $GlmModel
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL = $GlmModel
|
||||
}
|
||||
}
|
||||
return $Template | ConvertTo-Json -Depth 10
|
||||
}
|
||||
|
||||
function New-GlmProfile {
|
||||
param([string]$Provider)
|
||||
|
||||
$CurrentSettings = "$ClaudeDir\settings.json"
|
||||
$GlmSettings = "$CcsDir\glm.settings.json"
|
||||
|
||||
if ($Provider -eq "glm" -and (Test-Path $CurrentSettings)) {
|
||||
Write-Host "[OK] Copying current GLM config to profile..."
|
||||
|
||||
try {
|
||||
$Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json
|
||||
if (-not $Config.env) {
|
||||
$Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force
|
||||
}
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $GlmModel -Force
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $GlmModel -Force
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $GlmModel -Force
|
||||
|
||||
$Config | ConvertTo-Json -Depth 10 | Set-Content $GlmSettings
|
||||
Write-Host " Created: $GlmSettings with your existing API key + enhanced settings"
|
||||
} catch {
|
||||
Write-Host " [i] Copying current settings failed, using template"
|
||||
New-GlmTemplate | Set-Content $GlmSettings
|
||||
}
|
||||
} else {
|
||||
Write-Host "Creating GLM profile template at $GlmSettings"
|
||||
New-GlmTemplate | Set-Content $GlmSettings
|
||||
Write-Host " Created: $GlmSettings"
|
||||
Write-Host " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key"
|
||||
}
|
||||
}
|
||||
|
||||
function New-KimiTemplate {
|
||||
$Template = @{
|
||||
env = @{
|
||||
ANTHROPIC_BASE_URL = "https://api.kimi.com/coding/"
|
||||
ANTHROPIC_AUTH_TOKEN = "YOUR_KIMI_API_KEY_HERE"
|
||||
ANTHROPIC_MODEL = $KimiModel
|
||||
ANTHROPIC_SMALL_FAST_MODEL = $KimiModel
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL = $KimiModel
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL = $KimiModel
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL = $KimiModel
|
||||
}
|
||||
alwaysThinkingEnabled = $true
|
||||
}
|
||||
return $Template | ConvertTo-Json -Depth 10
|
||||
}
|
||||
|
||||
function New-KimiProfile {
|
||||
param([string]$Provider)
|
||||
|
||||
$CurrentSettings = "$ClaudeDir\settings.json"
|
||||
$KimiSettings = "$CcsDir\kimi.settings.json"
|
||||
|
||||
if ($Provider -eq "kimi" -and (Test-Path $CurrentSettings)) {
|
||||
Write-Host "[OK] Copying current Kimi config to profile..."
|
||||
|
||||
try {
|
||||
$Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json
|
||||
if (-not $Config.env) {
|
||||
$Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force
|
||||
}
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $KimiModel -Force
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $KimiModel -Force
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $KimiModel -Force
|
||||
$Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $KimiModel -Force
|
||||
$Config | Add-Member -NotePropertyName alwaysThinkingEnabled -NotePropertyValue $true -Force
|
||||
|
||||
$Config | ConvertTo-Json -Depth 10 | Set-Content $KimiSettings
|
||||
Write-Host " Created: $KimiSettings with your existing API key + enhanced settings"
|
||||
} catch {
|
||||
Write-Host " [i] Copying current settings failed, using template"
|
||||
New-KimiTemplate | Set-Content $KimiSettings
|
||||
}
|
||||
} else {
|
||||
Write-Host "Creating Kimi profile template at $KimiSettings"
|
||||
New-KimiTemplate | Set-Content $KimiSettings
|
||||
Write-Host " Created: $KimiSettings"
|
||||
Write-Host " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key"
|
||||
}
|
||||
}
|
||||
|
||||
function Install-ClaudeFolder {
|
||||
param(
|
||||
[string]$SourceDir
|
||||
)
|
||||
|
||||
$TargetDir = "$CcsDir\.claude"
|
||||
|
||||
# Check if already exists
|
||||
if (Test-Path $TargetDir) {
|
||||
Write-Host "| [i] .claude/ folder already exists, skipping"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Create directory structure
|
||||
$null = New-Item -ItemType Directory -Force -Path "$TargetDir\commands"
|
||||
$null = New-Item -ItemType Directory -Force -Path "$TargetDir\skills\ccs-delegation\references"
|
||||
|
||||
if ($InstallMethod -eq "git" -and $SourceDir) {
|
||||
# Copy from local git repo
|
||||
$SourceClaudeDir = Join-Path $SourceDir ".claude"
|
||||
if (Test-Path $SourceClaudeDir) {
|
||||
try {
|
||||
Copy-Item -Path "$SourceClaudeDir\*" -Destination $TargetDir -Recurse -Force
|
||||
Write-Host "| [OK] Installed .claude/ folder"
|
||||
return $true
|
||||
} catch {
|
||||
Write-Host "| [!] Failed to copy .claude/ folder"
|
||||
return $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "| [!] .claude/ folder not found in source"
|
||||
return $false
|
||||
}
|
||||
} else {
|
||||
# Standalone: download from GitHub
|
||||
try {
|
||||
$BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude"
|
||||
|
||||
Invoke-WebRequest -Uri "$BaseUrl/commands/ccs.md" `
|
||||
-OutFile "$TargetDir\commands\ccs.md" -UseBasicParsing
|
||||
Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/SKILL.md" `
|
||||
-OutFile "$TargetDir\skills\ccs-delegation\SKILL.md" -UseBasicParsing
|
||||
Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/references/delegation-patterns.md" `
|
||||
-OutFile "$TargetDir\skills\ccs-delegation\references\delegation-patterns.md" -UseBasicParsing
|
||||
|
||||
Write-Host "| [OK] Downloaded .claude/ folder"
|
||||
return $true
|
||||
} catch {
|
||||
Write-Host "| [!] Failed to download .claude/ folder"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Main Installation
|
||||
|
||||
# Check Node.js requirement (warn if missing, continue anyway)
|
||||
$null = Test-NodeJs
|
||||
|
||||
Write-Host '===== Installing CCS (Windows) ====='
|
||||
|
||||
# Create directories
|
||||
New-Item -ItemType Directory -Force -Path $CcsDir | Out-Null
|
||||
|
||||
# Install main executable
|
||||
if ($InstallMethod -eq "standalone") {
|
||||
# Standalone install - download from GitHub
|
||||
Write-Host "| Downloading CCS from GitHub..."
|
||||
|
||||
try {
|
||||
$BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main"
|
||||
Invoke-WebRequest -Uri "$BaseUrl/lib/ccs.ps1" -OutFile "$CcsDir\ccs.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded ccs.ps1"
|
||||
|
||||
# Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Download shell completion files
|
||||
$CompletionsDir = "$CcsDir\completions"
|
||||
if (-not (Test-Path $CompletionsDir)) {
|
||||
New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/scripts/completion/ccs.ps1" -OutFile "$CompletionsDir\ccs.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded completion files"
|
||||
} catch {
|
||||
Write-Host "| [!] Warning: Failed to download completion files"
|
||||
}
|
||||
} catch {
|
||||
Write-Host "|"
|
||||
Write-Host "[X] Error: Failed to download ccs.ps1 from GitHub" -ForegroundColor Red
|
||||
Write-Host " $_"
|
||||
return
|
||||
}
|
||||
} else {
|
||||
# Git install - copy local file
|
||||
$CcsPs1Path = if (Test-Path "$ScriptDir\lib\ccs.ps1") {
|
||||
"$ScriptDir\lib\ccs.ps1"
|
||||
} elseif (Test-Path "$ScriptDir\..\lib\ccs.ps1") {
|
||||
"$ScriptDir\..\lib\ccs.ps1"
|
||||
} else {
|
||||
throw "lib\ccs.ps1 not found"
|
||||
}
|
||||
Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force
|
||||
Write-Host "| [OK] Installed ccs.ps1"
|
||||
|
||||
# Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Copy shell completion files
|
||||
$CompletionsDir = "$CcsDir\completions"
|
||||
if (-not (Test-Path $CompletionsDir)) {
|
||||
New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$SourceCompletionDir = if (Test-Path "$ScriptDir\scripts\completion") {
|
||||
"$ScriptDir\scripts\completion"
|
||||
} elseif (Test-Path "$ScriptDir\..\scripts\completion") {
|
||||
"$ScriptDir\..\scripts\completion"
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
|
||||
if ($SourceCompletionDir -and (Test-Path "$SourceCompletionDir\ccs.ps1")) {
|
||||
Copy-Item "$SourceCompletionDir\ccs.ps1" "$CompletionsDir\ccs.ps1" -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "| [OK] Copied completion files"
|
||||
}
|
||||
}
|
||||
|
||||
# Install uninstall script as ccs-uninstall.ps1
|
||||
if ($ScriptDir -and (Test-Path "$ScriptDir\uninstall.ps1")) {
|
||||
# Copy uninstall.ps1 as ccs-uninstall.ps1 (similar to Linux symlink approach)
|
||||
if ($ScriptDir -ne $CcsDir) {
|
||||
Copy-Item "$ScriptDir\uninstall.ps1" "$CcsDir\ccs-uninstall.ps1" -Force
|
||||
}
|
||||
# Clean up old uninstall.ps1 from previous installations
|
||||
if (Test-Path "$CcsDir\uninstall.ps1") {
|
||||
Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "| [OK] Installed uninstaller"
|
||||
} elseif ($InstallMethod -eq "standalone") {
|
||||
try {
|
||||
$BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main"
|
||||
# Download uninstall.ps1 as ccs-uninstall.ps1
|
||||
Invoke-WebRequest -Uri "$BaseUrl/installers/uninstall.ps1" -OutFile "$CcsDir\ccs-uninstall.ps1" -UseBasicParsing
|
||||
# Clean up old uninstall.ps1 from previous installations
|
||||
if (Test-Path "$CcsDir\uninstall.ps1") {
|
||||
Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "| [OK] Installed uninstaller"
|
||||
} catch {
|
||||
Write-Host "| [!] Could not download uninstaller (optional)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "| [OK] Created directories"
|
||||
|
||||
# Install .claude/ folder
|
||||
if ($InstallMethod -eq "git" -and $ScriptDir) {
|
||||
$ParentDir = Split-Path -Parent $ScriptDir
|
||||
$null = Install-ClaudeFolder -SourceDir $ParentDir
|
||||
} else {
|
||||
$null = Install-ClaudeFolder -SourceDir ""
|
||||
}
|
||||
|
||||
Write-Host "========================================="
|
||||
Write-Host ""
|
||||
|
||||
# Profile Setup
|
||||
|
||||
$CurrentProvider = Detect-CurrentProvider
|
||||
|
||||
$ProviderLabel = switch ($CurrentProvider) {
|
||||
"glm" { ' (detected: GLM)' }
|
||||
"kimi" { ' (detected: Kimi)' }
|
||||
"claude" { ' (detected: Claude)' }
|
||||
"custom" { ' (detected: custom)' }
|
||||
default { "" }
|
||||
}
|
||||
|
||||
Write-Host "===== Configuring Profiles v$CcsVersion$ProviderLabel"
|
||||
|
||||
# Backup existing config (single backup, no timestamp)
|
||||
$ConfigFile = "$CcsDir\config.json"
|
||||
$BackupFile = "$CcsDir\config.json.backup"
|
||||
if (Test-Path $ConfigFile) {
|
||||
Copy-Item $ConfigFile $BackupFile -Force
|
||||
}
|
||||
|
||||
$NeedsGlmKey = $false
|
||||
$GlmSettings = "$CcsDir\glm.settings.json"
|
||||
|
||||
# Create GLM profile if missing
|
||||
if (-not (Test-Path $GlmSettings)) {
|
||||
New-GlmProfile -Provider $CurrentProvider
|
||||
if ($CurrentProvider -ne "glm") {
|
||||
$NeedsGlmKey = $true
|
||||
}
|
||||
} else {
|
||||
Write-Host '| [OK] GLM profile exists'
|
||||
}
|
||||
|
||||
$NeedsKimiKey = $false
|
||||
$KimiSettings = "$CcsDir\kimi.settings.json"
|
||||
|
||||
# Create Kimi profile if missing
|
||||
if (-not (Test-Path $KimiSettings)) {
|
||||
New-KimiProfile -Provider $CurrentProvider
|
||||
if ($CurrentProvider -ne "kimi") {
|
||||
$NeedsKimiKey = $true
|
||||
}
|
||||
} else {
|
||||
Write-Host '| [OK] Kimi profile exists'
|
||||
}
|
||||
|
||||
# Create config if missing
|
||||
if (-not (Test-Path $ConfigFile)) {
|
||||
$ConfigContent = @{
|
||||
profiles = @{
|
||||
glm = "~/.ccs/glm.settings.json"
|
||||
kimi = "~/.ccs/kimi.settings.json"
|
||||
default = "~/.claude/settings.json"
|
||||
}
|
||||
}
|
||||
$ConfigContent | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile
|
||||
Write-Host ('| OK: Config created at {0}\.ccs\config.json' -f $env:USERPROFILE)
|
||||
}
|
||||
|
||||
# Validate config JSON
|
||||
if (Test-Path $ConfigFile) {
|
||||
try {
|
||||
$null = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-Host '| [!] Warning: Invalid JSON in config.json' -ForegroundColor Yellow
|
||||
if (Test-Path $BackupFile) {
|
||||
Write-Host ('| Restore from: {0}' -f $BackupFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Validate GLM settings JSON
|
||||
if (Test-Path $GlmSettings) {
|
||||
try {
|
||||
$null = Get-Content $GlmSettings -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-Host '| [!] Warning: Invalid JSON in glm.settings.json' -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "========================================="
|
||||
Write-Host ""
|
||||
|
||||
# Detect circular symlink
|
||||
function Test-CircularSymlink {
|
||||
param(
|
||||
[string]$Target,
|
||||
[string]$LinkPath
|
||||
)
|
||||
|
||||
# Check if target exists and is symlink
|
||||
if (-not (Test-Path $Target)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$Item = Get-Item $Target -ErrorAction Stop
|
||||
if ($Item.LinkType -ne "SymbolicLink") {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve target's link
|
||||
$TargetLink = $Item.Target
|
||||
$SharedDir = "$env:USERPROFILE\.ccs\shared"
|
||||
|
||||
# Check if target points back to our shared dir
|
||||
if ($TargetLink -like "$SharedDir*" -or $TargetLink -eq $LinkPath) {
|
||||
Write-Host "[!] Circular symlink detected: $Target → $TargetLink"
|
||||
return $true
|
||||
}
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Setup shared directories as symlinks to ~/.claude/ (v3.2.0)
|
||||
function Initialize-SharedSymlinks {
|
||||
$SharedDir = "$CcsDir\shared"
|
||||
$ClaudeDir = "$env:USERPROFILE\.claude"
|
||||
|
||||
# Create ~/.claude/ if missing
|
||||
if (-not (Test-Path $ClaudeDir)) {
|
||||
Write-Host "[i] Creating ~/.claude/ directory structure"
|
||||
New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null
|
||||
@('commands', 'skills', 'agents') | ForEach-Object {
|
||||
New-Item -ItemType Directory -Path "$ClaudeDir\$_" -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Create shared directory
|
||||
if (-not (Test-Path $SharedDir)) {
|
||||
New-Item -ItemType Directory -Path $SharedDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Create symlinks ~/.ccs/shared/* → ~/.claude/*
|
||||
foreach ($Dir in @('commands', 'skills', 'agents')) {
|
||||
$ClaudePath = "$ClaudeDir\$Dir"
|
||||
$SharedPath = "$SharedDir\$Dir"
|
||||
|
||||
# Create directory in ~/.claude/ if missing
|
||||
if (-not (Test-Path $ClaudePath)) {
|
||||
New-Item -ItemType Directory -Path $ClaudePath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Check for circular symlink
|
||||
if (Test-CircularSymlink -Target $ClaudePath -LinkPath $SharedPath) {
|
||||
Write-Host "[!] Skipping $Dir`: circular symlink detected"
|
||||
continue
|
||||
}
|
||||
|
||||
# If already correct symlink, skip
|
||||
if (Test-Path $SharedPath) {
|
||||
try {
|
||||
$Item = Get-Item $SharedPath -ErrorAction Stop
|
||||
if ($Item.LinkType -eq "SymbolicLink") {
|
||||
$CurrentTarget = $Item.Target
|
||||
if ($CurrentTarget -eq $ClaudePath) {
|
||||
continue # Already correct
|
||||
}
|
||||
}
|
||||
# Backup existing data before replacing
|
||||
if ((Get-ChildItem $SharedPath -ErrorAction SilentlyContinue).Count -gt 0) {
|
||||
Write-Host "[i] Migrating existing $Dir to ~/.claude/$Dir"
|
||||
Get-ChildItem $SharedPath -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$DestPath = Join-Path $ClaudePath $_.Name
|
||||
if (-not (Test-Path $DestPath)) {
|
||||
Copy-Item $_.FullName $DestPath -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
Remove-Item $SharedPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
# Continue to recreate
|
||||
}
|
||||
}
|
||||
|
||||
# Create symlink (requires Developer Mode or admin)
|
||||
try {
|
||||
New-Item -ItemType SymbolicLink -Path $SharedPath -Target $ClaudePath -Force -ErrorAction Stop | Out-Null
|
||||
} catch {
|
||||
Write-Host "[!] Symlink failed for $Dir, copying instead (enable Developer Mode)"
|
||||
if (-not (Test-Path $SharedPath)) {
|
||||
New-Item -ItemType Directory -Path $SharedPath -Force | Out-Null
|
||||
}
|
||||
if (Test-Path $ClaudePath) {
|
||||
Copy-Item "$ClaudePath\*" $SharedPath -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[i] Setting up shared directories..."
|
||||
Initialize-SharedSymlinks
|
||||
Write-Host ""
|
||||
|
||||
# Install CCS items to ~/.claude/ via symlinks (v4.1.0)
|
||||
Write-Host "[i] Installing CCS items to ~/.claude/..."
|
||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
||||
# Check if .claude/ was successfully installed
|
||||
if (Test-Path "$CcsDir\.claude") {
|
||||
# Download or copy claude-symlink-manager.js
|
||||
$UtilsDir = "$CcsDir\bin\utils"
|
||||
if (-not (Test-Path $UtilsDir)) {
|
||||
New-Item -ItemType Directory -Path $UtilsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
if ($InstallMethod -eq "git" -and $ScriptDir) {
|
||||
# Git install - copy from local repo
|
||||
$SourcePath = $null
|
||||
if (Test-Path "$ScriptDir\..\bin\utils\claude-symlink-manager.js") {
|
||||
$SourcePath = "$ScriptDir\..\bin\utils\claude-symlink-manager.js"
|
||||
} elseif (Test-Path "$ScriptDir\bin\utils\claude-symlink-manager.js") {
|
||||
$SourcePath = "$ScriptDir\bin\utils\claude-symlink-manager.js"
|
||||
}
|
||||
|
||||
if ($SourcePath) {
|
||||
Copy-Item $SourcePath "$UtilsDir\claude-symlink-manager.js" -Force
|
||||
}
|
||||
} else {
|
||||
# Standalone install - download from GitHub
|
||||
try {
|
||||
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" `
|
||||
-OutFile "$UtilsDir\claude-symlink-manager.js" -UseBasicParsing
|
||||
} catch {
|
||||
Write-Host "[!] Failed to download claude-symlink-manager.js"
|
||||
}
|
||||
}
|
||||
|
||||
# Call ClaudeSymlinkManager if available
|
||||
if (Test-Path "$UtilsDir\claude-symlink-manager.js") {
|
||||
try {
|
||||
$scriptBlock = @"
|
||||
try {
|
||||
const ClaudeSymlinkManager = require('$($UtilsDir -replace '\\', '/')/claude-symlink-manager.js');
|
||||
const manager = new ClaudeSymlinkManager();
|
||||
manager.install();
|
||||
} catch (err) {
|
||||
console.log('[!] CCS item installation warning: ' + err.message);
|
||||
console.log(' Run "ccs sync" to retry');
|
||||
}
|
||||
"@
|
||||
node -e $scriptBlock 2>$null
|
||||
if (-not $?) {
|
||||
Write-Host "[!] CCS item installation skipped (run 'ccs sync' later)"
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[!] CCS item installation failed: $($_.Exception.Message)"
|
||||
Write-Host " Run 'ccs sync' after installation to complete setup"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[!] claude-symlink-manager.js not found, skipping"
|
||||
Write-Host " Run 'ccs sync' after installation to complete setup"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[!] .claude/ folder not found, skipping CCS item installation"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[!] Node.js not found, skipping CCS item installation"
|
||||
Write-Host " Install Node.js and run 'ccs sync' to complete setup"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "[i] Note: Windows symlink support requires Developer Mode (v4.2 will add fallback)"
|
||||
Write-Host ""
|
||||
|
||||
# Check and update PATH
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
|
||||
if ($UserPath -notlike "*$CcsDir*") {
|
||||
Write-Host "[!] PATH Configuration Required"
|
||||
Write-Host ""
|
||||
Write-Host " Adding $CcsDir to your PATH..."
|
||||
|
||||
try {
|
||||
$NewPath = if ($UserPath) { "$UserPath;$CcsDir" } else { $CcsDir }
|
||||
[Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User)
|
||||
|
||||
Write-Host " [OK] PATH updated. Restart your terminal for changes to take effect."
|
||||
Write-Host ""
|
||||
} catch {
|
||||
Write-Host " [X] Could not update PATH automatically." -ForegroundColor Yellow
|
||||
Write-Host " Please add manually: $CcsDir"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# Show API key warning if needed
|
||||
if ($NeedsGlmKey) {
|
||||
Write-Critical @"
|
||||
Configure GLM API Key:
|
||||
|
||||
1. Get API key from: https://api.z.ai
|
||||
|
||||
2. Edit: $env:USERPROFILE\.ccs\glm.settings.json
|
||||
|
||||
3. Replace: YOUR_GLM_API_KEY_HERE
|
||||
With your actual API key
|
||||
|
||||
4. Test: ccs glm --version
|
||||
"@
|
||||
}
|
||||
|
||||
# Show API key warning for Kimi if needed
|
||||
if ($NeedsKimiKey) {
|
||||
Write-Critical @"
|
||||
Configure Kimi API Key:
|
||||
|
||||
1. Get API key from: https://www.kimi.com/coding
|
||||
|
||||
2. Edit: $env:USERPROFILE\.ccs\kimi.settings.json
|
||||
|
||||
3. Replace: YOUR_KIMI_API_KEY_HERE
|
||||
With your actual API key
|
||||
|
||||
4. Test: ccs kimi --version
|
||||
"@
|
||||
}
|
||||
|
||||
Write-Success "CCS installed successfully!"
|
||||
Write-Host ""
|
||||
Write-Host " Installed components:"
|
||||
Write-Host " * ccs command -> $CcsDir\ccs.ps1"
|
||||
Write-Host " * config -> $CcsDir\config.json"
|
||||
Write-Host " * glm profile -> $CcsDir\glm.settings.json"
|
||||
Write-Host " * kimi profile -> $CcsDir\kimi.settings.json"
|
||||
Write-Host " * .claude/ folder -> $CcsDir\.claude\"
|
||||
Write-Host ""
|
||||
Write-Host " Requirements:"
|
||||
$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "NOT FOUND" }
|
||||
Write-Host " * Node.js 14+ (detected: $nodeVer)"
|
||||
Write-Host " * npm 5.2+ (for npx, comes with Node.js 8.2+)"
|
||||
Write-Host ""
|
||||
Write-Host " First Run:"
|
||||
Write-Host " The first time you run 'ccs', it will automatically install"
|
||||
Write-Host " the @kaitranntt/ccs npm package globally via npx."
|
||||
Write-Host ""
|
||||
Write-Host " Quick start:"
|
||||
Write-Host " ccs # Use Claude subscription (default)"
|
||||
Write-Host " ccs glm # Use GLM fallback"
|
||||
Write-Host " ccs kimi # Use Kimi for Coding"
|
||||
Write-Host ""
|
||||
Write-Host " To uninstall: ccs-uninstall"
|
||||
Write-Host ""
|
||||
@@ -1,927 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# CCS Installation Script (v4.5.0) - DEPRECATED
|
||||
# DEPRECATED: This installer is deprecated. Use npm instead.
|
||||
# Bootstrap-based: Installs lightweight shell wrappers (LEGACY)
|
||||
# Requires: Node.js 14+ (npm recommended)
|
||||
# ============================================================================
|
||||
|
||||
# --- Deprecation Notice ---
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo " "
|
||||
echo " [!] DEPRECATION NOTICE "
|
||||
echo " "
|
||||
echo " Native shell installers are deprecated and will be removed "
|
||||
echo " in a future version. Please use npm installation instead: "
|
||||
echo " "
|
||||
echo " npm install -g @kaitranntt/ccs "
|
||||
echo " "
|
||||
echo " Proceeding with legacy install (auto-runs npm if available)... "
|
||||
echo " "
|
||||
echo "======================================================================="
|
||||
echo ""
|
||||
sleep 3 # Give users time to read
|
||||
|
||||
# --- Auto-redirect to npm installation ---
|
||||
if command -v npm &> /dev/null; then
|
||||
echo "[i] Node.js detected, using npm installation (recommended)..."
|
||||
echo ""
|
||||
npm install -g @kaitranntt/ccs
|
||||
exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo ""
|
||||
echo "[OK] CCS installed via npm successfully!"
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " ccs # Use Claude (default)"
|
||||
echo " ccs glm # Use GLM"
|
||||
echo " ccs --help # Show all commands"
|
||||
echo ""
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "[!] npm installation failed. Falling back to legacy install..."
|
||||
echo ""
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
echo "[!] npm not found. Falling back to legacy install..."
|
||||
echo "[!] Install Node.js from https://nodejs.org for the recommended method."
|
||||
echo ""
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Continue with legacy bash installation...
|
||||
|
||||
# --- Configuration ---
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
CCS_DIR="$HOME/.ccs"
|
||||
CLAUDE_DIR="$HOME/.claude"
|
||||
GLM_MODEL="glm-4.6"
|
||||
KIMI_MODEL="kimi-k2-thinking-turbo"
|
||||
|
||||
# Resolve script directory (handles both file-based and piped execution)
|
||||
if [[ -n "${BASH_SOURCE[0]:-}" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${0:-$PWD}")" && pwd)"
|
||||
fi
|
||||
|
||||
# Detect installation method (git vs standalone)
|
||||
# Check if ccs executable exists in SCRIPT_DIR or parent (real git install)
|
||||
# Don't just check .git (user might run curl | bash inside their own git repo)
|
||||
if [[ -f "$SCRIPT_DIR/lib/ccs" ]] || [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then
|
||||
INSTALL_METHOD="git"
|
||||
else
|
||||
INSTALL_METHOD="standalone"
|
||||
fi
|
||||
|
||||
# Version configuration
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (curl | bash)
|
||||
# For git installations, VERSION file is read if available
|
||||
CCS_VERSION="6.5.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||
CCS_VERSION="$(cat "$SCRIPT_DIR/VERSION" | tr -d '\n' | tr -d '\r')"
|
||||
elif [[ -f "$SCRIPT_DIR/../VERSION" ]]; then
|
||||
CCS_VERSION="$(cat "$SCRIPT_DIR/../VERSION" | tr -d '\n' | tr -d '\r')"
|
||||
fi
|
||||
|
||||
# --- Platform Detection ---
|
||||
# Detect platform and redirect to Windows installer if needed
|
||||
detect_platform() {
|
||||
case "$OSTYPE" in
|
||||
msys*|mingw*|cygwin*|win32*)
|
||||
echo "windows"
|
||||
;;
|
||||
*)
|
||||
echo "unix"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PLATFORM=$(detect_platform)
|
||||
|
||||
if [[ "$PLATFORM" == "windows" ]]; then
|
||||
echo "Windows detected. Using PowerShell installer..."
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/install.ps1" ]]; then
|
||||
powershell.exe -ExecutionPolicy Bypass -File "$SCRIPT_DIR/install.ps1"
|
||||
exit $?
|
||||
else
|
||||
echo "Error: install.ps1 not found."
|
||||
echo "Please download the full CCS package from:"
|
||||
echo " https://github.com/kaitranntt/ccs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Continue with Unix installation...
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
detect_current_provider() {
|
||||
local settings="$CLAUDE_DIR/settings.json"
|
||||
if [[ ! -f "$settings" ]]; then
|
||||
echo "unknown"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -q "api.kimi.com\|kimi-for-coding" "$settings" 2>/dev/null; then
|
||||
echo "kimi"
|
||||
elif grep -q "api.z.ai\|glm-4" "$settings" 2>/dev/null; then
|
||||
echo "glm"
|
||||
elif grep -q "ANTHROPIC_BASE_URL" "$settings" 2>/dev/null && ! grep -q "api.z.ai\|api.kimi.com" "$settings" 2>/dev/null; then
|
||||
echo "custom"
|
||||
else
|
||||
echo "claude"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Color/Format Functions (ANSI) ---
|
||||
setup_colors() {
|
||||
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET=''
|
||||
fi
|
||||
}
|
||||
|
||||
msg_critical() {
|
||||
echo "" >&2
|
||||
echo -e "${RED}${BOLD}╔═════════════════════════════════════════════╗${RESET}" >&2
|
||||
echo -e "${RED}${BOLD}║ ACTION REQUIRED ║${RESET}" >&2
|
||||
echo -e "${RED}${BOLD}╚═════════════════════════════════════════════╝${RESET}" >&2
|
||||
echo "" >&2
|
||||
echo -e "${RED}$1${RESET}" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
msg_warning() {
|
||||
echo "" >&2
|
||||
echo -e "${YELLOW}${BOLD}[!] WARNING${RESET}" >&2
|
||||
echo -e "${YELLOW}$1${RESET}" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
msg_success() {
|
||||
echo -e "${GREEN}[OK] $1${RESET}"
|
||||
}
|
||||
|
||||
msg_info() {
|
||||
echo -e "[i] $1"
|
||||
}
|
||||
|
||||
msg_section() {
|
||||
echo ""
|
||||
echo -e "${BOLD}===== $1 =====${RESET}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
setup_colors
|
||||
|
||||
# --- Node.js Detection (v4.5) ---
|
||||
check_nodejs() {
|
||||
if ! command -v node &> /dev/null; then
|
||||
msg_warning "Node.js not found
|
||||
|
||||
CCS v4.5+ requires Node.js 14+ to run.
|
||||
The bootstrap scripts will check and install the npm package on first use.
|
||||
|
||||
Install Node.js: https://nodejs.org (LTS recommended)
|
||||
|
||||
Installation will continue, but 'ccs' will not work until Node.js is installed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local node_major
|
||||
node_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [[ $node_major -lt 14 ]]; then
|
||||
msg_warning "Node.js 14+ required (found: $(node -v))
|
||||
|
||||
CCS v4.5+ requires Node.js 14 or newer.
|
||||
Upgrade from: https://nodejs.org
|
||||
|
||||
Installation will continue, but 'ccs' may not work correctly."
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_success "Node.js $(node -v) detected"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Shell Profile Management ---
|
||||
|
||||
detect_shell_profile() {
|
||||
# Safe extraction of shell name (no command substitution)
|
||||
local shell_path="${SHELL:-/bin/bash}"
|
||||
local shell_name="${shell_path##*/}"
|
||||
|
||||
# Validate shell_name is alphanumeric (defense in depth)
|
||||
if [[ ! "$shell_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
shell_name="bash"
|
||||
fi
|
||||
|
||||
case "$shell_name" in
|
||||
zsh)
|
||||
echo "$HOME/.zshrc"
|
||||
;;
|
||||
bash)
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
# macOS prefers bash_profile
|
||||
[[ -f "$HOME/.bash_profile" ]] && echo "$HOME/.bash_profile" || echo "$HOME/.bashrc"
|
||||
else
|
||||
echo "$HOME/.bashrc"
|
||||
fi
|
||||
;;
|
||||
fish)
|
||||
echo "$HOME/.config/fish/config.fish"
|
||||
;;
|
||||
*)
|
||||
# Default to bashrc
|
||||
echo "$HOME/.bashrc"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_path_configured() {
|
||||
[[ ":$PATH:" == *":$HOME/.local/bin:"* ]]
|
||||
}
|
||||
|
||||
add_to_path() {
|
||||
local profile_file="$1"
|
||||
local dir_to_add="$HOME/.local/bin"
|
||||
|
||||
# Create profile file if doesn't exist
|
||||
if [[ ! -f "$profile_file" ]]; then
|
||||
local profile_dir="$(dirname "$profile_file")"
|
||||
|
||||
if ! mkdir -p "$profile_dir" 2>/dev/null; then
|
||||
echo "[!] Failed to create directory: $profile_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! touch "$profile_file" 2>/dev/null; then
|
||||
echo "[!] Failed to create profile file: $profile_file" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if already in profile (avoid duplicates)
|
||||
if grep -q "# CCS: Added by Claude Code Switch installer" "$profile_file" 2>/dev/null; then
|
||||
return 0 # Already added
|
||||
fi
|
||||
|
||||
# Check for fish shell (different syntax)
|
||||
if [[ "$profile_file" == *"config.fish" ]]; then
|
||||
cat >> "$profile_file" << 'EOF'
|
||||
|
||||
# CCS: Added by Claude Code Switch installer
|
||||
set -gx PATH $HOME/.local/bin $PATH
|
||||
EOF
|
||||
else
|
||||
# Bash/Zsh syntax
|
||||
cat >> "$profile_file" << 'EOF'
|
||||
|
||||
# CCS: Added by Claude Code Switch installer
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
EOF
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_shell_path() {
|
||||
if check_path_configured; then
|
||||
msg_info "PATH already configured for ~/.local/bin"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local profile_file=$(detect_shell_profile)
|
||||
|
||||
echo ""
|
||||
msg_section "Configuring Shell PATH"
|
||||
msg_info "Detected shell profile: $profile_file"
|
||||
|
||||
if add_to_path "$profile_file"; then
|
||||
msg_success "Added ~/.local/bin to PATH in $profile_file"
|
||||
echo ""
|
||||
|
||||
# Show reload instructions
|
||||
msg_critical "Reload your shell to use 'ccs' command:
|
||||
|
||||
Option 1 (current session):
|
||||
source $profile_file
|
||||
|
||||
Option 2 (new session):
|
||||
Open a new terminal window
|
||||
|
||||
Then verify:
|
||||
ccs --version"
|
||||
|
||||
return 0
|
||||
else
|
||||
msg_warning "Could not auto-configure PATH
|
||||
|
||||
Manually add this line to $profile_file:
|
||||
export PATH=\"\$HOME/.local/bin:\$PATH\"
|
||||
|
||||
Then reload:
|
||||
source $profile_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_glm_template() {
|
||||
cat << EOF
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "$GLM_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "$GLM_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "$GLM_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "$GLM_MODEL"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
create_kimi_template() {
|
||||
cat << EOF
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "$KIMI_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "$KIMI_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "$KIMI_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "$KIMI_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "$KIMI_MODEL"
|
||||
},
|
||||
"alwaysThinkingEnabled": true
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
atomic_mv() {
|
||||
local src="$1"
|
||||
local dest="$2"
|
||||
if mv "$src" "$dest" 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
rm -f "$src"
|
||||
echo " [X] Error: Failed to create $dest (check permissions)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local dest="$2"
|
||||
|
||||
if ! curl -fsSL "$url" -o "$dest"; then
|
||||
echo " [!] Failed to download: $(basename "$dest")"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
install_claude_folder() {
|
||||
local source_dir="$1"
|
||||
local target_dir="$CCS_DIR/.claude"
|
||||
|
||||
# Check if already exists
|
||||
if [[ -d "$target_dir" ]]; then
|
||||
echo "| [i] .claude/ folder already exists, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir/commands" "$target_dir/skills/ccs-delegation/references"
|
||||
|
||||
if [[ "$INSTALL_METHOD" == "git" ]]; then
|
||||
# Copy from local git repo
|
||||
if [[ -d "$source_dir/.claude" ]]; then
|
||||
cp -r "$source_dir/.claude"/* "$target_dir/" 2>/dev/null || {
|
||||
echo "| [!] Failed to copy .claude/ folder"
|
||||
return 1
|
||||
}
|
||||
echo "| [OK] Installed .claude/ folder"
|
||||
else
|
||||
echo "| [!] .claude/ folder not found in source"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Standalone: download from GitHub
|
||||
local base_url="https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude"
|
||||
|
||||
download_file "$base_url/commands/ccs.md" "$target_dir/commands/ccs.md" || return 1
|
||||
download_file "$base_url/skills/ccs-delegation/SKILL.md" "$target_dir/skills/ccs-delegation/SKILL.md" || return 1
|
||||
download_file "$base_url/skills/ccs-delegation/references/delegation-patterns.md" "$target_dir/skills/ccs-delegation/references/delegation-patterns.md" || return 1
|
||||
|
||||
echo "| [OK] Downloaded .claude/ folder"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
create_glm_profile() {
|
||||
local current_settings="$CLAUDE_DIR/settings.json"
|
||||
local glm_settings="$CCS_DIR/glm.settings.json"
|
||||
local provider="$1"
|
||||
|
||||
if [[ "$provider" == "glm" ]]; then
|
||||
echo "[OK] Copying current GLM config to profile..."
|
||||
if command -v jq &> /dev/null; then
|
||||
if jq '.env |= (. // {}) + {
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'"
|
||||
}' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then
|
||||
atomic_mv "$glm_settings.tmp" "$glm_settings"
|
||||
echo " Created: $glm_settings (with your existing API key + enhanced settings)"
|
||||
else
|
||||
rm -f "$glm_settings.tmp"
|
||||
cp "$current_settings" "$glm_settings"
|
||||
echo " Created: $glm_settings (copied as-is, jq enhancement failed)"
|
||||
fi
|
||||
else
|
||||
cp "$current_settings" "$glm_settings"
|
||||
echo " Created: $glm_settings (copied as-is, jq not available)"
|
||||
fi
|
||||
else
|
||||
echo "Creating GLM profile template at $glm_settings"
|
||||
if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then
|
||||
if jq '.env |= (. // {}) + {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "'"$GLM_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'"
|
||||
}' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then
|
||||
atomic_mv "$glm_settings.tmp" "$glm_settings"
|
||||
else
|
||||
rm -f "$glm_settings.tmp"
|
||||
echo " [i] jq failed, using basic template"
|
||||
create_glm_template > "$glm_settings"
|
||||
fi
|
||||
else
|
||||
create_glm_template > "$glm_settings"
|
||||
fi
|
||||
echo " Created: $glm_settings"
|
||||
echo " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key"
|
||||
fi
|
||||
}
|
||||
|
||||
create_kimi_profile() {
|
||||
local current_settings="$CLAUDE_DIR/settings.json"
|
||||
local kimi_settings="$CCS_DIR/kimi.settings.json"
|
||||
local provider="$1"
|
||||
|
||||
if [[ "$provider" == "kimi" ]]; then
|
||||
echo "[OK] Copying current Kimi config to profile..."
|
||||
if command -v jq &> /dev/null; then
|
||||
if jq '.env |= (. // {}) + {
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'"
|
||||
}' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then
|
||||
atomic_mv "$kimi_settings.tmp" "$kimi_settings"
|
||||
echo " Created: $kimi_settings (with your existing API key + enhanced settings)"
|
||||
else
|
||||
rm -f "$kimi_settings.tmp"
|
||||
cp "$current_settings" "$kimi_settings"
|
||||
echo " Created: $kimi_settings (copied as-is, jq enhancement failed)"
|
||||
fi
|
||||
else
|
||||
cp "$current_settings" "$kimi_settings"
|
||||
echo " Created: $kimi_settings (copied as-is, jq not available)"
|
||||
fi
|
||||
else
|
||||
echo "Creating Kimi profile template at $kimi_settings"
|
||||
if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then
|
||||
if jq '.env |= (. // {}) + {
|
||||
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'"
|
||||
} | . + {"alwaysThinkingEnabled": true}' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then
|
||||
atomic_mv "$kimi_settings.tmp" "$kimi_settings"
|
||||
else
|
||||
rm -f "$kimi_settings.tmp"
|
||||
echo " [i] jq failed, using basic template"
|
||||
create_kimi_template > "$kimi_settings"
|
||||
fi
|
||||
else
|
||||
create_kimi_template > "$kimi_settings"
|
||||
fi
|
||||
echo " Created: $kimi_settings"
|
||||
echo " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main Installation ---
|
||||
|
||||
# Check Node.js requirement (warn if missing, continue anyway)
|
||||
check_nodejs || true
|
||||
|
||||
echo "┌─ Installing CCS"
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$INSTALL_DIR" "$CCS_DIR"
|
||||
|
||||
# Install main executable
|
||||
if [[ "$INSTALL_METHOD" == "standalone" ]]; then
|
||||
# Standalone install - download ccs from GitHub
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo "[X] Error: curl is required for standalone installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_URL="https://raw.githubusercontent.com/kaitranntt/ccs/main"
|
||||
|
||||
# Download main executable
|
||||
if curl -fsSL "$BASE_URL/lib/ccs" -o "$CCS_DIR/ccs"; then
|
||||
chmod +x "$CCS_DIR/ccs"
|
||||
ln -sf "$CCS_DIR/ccs" "$INSTALL_DIR/ccs"
|
||||
echo "| [OK] Downloaded executable"
|
||||
else
|
||||
echo "|"
|
||||
echo "[X] Error: Failed to download ccs from GitHub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Download shell completion files
|
||||
mkdir -p "$CCS_DIR/completions"
|
||||
if curl -fsSL "$BASE_URL/scripts/completion/ccs.bash" -o "$CCS_DIR/completions/ccs.bash" 2>/dev/null; then
|
||||
echo "| [OK] Downloaded completion files"
|
||||
fi
|
||||
curl -fsSL "$BASE_URL/scripts/completion/ccs.zsh" -o "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true
|
||||
curl -fsSL "$BASE_URL/scripts/completion/ccs.fish" -o "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true
|
||||
else
|
||||
# Git install - use local ccs file
|
||||
# Handle both running from root or from installers/ subdirectory
|
||||
local LIB_DIR=""
|
||||
if [[ -f "$SCRIPT_DIR/lib/ccs" ]]; then
|
||||
chmod +x "$SCRIPT_DIR/lib/ccs"
|
||||
ln -sf "$SCRIPT_DIR/lib/ccs" "$INSTALL_DIR/ccs"
|
||||
LIB_DIR="$SCRIPT_DIR/lib"
|
||||
elif [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then
|
||||
chmod +x "$SCRIPT_DIR/../lib/ccs"
|
||||
ln -sf "$SCRIPT_DIR/../lib/ccs" "$INSTALL_DIR/ccs"
|
||||
LIB_DIR="$SCRIPT_DIR/../lib"
|
||||
else
|
||||
echo "|"
|
||||
echo "[X] Error: lib/ccs executable not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "| [OK] Installed executable"
|
||||
|
||||
# Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Copy shell completion files
|
||||
mkdir -p "$CCS_DIR/completions"
|
||||
local COMPLETION_DIR=""
|
||||
if [[ -d "$SCRIPT_DIR/scripts/completion" ]]; then
|
||||
COMPLETION_DIR="$SCRIPT_DIR/scripts/completion"
|
||||
elif [[ -d "$SCRIPT_DIR/../scripts/completion" ]]; then
|
||||
COMPLETION_DIR="$SCRIPT_DIR/../scripts/completion"
|
||||
fi
|
||||
|
||||
if [[ -n "$COMPLETION_DIR" ]]; then
|
||||
cp "$COMPLETION_DIR/ccs.bash" "$CCS_DIR/completions/ccs.bash" 2>/dev/null || true
|
||||
cp "$COMPLETION_DIR/ccs.zsh" "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true
|
||||
cp "$COMPLETION_DIR/ccs.fish" "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true
|
||||
echo "| [OK] Copied completion files"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -L "$INSTALL_DIR/ccs" ]]; then
|
||||
echo "|"
|
||||
echo "[X] Error: Failed to create symlink at $INSTALL_DIR/ccs"
|
||||
echo " Check directory permissions and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install uninstall script (with idempotency check)
|
||||
if [[ -f "$SCRIPT_DIR/uninstall.sh" ]]; then
|
||||
# Only copy if source and destination are different
|
||||
if [[ "$SCRIPT_DIR/uninstall.sh" != "$CCS_DIR/uninstall.sh" ]]; then
|
||||
cp "$SCRIPT_DIR/uninstall.sh" "$CCS_DIR/uninstall.sh"
|
||||
fi
|
||||
chmod +x "$CCS_DIR/uninstall.sh"
|
||||
ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall"
|
||||
echo "| [OK] Installed uninstaller"
|
||||
elif [[ "$INSTALL_METHOD" == "standalone" ]] && command -v curl &> /dev/null; then
|
||||
if curl -fsSL https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/uninstall.sh -o "$CCS_DIR/uninstall.sh"; then
|
||||
chmod +x "$CCS_DIR/uninstall.sh"
|
||||
ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall"
|
||||
echo "| [OK] Installed uninstaller"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "| [OK] Created directories"
|
||||
|
||||
# Install .claude/ folder
|
||||
if [[ "$INSTALL_METHOD" == "git" ]]; then
|
||||
install_claude_folder "$SCRIPT_DIR/.." || echo "| [!] Optional .claude/ installation skipped"
|
||||
else
|
||||
install_claude_folder "" || echo "| [!] Optional .claude/ installation skipped"
|
||||
fi
|
||||
|
||||
echo "└─"
|
||||
echo ""
|
||||
|
||||
# --- Profile Setup ---
|
||||
|
||||
CURRENT_PROVIDER=$(detect_current_provider)
|
||||
GLM_SETTINGS="$CCS_DIR/glm.settings.json"
|
||||
KIMI_SETTINGS="$CCS_DIR/kimi.settings.json"
|
||||
|
||||
# Build provider label
|
||||
PROVIDER_LABEL=""
|
||||
[[ "$CURRENT_PROVIDER" == "glm" ]] && PROVIDER_LABEL=" (detected: GLM)"
|
||||
[[ "$CURRENT_PROVIDER" == "kimi" ]] && PROVIDER_LABEL=" (detected: Kimi)"
|
||||
[[ "$CURRENT_PROVIDER" == "claude" ]] && PROVIDER_LABEL=" (detected: Claude)"
|
||||
[[ "$CURRENT_PROVIDER" == "custom" ]] && PROVIDER_LABEL=" (detected: custom)"
|
||||
|
||||
echo "┌─ Configuring Profiles (v${CCS_VERSION})${PROVIDER_LABEL}"
|
||||
|
||||
# Backup existing config if present (single backup, no timestamp)
|
||||
BACKUP_FILE="$CCS_DIR/config.json.backup"
|
||||
if [[ -f "$CCS_DIR/config.json" ]]; then
|
||||
cp "$CCS_DIR/config.json" "$BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# Track if GLM needs API key
|
||||
NEEDS_GLM_KEY=false
|
||||
|
||||
# Create GLM profile if missing
|
||||
if [[ ! -f "$GLM_SETTINGS" ]]; then
|
||||
create_glm_profile "$CURRENT_PROVIDER" >/dev/null 2>&1
|
||||
echo "| [OK] GLM profile -> ~/.ccs/glm.settings.json"
|
||||
[[ "$CURRENT_PROVIDER" != "glm" ]] && NEEDS_GLM_KEY=true
|
||||
fi
|
||||
|
||||
# Track if Kimi needs API key
|
||||
NEEDS_KIMI_KEY=false
|
||||
|
||||
# Create Kimi profile if missing
|
||||
if [[ ! -f "$KIMI_SETTINGS" ]]; then
|
||||
create_kimi_profile "$CURRENT_PROVIDER" >/dev/null 2>&1
|
||||
echo "| [OK] Kimi profile -> ~/.ccs/kimi.settings.json"
|
||||
[[ "$CURRENT_PROVIDER" != "kimi" ]] && NEEDS_KIMI_KEY=true
|
||||
fi
|
||||
|
||||
# Create config if missing
|
||||
if [[ ! -f "$CCS_DIR/config.json" ]]; then
|
||||
cat > "$CCS_DIR/config.json.tmp" << 'EOF'
|
||||
{
|
||||
"profiles": {
|
||||
"glm": "~/.ccs/glm.settings.json",
|
||||
"kimi": "~/.ccs/kimi.settings.json",
|
||||
"default": "~/.claude/settings.json"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
atomic_mv "$CCS_DIR/config.json.tmp" "$CCS_DIR/config.json"
|
||||
echo "| [OK] Config -> ~/.ccs/config.json"
|
||||
fi
|
||||
|
||||
# Validate config JSON
|
||||
if [[ -f "$CCS_DIR/config.json" ]]; then
|
||||
if command -v jq &> /dev/null; then
|
||||
if ! jq -e . "$CCS_DIR/config.json" &>/dev/null; then
|
||||
echo "| [!] Warning: Invalid JSON in config.json"
|
||||
if [[ -f "$BACKUP_FILE" ]]; then
|
||||
echo "| Restore from: $BACKUP_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate GLM settings JSON
|
||||
if [[ -f "$GLM_SETTINGS" ]]; then
|
||||
if command -v jq &> /dev/null; then
|
||||
if ! jq -e . "$GLM_SETTINGS" &>/dev/null; then
|
||||
echo "| [!] Warning: Invalid JSON in glm.settings.json"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "└─"
|
||||
echo ""
|
||||
|
||||
# Detect circular symlink
|
||||
detect_circular_symlink() {
|
||||
local target="$1"
|
||||
local link_path="$2"
|
||||
|
||||
# Check if target exists and is symlink
|
||||
if [[ ! -L "$target" ]]; then
|
||||
return 1 # Not circular
|
||||
fi
|
||||
|
||||
# Resolve target's link
|
||||
local target_link=$(readlink "$target" 2>/dev/null || echo "")
|
||||
local shared_dir="$HOME/.ccs/shared"
|
||||
|
||||
# Check if target points back to our shared dir
|
||||
if [[ "$target_link" == "$shared_dir"* ]] || [[ "$target_link" == "$link_path" ]]; then
|
||||
echo "[!] Circular symlink detected: $target → $target_link"
|
||||
return 0 # Circular
|
||||
fi
|
||||
|
||||
return 1 # Not circular
|
||||
}
|
||||
|
||||
# Setup shared directories as symlinks to ~/.claude/ (v3.2.0)
|
||||
setup_shared_symlinks() {
|
||||
local shared_dir="$CCS_DIR/shared"
|
||||
local claude_dir="$HOME/.claude"
|
||||
|
||||
# Create ~/.claude/ if missing
|
||||
if [[ ! -d "$claude_dir" ]]; then
|
||||
echo "[i] Creating ~/.claude/ directory structure"
|
||||
mkdir -p "$claude_dir"/{commands,skills,agents}
|
||||
fi
|
||||
|
||||
# Create shared directory
|
||||
mkdir -p "$shared_dir"
|
||||
|
||||
# Create symlinks ~/.ccs/shared/* → ~/.claude/*
|
||||
for dir in commands skills agents; do
|
||||
local claude_path="$claude_dir/$dir"
|
||||
local shared_path="$shared_dir/$dir"
|
||||
|
||||
# Create directory in ~/.claude/ if missing
|
||||
if [[ ! -d "$claude_path" ]]; then
|
||||
mkdir -p "$claude_path"
|
||||
fi
|
||||
|
||||
# Check for circular symlink
|
||||
if detect_circular_symlink "$claude_path" "$shared_path"; then
|
||||
echo "[!] Skipping $dir: circular symlink detected"
|
||||
continue
|
||||
fi
|
||||
|
||||
# If already correct symlink, skip
|
||||
if [[ -L "$shared_path" ]]; then
|
||||
local current_target=$(readlink "$shared_path" 2>/dev/null || echo "")
|
||||
if [[ "$current_target" == "$claude_path" ]]; then
|
||||
continue # Already correct
|
||||
fi
|
||||
rm -rf "$shared_path"
|
||||
elif [[ -e "$shared_path" ]]; then
|
||||
# Backup existing data before replacing
|
||||
if [[ -d "$shared_path" ]] && [[ -n "$(ls -A "$shared_path" 2>/dev/null)" ]]; then
|
||||
echo "[i] Migrating existing $dir to ~/.claude/$dir"
|
||||
# Copy to claude dir (preserve user modifications)
|
||||
for item in "$shared_path"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
local basename=$(basename "$item")
|
||||
if [[ ! -e "$claude_path/$basename" ]]; then
|
||||
cp -r "$item" "$claude_path/" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
fi
|
||||
rm -rf "$shared_path"
|
||||
fi
|
||||
|
||||
# Create symlink
|
||||
ln -s "$claude_path" "$shared_path" 2>/dev/null || {
|
||||
echo "[!] Failed to create symlink for $dir, copying instead"
|
||||
mkdir -p "$shared_path"
|
||||
if [[ -d "$claude_path" ]]; then
|
||||
cp -r "$claude_path"/* "$shared_path/" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
echo "[i] Setting up shared directories..."
|
||||
setup_shared_symlinks
|
||||
echo ""
|
||||
|
||||
# Install CCS items to ~/.claude/ via symlinks (v4.1.0)
|
||||
echo "[i] Installing CCS items to ~/.claude/..."
|
||||
if command -v node &> /dev/null; then
|
||||
# Check if .claude/ was successfully installed
|
||||
if [[ -d "$CCS_DIR/.claude" ]]; then
|
||||
# Download or copy claude-symlink-manager.js
|
||||
mkdir -p "$CCS_DIR/bin/utils"
|
||||
|
||||
if [[ "$INSTALL_METHOD" == "git" ]]; then
|
||||
# Git install - copy from local repo
|
||||
if [[ -f "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" ]]; then
|
||||
cp "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js"
|
||||
elif [[ -f "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" ]]; then
|
||||
cp "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js"
|
||||
fi
|
||||
else
|
||||
# Standalone install - download from GitHub
|
||||
if ! curl -fsSL "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" -o "$CCS_DIR/bin/utils/claude-symlink-manager.js" 2>/dev/null; then
|
||||
echo "[!] Failed to download claude-symlink-manager.js"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Call ClaudeSymlinkManager if available
|
||||
if [[ -f "$CCS_DIR/bin/utils/claude-symlink-manager.js" ]]; then
|
||||
node -e "
|
||||
try {
|
||||
const ClaudeSymlinkManager = require('$CCS_DIR/bin/utils/claude-symlink-manager.js');
|
||||
const manager = new ClaudeSymlinkManager();
|
||||
manager.install();
|
||||
} catch (err) {
|
||||
console.log('[!] CCS item installation warning: ' + err.message);
|
||||
console.log(' Run \"ccs sync\" to retry');
|
||||
}
|
||||
" 2>/dev/null || echo "[!] CCS item installation skipped (run 'ccs sync' later)"
|
||||
else
|
||||
echo "[!] claude-symlink-manager.js not found, skipping"
|
||||
echo " Run 'ccs sync' after installation to complete setup"
|
||||
fi
|
||||
else
|
||||
echo "[!] .claude/ folder not found, skipping CCS item installation"
|
||||
fi
|
||||
else
|
||||
echo "[!] Node.js not found, skipping CCS item installation"
|
||||
echo " Install Node.js and run 'ccs sync' to complete setup"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Auto-configure PATH if needed (all Unix platforms)
|
||||
configure_shell_path
|
||||
|
||||
# Show API key warning if needed
|
||||
if [[ "$NEEDS_GLM_KEY" == "true" ]]; then
|
||||
msg_critical "Configure GLM API Key:
|
||||
|
||||
1. Get API key from: https://api.z.ai
|
||||
|
||||
2. Edit: ~/.ccs/glm.settings.json
|
||||
|
||||
3. Replace: YOUR_GLM_API_KEY_HERE
|
||||
With your actual API key
|
||||
|
||||
4. Test: ccs glm --version"
|
||||
fi
|
||||
|
||||
# Show API key warning for Kimi if needed
|
||||
if [[ "$NEEDS_KIMI_KEY" == "true" ]]; then
|
||||
msg_critical "Configure Kimi API Key:
|
||||
|
||||
1. Get API key from: https://www.kimi.com/coding
|
||||
|
||||
2. Edit: ~/.ccs/kimi.settings.json
|
||||
|
||||
3. Replace: YOUR_KIMI_API_KEY_HERE
|
||||
With your actual API key
|
||||
|
||||
4. Test: ccs kimi --version"
|
||||
fi
|
||||
|
||||
msg_success "CCS installed successfully!"
|
||||
echo ""
|
||||
echo " Installed components:"
|
||||
echo " * ccs command -> ~/.local/bin/ccs"
|
||||
echo " * config -> ~/.ccs/config.json"
|
||||
echo " * glm profile -> ~/.ccs/glm.settings.json"
|
||||
echo " * kimi profile -> ~/.ccs/kimi.settings.json"
|
||||
echo " * .claude/ folder -> ~/.ccs/.claude/"
|
||||
echo ""
|
||||
echo " Requirements:"
|
||||
echo " * Node.js 14+ (detected: $(node -v 2>/dev/null || echo 'NOT FOUND'))"
|
||||
echo " * npm 5.2+ (for npx, comes with Node.js 8.2+)"
|
||||
echo ""
|
||||
echo " First Run:"
|
||||
echo " The first time you run 'ccs', it will automatically install"
|
||||
echo " the @kaitranntt/ccs npm package globally via npx."
|
||||
echo ""
|
||||
echo " Quick start:"
|
||||
echo " ccs # Use Claude subscription (default)"
|
||||
echo " ccs glm # Use GLM fallback"
|
||||
echo " ccs kimi # Use Kimi for Coding"
|
||||
echo ""
|
||||
echo " To uninstall: ccs-uninstall"
|
||||
echo ""
|
||||
@@ -1,99 +0,0 @@
|
||||
# CCS Uninstallation Script (Windows PowerShell)
|
||||
# https://github.com/kaitranntt/ccs
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Color/Format Functions ---
|
||||
function Write-Success {
|
||||
param([string]$Message)
|
||||
Write-Host "[OK] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[i] $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# --- Selective Cleanup Function ---
|
||||
function Invoke-SelectiveCleanup {
|
||||
param([string]$CcsDir)
|
||||
|
||||
$Removed = @()
|
||||
$Kept = @()
|
||||
|
||||
# Remove executables and version metadata
|
||||
$FilesToRemove = @("ccs.ps1", "VERSION")
|
||||
|
||||
# Also remove the uninstall script itself
|
||||
$UninstallScript = $PSCommandPath
|
||||
if ($UninstallScript -and (Test-Path $UninstallScript)) {
|
||||
$FilesToRemove += $UninstallScript
|
||||
}
|
||||
|
||||
foreach ($File in $FilesToRemove) {
|
||||
$FilePath = if ([System.IO.Path]::IsPathRooted($File)) { $File } else { Join-Path $CcsDir $File }
|
||||
if (Test-Path $FilePath) {
|
||||
Remove-Item $FilePath -Force
|
||||
$Removed += Split-Path $FilePath -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
# Remove .claude folder
|
||||
if (Test-Path "$CcsDir\.claude") {
|
||||
Remove-Item "$CcsDir\.claude" -Recurse -Force
|
||||
$Removed += ".claude/"
|
||||
}
|
||||
|
||||
# Track kept files
|
||||
if (Test-Path "$CcsDir\config.json") { $Kept += "config.json" }
|
||||
if (Test-Path "$CcsDir\config.json.backup") { $Kept += "config.json.backup" }
|
||||
Get-ChildItem "$CcsDir\*.settings.json" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$Kept += $_.Name
|
||||
}
|
||||
|
||||
# Report results
|
||||
if ($Removed.Count -gt 0) {
|
||||
Write-Info "Cleaned up: $($Removed -join ', ')"
|
||||
}
|
||||
|
||||
if ($Kept.Count -gt 0) {
|
||||
Write-Info "Kept config files: $($Kept -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Uninstalling ccs..."
|
||||
Write-Host ""
|
||||
|
||||
$CcsDir = "$env:USERPROFILE\.ccs"
|
||||
|
||||
# Remove from PATH
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User)
|
||||
if ($UserPath -like "*$CcsDir*") {
|
||||
try {
|
||||
$NewPath = ($UserPath -split ';' | Where-Object { $_ -ne $CcsDir }) -join ';'
|
||||
[Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User)
|
||||
Write-Success "Removed from PATH: $CcsDir"
|
||||
Write-Host " Restart your terminal for changes to take effect."
|
||||
} catch {
|
||||
Write-Host "[!] Could not remove from PATH automatically. Please remove manually: $CcsDir" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Ask about ~/.ccs directory
|
||||
if (Test-Path $CcsDir) {
|
||||
Write-Host ""
|
||||
$Response = Read-Host "Remove CCS directory $CcsDir`? This includes config and profiles. (y/N)"
|
||||
if ($Response -match '^[Yy]$') {
|
||||
Remove-Item $CcsDir -Recurse -Force
|
||||
Write-Success "Removed: $CcsDir"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Invoke-SelectiveCleanup -CcsDir $CcsDir
|
||||
}
|
||||
} else {
|
||||
Write-Info "No CCS directory found at $CcsDir"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Success "Uninstall complete!"
|
||||
Write-Host ""
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# --- Color/Format Functions ---
|
||||
setup_colors() {
|
||||
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
GREEN='' CYAN='' RESET=''
|
||||
fi
|
||||
}
|
||||
|
||||
msg_success() {
|
||||
echo -e "${GREEN}[OK] $1${RESET}"
|
||||
}
|
||||
|
||||
msg_info() {
|
||||
echo -e "${CYAN}[i] $1${RESET}"
|
||||
}
|
||||
|
||||
# --- Selective Cleanup Function ---
|
||||
selective_cleanup() {
|
||||
local ccs_dir="$1"
|
||||
local removed=()
|
||||
local kept=()
|
||||
|
||||
# Remove executables, version metadata, and .claude folder
|
||||
for file in "ccs" "uninstall.sh" "VERSION"; do
|
||||
if [[ -f "$ccs_dir/$file" ]]; then
|
||||
rm "$ccs_dir/$file"
|
||||
removed+=("$file")
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove .claude folder
|
||||
if [[ -d "$ccs_dir/.claude" ]]; then
|
||||
rm -rf "$ccs_dir/.claude"
|
||||
removed+=(".claude/")
|
||||
fi
|
||||
|
||||
# Track kept files
|
||||
[[ -f "$ccs_dir/config.json" ]] && kept+=("config.json")
|
||||
[[ -f "$ccs_dir/config.json.backup" ]] && kept+=("config.json.backup")
|
||||
for settings in "$ccs_dir"/*.settings.json; do
|
||||
[[ -f "$settings" ]] && kept+=("$(basename "$settings")")
|
||||
done
|
||||
|
||||
# Report results
|
||||
if [[ ${#removed[@]} -gt 0 ]]; then
|
||||
msg_info "Cleaned up: ${removed[*]}"
|
||||
fi
|
||||
|
||||
if [[ ${#kept[@]} -gt 0 ]]; then
|
||||
msg_info "Kept config files: ${kept[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_colors
|
||||
|
||||
echo "Uninstalling ccs..."
|
||||
echo ""
|
||||
|
||||
# Remove from ~/.local/bin (standard location)
|
||||
if [[ -L "$HOME/.local/bin/ccs" ]]; then
|
||||
rm "$HOME/.local/bin/ccs"
|
||||
msg_success "Removed: $HOME/.local/bin/ccs"
|
||||
elif [[ -f "$HOME/.local/bin/ccs" ]]; then
|
||||
rm "$HOME/.local/bin/ccs"
|
||||
msg_success "Removed: $HOME/.local/bin/ccs"
|
||||
fi
|
||||
|
||||
if [[ -L "$HOME/.local/bin/ccs-uninstall" ]]; then
|
||||
rm "$HOME/.local/bin/ccs-uninstall"
|
||||
msg_success "Removed: $HOME/.local/bin/ccs-uninstall"
|
||||
fi
|
||||
|
||||
# Ask about ~/.ccs directory
|
||||
if [[ -d "$HOME/.ccs" ]]; then
|
||||
read -p "Remove CCS directory ~/.ccs? This includes config and profiles. (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
rm -rf "$HOME/.ccs"
|
||||
msg_success "Removed: $HOME/.ccs"
|
||||
else
|
||||
echo ""
|
||||
selective_cleanup "$HOME/.ccs"
|
||||
fi
|
||||
else
|
||||
msg_info "No CCS directory found at $HOME/.ccs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
msg_success "Uninstall complete!"
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "6.5.0",
|
||||
"version": "6.6.0-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -75,8 +75,6 @@
|
||||
"ui:build": "cd ui && bun run build",
|
||||
"ui:preview": "cd ui && bun run preview",
|
||||
"ui:validate": "cd ui && bun run validate",
|
||||
"prepublishOnly": "node scripts/sync-version.js",
|
||||
"prepack": "node scripts/sync-version.js",
|
||||
"prepare": "husky",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
@@ -98,7 +96,11 @@
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@semantic-release/npm": "^13.1.3",
|
||||
"@semantic-release/release-notes-generator": "^14.1.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/chokidar": "^2.1.7",
|
||||
"@types/express": "^4.17.21",
|
||||
@@ -108,6 +110,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Send Release Notification to Discord using Embeds
|
||||
*
|
||||
* Usage:
|
||||
* node send-discord-release.cjs <type> <webhook-url>
|
||||
*
|
||||
* Args:
|
||||
* type: 'production' or 'dev'
|
||||
* webhook-url: Discord webhook URL
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
|
||||
const releaseType = process.argv[2]; // 'production' or 'dev'
|
||||
const webhookUrl = process.argv[3];
|
||||
|
||||
if (!releaseType || !webhookUrl) {
|
||||
console.error('Usage: node send-discord-release.cjs <type> <webhook-url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate webhook URL is Discord
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
if (!parsed.hostname.endsWith('discord.com') || !parsed.pathname.startsWith('/api/webhooks/')) {
|
||||
console.error('[X] Invalid Discord webhook URL');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error('[X] Invalid URL format');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract latest release from CHANGELOG.md
|
||||
*/
|
||||
function extractLatestRelease() {
|
||||
const changelogPath = 'CHANGELOG.md';
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
return {
|
||||
version: 'Unknown',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
sections: {},
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(changelogPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let version = 'Unknown';
|
||||
let date = new Date().toISOString().split('T')[0];
|
||||
let collecting = false;
|
||||
let currentSection = null;
|
||||
const sections = {};
|
||||
|
||||
for (const line of lines) {
|
||||
// Match: ## [1.0.0](url) (2025-01-01) or ## 1.0.0 (2025-01-01)
|
||||
const versionMatch = line.match(/^## \[?(\d+\.\d+\.\d+(?:-dev\.\d+)?)\]?.*?\((\d{4}-\d{2}-\d{2})\)/);
|
||||
if (versionMatch) {
|
||||
if (!collecting) {
|
||||
version = versionMatch[1];
|
||||
date = versionMatch[2];
|
||||
collecting = true;
|
||||
continue;
|
||||
} else {
|
||||
break; // Found next version, stop
|
||||
}
|
||||
}
|
||||
|
||||
if (!collecting) continue;
|
||||
|
||||
// Match section headers: ### Features, ### Bug Fixes
|
||||
const sectionMatch = line.match(/^### (.+)/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1];
|
||||
sections[currentSection] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect bullet points
|
||||
if (currentSection && line.trim().startsWith('*')) {
|
||||
const item = line.trim().substring(1).trim();
|
||||
if (item) {
|
||||
sections[currentSection].push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version, date, sections };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Discord embed
|
||||
*/
|
||||
function createEmbed(release) {
|
||||
const isDev = releaseType === 'dev';
|
||||
const color = isDev ? 0xf59e0b : 0x10b981; // Orange for dev, Green for production
|
||||
const title = isDev ? `Dev Release ${release.version}` : `Release ${release.version}`;
|
||||
const url = `https://github.com/kaitranntt/ccs/releases/tag/v${release.version}`;
|
||||
|
||||
// Section name to indicator mapping (ASCII only per CLAUDE.md)
|
||||
const sectionIndicators = {
|
||||
Features: '[+]',
|
||||
'Bug Fixes': '[X]',
|
||||
Documentation: '[i]',
|
||||
Styles: '[~]',
|
||||
'Code Refactoring': '[~]',
|
||||
'Performance Improvements': '[!]',
|
||||
Tests: '[T]',
|
||||
'Build System': '[B]',
|
||||
CI: '[C]',
|
||||
};
|
||||
|
||||
const fields = [];
|
||||
|
||||
for (const [sectionName, items] of Object.entries(release.sections)) {
|
||||
if (items.length === 0) continue;
|
||||
|
||||
const indicator = sectionIndicators[sectionName] || '[*]';
|
||||
let fieldValue = items.map((item) => `• ${item}`).join('\n');
|
||||
|
||||
// Discord field value max is 1024 characters
|
||||
if (fieldValue.length > 1024) {
|
||||
const truncateAt = fieldValue.lastIndexOf('\n', 1000);
|
||||
fieldValue = fieldValue.substring(0, truncateAt > 0 ? truncateAt : 1000) + '\n... *(truncated)*';
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: `${indicator} ${sectionName}`,
|
||||
value: fieldValue,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
fields.push({
|
||||
name: '[i] Release Notes',
|
||||
value: 'Release completed. See changelog on GitHub.',
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: {
|
||||
text: isDev ? 'npm i @kaitranntt/ccs@dev' : 'npm i @kaitranntt/ccs@latest',
|
||||
},
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to Discord webhook
|
||||
*/
|
||||
function sendToDiscord(embed) {
|
||||
const payload = {
|
||||
username: releaseType === 'dev' ? 'CCS Dev Release' : 'CCS Release',
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const url = new URL(webhookUrl);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('[OK] Discord notification sent');
|
||||
} else {
|
||||
console.error(`[X] Discord webhook failed: ${res.statusCode}`);
|
||||
console.error(data);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error('[X] Error sending Discord notification:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify(payload));
|
||||
req.end();
|
||||
}
|
||||
|
||||
// Main
|
||||
try {
|
||||
const release = extractLatestRelease();
|
||||
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
|
||||
|
||||
const embed = createEmbed(release);
|
||||
sendToDiscord(embed);
|
||||
} catch (error) {
|
||||
console.error('[X] Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* semantic-release plugin to sync VERSION file
|
||||
*
|
||||
* semantic-release updates package.json but not the VERSION file.
|
||||
* This plugin keeps VERSION in sync for shell scripts and installers.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Called during the prepare step before git commit
|
||||
*/
|
||||
prepare(_pluginConfig, context) {
|
||||
const { nextRelease, logger } = context;
|
||||
const versionFile = path.join(process.cwd(), 'VERSION');
|
||||
|
||||
// Write version without 'v' prefix (e.g., "5.1.0" not "v5.1.0")
|
||||
const version = nextRelease.version;
|
||||
fs.writeFileSync(versionFile, version + '\n');
|
||||
logger.log('[sync-version-plugin] Updated VERSION file to %s', version);
|
||||
|
||||
// Also update installers for standalone installs
|
||||
const installSh = path.join(process.cwd(), 'installers', 'install.sh');
|
||||
const installPs1 = path.join(process.cwd(), 'installers', 'install.ps1');
|
||||
|
||||
if (fs.existsSync(installSh)) {
|
||||
let content = fs.readFileSync(installSh, 'utf8');
|
||||
content = content.replace(/^CCS_VERSION=".*"/m, `CCS_VERSION="${version}"`);
|
||||
fs.writeFileSync(installSh, content);
|
||||
logger.log('[sync-version-plugin] Updated installers/install.sh');
|
||||
}
|
||||
|
||||
if (fs.existsSync(installPs1)) {
|
||||
let content = fs.readFileSync(installPs1, 'utf8');
|
||||
content = content.replace(/^\$CcsVersion = ".*"/m, `$CcsVersion = "${version}"`);
|
||||
fs.writeFileSync(installPs1, content);
|
||||
logger.log('[sync-version-plugin] Updated installers/install.ps1');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read VERSION file
|
||||
const versionFile = path.join(__dirname, '..', 'VERSION');
|
||||
const version = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
|
||||
// Update package.json
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = version;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
console.log(`✓ Synced version ${version} to package.json`);
|
||||
+18
-38
@@ -1,46 +1,26 @@
|
||||
/**
|
||||
* CCS CloudFlare Worker - Redirect to npm Installation
|
||||
*
|
||||
* Legacy shell installers are deprecated. This worker now redirects
|
||||
* all /install* and /uninstall* requests to the npm installation docs.
|
||||
*/
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
const docsUrl = 'https://docs.ccs.kaitran.ca/getting-started/installation';
|
||||
|
||||
// Detect platform from User-Agent header
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const isWindows = userAgent.includes('Windows') || userAgent.includes('Win32');
|
||||
const isPowerShell = userAgent.includes('PowerShell') || userAgent.includes('pwsh');
|
||||
|
||||
// Smart routing with platform detection
|
||||
let filePath;
|
||||
if (url.pathname === '/install' || url.pathname === '/install.sh') {
|
||||
filePath = (isWindows && isPowerShell) ? 'installers/install.ps1' : 'installers/install.sh';
|
||||
} else if (url.pathname === '/install.ps1') {
|
||||
filePath = 'installers/install.ps1';
|
||||
} else if (url.pathname === '/uninstall' || url.pathname === '/uninstall.sh') {
|
||||
filePath = (isWindows && isPowerShell) ? 'installers/uninstall.ps1' : 'installers/uninstall.sh';
|
||||
} else if (url.pathname === '/uninstall.ps1') {
|
||||
filePath = 'installers/uninstall.ps1';
|
||||
} else {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
// Redirect all install/uninstall paths to npm installation docs
|
||||
if (
|
||||
url.pathname === '/install' ||
|
||||
url.pathname === '/install.sh' ||
|
||||
url.pathname === '/install.ps1' ||
|
||||
url.pathname === '/uninstall' ||
|
||||
url.pathname === '/uninstall.sh' ||
|
||||
url.pathname === '/uninstall.ps1'
|
||||
) {
|
||||
return Response.redirect(docsUrl, 301);
|
||||
}
|
||||
|
||||
try {
|
||||
const githubUrl = `https://raw.githubusercontent.com/kaitranntt/ccs/main/${filePath}`;
|
||||
const response = await fetch(githubUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response('File not found on GitHub', { status: 404 });
|
||||
}
|
||||
|
||||
const contentType = filePath.endsWith('.ps1')
|
||||
? 'text/plain; charset=utf-8'
|
||||
: 'text/x-shellscript; charset=utf-8';
|
||||
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=300'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response('Server Error', { status: 500 });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
};
|
||||
+18
-5
@@ -2,7 +2,7 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import { getSettingsPath } from './utils/config-manager';
|
||||
import { getSettingsPath, loadSettings } from './utils/config-manager';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
displayWebSearchStatus,
|
||||
getWebSearchHookEnv,
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||
|
||||
// Import extracted command handlers
|
||||
import { handleVersionCommand } from './commands/version-command';
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
checkCachedUpdate,
|
||||
isCacheStale,
|
||||
} from './utils/update-checker';
|
||||
import { detectInstallationMethod } from './utils/package-manager-detector';
|
||||
// Note: npm is now the only supported installation method
|
||||
|
||||
// ========== Profile Detection ==========
|
||||
|
||||
@@ -219,9 +220,8 @@ interface ProfileError extends Error {
|
||||
async function refreshUpdateCache(): Promise<void> {
|
||||
try {
|
||||
const currentVersion = getVersion();
|
||||
const installMethod = detectInstallationMethod();
|
||||
// Force=true to always fetch fresh data
|
||||
await checkForUpdates(currentVersion, true, installMethod);
|
||||
// npm is now the only supported installation method
|
||||
await checkForUpdates(currentVersion, true, 'npm');
|
||||
} catch (_e) {
|
||||
// Silently fail - update check shouldn't crash main CLI
|
||||
}
|
||||
@@ -495,7 +495,20 @@ async function main(): Promise<void> {
|
||||
// Use --settings flag (backward compatible)
|
||||
const expandedSettingsPath = getSettingsPath(profileInfo.name);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||
|
||||
// CRITICAL: Load settings and explicitly set ANTHROPIC_* env vars
|
||||
// to prevent inheriting stale values from previous CLIProxy sessions.
|
||||
// Environment variables take precedence over --settings file values,
|
||||
// so we must explicitly set them here to ensure correct routing.
|
||||
const settings = loadSettings(expandedSettingsPath);
|
||||
const settingsEnv = settings.env || {};
|
||||
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
...globalEnv,
|
||||
...settingsEnv, // Explicitly inject all settings env vars
|
||||
...webSearchEnv,
|
||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
@@ -19,7 +19,8 @@ import * as crypto from 'crypto';
|
||||
import * as zlib from 'zlib';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { ok, info } from '../utils/ui';
|
||||
import { getBinDir, getCliproxyDir } from './config-generator';
|
||||
import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
import {
|
||||
BinaryInfo,
|
||||
BinaryManagerConfig,
|
||||
@@ -58,6 +59,7 @@ interface UpdateCheckResult {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
/** Default configuration */
|
||||
@@ -103,17 +105,31 @@ export class BinaryManager {
|
||||
try {
|
||||
const updateResult = await this.checkForUpdates();
|
||||
if (updateResult.hasUpdate) {
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Updating CLIProxyAPI...'));
|
||||
// Check if CLIProxyAPI is currently running - can't update while running
|
||||
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
|
||||
if (proxyRunning) {
|
||||
// Proxy is running - can't update, just notify user
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Run "ccs cliproxy stop" then restart to apply update'));
|
||||
this.log('Skipping update: CLIProxyAPI is currently running');
|
||||
} else {
|
||||
// Proxy not running - safe to update
|
||||
console.log(
|
||||
info(
|
||||
`CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
|
||||
)
|
||||
);
|
||||
console.log(info('Updating CLIProxyAPI...'));
|
||||
|
||||
// Delete old binary and download new version
|
||||
this.deleteBinary();
|
||||
this.config.version = updateResult.latestVersion;
|
||||
await this.downloadAndInstall();
|
||||
// Delete old binary and download new version
|
||||
this.deleteBinary();
|
||||
this.config.version = updateResult.latestVersion;
|
||||
await this.downloadAndInstall();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - don't block startup if update check fails
|
||||
@@ -157,19 +173,21 @@ export class BinaryManager {
|
||||
const currentVersion = this.getInstalledVersion();
|
||||
|
||||
// Try cache first
|
||||
const cachedVersion = this.getCachedLatestVersion();
|
||||
if (cachedVersion) {
|
||||
this.log(`Using cached version: ${cachedVersion}`);
|
||||
const cache = this.getVersionCache();
|
||||
if (cache) {
|
||||
this.log(`Using cached version: ${cache.latestVersion}`);
|
||||
return {
|
||||
hasUpdate: this.isNewerVersion(cachedVersion, currentVersion),
|
||||
hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion: cachedVersion,
|
||||
latestVersion: cache.latestVersion,
|
||||
fromCache: true,
|
||||
checkedAt: cache.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch from GitHub API
|
||||
const latestVersion = await this.fetchLatestVersion();
|
||||
const now = Date.now();
|
||||
this.cacheLatestVersion(latestVersion);
|
||||
|
||||
return {
|
||||
@@ -177,6 +195,7 @@ export class BinaryManager {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
fromCache: false,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -272,9 +291,9 @@ export class BinaryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached latest version if still valid
|
||||
* Get version cache data if still valid
|
||||
*/
|
||||
private getCachedLatestVersion(): string | null {
|
||||
private getVersionCache(): VersionCache | null {
|
||||
const cachePath = this.getVersionCachePath();
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
@@ -286,7 +305,7 @@ export class BinaryManager {
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache.latestVersion;
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Cache expired
|
||||
@@ -980,6 +999,24 @@ export async function fetchLatestCliproxyVersion(): Promise<string> {
|
||||
return result.latestVersion;
|
||||
}
|
||||
|
||||
/** Update check result for API response */
|
||||
export interface CliproxyUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for CLIProxyAPI binary updates
|
||||
* @returns Update check result with version info
|
||||
*/
|
||||
export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult> {
|
||||
const manager = new BinaryManager();
|
||||
return manager.checkForUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to version pin file
|
||||
* @returns Absolute path to .version-pin file
|
||||
|
||||
+245
-135
@@ -21,13 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
getEffectiveEnvVars,
|
||||
getRemoteEnvVars,
|
||||
getProviderConfig,
|
||||
ensureProviderSettings,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
getCliproxyWritablePath,
|
||||
} from './config-generator';
|
||||
import { checkRemoteProxy } from './remote-proxy-client';
|
||||
import { isAuthenticated } from './auth-handler';
|
||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||
import { configureProviderModel, getCurrentModel } from './model-config';
|
||||
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||
import {
|
||||
@@ -125,6 +129,22 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
};
|
||||
|
||||
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||
// This filters proxy flags from args and returns resolved config
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args);
|
||||
|
||||
// Use resolved port from proxy config (overrides ExecutorConfig)
|
||||
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
cfg.port = proxyConfig.port;
|
||||
}
|
||||
|
||||
log(`Proxy mode: ${proxyConfig.mode}`);
|
||||
if (proxyConfig.mode === 'remote') {
|
||||
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
|
||||
}
|
||||
|
||||
// Note: proxyConfig is available for Phase 4 (remote mode integration)
|
||||
|
||||
// Ensure MCP web-search is configured for third-party profiles
|
||||
// WebSearch is a server-side tool executed by Anthropic's API
|
||||
// Third-party providers don't have access, so we use MCP fallback
|
||||
@@ -141,39 +161,102 @@ export async function execClaudeWithCLIProxy(
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
// 1. Ensure binary exists (downloads if needed)
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
// Check remote proxy if configured (before binary download)
|
||||
let useRemoteProxy = false;
|
||||
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
|
||||
const status = await checkRemoteProxy({
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
timeout: 2000,
|
||||
allowSelfSigned: proxyConfig.protocol === 'https',
|
||||
});
|
||||
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
throw error;
|
||||
if (status.reachable) {
|
||||
useRemoteProxy = true;
|
||||
console.log(
|
||||
ok(
|
||||
`Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(warn(`Remote proxy unreachable: ${status.error}`));
|
||||
|
||||
if (proxyConfig.remoteOnly) {
|
||||
throw new Error('Remote proxy unreachable and --remote-only specified');
|
||||
}
|
||||
|
||||
if (proxyConfig.fallbackEnabled) {
|
||||
if (proxyConfig.autoStartLocal) {
|
||||
console.log(info('Falling back to local proxy...'));
|
||||
} else {
|
||||
// Prompt user for fallback (only in TTY)
|
||||
if (process.stdin.isTTY) {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question('Start local proxy instead? [Y/n] ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() === 'n') {
|
||||
throw new Error('Remote proxy unreachable and user declined fallback');
|
||||
}
|
||||
}
|
||||
console.log(info('Starting local proxy...'));
|
||||
}
|
||||
} else {
|
||||
throw new Error('Remote proxy unreachable and fallback disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle special flags
|
||||
const forceAuth = args.includes('--auth');
|
||||
const forceHeadless = args.includes('--headless');
|
||||
const forceLogout = args.includes('--logout');
|
||||
const forceConfig = args.includes('--config');
|
||||
const addAccount = args.includes('--add');
|
||||
const showAccounts = args.includes('--accounts');
|
||||
// Variables for local proxy mode
|
||||
let binaryPath: string | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
// 1. Ensure binary exists (downloads if needed) - SKIP for remote mode
|
||||
if (!useRemoteProxy) {
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
||||
const forceAuth = argsWithoutProxy.includes('--auth');
|
||||
const forceHeadless = argsWithoutProxy.includes('--headless');
|
||||
const forceLogout = argsWithoutProxy.includes('--logout');
|
||||
const forceConfig = argsWithoutProxy.includes('--config');
|
||||
const addAccount = argsWithoutProxy.includes('--add');
|
||||
const showAccounts = argsWithoutProxy.includes('--accounts');
|
||||
|
||||
// Parse --use <account> flag
|
||||
let useAccount: string | undefined;
|
||||
const useIdx = args.indexOf('--use');
|
||||
if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) {
|
||||
useAccount = args[useIdx + 1];
|
||||
const useIdx = argsWithoutProxy.indexOf('--use');
|
||||
if (
|
||||
useIdx !== -1 &&
|
||||
argsWithoutProxy[useIdx + 1] &&
|
||||
!argsWithoutProxy[useIdx + 1].startsWith('-')
|
||||
) {
|
||||
useAccount = argsWithoutProxy[useIdx + 1];
|
||||
}
|
||||
|
||||
// Parse --nickname <name> flag
|
||||
let setNickname: string | undefined;
|
||||
const nicknameIdx = args.indexOf('--nickname');
|
||||
if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) {
|
||||
setNickname = args[nicknameIdx + 1];
|
||||
const nicknameIdx = argsWithoutProxy.indexOf('--nickname');
|
||||
if (
|
||||
nicknameIdx !== -1 &&
|
||||
argsWithoutProxy[nicknameIdx + 1] &&
|
||||
!argsWithoutProxy[nicknameIdx + 1].startsWith('-')
|
||||
) {
|
||||
setNickname = argsWithoutProxy[nicknameIdx + 1];
|
||||
}
|
||||
|
||||
// Handle --accounts: list accounts and exit
|
||||
@@ -305,118 +388,135 @@ export async function execClaudeWithCLIProxy(
|
||||
// 6. Ensure user settings file exists (creates from defaults if not)
|
||||
ensureProviderSettings(provider);
|
||||
|
||||
// 6. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||
// Clean up orphaned sessions first (from crashed proxies)
|
||||
cleanupOrphanedSessions(cfg.port);
|
||||
|
||||
// Check if there's an existing healthy proxy we can reuse
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
// Local proxy mode: generate config, spawn proxy, track session
|
||||
let proxy: ChildProcess | null = null;
|
||||
let sessionId: string;
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
} else {
|
||||
// No existing proxy - check if port is free
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||
// Only kill if no active sessions registered
|
||||
if (!hasActiveSessions()) {
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (!useRemoteProxy) {
|
||||
// 6. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||
// Clean up orphaned sessions first (from crashed proxies)
|
||||
cleanupOrphanedSessions(cfg.port);
|
||||
|
||||
// Check if there's an existing healthy proxy we can reuse
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
} else {
|
||||
// No existing proxy - check if port is free
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||
// Only kill if no active sessions registered
|
||||
if (!hasActiveSessions()) {
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
} else {
|
||||
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||
// Try to connect anyway
|
||||
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||
}
|
||||
} else {
|
||||
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||
// Try to connect anyway
|
||||
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`
|
||||
)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
} else {
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
// Use detached mode so proxy persists after terminal closes
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxy = spawn(binaryPath as string, proxyArgs, {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
detached: true, // Persist after parent terminal closes
|
||||
env: {
|
||||
...process.env,
|
||||
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
|
||||
},
|
||||
});
|
||||
|
||||
// Unref so parent process can exit independently
|
||||
proxy.unref();
|
||||
|
||||
// Handle proxy errors (only fires if spawn itself fails)
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
// Use detached mode so proxy persists after terminal closes
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
detached: true, // Persist after parent terminal closes
|
||||
});
|
||||
|
||||
// Unref so parent process can exit independently
|
||||
proxy.unref();
|
||||
|
||||
// Handle proxy errors (only fires if spawn itself fails)
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
}
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Uses custom settings path (for variants), user settings, or bundled defaults
|
||||
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
// Use remote or local env vars based on mode
|
||||
const envVars = useRemoteProxy
|
||||
? getRemoteEnvVars(provider, {
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
})
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -432,6 +532,7 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// Filter out CCS-specific flags before passing to Claude CLI
|
||||
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
|
||||
const ccsFlags = [
|
||||
'--auth',
|
||||
'--headless',
|
||||
@@ -441,12 +542,15 @@ export async function execClaudeWithCLIProxy(
|
||||
'--accounts',
|
||||
'--use',
|
||||
'--nickname',
|
||||
// Proxy flags are handled by resolveProxyConfig, but list for documentation
|
||||
...PROXY_CLI_FLAGS,
|
||||
];
|
||||
const claudeArgs = args.filter((arg, idx) => {
|
||||
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
||||
// Filter out CCS flags
|
||||
if (ccsFlags.includes(arg)) return false;
|
||||
// Filter out value after --use or --nickname
|
||||
if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false;
|
||||
if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname')
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -470,14 +574,16 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Cleanup: unregister session when Claude exits
|
||||
// 8. Cleanup: unregister session when Claude exits (local mode only)
|
||||
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
// Unregister this session (proxy keeps running for persistence)
|
||||
unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
@@ -489,8 +595,10 @@ export async function execClaudeWithCLIProxy(
|
||||
claude.on('error', (error) => {
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -498,8 +606,10 @@ export async function execClaudeWithCLIProxy(
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
|
||||
// Unregister session, proxy keeps running
|
||||
unregisterSession(sessionId);
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId);
|
||||
}
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getCcsDir } from '../utils/config-manager';
|
||||
import { warn } from '../utils/ui';
|
||||
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
|
||||
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -30,6 +30,15 @@ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
/** Simple secret key for Control Panel login (user-facing) */
|
||||
export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
|
||||
/**
|
||||
* Get CLIProxy writable directory for logs and runtime files.
|
||||
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
|
||||
* Logs will be stored in ~/.ccs/cliproxy/logs/
|
||||
*/
|
||||
export function getCliproxyWritablePath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Config version - bump when config format changes to trigger regeneration
|
||||
* v1: Initial config (port, auth-dir, api-keys only)
|
||||
@@ -371,6 +380,18 @@ export function getClaudeEnvVars(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global env vars to inject into all third-party profiles.
|
||||
* Returns empty object if disabled.
|
||||
*/
|
||||
function getGlobalEnvVars(): Record<string, string> {
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
if (!globalEnvConfig.enabled) {
|
||||
return {};
|
||||
}
|
||||
return globalEnvConfig.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective environment variables for provider
|
||||
*
|
||||
@@ -379,7 +400,7 @@ export function getClaudeEnvVars(
|
||||
* 2. User settings file (~/.ccs/{provider}.settings.json) if exists
|
||||
* 3. Bundled defaults from PROVIDER_CONFIGS
|
||||
*
|
||||
* This allows users to customize model mappings without code changes.
|
||||
* All results are merged with global_env vars (telemetry/reporting disables).
|
||||
* User takes full responsibility for custom settings.
|
||||
*/
|
||||
export function getEffectiveEnvVars(
|
||||
@@ -387,6 +408,9 @@ export function getEffectiveEnvVars(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
customSettingsPath?: string
|
||||
): NodeJS.ProcessEnv {
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Priority 1: Custom settings path (for user-defined variants)
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
|
||||
@@ -396,8 +420,8 @@ export function getEffectiveEnvVars(
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Custom variant settings found - use them
|
||||
return settings.env;
|
||||
// Custom variant settings found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to provider defaults
|
||||
@@ -418,9 +442,8 @@ export function getEffectiveEnvVars(
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// User override found - use their settings
|
||||
// Note: User is responsible for correctness
|
||||
return settings.env;
|
||||
// User override found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON or structure - fall through to defaults
|
||||
@@ -428,8 +451,8 @@ export function getEffectiveEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
// No override or invalid - use bundled defaults
|
||||
return getClaudeEnvVars(provider, port);
|
||||
// No override or invalid - use bundled defaults merged with global env
|
||||
return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,3 +479,49 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for remote proxy mode.
|
||||
* Uses the remote proxy's provider endpoint as the base URL.
|
||||
*
|
||||
* @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow)
|
||||
* @param remoteConfig Remote proxy connection details
|
||||
* @returns Environment variables for Claude CLI
|
||||
*/
|
||||
export function getRemoteEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string }
|
||||
): Record<string, string> {
|
||||
const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`;
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...globalEnv,
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Proxy Config Resolver
|
||||
*
|
||||
* Resolves proxy configuration from multiple sources with priority:
|
||||
* CLI flags > Environment variables > config.yaml > defaults
|
||||
*
|
||||
* Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes.
|
||||
*/
|
||||
|
||||
import { ResolvedProxyConfig } from './types';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** CLI flags for proxy configuration */
|
||||
export const PROXY_CLI_FLAGS = [
|
||||
'--proxy-host',
|
||||
'--proxy-port',
|
||||
'--proxy-protocol',
|
||||
'--proxy-auth-token',
|
||||
'--local-proxy',
|
||||
'--remote-only',
|
||||
] as const;
|
||||
|
||||
/** Environment variable names for proxy configuration */
|
||||
export const PROXY_ENV_VARS = {
|
||||
host: 'CCS_PROXY_HOST',
|
||||
port: 'CCS_PROXY_PORT',
|
||||
protocol: 'CCS_PROXY_PROTOCOL',
|
||||
authToken: 'CCS_PROXY_AUTH_TOKEN',
|
||||
fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED',
|
||||
} as const;
|
||||
|
||||
/** Parsed CLI proxy flags */
|
||||
interface ParsedProxyFlags {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
authToken?: string;
|
||||
localProxy: boolean;
|
||||
remoteOnly: boolean;
|
||||
}
|
||||
|
||||
/** Proxy config from environment variables */
|
||||
interface EnvProxyConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
authToken?: string;
|
||||
fallbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse proxy-related CLI flags from argv.
|
||||
* Returns parsed flags and remaining args (with proxy flags removed).
|
||||
*/
|
||||
export function parseProxyFlags(args: string[]): {
|
||||
flags: ParsedProxyFlags;
|
||||
remainingArgs: string[];
|
||||
} {
|
||||
const flags: ParsedProxyFlags = {
|
||||
localProxy: false,
|
||||
remoteOnly: false,
|
||||
};
|
||||
const remainingArgs: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
flags.host = args[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
const port = parseInt(args[i + 1], 10);
|
||||
if (!isNaN(port) && port > 0 && port <= 65535) {
|
||||
flags.port = port;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
const proto = args[i + 1].toLowerCase();
|
||||
if (proto === 'http' || proto === 'https') {
|
||||
flags.protocol = proto;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) {
|
||||
flags.authToken = args[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--local-proxy') {
|
||||
flags.localProxy = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--remote-only') {
|
||||
flags.remoteOnly = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a proxy flag - keep in remaining args
|
||||
remainingArgs.push(arg);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return { flags, remainingArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy configuration from environment variables.
|
||||
*/
|
||||
export function getProxyEnvVars(): EnvProxyConfig {
|
||||
const config: EnvProxyConfig = {};
|
||||
|
||||
const host = process.env[PROXY_ENV_VARS.host];
|
||||
if (host) {
|
||||
config.host = host;
|
||||
}
|
||||
|
||||
const port = process.env[PROXY_ENV_VARS.port];
|
||||
if (port) {
|
||||
const portNum = parseInt(port, 10);
|
||||
if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) {
|
||||
config.port = portNum;
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = process.env[PROXY_ENV_VARS.protocol];
|
||||
if (protocol) {
|
||||
const proto = protocol.toLowerCase();
|
||||
if (proto === 'http' || proto === 'https') {
|
||||
config.protocol = proto;
|
||||
}
|
||||
}
|
||||
|
||||
const authToken = process.env[PROXY_ENV_VARS.authToken];
|
||||
if (authToken) {
|
||||
config.authToken = authToken;
|
||||
}
|
||||
|
||||
const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled];
|
||||
if (fallback !== undefined) {
|
||||
// Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled
|
||||
const lower = fallback.toLowerCase();
|
||||
if (lower === '1' || lower === 'true' || lower === 'yes') {
|
||||
config.fallbackEnabled = true;
|
||||
} else if (lower === '0' || lower === 'false' || lower === 'no') {
|
||||
config.fallbackEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default proxy configuration values.
|
||||
*/
|
||||
const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = {
|
||||
mode: 'local',
|
||||
port: CLIPROXY_DEFAULT_PORT,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: true,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults.
|
||||
*
|
||||
* @param cliArgs - Raw CLI arguments
|
||||
* @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1)
|
||||
* @returns Resolved configuration and remaining args (without proxy flags)
|
||||
*/
|
||||
export function resolveProxyConfig(
|
||||
cliArgs: string[],
|
||||
|
||||
_configYamlProxy?: {
|
||||
remote?: {
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: 'http' | 'https';
|
||||
auth_token?: string;
|
||||
fallback_enabled?: boolean;
|
||||
};
|
||||
local?: {
|
||||
port?: number;
|
||||
auto_start?: boolean;
|
||||
};
|
||||
}
|
||||
): { config: ResolvedProxyConfig; remainingArgs: string[] } {
|
||||
// 1. Parse CLI flags (highest priority)
|
||||
const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs);
|
||||
|
||||
// 2. Get environment variables
|
||||
const envConfig = getProxyEnvVars();
|
||||
|
||||
// 3. config.yaml proxy section (passed as parameter - Phase 1 provides this)
|
||||
// For now, we use empty object if not provided; Phase 1 integrates unified config loading
|
||||
const yamlConfig = _configYamlProxy || {};
|
||||
|
||||
// 4. Build resolved config with priority merge
|
||||
const resolved: ResolvedProxyConfig = {
|
||||
...DEFAULT_PROXY_CONFIG,
|
||||
};
|
||||
|
||||
// Determine mode: remote if host is specified anywhere (unless --local-proxy)
|
||||
const hasRemoteHost =
|
||||
cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled;
|
||||
|
||||
// --local-proxy forces local mode regardless of remote config
|
||||
if (cliFlags.localProxy) {
|
||||
resolved.mode = 'local';
|
||||
resolved.forceLocal = true;
|
||||
} else if (hasRemoteHost) {
|
||||
resolved.mode = 'remote';
|
||||
}
|
||||
|
||||
// Merge host: CLI > ENV > config.yaml
|
||||
resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host;
|
||||
|
||||
// Merge port: CLI > ENV > config.yaml (remote or local) > default
|
||||
resolved.port =
|
||||
cliFlags.port ??
|
||||
envConfig.port ??
|
||||
(resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ??
|
||||
DEFAULT_PROXY_CONFIG.port;
|
||||
|
||||
// Merge protocol: CLI > ENV > config.yaml > default
|
||||
resolved.protocol =
|
||||
cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http';
|
||||
|
||||
// Merge auth token: CLI > ENV > config.yaml
|
||||
resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token;
|
||||
|
||||
// Merge fallback enabled: ENV > config.yaml > default
|
||||
resolved.fallbackEnabled =
|
||||
envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true;
|
||||
|
||||
// --remote-only from CLI
|
||||
resolved.remoteOnly = cliFlags.remoteOnly;
|
||||
|
||||
// If --remote-only, disable fallback
|
||||
if (resolved.remoteOnly) {
|
||||
resolved.fallbackEnabled = false;
|
||||
}
|
||||
|
||||
// Auto-start local from config.yaml > default
|
||||
resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true;
|
||||
|
||||
return { config: resolved, remainingArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if args contain any proxy flags.
|
||||
* Used for quick filtering before full parse.
|
||||
*/
|
||||
export function hasProxyFlags(args: string[]): boolean {
|
||||
return args.some(
|
||||
(arg) =>
|
||||
arg === '--proxy-host' ||
|
||||
arg === '--proxy-port' ||
|
||||
arg === '--proxy-protocol' ||
|
||||
arg === '--proxy-auth-token' ||
|
||||
arg === '--local-proxy' ||
|
||||
arg === '--remote-only'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Remote Proxy Client for CLIProxyAPI
|
||||
*
|
||||
* HTTP client for health checks and connection testing against remote CLIProxyAPI instances.
|
||||
* Uses native fetch API with aggressive timeout for CLI responsiveness.
|
||||
*/
|
||||
|
||||
import * as https from 'https';
|
||||
|
||||
/** Error codes for remote proxy status */
|
||||
export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN';
|
||||
|
||||
/** Status returned from remote proxy health check */
|
||||
export interface RemoteProxyStatus {
|
||||
/** Whether the remote proxy is reachable */
|
||||
reachable: boolean;
|
||||
/** Latency in milliseconds (only set if reachable) */
|
||||
latencyMs?: number;
|
||||
/** Error message (only set if not reachable) */
|
||||
error?: string;
|
||||
/** Error code for programmatic handling */
|
||||
errorCode?: RemoteProxyErrorCode;
|
||||
}
|
||||
|
||||
/** Configuration for remote proxy client */
|
||||
export interface RemoteProxyClientConfig {
|
||||
/** Remote proxy host (IP or hostname) */
|
||||
host: string;
|
||||
/** Remote proxy port */
|
||||
port: number;
|
||||
/** Protocol to use (http or https) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Optional auth token for Authorization header */
|
||||
authToken?: string;
|
||||
/** Request timeout in ms (default: 2000) */
|
||||
timeout?: number;
|
||||
/** Allow self-signed certificates (default: false) */
|
||||
allowSelfSigned?: boolean;
|
||||
}
|
||||
|
||||
/** Default timeout for remote proxy requests (aggressive for CLI UX) */
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Map error to RemoteProxyErrorCode
|
||||
*/
|
||||
function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode {
|
||||
const message = error.message.toLowerCase();
|
||||
const code = (error as NodeJS.ErrnoException).code?.toLowerCase();
|
||||
|
||||
// Connection refused
|
||||
if (code === 'econnrefused' || message.includes('connection refused')) {
|
||||
return 'CONNECTION_REFUSED';
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (
|
||||
code === 'etimedout' ||
|
||||
code === 'timeout' ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('aborted')
|
||||
) {
|
||||
return 'TIMEOUT';
|
||||
}
|
||||
|
||||
// Auth failed (401/403)
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'AUTH_FAILED';
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message from error code
|
||||
*/
|
||||
function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string {
|
||||
switch (errorCode) {
|
||||
case 'CONNECTION_REFUSED':
|
||||
return 'Connection refused - is the proxy running?';
|
||||
case 'TIMEOUT':
|
||||
return 'Connection timed out';
|
||||
case 'AUTH_FAILED':
|
||||
return 'Authentication failed - check auth token';
|
||||
default:
|
||||
return rawError || 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom HTTPS agent for self-signed certificate support
|
||||
*/
|
||||
function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined {
|
||||
if (!allowSelfSigned) return undefined;
|
||||
|
||||
return new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of remote CLIProxyAPI instance
|
||||
*
|
||||
* @param config Remote proxy client configuration
|
||||
* @returns RemoteProxyStatus with reachability and latency
|
||||
*/
|
||||
export async function checkRemoteProxy(
|
||||
config: RemoteProxyClientConfig
|
||||
): Promise<RemoteProxyStatus> {
|
||||
const { host, port, protocol, authToken, allowSelfSigned = false } = config;
|
||||
const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
const url = `${protocol}://${host}:${port}/health`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// Build request options
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// For HTTPS with self-signed certs, we need to use native https module
|
||||
// Bun's fetch doesn't support custom agents
|
||||
let response: Response;
|
||||
|
||||
if (protocol === 'https' && allowSelfSigned) {
|
||||
// Warn about security implications
|
||||
console.error('[!] Allowing self-signed certificate - not recommended for production');
|
||||
|
||||
// Use native https module for self-signed cert support
|
||||
response = await new Promise<Response>((resolve, reject) => {
|
||||
const agent = createHttpsAgent(true);
|
||||
const reqTimeout = setTimeout(() => {
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeout);
|
||||
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
agent,
|
||||
timeout,
|
||||
},
|
||||
(res) => {
|
||||
clearTimeout(reqTimeout);
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
resolve(
|
||||
new Response(data, {
|
||||
status: res.statusCode || 500,
|
||||
statusText: res.statusMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(reqTimeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
} else {
|
||||
// Standard fetch for HTTP or HTTPS without self-signed
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
// Check for auth failure
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
reachable: false,
|
||||
error: getErrorMessage('AUTH_FAILED'),
|
||||
errorCode: 'AUTH_FAILED',
|
||||
};
|
||||
}
|
||||
|
||||
// 200 OK = healthy
|
||||
if (response.ok) {
|
||||
return {
|
||||
reachable: true,
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-200 but connected
|
||||
return {
|
||||
reachable: false,
|
||||
error: `Unexpected status: ${response.status}`,
|
||||
errorCode: 'UNKNOWN',
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const errorCode = mapErrorToCode(err);
|
||||
|
||||
return {
|
||||
reachable: false,
|
||||
error: getErrorMessage(errorCode, err.message),
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to remote CLIProxyAPI (alias for dashboard use)
|
||||
*
|
||||
* This is an alias for checkRemoteProxy() for semantic clarity in UI contexts.
|
||||
*
|
||||
* @param config Remote proxy client configuration
|
||||
* @returns RemoteProxyStatus with reachability and latency
|
||||
*/
|
||||
export async function testConnection(config: RemoteProxyClientConfig): Promise<RemoteProxyStatus> {
|
||||
return checkRemoteProxy(config);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
getCliproxyWritablePath,
|
||||
} from './config-generator';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
|
||||
@@ -171,6 +172,10 @@ export async function ensureCliproxyService(
|
||||
proxyProcess = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: true, // Allow process to run independently
|
||||
env: {
|
||||
...process.env,
|
||||
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
|
||||
},
|
||||
});
|
||||
|
||||
// Forward output in verbose mode
|
||||
|
||||
@@ -271,6 +271,92 @@ export async function fetchCliproxyModels(
|
||||
}
|
||||
}
|
||||
|
||||
/** Error log file metadata from CLIProxyAPI */
|
||||
export interface CliproxyErrorLog {
|
||||
/** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */
|
||||
name: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp (Unix seconds) */
|
||||
modified: number;
|
||||
/** Absolute path to the log file (injected by backend) */
|
||||
absolutePath?: string;
|
||||
}
|
||||
|
||||
/** Response from /v0/management/request-error-logs endpoint */
|
||||
interface ErrorLogsApiResponse {
|
||||
files: CliproxyErrorLog[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch error log file list from CLIProxyAPI management API
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Array of error log metadata or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogs(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyErrorLog[] | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ErrorLogsApiResponse;
|
||||
return data.files ?? [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch error log file content from CLIProxyAPI management API
|
||||
* @param name Error log filename
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Log file content as string or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogContent(
|
||||
name: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxyAPI is running and responsive
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
|
||||
@@ -187,3 +187,28 @@ export interface ProviderConfig {
|
||||
/** Whether OAuth is required */
|
||||
requiresOAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved proxy configuration after merging CLI > ENV > config.yaml > defaults.
|
||||
* Used by executor to determine local vs remote proxy mode.
|
||||
*/
|
||||
export interface ResolvedProxyConfig {
|
||||
/** Proxy mode: 'local' spawns CLIProxyAPI locally, 'remote' connects to external server */
|
||||
mode: 'local' | 'remote';
|
||||
/** Remote proxy hostname/IP (only for remote mode) */
|
||||
host?: string;
|
||||
/** Proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Protocol for remote connection (default: http) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy authentication */
|
||||
authToken?: string;
|
||||
/** Enable fallback to local when remote unreachable (default: true) */
|
||||
fallbackEnabled: boolean;
|
||||
/** Auto-start local proxy if not running (default: true) */
|
||||
autoStartLocal: boolean;
|
||||
/** --remote-only flag: fail if remote unreachable, no fallback */
|
||||
remoteOnly: boolean;
|
||||
/** --local-proxy flag: force local mode, ignore remote config */
|
||||
forceLocal: boolean;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cleanup Command Handler
|
||||
*
|
||||
* Removes old CLIProxy logs to free up disk space.
|
||||
* Supports both main logs and error request logs with age-based filtering.
|
||||
* Logs can accumulate to several GB without user awareness.
|
||||
*/
|
||||
|
||||
@@ -10,6 +11,9 @@ import * as path from 'path';
|
||||
import { getCliproxyDir } from '../cliproxy/config-generator';
|
||||
import { info, ok, warn } from '../utils/ui';
|
||||
|
||||
/** Default age in days for error log cleanup */
|
||||
const DEFAULT_ERROR_LOG_AGE_DAYS = 7;
|
||||
|
||||
/** Get the CLIProxy logs directory */
|
||||
function getLogsDir(): string {
|
||||
return path.join(getCliproxyDir(), 'logs');
|
||||
@@ -95,6 +99,76 @@ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number
|
||||
return { deleted, freedBytes };
|
||||
}
|
||||
|
||||
/** Error log file info */
|
||||
interface ErrorLogInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
ageInDays: number;
|
||||
}
|
||||
|
||||
/** Get error log files with metadata */
|
||||
function getErrorLogFiles(logsDir: string): ErrorLogInfo[] {
|
||||
if (!fs.existsSync(logsDir)) return [];
|
||||
|
||||
const now = Date.now();
|
||||
const files: ErrorLogInfo[] = [];
|
||||
const entries = fs.readdirSync(logsDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
// Only process error-*.log files
|
||||
if (!entry.startsWith('error-') || !entry.endsWith('.log')) continue;
|
||||
|
||||
const filePath = path.join(logsDir, entry);
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath);
|
||||
if (stats.isFile() && !stats.isSymbolicLink()) {
|
||||
const ageMs = now - stats.mtime.getTime();
|
||||
files.push({
|
||||
name: entry,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
mtime: stats.mtime,
|
||||
ageInDays: Math.floor(ageMs / (1000 * 60 * 60 * 24)),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted - skip
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by age, oldest first
|
||||
return files.sort((a, b) => b.ageInDays - a.ageInDays);
|
||||
}
|
||||
|
||||
/** Delete error logs older than specified days */
|
||||
function cleanErrorLogs(
|
||||
logsDir: string,
|
||||
maxAgeDays: number
|
||||
): { deleted: number; freedBytes: number; kept: number } {
|
||||
const files = getErrorLogFiles(logsDir);
|
||||
let deleted = 0;
|
||||
let freedBytes = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.ageInDays >= maxAgeDays) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
deleted++;
|
||||
freedBytes += file.size;
|
||||
} catch {
|
||||
// File may be locked or already deleted
|
||||
}
|
||||
} else {
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted, freedBytes, kept };
|
||||
}
|
||||
|
||||
/** Print help for cleanup command */
|
||||
function printHelp(): void {
|
||||
console.log('');
|
||||
@@ -103,20 +177,19 @@ function printHelp(): void {
|
||||
console.log('Remove old CLIProxy logs to free up disk space.');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --errors Clean error request logs (error-*.log files)');
|
||||
console.log(' --days=N Delete error logs older than N days (default: 7)');
|
||||
console.log(' --dry-run Show what would be deleted without deleting');
|
||||
console.log(' --force Skip confirmation prompt');
|
||||
console.log(' --help, -h Show this help message');
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' ccs cleanup Interactive cleanup with confirmation');
|
||||
console.log(' ccs cleanup --dry-run Preview cleanup without deleting');
|
||||
console.log(' ccs cleanup --force Clean without confirmation');
|
||||
console.log('');
|
||||
console.log('Note: CLIProxy logging is disabled by default.');
|
||||
console.log('To enable logging, edit ~/.ccs/config.yaml:');
|
||||
console.log(' cliproxy:');
|
||||
console.log(' logging:');
|
||||
console.log(' enabled: true');
|
||||
console.log(' ccs cleanup Interactive main log cleanup');
|
||||
console.log(' ccs cleanup --errors Clean error logs older than 7 days');
|
||||
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
|
||||
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
|
||||
console.log(' ccs cleanup --dry-run Preview main log cleanup');
|
||||
console.log(' ccs cleanup --force Clean main logs without confirmation');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -132,8 +205,125 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
|
||||
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const force = args.includes('--force');
|
||||
const cleanErrors = args.includes('--errors');
|
||||
const logsDir = getLogsDir();
|
||||
|
||||
// Parse --days=N option
|
||||
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
|
||||
const daysArg = args.find((arg) => arg.startsWith('--days='));
|
||||
if (daysArg) {
|
||||
const parsed = parseInt(daysArg.split('=')[1], 10);
|
||||
if (isNaN(parsed) || parsed < 1) {
|
||||
console.log(warn('Invalid --days value. Must be a positive integer.'));
|
||||
return;
|
||||
}
|
||||
maxAgeDays = parsed;
|
||||
}
|
||||
|
||||
// Route to error log cleanup or main log cleanup
|
||||
if (cleanErrors) {
|
||||
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
|
||||
} else {
|
||||
await handleMainLogCleanup(logsDir, dryRun, force);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error log cleanup (error-*.log files)
|
||||
*/
|
||||
async function handleErrorLogCleanup(
|
||||
logsDir: string,
|
||||
maxAgeDays: number,
|
||||
dryRun: boolean,
|
||||
force: boolean
|
||||
): Promise<void> {
|
||||
// Check if logs directory exists
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
console.log(info('No CLIProxy logs directory found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get error log files
|
||||
const errorLogs = getErrorLogFiles(logsDir);
|
||||
if (errorLogs.length === 0) {
|
||||
console.log(info('No error logs found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate what would be deleted
|
||||
const toDelete = errorLogs.filter((f) => f.ageInDays >= maxAgeDays);
|
||||
const toKeep = errorLogs.filter((f) => f.ageInDays < maxAgeDays);
|
||||
const totalDeleteSize = toDelete.reduce((sum, f) => sum + f.size, 0);
|
||||
|
||||
console.log('');
|
||||
console.log(`Error Logs: ${logsDir}`);
|
||||
console.log(` Total: ${errorLogs.length} files`);
|
||||
console.log(
|
||||
` To delete: ${toDelete.length} files older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})`
|
||||
);
|
||||
console.log(` To keep: ${toKeep.length} files newer than ${maxAgeDays} days`);
|
||||
console.log('');
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
console.log(info(`No error logs older than ${maxAgeDays} days.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show oldest files in dry-run or verbose mode
|
||||
if (dryRun || toDelete.length <= 5) {
|
||||
console.log('Files to delete:');
|
||||
for (const file of toDelete.slice(0, 10)) {
|
||||
console.log(` ${file.name} (${file.ageInDays}d old, ${formatBytes(file.size)})`);
|
||||
}
|
||||
if (toDelete.length > 10) {
|
||||
console.log(` ... and ${toDelete.length - 10} more`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(info('Dry run - no files deleted.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm unless --force
|
||||
if (!force) {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`Delete ${toDelete.length} error logs older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})? [y/N] `,
|
||||
resolve
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform cleanup
|
||||
const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays);
|
||||
console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`));
|
||||
if (kept > 0) {
|
||||
console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle main log cleanup (main.log and rotated files)
|
||||
*/
|
||||
async function handleMainLogCleanup(
|
||||
logsDir: string,
|
||||
dryRun: boolean,
|
||||
force: boolean
|
||||
): Promise<void> {
|
||||
// Check if logs directory exists
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
console.log(info('No CLIProxy logs found.'));
|
||||
|
||||
@@ -241,6 +241,25 @@ Claude Code Profile & Model Switcher`.trim();
|
||||
['ccs cliproxy --latest', 'Update to latest version'],
|
||||
]);
|
||||
|
||||
// CLI Proxy configuration flags (new)
|
||||
printSubSection('CLI Proxy Configuration', [
|
||||
['--proxy-host <host>', 'Remote proxy hostname/IP'],
|
||||
['--proxy-port <port>', 'Proxy port (default: 8317)'],
|
||||
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
|
||||
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
|
||||
['--local-proxy', 'Force local mode, ignore remote config'],
|
||||
['--remote-only', 'Fail if remote unreachable (no fallback)'],
|
||||
]);
|
||||
|
||||
// CLI Proxy env vars
|
||||
printSubSection('CLI Proxy Environment Variables', [
|
||||
['CCS_PROXY_HOST', 'Remote proxy hostname'],
|
||||
['CCS_PROXY_PORT', 'Proxy port'],
|
||||
['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'],
|
||||
['CCS_PROXY_AUTH_TOKEN', 'Auth token'],
|
||||
['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'],
|
||||
]);
|
||||
|
||||
// CLI Proxy paths
|
||||
console.log(subheader('CLI Proxy:'));
|
||||
console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`);
|
||||
|
||||
+26
-139
@@ -2,12 +2,12 @@
|
||||
* Update Command Handler
|
||||
*
|
||||
* Handles `ccs update` command - checks for updates and installs latest version.
|
||||
* Supports both npm and direct installation methods.
|
||||
* Uses npm/yarn/pnpm/bun package managers exclusively.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { initUI, header, ok, fail, warn, info, color } from '../utils/ui';
|
||||
import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector';
|
||||
import { detectPackageManager } from '../utils/package-manager-detector';
|
||||
import { compareVersionsWithPrerelease } from '../utils/update-checker';
|
||||
import { getVersion } from '../utils/version';
|
||||
|
||||
@@ -35,33 +35,20 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise<
|
||||
console.log(header('Checking for updates...'));
|
||||
console.log('');
|
||||
|
||||
const installMethod = detectInstallationMethod();
|
||||
const isNpmInstall = installMethod === 'npm';
|
||||
|
||||
// Force reinstall - skip update check
|
||||
if (force) {
|
||||
console.log(info(`Force reinstall from @${targetTag} channel...`));
|
||||
console.log('');
|
||||
|
||||
if (isNpmInstall) {
|
||||
await performNpmUpdate(targetTag, true);
|
||||
} else {
|
||||
// Direct install doesn't support --beta
|
||||
if (beta) {
|
||||
handleDirectBetaNotSupported();
|
||||
return;
|
||||
}
|
||||
await performDirectUpdate();
|
||||
}
|
||||
await performNpmUpdate(targetTag, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { checkForUpdates } = await import('../utils/update-checker');
|
||||
|
||||
const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod, targetTag);
|
||||
const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag);
|
||||
|
||||
if (updateResult.status === 'check_failed') {
|
||||
handleCheckFailed(updateResult.message ?? 'Update check failed', isNpmInstall, targetTag);
|
||||
handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,21 +89,13 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise<
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (isNpmInstall) {
|
||||
await performNpmUpdate(targetTag);
|
||||
} else {
|
||||
await performDirectUpdate();
|
||||
}
|
||||
await performNpmUpdate(targetTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed update check
|
||||
*/
|
||||
function handleCheckFailed(
|
||||
message: string,
|
||||
isNpmInstall: boolean,
|
||||
targetTag: string = 'latest'
|
||||
): void {
|
||||
function handleCheckFailed(message: string, targetTag: string = 'latest'): void {
|
||||
console.log(fail(message));
|
||||
console.log('');
|
||||
console.log(warn('Possible causes:'));
|
||||
@@ -126,36 +105,27 @@ function handleCheckFailed(
|
||||
console.log('');
|
||||
console.log('Try again later or update manually:');
|
||||
|
||||
if (isNpmInstall) {
|
||||
const packageManager = detectPackageManager();
|
||||
let manualCommand: string;
|
||||
const packageManager = detectPackageManager();
|
||||
let manualCommand: string;
|
||||
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'yarn':
|
||||
manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'pnpm':
|
||||
manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'bun':
|
||||
manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
default:
|
||||
manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`;
|
||||
}
|
||||
|
||||
console.log(color(` ${manualCommand}`, 'command'));
|
||||
} else {
|
||||
const isWindows = process.platform === 'win32';
|
||||
if (isWindows) {
|
||||
console.log(color(' irm ccs.kaitran.ca/install | iex', 'command'));
|
||||
} else {
|
||||
console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command'));
|
||||
}
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'yarn':
|
||||
manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'pnpm':
|
||||
manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
case 'bun':
|
||||
manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`;
|
||||
break;
|
||||
default:
|
||||
manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`;
|
||||
}
|
||||
|
||||
console.log(color(` ${manualCommand}`, 'command'));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -300,86 +270,3 @@ async function performNpmUpdate(
|
||||
performUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle direct install beta not supported error
|
||||
*/
|
||||
function handleDirectBetaNotSupported(): void {
|
||||
console.log(fail('--beta flag requires npm installation'));
|
||||
console.log('');
|
||||
console.log('Current installation method: direct installer');
|
||||
console.log('To use beta releases, install via npm:');
|
||||
console.log('');
|
||||
console.log(color(' npm install -g @kaitranntt/ccs', 'command'));
|
||||
console.log(color(' ccs update --beta', 'command'));
|
||||
console.log('');
|
||||
console.log('Or continue using stable releases via direct installer.');
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform update via direct installer (curl/irm)
|
||||
*/
|
||||
async function performDirectUpdate(): Promise<void> {
|
||||
console.log(info('Updating via installer...'));
|
||||
console.log('');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
let command: string;
|
||||
let args: string[];
|
||||
|
||||
if (isWindows) {
|
||||
command = 'powershell.exe';
|
||||
args = [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
'irm ccs.kaitran.ca/install | iex',
|
||||
];
|
||||
} else {
|
||||
command = '/bin/bash';
|
||||
args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash'];
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('');
|
||||
console.log(ok('Update successful!'));
|
||||
console.log('');
|
||||
console.log(`Run ${color('ccs --version', 'command')} to verify`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(fail('Update failed'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
if (isWindows) {
|
||||
console.log(color(' irm ccs.kaitran.ca/install | iex', 'command'));
|
||||
} else {
|
||||
console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
console.log('');
|
||||
console.log(fail('Failed to run installer'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
if (isWindows) {
|
||||
console.log(color(' irm ccs.kaitran.ca/install | iex', 'command'));
|
||||
} else {
|
||||
console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command'));
|
||||
}
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
createEmptyUnifiedConfig,
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
GlobalEnvConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
@@ -170,6 +173,40 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
|
||||
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
|
||||
},
|
||||
// Global env - injected into all non-Claude subscription profiles
|
||||
global_env: {
|
||||
enabled: partial.global_env?.enabled ?? true,
|
||||
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
// CLIProxy server config - remote/local CLIProxyAPI settings
|
||||
cliproxy_server: {
|
||||
remote: {
|
||||
enabled:
|
||||
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
|
||||
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
|
||||
port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port,
|
||||
protocol:
|
||||
partial.cliproxy_server?.remote?.protocol ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol,
|
||||
auth_token:
|
||||
partial.cliproxy_server?.remote?.auth_token ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
|
||||
},
|
||||
fallback: {
|
||||
enabled:
|
||||
partial.cliproxy_server?.fallback?.enabled ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled,
|
||||
auto_start:
|
||||
partial.cliproxy_server?.fallback?.auto_start ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start,
|
||||
},
|
||||
local: {
|
||||
port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port,
|
||||
auto_start:
|
||||
partial.cliproxy_server?.local?.auto_start ??
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -336,6 +373,28 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Global env section
|
||||
if (config.global_env) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
'# Global Environment Variables: Injected into all non-Claude subscription profiles'
|
||||
);
|
||||
lines.push('# These env vars disable telemetry/reporting for third-party providers.');
|
||||
lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.');
|
||||
lines.push('#');
|
||||
lines.push('# Default variables:');
|
||||
lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)');
|
||||
lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic');
|
||||
lines.push('# DISABLE_TELEMETRY: Disables usage telemetry');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' })
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -462,3 +521,15 @@ export function getWebSearchConfig(): {
|
||||
gemini: config.websearch?.gemini,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global_env configuration.
|
||||
* Returns defaults if not configured.
|
||||
*/
|
||||
export function getGlobalEnvConfig(): GlobalEnvConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return {
|
||||
enabled: config.global_env?.enabled ?? true,
|
||||
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* Version 2 = YAML unified format
|
||||
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
|
||||
* Version 4 = Copilot API integration (GitHub Copilot proxy)
|
||||
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 4;
|
||||
export const UNIFIED_CONFIG_VERSION = 5;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -185,6 +186,78 @@ export interface CopilotConfig {
|
||||
haiku_model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote proxy configuration.
|
||||
* Connect to a remote CLIProxyAPI instance instead of spawning local binary.
|
||||
*/
|
||||
export interface ProxyRemoteConfig {
|
||||
/** Enable remote proxy mode (default: false = local mode) */
|
||||
enabled: boolean;
|
||||
/** Remote proxy hostname or IP (empty = not configured) */
|
||||
host: string;
|
||||
/** Remote proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Protocol for remote connection */
|
||||
protocol: 'http' | 'https';
|
||||
/** Auth token for remote proxy (optional, sent as header) */
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback configuration when remote proxy is unreachable.
|
||||
*/
|
||||
export interface ProxyFallbackConfig {
|
||||
/** Enable fallback to local proxy (default: true) */
|
||||
enabled: boolean;
|
||||
/** Auto-start local proxy without prompting (default: false = prompt user) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local proxy configuration.
|
||||
*/
|
||||
export interface ProxyLocalConfig {
|
||||
/** Local proxy port (default: 8317) */
|
||||
port: number;
|
||||
/** Auto-start local binary (default: true) */
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy server configuration section.
|
||||
* Controls whether CCS uses local or remote CLIProxyAPI instance.
|
||||
*/
|
||||
export interface CliproxyServerConfig {
|
||||
/** Remote proxy settings */
|
||||
remote: ProxyRemoteConfig;
|
||||
/** Fallback behavior when remote is unreachable */
|
||||
fallback: ProxyFallbackConfig;
|
||||
/** Local proxy settings */
|
||||
local: ProxyLocalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global environment variables configuration.
|
||||
* These env vars are injected into ALL non-Claude subscription profiles.
|
||||
* Useful for disabling telemetry, bug commands, error reporting, etc.
|
||||
*/
|
||||
export interface GlobalEnvConfig {
|
||||
/** Enable global env injection (default: true) */
|
||||
enabled: boolean;
|
||||
/** Environment variables to inject */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default global env vars for third-party profiles.
|
||||
* These disable Claude Code telemetry/reporting since we're using proxy.
|
||||
*/
|
||||
export const DEFAULT_GLOBAL_ENV: Record<string, string> = {
|
||||
DISABLE_BUG_COMMAND: '1',
|
||||
DISABLE_ERROR_REPORTING: '1',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSearch configuration.
|
||||
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
|
||||
@@ -220,7 +293,7 @@ export interface WebSearchConfig {
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
*/
|
||||
export interface UnifiedConfig {
|
||||
/** Config version (4 for copilot support) */
|
||||
/** Config version (5 for remote proxy support) */
|
||||
version: number;
|
||||
/** Default profile name to use when none specified */
|
||||
default?: string;
|
||||
@@ -234,8 +307,12 @@ export interface UnifiedConfig {
|
||||
preferences: PreferencesConfig;
|
||||
/** WebSearch configuration */
|
||||
websearch?: WebSearchConfig;
|
||||
/** Global environment variables for all non-Claude subscription profiles */
|
||||
global_env?: GlobalEnvConfig;
|
||||
/** Copilot API configuration (GitHub Copilot proxy) */
|
||||
copilot?: CopilotConfig;
|
||||
/** CLIProxy server configuration for remote/local mode */
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,6 +342,28 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
|
||||
model: 'gpt-4.1', // Free tier compatible
|
||||
};
|
||||
|
||||
/**
|
||||
* Default CLIProxy server configuration.
|
||||
* Local mode by default - remote must be explicitly enabled.
|
||||
*/
|
||||
export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
|
||||
remote: {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
auth_token: '',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an empty unified config with defaults.
|
||||
*/
|
||||
@@ -307,7 +406,12 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
},
|
||||
},
|
||||
},
|
||||
global_env: {
|
||||
enabled: true,
|
||||
env: { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { CopilotConfig } from '../config/unified-config-types';
|
||||
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
||||
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
||||
import { ensureCopilotApi } from './copilot-package-manager';
|
||||
@@ -127,9 +128,14 @@ export async function executeCopilotProfile(
|
||||
// Generate environment for Claude
|
||||
const copilotEnv = generateCopilotEnv(config);
|
||||
|
||||
// Merge with current environment
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||
|
||||
// Merge with current environment (global env first, copilot overrides)
|
||||
const env = {
|
||||
...process.env,
|
||||
...globalEnv,
|
||||
...copilotEnv,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,77 +2,13 @@
|
||||
* Package Manager Detector Utilities
|
||||
*
|
||||
* Cross-platform package manager detection utilities for CCS.
|
||||
* Now only supports npm-based installation (npm/yarn/pnpm/bun).
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Detect installation method
|
||||
*/
|
||||
export function detectInstallationMethod(): 'npm' | 'direct' {
|
||||
const scriptPath = process.argv[1];
|
||||
|
||||
// Method 1: Check if script is inside node_modules
|
||||
if (scriptPath.includes('node_modules')) {
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
// Method 2: Check if script is in npm global bin directory
|
||||
const npmGlobalBinPatterns = [
|
||||
/\.npm\/global\/bin\//,
|
||||
/\/\.nvm\/versions\/node\/[^/]+\/bin\//,
|
||||
/\/usr\/local\/bin\//,
|
||||
/\/usr\/bin\//,
|
||||
];
|
||||
|
||||
for (const pattern of npmGlobalBinPatterns) {
|
||||
if (pattern.test(scriptPath)) {
|
||||
try {
|
||||
const binDir = path.dirname(scriptPath);
|
||||
const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs');
|
||||
const globalModulesDir = path.join(binDir, '..', 'node_modules', '@kaitranntt', 'ccs');
|
||||
|
||||
if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue checking other patterns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check if package.json exists in parent directory
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === '@kaitranntt/ccs') {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Check if script is a symlink pointing to node_modules
|
||||
try {
|
||||
const stats = fs.lstatSync(scriptPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const targetPath = fs.readlinkSync(scriptPath);
|
||||
if (targetPath.includes('node_modules') || targetPath.includes('@kaitranntt/ccs')) {
|
||||
return 'npm';
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to default
|
||||
}
|
||||
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager was used for installation
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const { usageRoutes } = await import('./usage-routes');
|
||||
app.use('/api/usage', usageRoutes);
|
||||
|
||||
// CLIProxy server settings routes (Phase 5)
|
||||
const cliproxyServerRoutes = (await import('./routes/proxy-routes')).default;
|
||||
app.use('/api/cliproxy-server', cliproxyServerRoutes);
|
||||
|
||||
// Dev mode: use Vite middleware for HMR
|
||||
if (options.dev) {
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
|
||||
+157
-2
@@ -22,7 +22,10 @@ import {
|
||||
fetchCliproxyStats,
|
||||
fetchCliproxyModels,
|
||||
isCliproxyRunning,
|
||||
fetchCliproxyErrorLogs,
|
||||
fetchCliproxyErrorLogContent,
|
||||
} from '../cliproxy/stats-fetcher';
|
||||
import { getCliproxyWritablePath } from '../cliproxy/config-generator';
|
||||
import {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
@@ -41,8 +44,9 @@ import {
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { checkCliproxyUpdate } from '../cliproxy/binary-manager';
|
||||
// Unified config imports
|
||||
import {
|
||||
hasUnifiedConfig,
|
||||
@@ -1386,6 +1390,32 @@ apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Pr
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/proxy-stop - Stop the CLIProxy service
|
||||
* Returns: { stopped, pid?, sessionCount?, error? }
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const result = stopProxy();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates
|
||||
* Returns: { hasUpdate, currentVersion, latestVersion, fromCache }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await checkCliproxyUpdate();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
|
||||
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
|
||||
@@ -1418,6 +1448,84 @@ apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise<
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Error Logs ====================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/error-logs - Get list of error log files
|
||||
* Returns: { files: CliproxyErrorLog[] } or error if proxy not running
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const running = await isCliproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({
|
||||
error: 'CLIProxyAPI not running',
|
||||
message: 'Start a CLIProxy session to view error logs',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fetchCliproxyErrorLogs();
|
||||
if (files === null) {
|
||||
res.status(503).json({
|
||||
error: 'Error logs unavailable',
|
||||
message: 'CLIProxyAPI is running but error logs endpoint not responding',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject absolute paths into each file entry
|
||||
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
|
||||
const filesWithPaths = files.map((file) => ({
|
||||
...file,
|
||||
absolutePath: path.join(logsDir, file.name),
|
||||
}));
|
||||
|
||||
res.json({ files: filesWithPaths });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/error-logs/:name - Get content of a specific error log
|
||||
* Returns: plain text log content
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/error-logs/:name', async (req: Request, res: Response): Promise<void> => {
|
||||
const { name } = req.params;
|
||||
|
||||
// Validate filename format and prevent path traversal
|
||||
if (
|
||||
!name ||
|
||||
!name.startsWith('error-') ||
|
||||
!name.endsWith('.log') ||
|
||||
name.includes('..') ||
|
||||
name.includes('/') ||
|
||||
name.includes('\\')
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid error log filename' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const running = await isCliproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({ error: 'CLIProxyAPI not running' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fetchCliproxyErrorLogContent(name);
|
||||
if (content === null) {
|
||||
res.status(404).json({ error: 'Error log not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.type('text/plain').send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// OpenAI Compatibility Layer Routes
|
||||
// ============================================
|
||||
@@ -1708,7 +1816,7 @@ import {
|
||||
getInstalledVersion as getCopilotInstalledVersion,
|
||||
} from '../copilot';
|
||||
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
|
||||
@@ -2000,3 +2108,50 @@ apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Global Environment Variables ====================
|
||||
|
||||
/**
|
||||
* GET /api/global-env - Get global environment variables configuration
|
||||
* Returns the global_env section from config.yaml
|
||||
*/
|
||||
apiRoutes.get('/global-env', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const config = getGlobalEnvConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/global-env - Update global environment variables configuration
|
||||
* Updates the global_env section in config.yaml
|
||||
*/
|
||||
apiRoutes.put('/global-env', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { enabled, env } = req.body;
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Validate env is an object with string values
|
||||
if (env !== undefined && typeof env === 'object' && env !== null) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value !== 'string') {
|
||||
res.status(400).json({ error: `Invalid value for ${key}: must be a string` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update global_env section
|
||||
config.global_env = {
|
||||
enabled: enabled ?? config.global_env?.enabled ?? true,
|
||||
env: env ?? config.global_env?.env ?? {},
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
res.json({ success: true, config: config.global_env });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* CLIProxy Server Routes - API endpoints for proxy configuration
|
||||
*
|
||||
* Provides REST endpoints for managing CLIProxyAPI connection settings:
|
||||
* - GET /api/cliproxy-server - Get proxy configuration
|
||||
* - PUT /api/cliproxy-server - Update proxy configuration
|
||||
* - POST /api/cliproxy-server/test - Test remote connection
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { testConnection } from '../../cliproxy/remote-proxy-client';
|
||||
import {
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
CliproxyServerConfig,
|
||||
} from '../../config/unified-config-types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy-server - Get proxy configuration
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const config = await loadOrCreateUnifiedConfig();
|
||||
res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to load proxy config:', error);
|
||||
res.status(500).json({ error: 'Failed to load proxy config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy-server - Update proxy configuration
|
||||
*/
|
||||
router.put('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = await loadOrCreateUnifiedConfig();
|
||||
const updates = req.body as Partial<CliproxyServerConfig>;
|
||||
|
||||
// Deep merge with defaults and current config
|
||||
config.cliproxy_server = {
|
||||
remote: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.remote,
|
||||
...config.cliproxy_server?.remote,
|
||||
...updates.remote,
|
||||
},
|
||||
fallback: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback,
|
||||
...config.cliproxy_server?.fallback,
|
||||
...updates.fallback,
|
||||
},
|
||||
local: {
|
||||
...DEFAULT_CLIPROXY_SERVER_CONFIG.local,
|
||||
...config.cliproxy_server?.local,
|
||||
...updates.local,
|
||||
},
|
||||
};
|
||||
|
||||
await saveUnifiedConfig(config);
|
||||
res.json(config.cliproxy_server);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to save proxy config:', error);
|
||||
res.status(500).json({ error: 'Failed to save proxy config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy-server/test - Test remote proxy connection
|
||||
*/
|
||||
router.post('/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { host, port, protocol, authToken, allowSelfSigned } = req.body;
|
||||
|
||||
if (!host || !port) {
|
||||
res.status(400).json({ error: 'Host and port are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await testConnection({
|
||||
host,
|
||||
port: typeof port === 'number' ? port : parseInt(port, 10),
|
||||
protocol: protocol || 'http',
|
||||
authToken,
|
||||
allowSelfSigned: allowSelfSigned || false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('[cliproxy-server-routes] Failed to test connection:', error);
|
||||
res.status(500).json({ error: 'Failed to test connection' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Unit tests for proxy-config-resolver module
|
||||
*/
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
||||
|
||||
// Import from compiled dist
|
||||
const {
|
||||
parseProxyFlags,
|
||||
getProxyEnvVars,
|
||||
resolveProxyConfig,
|
||||
hasProxyFlags,
|
||||
PROXY_CLI_FLAGS,
|
||||
PROXY_ENV_VARS,
|
||||
} = require('../../../dist/cliproxy/proxy-config-resolver');
|
||||
|
||||
describe('proxy-config-resolver', () => {
|
||||
describe('PROXY_CLI_FLAGS', () => {
|
||||
it('should define all expected proxy flags', () => {
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-host');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-port');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--local-proxy');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--remote-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROXY_ENV_VARS', () => {
|
||||
it('should define all expected environment variable names', () => {
|
||||
expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST');
|
||||
expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT');
|
||||
expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL');
|
||||
expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN');
|
||||
expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProxyFlags', () => {
|
||||
it('should parse --proxy-host flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']);
|
||||
expect(flags.host).toBe('192.168.1.100');
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-port flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']);
|
||||
expect(flags.port).toBe(9000);
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-protocol flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'https']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should parse --proxy-auth-token flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']);
|
||||
expect(flags.authToken).toBe('secret123');
|
||||
});
|
||||
|
||||
it('should parse --local-proxy boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--local-proxy']);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse --remote-only boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--remote-only']);
|
||||
expect(flags.remoteOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve non-proxy args in remainingArgs', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'--verbose',
|
||||
'--proxy-host',
|
||||
'localhost',
|
||||
'--some-other-flag',
|
||||
]);
|
||||
expect(flags.host).toBe('localhost');
|
||||
expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']);
|
||||
});
|
||||
|
||||
it('should handle mixed proxy and non-proxy args', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'arg1',
|
||||
'--proxy-port',
|
||||
'8080',
|
||||
'arg2',
|
||||
'--local-proxy',
|
||||
'arg3',
|
||||
]);
|
||||
expect(flags.port).toBe(8080);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']);
|
||||
});
|
||||
|
||||
it('should ignore invalid port values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-port', 'invalid']);
|
||||
expect(flags.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore out-of-range port values', () => {
|
||||
const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']);
|
||||
expect(flags1.port).toBeUndefined();
|
||||
|
||||
const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']);
|
||||
expect(flags2.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize protocol to lowercase', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should ignore invalid protocol values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']);
|
||||
expect(flags.protocol).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyEnvVars', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear proxy env vars
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return empty config when no env vars set', () => {
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBeUndefined();
|
||||
expect(config.port).toBeUndefined();
|
||||
expect(config.protocol).toBeUndefined();
|
||||
expect(config.authToken).toBeUndefined();
|
||||
expect(config.fallbackEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_HOST', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should read and parse CCS_PROXY_PORT', () => {
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_PROTOCOL', () => {
|
||||
process.env.CCS_PROXY_PROTOCOL = 'https';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_AUTH_TOKEN', () => {
|
||||
process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.authToken).toBe('my-secret-token');
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '1';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'true';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '0';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'false';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'no';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProxyConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return local mode by default', () => {
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.port).toBe(8317); // Default CLIProxy port
|
||||
expect(config.fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable remote mode when --proxy-host is provided', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('192.168.1.100');
|
||||
});
|
||||
|
||||
it('should enable remote mode when CCS_PROXY_HOST env is set', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should prioritize CLI flags over ENV vars', () => {
|
||||
process.env.CCS_PROXY_HOST = 'env-host';
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']);
|
||||
expect(config.host).toBe('cli-host');
|
||||
expect(config.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('should force local mode with --local-proxy', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig(['--local-proxy']);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.forceLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('should set remoteOnly and disable fallback with --remote-only', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']);
|
||||
expect(config.remoteOnly).toBe(true);
|
||||
expect(config.fallbackEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProxyFlags', () => {
|
||||
it('should return true when proxy flags are present', () => {
|
||||
expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true);
|
||||
expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true);
|
||||
expect(hasProxyFlags(['--local-proxy'])).toBe(true);
|
||||
expect(hasProxyFlags(['--remote-only'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no proxy flags are present', () => {
|
||||
expect(hasProxyFlags([])).toBe(false);
|
||||
expect(hasProxyFlags(['--verbose', '--help'])).toBe(false);
|
||||
expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for remote-proxy-client module
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client';
|
||||
|
||||
// We test the module's type exports and error handling logic
|
||||
// Actual HTTP calls are not mocked in this unit test - use integration tests for that
|
||||
|
||||
describe('remote-proxy-client', () => {
|
||||
describe('type exports', () => {
|
||||
it('should export RemoteProxyClientConfig interface', () => {
|
||||
// Type-level test - ensure the interface shape is correct
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
authToken: 'test-token',
|
||||
timeout: 2000,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
expect(config.host).toBe('localhost');
|
||||
expect(config.port).toBe(8317);
|
||||
expect(config.protocol).toBe('http');
|
||||
});
|
||||
|
||||
it('should export RemoteProxyStatus interface', () => {
|
||||
// Success case
|
||||
const successStatus: RemoteProxyStatus = {
|
||||
reachable: true,
|
||||
latencyMs: 50,
|
||||
};
|
||||
expect(successStatus.reachable).toBe(true);
|
||||
expect(successStatus.latencyMs).toBe(50);
|
||||
|
||||
// Error case
|
||||
const errorStatus: RemoteProxyStatus = {
|
||||
reachable: false,
|
||||
error: 'Connection refused',
|
||||
errorCode: 'CONNECTION_REFUSED',
|
||||
};
|
||||
expect(errorStatus.reachable).toBe(false);
|
||||
expect(errorStatus.error).toBe('Connection refused');
|
||||
expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteProxyErrorCode', () => {
|
||||
it('should define expected error codes', () => {
|
||||
const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN'];
|
||||
|
||||
// Type-level test - ensure error codes can be used
|
||||
const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' };
|
||||
const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' };
|
||||
const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' };
|
||||
const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' };
|
||||
|
||||
expect(validCodes).toContain(status1.errorCode);
|
||||
expect(validCodes).toContain(status2.errorCode);
|
||||
expect(validCodes).toContain(status3.errorCode);
|
||||
expect(validCodes).toContain(status4.errorCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config validation', () => {
|
||||
it('should require host and port', () => {
|
||||
const minimalConfig: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
expect(minimalConfig.host).toBeDefined();
|
||||
expect(minimalConfig.port).toBeDefined();
|
||||
expect(minimalConfig.protocol).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow optional fields', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
authToken: 'secret',
|
||||
timeout: 5000,
|
||||
allowSelfSigned: true,
|
||||
};
|
||||
expect(config.authToken).toBe('secret');
|
||||
expect(config.timeout).toBe(5000);
|
||||
expect(config.allowSelfSigned).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept http and https protocols', () => {
|
||||
const httpConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const httpsConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
};
|
||||
expect(httpConfig.protocol).toBe('http');
|
||||
expect(httpsConfig.protocol).toBe('https');
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check URL construction', () => {
|
||||
it('should construct correct health check URL pattern', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`;
|
||||
expect(expectedUrl).toBe('http://192.168.1.100:8317/health');
|
||||
});
|
||||
|
||||
it('should construct HTTPS URL when protocol is https', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'secure.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`;
|
||||
expect(expectedUrl).toBe('https://secure.example.com:443/health');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -118,9 +118,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
describe('Beta stability warning display', function () {
|
||||
it('should show beta warning when installing from dev channel', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
// Mock update checker to return update available
|
||||
@@ -166,7 +164,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert(returnStable, 'should show return to stable instruction');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
updateCheckerModule.checkForUpdates = originalCheckForUpdates;
|
||||
require('child_process').spawn = originalSpawn;
|
||||
@@ -175,9 +172,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
|
||||
it('should NOT show beta warning for stable channel', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
// Mock update checker to return update available
|
||||
@@ -204,7 +199,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert(!unstableWarning, 'should not show production warning for stable channel');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
updateCheckerModule.checkForUpdates = originalCheckForUpdates;
|
||||
}
|
||||
@@ -212,9 +206,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
|
||||
it('should show beta warning even with force flag', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -228,7 +220,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert(betaWarning, 'should show beta warning even with force');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
@@ -237,9 +228,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
describe('handleCheckFailed with targetTag parameter', function () {
|
||||
it('should show manual update command with dev tag for npm install', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -265,9 +254,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
|
||||
it('should show manual update command with latest tag for stable', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -304,9 +291,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
consoleOutput = [];
|
||||
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => name;
|
||||
|
||||
try {
|
||||
@@ -330,96 +315,13 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert(manualCommand, `should show manual ${name} command with dev tag`);
|
||||
|
||||
// Restore functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
});
|
||||
});
|
||||
|
||||
it('should show direct install commands when npm detection fails', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'direct';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
// Mock checkForUpdates to return failed
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
updateCheckerModule.checkForUpdates = async () => ({
|
||||
status: 'check_failed',
|
||||
message: 'Failed to check for updates'
|
||||
});
|
||||
|
||||
// Call with beta: false (beta not supported for direct)
|
||||
updateCommandModule.handleUpdateCommand({ beta: false });
|
||||
} catch (e) {
|
||||
// Expected to exit
|
||||
}
|
||||
|
||||
// Should show direct install commands
|
||||
if (process.platform === 'win32') {
|
||||
const powershellCmd = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('irm ccs.kaitran.ca/install | iex')
|
||||
);
|
||||
assert(powershellCmd, 'should show PowerShell command for Windows');
|
||||
} else {
|
||||
const curlCmd = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('curl -fsSL ccs.kaitran.ca/install | bash')
|
||||
);
|
||||
assert(curlCmd, 'should show curl command for Unix');
|
||||
}
|
||||
});
|
||||
|
||||
it('should show beta not supported message for direct install with beta', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'direct';
|
||||
|
||||
try {
|
||||
// Mock checkForUpdates to return beta not supported
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
updateCheckerModule.checkForUpdates = async () => ({
|
||||
status: 'check_failed',
|
||||
reason: 'beta_not_supported',
|
||||
message: '--beta requires npm installation method'
|
||||
});
|
||||
|
||||
// Call with beta: true
|
||||
updateCommandModule.handleUpdateCommand({ beta: true });
|
||||
} catch (e) {
|
||||
// Expected to exit
|
||||
}
|
||||
|
||||
// Should show beta not supported message
|
||||
const betaError = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('[X] --beta requires npm installation')
|
||||
);
|
||||
assert(betaError, 'should show beta not supported error');
|
||||
|
||||
const currentMethod = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('Current installation method: direct installer')
|
||||
);
|
||||
assert(currentMethod, 'should show current installation method');
|
||||
|
||||
// Should show npm install instructions
|
||||
const npmInstall = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('npm install -g @kaitranntt/ccs')
|
||||
);
|
||||
assert(npmInstall, 'should show npm install instructions');
|
||||
|
||||
const ccsUpdateBeta = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('ccs update --beta')
|
||||
);
|
||||
assert(ccsUpdateBeta, 'should show ccs update --beta instruction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', function () {
|
||||
it('should handle checkForUpdates throwing error', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
|
||||
try {
|
||||
// Mock checkForUpdates to throw error
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
@@ -435,10 +337,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
});
|
||||
|
||||
it('should exit with error code 1 when check fails', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
|
||||
try {
|
||||
// Mock checkForUpdates to return failed
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
@@ -458,10 +356,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
|
||||
describe('Integration with update checker', function () {
|
||||
it('should pass correct targetTag to checkForUpdates', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
|
||||
// Track calls to checkForUpdates
|
||||
let checkForUpdatesCalls = [];
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
@@ -480,16 +374,11 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert.strictEqual(devCall.installMethod, 'npm');
|
||||
} finally {
|
||||
// Restore function
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
updateCheckerModule.checkForUpdates = originalCheckForUpdates;
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass force parameter correctly', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
|
||||
// Track calls to checkForUpdates
|
||||
let checkForUpdatesCalls = [];
|
||||
const originalCheckForUpdates = updateCheckerModule.checkForUpdates;
|
||||
@@ -507,9 +396,8 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function (
|
||||
assert.strictEqual(checkForUpdatesCalls[0].force, true, 'should pass force parameter');
|
||||
} finally {
|
||||
// Restore function
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
updateCheckerModule.checkForUpdates = originalCheckForUpdates;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* - Skip update check when force is true
|
||||
* - Target tag calculation (latest vs dev) based on beta flag
|
||||
* - performNpmUpdate function with targetTag parameter
|
||||
* - handleDirectBetaNotSupported function for direct installs
|
||||
* - Success messages showing "Reinstall" vs "Update"
|
||||
*
|
||||
* NOTE: These tests are currently skipped because they require proper mocking
|
||||
@@ -113,9 +112,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
describe('Target tag calculation based on beta flag', function () {
|
||||
it('should set targetTag to "latest" when beta flag is false', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -130,16 +127,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(latestCall, 'should install latest tag when beta is false');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
|
||||
it('should set targetTag to "dev" when beta flag is true', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -154,7 +148,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(devCall, 'should install dev tag when beta is true');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
@@ -162,10 +155,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
|
||||
describe('Force flag behavior', function () {
|
||||
it('should show force reinstall message when force is true', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
|
||||
try {
|
||||
// Call with force: true
|
||||
updateCommandModule.handleUpdateCommand({ force: true, beta: false });
|
||||
@@ -176,16 +165,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
);
|
||||
assert(forceMessage, 'should show force reinstall message');
|
||||
} finally {
|
||||
// Restore original function
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
// No cleanup needed
|
||||
}
|
||||
});
|
||||
|
||||
it('should bypass update check when force is true', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -201,7 +187,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(npmCall.args.includes('install'), 'should call install command');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
@@ -210,9 +195,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
describe('Package manager tag syntax', function () {
|
||||
it('should use correct tag syntax for npm', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -226,16 +209,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(npmCall.args.includes('-g'), 'should use global flag for npm');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
|
||||
it('should use correct tag syntax for yarn', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'yarn';
|
||||
|
||||
try {
|
||||
@@ -249,16 +229,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(yarnCall.args.includes('global'), 'should use global flag for yarn');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
|
||||
it('should use correct tag syntax for pnpm', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'pnpm';
|
||||
|
||||
try {
|
||||
@@ -272,16 +249,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(pnpmCall.args.includes('-g'), 'should use global flag for pnpm');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
|
||||
it('should use correct tag syntax for bun', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'bun';
|
||||
|
||||
try {
|
||||
@@ -295,80 +269,15 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(bunCall.args.includes('-g'), 'should use global flag for bun');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct install beta not supported', function () {
|
||||
it('should show error for direct install with --beta', function () {
|
||||
// Mock installation method detection as direct
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'direct';
|
||||
|
||||
try {
|
||||
// Call with force: true, beta: true
|
||||
updateCommandModule.handleUpdateCommand({ force: true, beta: true });
|
||||
|
||||
// Should show beta not supported error
|
||||
const betaError = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('--beta flag requires npm installation')
|
||||
);
|
||||
assert(betaError, 'should show beta not supported error');
|
||||
|
||||
const directInstallMsg = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('Current installation method: direct installer')
|
||||
);
|
||||
assert(directInstallMsg, 'should show direct installer message');
|
||||
|
||||
// Should exit with error code
|
||||
assert(processExitCalls.length > 0, 'should call process.exit');
|
||||
assert(processExitCalls[0] === 1, 'should exit with error code 1');
|
||||
} finally {
|
||||
// Restore original function
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow force reinstall with direct install when beta is false', function () {
|
||||
// Mock installation method detection as direct
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'direct';
|
||||
|
||||
try {
|
||||
// Call with force: true, beta: false
|
||||
updateCommandModule.handleUpdateCommand({ force: true, beta: false });
|
||||
|
||||
// Should NOT show beta error
|
||||
const betaError = consoleOutput.find(output =>
|
||||
output[0] && output[0].includes('--beta flag requires npm installation')
|
||||
);
|
||||
assert(!betaError, 'should not show beta error when beta is false');
|
||||
|
||||
// Should call spawn for direct update
|
||||
assert(spawnCalls.length > 0, 'should call spawn for direct update');
|
||||
|
||||
// Should call curl or powershell
|
||||
const directUpdateCall = spawnCalls[0];
|
||||
if (process.platform === 'win32') {
|
||||
assert(directUpdateCall.command === 'powershell.exe', 'should call powershell on Windows');
|
||||
} else {
|
||||
assert(directUpdateCall.command === '/bin/bash', 'should call bash on Unix');
|
||||
}
|
||||
} finally {
|
||||
// Restore original function
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success messages', function () {
|
||||
it('should show "Reinstalling" message when force is true', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -382,7 +291,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(reinstallingMsg, 'should show reinstalling message');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
@@ -391,9 +299,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
describe('Combined force and beta behavior', function () {
|
||||
it('should handle force with beta for npm install', function () {
|
||||
// Mock package manager detection
|
||||
const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod;
|
||||
const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager;
|
||||
packageManagerDetectorModule.detectInstallationMethod = () => 'npm';
|
||||
packageManagerDetectorModule.detectPackageManager = () => 'npm';
|
||||
|
||||
try {
|
||||
@@ -412,9 +318,8 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct
|
||||
assert(forceMessage, 'should show force reinstall from dev channel message');
|
||||
} finally {
|
||||
// Restore original functions
|
||||
packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod;
|
||||
packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
useDeletePreset,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
@@ -543,7 +544,7 @@ export function ProviderEditor({
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
@@ -553,6 +554,12 @@ export function ProviderEditor({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
<div className="mx-6 mb-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<GlobalEnvIndicator profileEnv={settings?.env} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use
|
||||
import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
@@ -726,7 +727,7 @@ export function CopilotConfigForm() {
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
@@ -736,6 +737,12 @@ export function CopilotConfigForm() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
<div className="mx-6 mb-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<GlobalEnvIndicator profileEnv={rawSettings?.settings?.env} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Error Logs Monitor Component
|
||||
*
|
||||
* Displays CLIProxyAPI error logs with master-detail split view.
|
||||
* ETL: Parses raw logs into structured data for rich display.
|
||||
* - Left panel: Log list with status code, provider, endpoint, relative time
|
||||
* - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats';
|
||||
import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ProviderIcon } from '@/components/provider-icon';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import {
|
||||
AlertTriangle,
|
||||
FileWarning,
|
||||
Clock,
|
||||
FileText,
|
||||
Terminal,
|
||||
Info,
|
||||
Code,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
GripVertical,
|
||||
GripHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
parseErrorLog,
|
||||
parseFilename,
|
||||
formatRelativeTime,
|
||||
formatBytes,
|
||||
getStatusColor,
|
||||
getErrorTypeLabel,
|
||||
type ParsedErrorLog,
|
||||
} from '@/lib/error-log-parser';
|
||||
|
||||
type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw';
|
||||
|
||||
/** Tab button component */
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
icon: Icon,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
|
||||
active
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status badge component */
|
||||
function StatusBadge({ code }: { code: number }) {
|
||||
const colorClass = getStatusColor(code);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
|
||||
'bg-current/10 border border-current/20',
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overview tab content */
|
||||
function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge code={parsed.statusCode} />
|
||||
<span className="text-sm font-medium">{parsed.statusText}</span>
|
||||
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
|
||||
{getErrorTypeLabel(parsed.errorType)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key metrics grid */}
|
||||
<div className="grid grid-cols-4 gap-3 text-xs">
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Method</div>
|
||||
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Provider</div>
|
||||
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Version</div>
|
||||
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Endpoint</div>
|
||||
<div className="font-medium truncate" title={parsed.endpoint}>
|
||||
{parsed.endpoint || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">URL</div>
|
||||
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
||||
{parsed.url || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
||||
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion based on error type */}
|
||||
{parsed.errorType !== 'unknown' && (
|
||||
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
|
||||
<div className="text-blue-500/90 leading-relaxed">
|
||||
{parsed.errorType === 'rate_limit' &&
|
||||
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
|
||||
{parsed.errorType === 'auth' &&
|
||||
'Authentication failed. Check credentials or re-authenticate with the provider.'}
|
||||
{parsed.errorType === 'not_found' &&
|
||||
'Endpoint not found. This endpoint may not exist on this provider.'}
|
||||
{parsed.errorType === 'server' &&
|
||||
'Server error from upstream. Retry or check provider status.'}
|
||||
{parsed.errorType === 'timeout' &&
|
||||
'Request timed out. Check network or increase timeout settings.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Headers tab content */
|
||||
function HeadersTab({ headers }: { headers: Record<string, string> }) {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length === 0) {
|
||||
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-1">
|
||||
{entries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
|
||||
<span className="break-all">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/** JSON/Body tab content */
|
||||
function BodyTab({ content, label }: { content: string; label: string }) {
|
||||
if (!content || content.trim() === '') {
|
||||
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
|
||||
}
|
||||
|
||||
// Try to format as JSON
|
||||
let formatted = content;
|
||||
let isJson = false;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
formatted = JSON.stringify(parsed, null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<pre
|
||||
className={cn(
|
||||
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
|
||||
isJson
|
||||
? 'text-emerald-700 dark:text-green-400'
|
||||
: 'text-zinc-700 dark:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formatted}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/** Raw tab content */
|
||||
function RawTab({ content }: { content: string }) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/** Log content panel with tabs */
|
||||
function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const { data: content, isLoading, error } = useCliproxyErrorLogContent(name);
|
||||
|
||||
// Parse log content
|
||||
const parsed = useMemo(() => {
|
||||
if (!content) return null;
|
||||
return parseErrorLog(content);
|
||||
}, [content]);
|
||||
|
||||
// No log selected
|
||||
if (!name) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-3">
|
||||
<Terminal className="w-10 h-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">Select a log to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-5 w-5/6" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error or no content
|
||||
if (error || !content || !parsed) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">Failed to load log content</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
||||
{/* Header with status */}
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<StatusBadge code={parsed.statusCode} />
|
||||
<span className="text-xs font-semibold truncate text-foreground">
|
||||
{parsed.provider}/{parsed.endpoint || 'unknown'}
|
||||
</span>
|
||||
{/* Copy Absolute Path Button */}
|
||||
{name && (
|
||||
<CopyButton
|
||||
value={absolutePath || name}
|
||||
label="Copy absolute path"
|
||||
size="icon-sm"
|
||||
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Copy Raw Content Button */}
|
||||
{content && (
|
||||
<CopyButton
|
||||
value={content}
|
||||
label="Copy raw log content"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
|
||||
{parsed.method}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
|
||||
<TabButton
|
||||
active={activeTab === 'overview'}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
icon={Info}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === 'headers'}
|
||||
onClick={() => setActiveTab('headers')}
|
||||
icon={Code}
|
||||
>
|
||||
Headers
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === 'request'}
|
||||
onClick={() => setActiveTab('request')}
|
||||
icon={ArrowUpRight}
|
||||
>
|
||||
Request
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === 'response'}
|
||||
onClick={() => setActiveTab('response')}
|
||||
icon={ArrowDownLeft}
|
||||
>
|
||||
Response
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
|
||||
Raw
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden bg-card/30">
|
||||
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
|
||||
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
|
||||
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
|
||||
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
|
||||
{activeTab === 'raw' && <RawTab content={content} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Error log item in the list */
|
||||
interface ErrorLogItemProps {
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
|
||||
const parsed = useMemo(() => parseFilename(name), [name]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
|
||||
'hover:bg-muted/40 border-l-[3px]',
|
||||
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Provider Icon */}
|
||||
<ProviderIcon
|
||||
provider={parsed.provider}
|
||||
size={24}
|
||||
withBackground={true}
|
||||
className="shrink-0 mt-0.5"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Provider / Endpoint */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{parsed.provider}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1 rounded border',
|
||||
isSelected
|
||||
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
|
||||
: 'bg-muted border-border text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
LOG
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground truncate font-medium"
|
||||
title={parsed.endpoint}
|
||||
>
|
||||
{parsed.endpoint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta row: time + size */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(modified)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
{formatBytes(size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorLogsMonitor() {
|
||||
const { data: status, isLoading: isStatusLoading } = useCliproxyStatus();
|
||||
const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false);
|
||||
|
||||
// Vertical resize state
|
||||
const [height, setHeight] = useState(500);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Auto-scroll handler
|
||||
const stopAutoScroll = () => {
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current);
|
||||
scrollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Resize handlers
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const containerTopDoc = rect.top + window.scrollY;
|
||||
const newHeight = e.pageY - containerTopDoc;
|
||||
|
||||
// Constrain height (min 300, no max)
|
||||
setHeight(Math.max(300, newHeight));
|
||||
|
||||
// Auto-scroll logic
|
||||
const viewportHeight = window.innerHeight;
|
||||
const distFromBottom = viewportHeight - e.clientY;
|
||||
const scrollSpeed = 15;
|
||||
|
||||
stopAutoScroll();
|
||||
|
||||
if (distFromBottom < 50) {
|
||||
scrollIntervalRef.current = setInterval(() => {
|
||||
window.scrollBy(0, scrollSpeed);
|
||||
}, 16);
|
||||
} else if (e.clientY < 50) {
|
||||
scrollIntervalRef.current = setInterval(() => {
|
||||
window.scrollBy(0, -scrollSpeed);
|
||||
}, 16);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
stopAutoScroll();
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
stopAutoScroll();
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
const startResizing = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
// Compute default selection (first log name or null)
|
||||
const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]);
|
||||
|
||||
// Use controlled selection that defaults to first log
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
|
||||
// Effective selection: use user selection if available, otherwise default
|
||||
const effectiveSelection = selectedLog ?? defaultLogName;
|
||||
|
||||
// Get absolute path for the selected log
|
||||
const selectedAbsolutePath = useMemo(() => {
|
||||
if (!effectiveSelection || !logs) return undefined;
|
||||
const log = logs.find((l) => l.name === effectiveSelection);
|
||||
return log?.absolutePath;
|
||||
}, [effectiveSelection, logs]);
|
||||
|
||||
// Guards
|
||||
if (isStatusLoading) return null;
|
||||
if (!status?.running) return null;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!logs || logs.length === 0) return null;
|
||||
|
||||
const errorCount = logs.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileWarning className="w-3.5 h-3.5" />
|
||||
<span>CLIProxy Diagnostics</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Panel Layout */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<PanelGroup direction="horizontal">
|
||||
{/* Left Panel: Log List */}
|
||||
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="divide-y divide-border/50">
|
||||
{logs.slice(0, 50).map((log) => (
|
||||
<ErrorLogItem
|
||||
key={log.name}
|
||||
name={log.name}
|
||||
size={log.size}
|
||||
modified={log.modified}
|
||||
isSelected={effectiveSelection === log.name}
|
||||
onClick={() => setSelectedLog(log.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{logs.length > 50 && (
|
||||
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
|
||||
Showing 50 of {logs.length} logs
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Panel>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
|
||||
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Right Panel: Log Content */}
|
||||
<Panel className="flex flex-col min-w-0 bg-background/50">
|
||||
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Use standard footer if error, otherwise show resize handle */}
|
||||
{error ? (
|
||||
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
|
||||
{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
|
||||
onMouseDown={startResizing}
|
||||
>
|
||||
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Global Environment Variables Indicator
|
||||
*
|
||||
* Shows which env vars from global_env will be injected at runtime.
|
||||
* Displayed below the Raw Configuration (JSON) section in profile editors.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface GlobalEnvConfig {
|
||||
enabled: boolean;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GlobalEnvIndicatorProps {
|
||||
/** Current profile's env vars (to show which are overridden) */
|
||||
profileEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) {
|
||||
const [config, setConfig] = useState<GlobalEnvConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/global-env');
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
} catch {
|
||||
setConfig(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if loading or disabled or no vars
|
||||
if (loading) return null;
|
||||
if (!config?.enabled) return null;
|
||||
|
||||
const envVars = config.env || {};
|
||||
const envKeys = Object.keys(envVars);
|
||||
if (envKeys.length === 0) return null;
|
||||
|
||||
// Check which keys are already in profile (won't be overridden)
|
||||
const injectedKeys = envKeys.filter((key) => !(key in profileEnv));
|
||||
const overriddenKeys = envKeys.filter((key) => key in profileEnv);
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
{/* Header - clickable to expand */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<Info className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs text-muted-foreground flex-1 text-left">
|
||||
<span className="font-medium text-foreground">{injectedKeys.length}</span> global env var
|
||||
{injectedKeys.length !== 1 ? 's' : ''} will be injected at runtime
|
||||
{overriddenKeys.length > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400 ml-1">
|
||||
({overriddenKeys.length} overridden by profile)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{/* Injected vars */}
|
||||
{injectedKeys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{injectedKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs font-mono bg-green-500/10 text-green-700 dark:text-green-400 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-green-500">+</span>
|
||||
<span className="truncate">
|
||||
{key}={envVars[key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overridden vars (profile takes precedence) */}
|
||||
{overriddenKeys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Skipped (profile already defines):</p>
|
||||
{overriddenKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs font-mono bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-amber-500">~</span>
|
||||
<span className="truncate">{key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to settings */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1.5 -ml-2">
|
||||
<Link to="/settings?tab=globalenv">
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure in Settings
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-r
|
||||
import { toast } from 'sonner';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
|
||||
|
||||
// Lazy load CodeEditor to reduce initial bundle size
|
||||
const CodeEditor = lazy(() =>
|
||||
@@ -412,7 +413,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
@@ -422,6 +423,12 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
<div className="mx-6 mb-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<GlobalEnvIndicator profileEnv={settings?.env} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* Proxy Status Widget
|
||||
*
|
||||
* Displays CLIProxy process status with start button for recovery.
|
||||
* Shows: running state, port, session count, uptime.
|
||||
* Displays CLIProxy process status with start/stop/restart controls.
|
||||
* Shows: running state, port, session count, uptime, update availability.
|
||||
*/
|
||||
|
||||
import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react';
|
||||
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
useProxyStatus,
|
||||
useStartProxy,
|
||||
useStopProxy,
|
||||
useCliproxyUpdateCheck,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatUptime(startedAt?: string): string {
|
||||
@@ -25,11 +31,34 @@ function formatUptime(startedAt?: string): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp?: number): string {
|
||||
if (!timestamp) return '';
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
export function ProxyStatusWidget() {
|
||||
const { data: status, isLoading } = useProxyStatus();
|
||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||
const startProxy = useStartProxy();
|
||||
const stopProxy = useStopProxy();
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
const isActioning = startProxy.isPending || stopProxy.isPending;
|
||||
const hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||
|
||||
// Restart = stop then start
|
||||
const handleRestart = async () => {
|
||||
await stopProxy.mutateAsync();
|
||||
// Small delay to ensure port is released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
startProxy.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,6 +76,16 @@ export function ProxyStatusWidget() {
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">CLIProxy Service</span>
|
||||
{hasUpdate && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
|
||||
>
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -61,21 +100,66 @@ export function ProxyStatusWidget() {
|
||||
</div>
|
||||
|
||||
{isRunning && status ? (
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{status.startedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatUptime(status.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">Port {status.port}</span>
|
||||
{status.sessionCount !== undefined && status.sessionCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{status.startedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatUptime(status.startedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Control buttons when running */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant={hasUpdate ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 text-xs gap-1 flex-1',
|
||||
hasUpdate &&
|
||||
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
|
||||
)}
|
||||
onClick={handleRestart}
|
||||
disabled={isActioning}
|
||||
title={
|
||||
hasUpdate
|
||||
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
|
||||
: 'Restart CLIProxy service'
|
||||
}
|
||||
>
|
||||
{isActioning ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : hasUpdate ? (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
) : (
|
||||
<RotateCw className="w-3 h-3" />
|
||||
)}
|
||||
{hasUpdate ? 'Update' : 'Restart'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
|
||||
onClick={() => stopProxy.mutate()}
|
||||
disabled={isActioning}
|
||||
title="Stop CLIProxy service"
|
||||
>
|
||||
{stopProxy.isPending ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="w-3 h-3" />
|
||||
)}
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Not running</span>
|
||||
@@ -95,6 +179,18 @@ export function ProxyStatusWidget() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version sync indicator */}
|
||||
{updateCheck?.currentVersion && (
|
||||
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
|
||||
<span>v{updateCheck.currentVersion}</span>
|
||||
{updateCheck.checkedAt && (
|
||||
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
|
||||
Synced {formatTimeAgo(updateCheck.checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,19 +3,21 @@ import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { type buttonVariants } from '@/components/ui/button-variants';
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
className?: string;
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'secondary';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
variant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
size?: VariantProps<typeof buttonVariants>['size'];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
label = 'Copy to clipboard',
|
||||
}: CopyButtonProps) {
|
||||
@@ -34,19 +36,16 @@ export function CopyButton({
|
||||
<Button
|
||||
size={size}
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'h-6 w-6 relative z-10 text-foreground/70 hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
className={cn('relative z-10 text-muted-foreground hover:text-foreground', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
|
||||
@@ -130,3 +130,62 @@ export function useCliproxyModels(enabled = true) {
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Error log file metadata from CLIProxyAPI */
|
||||
export interface CliproxyErrorLog {
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
/** Absolute path to the log file (injected by backend) */
|
||||
absolutePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy error logs from API
|
||||
*/
|
||||
async function fetchCliproxyErrorLogs(): Promise<CliproxyErrorLog[]> {
|
||||
const response = await fetch('/api/cliproxy/error-logs');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch error logs');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.files ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch specific error log content
|
||||
*/
|
||||
async function fetchCliproxyErrorLogContent(name: string): Promise<string> {
|
||||
const response = await fetch(`/api/cliproxy/error-logs/${encodeURIComponent(name)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch error log content');
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get CLIProxy error logs list
|
||||
*/
|
||||
export function useCliproxyErrorLogs(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-error-logs'],
|
||||
queryFn: fetchCliproxyErrorLogs,
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
retry: 1,
|
||||
staleTime: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get specific error log content
|
||||
*/
|
||||
export function useCliproxyErrorLogContent(name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-error-log-content', name],
|
||||
queryFn: () => (name ? fetchCliproxyErrorLogContent(name) : Promise.resolve('')),
|
||||
enabled: !!name,
|
||||
staleTime: 60000, // Cache log content for 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,3 +240,36 @@ export function useStartProxy() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopProxy() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.cliproxy.proxyStop(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-status'] });
|
||||
if (data.stopped) {
|
||||
toast.success(
|
||||
`CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}`
|
||||
);
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to stop CLIProxy');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Update Check ====================
|
||||
|
||||
export function useCliproxyUpdateCheck() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-update-check'],
|
||||
queryFn: () => api.cliproxy.updateCheck(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache)
|
||||
refetchInterval: 60 * 60 * 1000, // Refresh every hour
|
||||
refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,42 @@ export interface CreatePreset {
|
||||
haiku?: string;
|
||||
}
|
||||
|
||||
/** Remote proxy status from health check */
|
||||
export interface RemoteProxyStatus {
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
/** Remote proxy configuration */
|
||||
export interface ProxyRemoteConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
auth_token: string;
|
||||
}
|
||||
|
||||
/** Fallback configuration */
|
||||
export interface ProxyFallbackConfig {
|
||||
enabled: boolean;
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/** Local proxy configuration */
|
||||
export interface ProxyLocalConfig {
|
||||
port: number;
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
/** CLIProxy server configuration */
|
||||
export interface CliproxyServerConfig {
|
||||
remote: ProxyRemoteConfig;
|
||||
fallback: ProxyFallbackConfig;
|
||||
local: ProxyLocalConfig;
|
||||
}
|
||||
|
||||
/** CLIProxy process status from session tracker */
|
||||
export interface ProxyProcessStatus {
|
||||
running: boolean;
|
||||
@@ -163,6 +199,16 @@ export interface ProxyProcessStatus {
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
/** Error log file metadata from CLIProxyAPI */
|
||||
export interface CliproxyErrorLog {
|
||||
/** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */
|
||||
name: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp (Unix seconds) */
|
||||
modified: number;
|
||||
}
|
||||
|
||||
/** Result from starting proxy service */
|
||||
export interface ProxyStartResult {
|
||||
started: boolean;
|
||||
@@ -172,6 +218,23 @@ export interface ProxyStartResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from stopping proxy service */
|
||||
export interface ProxyStopResult {
|
||||
stopped: boolean;
|
||||
pid?: number;
|
||||
sessionCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from checking for CLIProxyAPI updates */
|
||||
export interface CliproxyUpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
// API
|
||||
export const api = {
|
||||
profiles: {
|
||||
@@ -206,6 +269,8 @@ export const api = {
|
||||
// Proxy process status and control
|
||||
proxyStatus: () => request<ProxyProcessStatus>('/cliproxy/proxy-status'),
|
||||
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
|
||||
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
|
||||
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
@@ -266,6 +331,17 @@ export const api = {
|
||||
body: JSON.stringify({ nickname }),
|
||||
}),
|
||||
},
|
||||
// Error logs
|
||||
errorLogs: {
|
||||
/** List error log files */
|
||||
list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'),
|
||||
/** Get content of a specific error log */
|
||||
getContent: async (name: string): Promise<string> => {
|
||||
const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`);
|
||||
if (!res.ok) throw new Error('Failed to load error log');
|
||||
return res.text();
|
||||
},
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
|
||||
@@ -313,4 +389,27 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
/** CLIProxy server configuration API */
|
||||
cliproxyServer: {
|
||||
/** Get cliproxy server configuration */
|
||||
get: () => request<CliproxyServerConfig>('/cliproxy-server'),
|
||||
/** Update cliproxy server configuration */
|
||||
update: (config: Partial<CliproxyServerConfig>) =>
|
||||
request<CliproxyServerConfig>('/cliproxy-server', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
/** Test remote proxy connection */
|
||||
test: (params: {
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
authToken?: string;
|
||||
allowSelfSigned?: boolean;
|
||||
}) =>
|
||||
request<RemoteProxyStatus>('/cliproxy-server/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Error Log Parser Utility
|
||||
*
|
||||
* Parses CLIProxy error log content into structured data for display.
|
||||
* Extracts request info, headers, body, and response sections.
|
||||
*/
|
||||
|
||||
/** Parsed error log structure */
|
||||
export interface ParsedErrorLog {
|
||||
// Request Info
|
||||
version: string;
|
||||
url: string;
|
||||
method: string;
|
||||
timestamp: string;
|
||||
|
||||
// Response
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
|
||||
// Sections (raw strings)
|
||||
requestHeaders: Record<string, string>;
|
||||
requestBody: string;
|
||||
responseHeaders: Record<string, string>;
|
||||
responseBody: string;
|
||||
|
||||
// Computed metadata
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
isClientError: boolean;
|
||||
isServerError: boolean;
|
||||
errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown';
|
||||
}
|
||||
|
||||
/** Parsed filename metadata */
|
||||
export interface ParsedFilename {
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
timestamp: Date;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error log filename to extract provider, endpoint, and timestamp
|
||||
* Format: error-api-provider-{provider}-api-{endpoint}-{timestamp}-{id}.log
|
||||
*/
|
||||
export function parseFilename(name: string): ParsedFilename {
|
||||
const result: ParsedFilename = {
|
||||
provider: 'unknown',
|
||||
endpoint: 'unknown',
|
||||
timestamp: new Date(),
|
||||
raw: name,
|
||||
};
|
||||
|
||||
// Extract provider: error-api-provider-{PROVIDER}-api-...
|
||||
const providerMatch = name.match(/error-api-provider-([^-]+)-/);
|
||||
if (providerMatch) {
|
||||
result.provider = providerMatch[1];
|
||||
}
|
||||
|
||||
// Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp}
|
||||
// Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-...
|
||||
const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i);
|
||||
if (endpointMatch) {
|
||||
result.endpoint = endpointMatch[1].replace(/-/g, '/');
|
||||
}
|
||||
|
||||
// Extract timestamp: 2025-12-18T185041
|
||||
const tsMatch = name.match(/(\d{4}-\d{2}-\d{2}T\d{6})/);
|
||||
if (tsMatch) {
|
||||
const ts = tsMatch[1];
|
||||
// Parse: 2025-12-18T185041 → 2025-12-18T18:50:41
|
||||
const formatted = `${ts.slice(0, 10)}T${ts.slice(11, 13)}:${ts.slice(13, 15)}:${ts.slice(15, 17)}`;
|
||||
result.timestamp = new Date(formatted);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw error log content into structured data
|
||||
*/
|
||||
export function parseErrorLog(content: string): ParsedErrorLog {
|
||||
const result: ParsedErrorLog = {
|
||||
version: '',
|
||||
url: '',
|
||||
method: '',
|
||||
timestamp: '',
|
||||
statusCode: 0,
|
||||
statusText: '',
|
||||
requestHeaders: {},
|
||||
requestBody: '',
|
||||
responseHeaders: {},
|
||||
responseBody: '',
|
||||
provider: '',
|
||||
endpoint: '',
|
||||
isClientError: false,
|
||||
isServerError: false,
|
||||
errorType: 'unknown',
|
||||
};
|
||||
|
||||
// Split into sections
|
||||
const sections = content.split(/^===\s*(.+?)\s*===$/m);
|
||||
|
||||
let currentSection = '';
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const part = sections[i].trim();
|
||||
|
||||
if (part === 'REQUEST INFO') {
|
||||
currentSection = 'request_info';
|
||||
continue;
|
||||
} else if (part === 'HEADERS') {
|
||||
currentSection = 'headers';
|
||||
continue;
|
||||
} else if (part === 'REQUEST BODY') {
|
||||
currentSection = 'request_body';
|
||||
continue;
|
||||
} else if (part === 'RESPONSE') {
|
||||
currentSection = 'response';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse section content
|
||||
switch (currentSection) {
|
||||
case 'request_info':
|
||||
parseRequestInfo(part, result);
|
||||
break;
|
||||
case 'headers':
|
||||
result.requestHeaders = parseHeaders(part);
|
||||
break;
|
||||
case 'request_body':
|
||||
result.requestBody = part;
|
||||
break;
|
||||
case 'response':
|
||||
parseResponse(part, result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute derived fields
|
||||
computeDerivedFields(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse REQUEST INFO section */
|
||||
function parseRequestInfo(content: string, result: ParsedErrorLog): void {
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
|
||||
switch (key?.trim()?.toLowerCase()) {
|
||||
case 'version':
|
||||
result.version = value;
|
||||
break;
|
||||
case 'url':
|
||||
result.url = value;
|
||||
break;
|
||||
case 'method':
|
||||
result.method = value;
|
||||
break;
|
||||
case 'timestamp':
|
||||
result.timestamp = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse headers into key-value object */
|
||||
function parseHeaders(content: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
if (key) headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Parse RESPONSE section */
|
||||
function parseResponse(content: string, result: ParsedErrorLog): void {
|
||||
const lines = content.split('\n');
|
||||
let headersEnded = false;
|
||||
const bodyLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// First line might be "Status: 404"
|
||||
if (line.startsWith('Status:')) {
|
||||
const statusStr = line.replace('Status:', '').trim();
|
||||
const statusParts = statusStr.split(/\s+/);
|
||||
result.statusCode = parseInt(statusParts[0], 10) || 0;
|
||||
result.statusText = statusParts.slice(1).join(' ') || getStatusText(result.statusCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for empty line (separates headers from body)
|
||||
if (line.trim() === '' && !headersEnded) {
|
||||
headersEnded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse response headers
|
||||
if (!headersEnded) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
if (key) result.responseHeaders[key] = value;
|
||||
}
|
||||
} else {
|
||||
bodyLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
result.responseBody = bodyLines.join('\n').trim();
|
||||
}
|
||||
|
||||
/** Compute derived fields from parsed data */
|
||||
function computeDerivedFields(result: ParsedErrorLog): void {
|
||||
// Extract provider from URL: /api/provider/{PROVIDER}/...
|
||||
const providerMatch = result.url.match(/\/api\/provider\/([^/]+)/);
|
||||
if (providerMatch) {
|
||||
result.provider = providerMatch[1];
|
||||
}
|
||||
|
||||
// Extract endpoint from URL
|
||||
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/);
|
||||
if (endpointMatch) {
|
||||
result.endpoint = endpointMatch[1];
|
||||
}
|
||||
|
||||
// Status code classification
|
||||
result.isClientError = result.statusCode >= 400 && result.statusCode < 500;
|
||||
result.isServerError = result.statusCode >= 500;
|
||||
|
||||
// Error type classification
|
||||
if (result.statusCode === 429) {
|
||||
result.errorType = 'rate_limit';
|
||||
} else if (result.statusCode === 401 || result.statusCode === 403) {
|
||||
result.errorType = 'auth';
|
||||
} else if (result.statusCode === 404) {
|
||||
result.errorType = 'not_found';
|
||||
} else if (result.statusCode >= 500) {
|
||||
result.errorType = 'server';
|
||||
} else if (result.statusCode === 408 || result.statusCode === 504) {
|
||||
result.errorType = 'timeout';
|
||||
}
|
||||
}
|
||||
|
||||
/** Get status text for common codes */
|
||||
function getStatusText(code: number): string {
|
||||
const statusTexts: Record<number, string> = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
408: 'Request Timeout',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
};
|
||||
return statusTexts[code] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Unix timestamp (seconds) to relative time string
|
||||
*/
|
||||
export function formatRelativeTime(modifiedSeconds: number): string {
|
||||
const now = Date.now();
|
||||
const modified = modifiedSeconds * 1000; // Convert to milliseconds
|
||||
const diff = now - modified;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
|
||||
// Format as date for older logs
|
||||
const date = new Date(modified);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status code badge color class
|
||||
*/
|
||||
export function getStatusColor(code: number): string {
|
||||
if (code >= 500) return 'text-red-500';
|
||||
if (code === 429) return 'text-orange-500';
|
||||
if (code >= 400) return 'text-yellow-500';
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type label
|
||||
*/
|
||||
export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
|
||||
const labels: Record<string, string> = {
|
||||
rate_limit: 'Rate Limited',
|
||||
auth: 'Auth Error',
|
||||
not_found: 'Not Found',
|
||||
server: 'Server Error',
|
||||
timeout: 'Timeout',
|
||||
unknown: 'Error',
|
||||
};
|
||||
return labels[type] || 'Error';
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export function CliproxyPage() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-64 border-r flex flex-col bg-muted/30">
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { HeroSection } from '@/components/hero-section';
|
||||
import { AuthMonitor } from '@/components/auth-monitor';
|
||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react';
|
||||
@@ -175,6 +176,9 @@ export function HomePage() {
|
||||
|
||||
{/* Auth Monitor */}
|
||||
<AuthMonitor />
|
||||
|
||||
{/* Error Logs Monitor - shows only when there are errors */}
|
||||
<ErrorLogsMonitor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1314
-339
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user