mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
Merge remote-tracking branch 'origin/dev' into kai/fix/issue-768-localhost-disclaimer-layout
# Conflicts: # ui/src/pages/api.tsx
This commit is contained in:
commit
32b2216f0f
75 files changed
+3489
-1558
No files matched your search
@@ -31,9 +31,9 @@ This applies to project spaces, including:
|
||||
|
||||
Maintainers may edit or remove content, lock conversations, close threads, reject contributions, or block participants when needed to protect the project and contributors.
|
||||
|
||||
For non-sensitive concerns, open a GitHub Discussion or issue.
|
||||
For non-sensitive concerns, use the public issue templates.
|
||||
|
||||
For sensitive concerns, do not post details publicly. Ask a maintainer for a private reporting path first and keep the initial message minimal.
|
||||
For sensitive concerns, do not post details publicly. Use the private reporting path in `SECURITY.md`.
|
||||
|
||||
## Practical Rule
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ body:
|
||||
value: |
|
||||
Thanks for reporting this. Use as much of this template as you can.
|
||||
A partial but useful report is better than no report.
|
||||
|
||||
If this is a suspected security vulnerability, do not file it publicly. Use the private reporting path in `SECURITY.md`.
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and discussion
|
||||
url: https://github.com/kaitranntt/ccs/discussions
|
||||
about: Use Discussions for open-ended questions, support requests, and idea shaping.
|
||||
- name: Private security report
|
||||
url: https://github.com/kaitranntt/ccs/security/advisories/new
|
||||
about: Report suspected vulnerabilities privately. Do not open public issues for security reports.
|
||||
- name: Documentation hub
|
||||
url: https://docs.ccs.kaitran.ca
|
||||
about: Installation, provider guides, CLI reference, and troubleshooting.
|
||||
@@ -60,5 +60,5 @@ body:
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and discussions first.
|
||||
- label: I searched existing issues and docs first.
|
||||
- label: This request describes a concrete user problem, not just a broad idea dump.
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Question
|
||||
description: Ask for setup help, usage clarification, or behavior explanation when docs do not answer it.
|
||||
title: "question: "
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Questions are welcome.
|
||||
|
||||
If this is a suspected security vulnerability, stop here and use the private reporting path in `SECURITY.md` instead of posting details publicly.
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Topic
|
||||
options:
|
||||
- Installation or setup
|
||||
- CLI usage
|
||||
- Dashboard or config
|
||||
- Provider or auth flow
|
||||
- API profiles or proxy
|
||||
- Contributor workflow
|
||||
- Other
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: What do you need help with?
|
||||
placeholder: I expected `ccs ...` to do ..., but I am not sure whether I configured it correctly.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Include what you tried, what docs you checked, and what blocked you.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: CCS version
|
||||
description: Output of `ccs --version`, if you have it
|
||||
placeholder: 7.56.0
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I checked the docs and existing issues first.
|
||||
- label: This is not a private security disclosure.
|
||||
@@ -11,8 +11,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Skip if commit message contains [skip ci]
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
if: ${{ github.ref == 'refs/heads/dev' && (github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')) }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
@@ -26,6 +25,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Always use the built-in workflow token; PAT rotation/breakage must not block dev releases.
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -65,12 +65,7 @@ jobs:
|
||||
# Use custom dev release script instead of semantic-release
|
||||
# This ensures dev versions follow {stable}-dev.{N} pattern
|
||||
chmod +x scripts/dev-release.sh
|
||||
if ./scripts/dev-release.sh; then
|
||||
echo "released=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Dev release failed or skipped"
|
||||
echo "released=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
./scripts/dev-release.sh
|
||||
|
||||
- name: Notify Discord
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
@@ -87,28 +82,46 @@ jobs:
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PREVIOUS_DEV_TAG: ${{ steps.release.outputs.previous_dev_tag }}
|
||||
STABLE_TAG: ${{ steps.release.outputs.stable_tag }}
|
||||
run: |
|
||||
# Get version from package.json
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
|
||||
# Get commits ONLY since last dev tag (not all release commits)
|
||||
# This prevents re-tagging issues from older releases
|
||||
LAST_DEV_TAG=$(git tag -l "v*-dev.*" --sort=-v:refname | head -1 || echo "")
|
||||
|
||||
if [ -n "$LAST_DEV_TAG" ]; then
|
||||
if [[ -n "$PREVIOUS_DEV_TAG" ]]; then
|
||||
# Commits between last dev tag and current (excluding release commit)
|
||||
RANGE="${LAST_DEV_TAG}..HEAD~1"
|
||||
RANGE="${PREVIOUS_DEV_TAG}..HEAD~1"
|
||||
else
|
||||
# First dev release - check commits since last stable tag
|
||||
STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --merged origin/main --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || echo "")
|
||||
RANGE="${STABLE_TAG:-HEAD~10}..HEAD~1"
|
||||
fi
|
||||
|
||||
echo "Checking commits in range: $RANGE"
|
||||
|
||||
# Extract issue numbers from commit messages
|
||||
ISSUES=$(git log $RANGE --pretty=format:"%s %b" 2>/dev/null | \
|
||||
grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
|
||||
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
|
||||
ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' || true)
|
||||
|
||||
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
|
||||
grep -oE "[0-9]+" | sort -u || true)
|
||||
|
||||
PR_TEXT=""
|
||||
for PR_NUM in $PR_CANDIDATES; do
|
||||
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
|
||||
if [[ -n "$DETAILS" ]]; then
|
||||
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
|
||||
fi
|
||||
done
|
||||
|
||||
ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' || true)
|
||||
|
||||
ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \
|
||||
grep -oE "#[0-9]+" | sort -u || true)
|
||||
|
||||
if [[ -z "$ISSUES" ]]; then
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Stable tag to publish manually, for example v7.55.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.ref }}
|
||||
group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
@@ -19,19 +25,36 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v4
|
||||
- name: Resolve target tag
|
||||
id: target
|
||||
env:
|
||||
MANUAL_TAG: ${{ inputs.tag }}
|
||||
RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
|
||||
TARGET_TAG="${RELEASE_EVENT_TAG}"
|
||||
else
|
||||
TARGET_TAG="${MANUAL_TAG}"
|
||||
fi
|
||||
|
||||
echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate stable semver tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping non-stable semver tag ${GITHUB_REF_NAME}"
|
||||
echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}"
|
||||
|
||||
- name: Checkout release tag
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
@@ -44,18 +67,34 @@ jobs:
|
||||
- name: Derive image metadata
|
||||
if: steps.tag.outputs.publish == 'true'
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
VERSION="${{ steps.target.outputs.tag }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MINOR="${VERSION%.*}"
|
||||
MAJOR="${VERSION%%.*}"
|
||||
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE="ghcr.io/${OWNER_LOWER}/ccs-dashboard"
|
||||
REVISION=$(git rev-parse HEAD)
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
|
||||
TAGS="${TAGS}
|
||||
${IMAGE}:${MINOR}
|
||||
${IMAGE}:${MAJOR}
|
||||
${IMAGE}:latest"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "minor=${MINOR}"
|
||||
echo "major=${MAJOR}"
|
||||
echo "image=${IMAGE}"
|
||||
echo "revision=${REVISION}"
|
||||
echo "tags<<EOF"
|
||||
echo "${TAGS}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
@@ -74,17 +113,13 @@ jobs:
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.minor }}
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major }}
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=ccs-dashboard
|
||||
org.opencontainers.image.description=CCS Dashboard container image
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -2,7 +2,7 @@ name: Label Pending Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
@@ -10,34 +10,62 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Label linked issues as pending-release
|
||||
- name: Reconcile pending-release labels for open dev PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
# Extract issue numbers from PR title and body (passed via env vars for safety)
|
||||
# Using env vars prevents shell injection from backticks in markdown
|
||||
PR_TEXT="$PR_TITLE $PR_BODY"
|
||||
ISSUES=$(echo "$PR_TEXT" | grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | grep -oE "#[0-9]+" | sort -u || true)
|
||||
# Recompute the complete open-PR issue set so edited/synchronized PRs
|
||||
# can remove stale pending-release labels as well as add new ones.
|
||||
OPEN_PRS=$(gh pr list \
|
||||
--repo "${{ github.repository }}" \
|
||||
--base dev \
|
||||
--state open \
|
||||
--json number,title,body \
|
||||
--jq '.[] | @base64' 2>/dev/null || true)
|
||||
|
||||
ALL_REFERENCED_ISSUES=""
|
||||
while IFS= read -r PR_ROW; do
|
||||
[[ -z "$PR_ROW" ]] && continue
|
||||
PR_JSON=$(printf '%s' "$PR_ROW" | base64 --decode)
|
||||
PR_NUM=$(printf '%s' "$PR_JSON" | jq -r '.number')
|
||||
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
|
||||
PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""')
|
||||
COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUM}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true)
|
||||
PR_TEXT="$PR_TITLE
|
||||
$PR_BODY
|
||||
$COMMIT_TEXT"
|
||||
PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' || true)
|
||||
if [[ -n "$PR_ISSUES" ]]; then
|
||||
ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES")
|
||||
fi
|
||||
done <<< "$OPEN_PRS"
|
||||
|
||||
ISSUES=$(printf '%s\n' "$ALL_REFERENCED_ISSUES" | grep -oE '#[0-9]+' | sort -u || true)
|
||||
CURRENT_PENDING=$(gh issue list --repo "${{ github.repository }}" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || true)
|
||||
|
||||
gh label create "pending-release" \
|
||||
--color "fbca04" \
|
||||
--description "Fix in review, awaiting merge" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
|
||||
if [[ -z "$ISSUES" ]]; then
|
||||
echo "No linked issues found in PR"
|
||||
exit 0
|
||||
echo "No linked issues found across open dev PRs"
|
||||
else
|
||||
for ISSUE in $ISSUES; do
|
||||
NUM=${ISSUE#\#}
|
||||
echo "Ensuring issue #$NUM has pending-release"
|
||||
gh issue edit "$NUM" --add-label "pending-release" --repo "${{ github.repository }}" || true
|
||||
done
|
||||
fi
|
||||
|
||||
for ISSUE in $ISSUES; do
|
||||
NUM=${ISSUE#\#}
|
||||
echo "Labeling issue #$NUM as pending-release"
|
||||
|
||||
# Create label if doesn't exist
|
||||
gh label create "pending-release" \
|
||||
--color "fbca04" \
|
||||
--description "Fix in review, awaiting merge" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
|
||||
# Add label to issue
|
||||
gh issue edit $NUM --add-label "pending-release" --repo ${{ github.repository }} || true
|
||||
for NUM in $CURRENT_PENDING; do
|
||||
if ! printf '%s\n' "$ISSUES" | grep -qx "#${NUM}"; then
|
||||
echo "Removing stale pending-release from issue #$NUM"
|
||||
gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
@@ -57,10 +58,17 @@ jobs:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
OUTPUT=$(bunx semantic-release 2>&1) || true
|
||||
set +e
|
||||
OUTPUT=$(bunx semantic-release 2>&1)
|
||||
STATUS=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
# Strip ANSI color codes before matching (semantic-release outputs colored text)
|
||||
CLEAN=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
if [[ "$STATUS" -ne 0 ]]; then
|
||||
echo "released=false" >> $GITHUB_OUTPUT
|
||||
exit "$STATUS"
|
||||
fi
|
||||
if echo "$CLEAN" | grep -q "Published GitHub release"; then
|
||||
echo "released=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -83,31 +91,73 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# semantic-release already adds "released" label via .releaserc.cjs
|
||||
# This step removes transitional labels and closes issues now in stable.
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
|
||||
PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
|
||||
grep -vx "v${VERSION}" | head -1 || echo "")
|
||||
RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}"
|
||||
if [[ -z "$RANGE" ]]; then
|
||||
RANGE="HEAD~50..HEAD~1"
|
||||
fi
|
||||
|
||||
# Find issues with both "released" and "released-dev" labels
|
||||
ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
|
||||
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
|
||||
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
|
||||
grep -oE "[0-9]+" | sort -u || true)
|
||||
|
||||
for NUM in $ISSUES; do
|
||||
echo "Cleaning up labels on issue #$NUM"
|
||||
gh issue edit "$NUM" --remove-label "released-dev" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
|
||||
PR_TEXT=""
|
||||
for PR_NUM in $PR_CANDIDATES; do
|
||||
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
|
||||
if [[ -n "$DETAILS" ]]; then
|
||||
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Also clean pending-release from any issues with released label
|
||||
PENDING=$(gh issue list --label "released" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
|
||||
RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \
|
||||
"$RELEASE_ISSUES_FROM_BODY" \
|
||||
"$RELEASE_ISSUES_FROM_COMMITS" \
|
||||
"$RELEASE_ISSUES_FROM_PRS" | sort -u || true)
|
||||
|
||||
for NUM in $PENDING; do
|
||||
echo "Removing pending-release from issue #$NUM"
|
||||
gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
|
||||
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
|
||||
grep -oE '#[0-9]+' | tr -d '#' || true)
|
||||
RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \
|
||||
"$RESOLVED_ISSUES_FROM_BODY" \
|
||||
"$RESOLVED_ISSUES_FROM_COMMITS" \
|
||||
"$RESOLVED_ISSUES_FROM_PRS" | sort -u || true)
|
||||
|
||||
# Close open issues that are marked as released
|
||||
OPEN_RELEASED=$(gh issue list --label "released" --state open --json number --jq '.[].number' 2>/dev/null || echo "")
|
||||
if [[ -z "$RELEASE_ISSUES" ]]; then
|
||||
echo "No release-scoped issues found for v${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for NUM in $OPEN_RELEASED; do
|
||||
echo "Closing released issue #$NUM"
|
||||
gh issue close "$NUM" \
|
||||
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
|
||||
--repo "${{ github.repository }}" || true
|
||||
for NUM in $RELEASE_ISSUES; do
|
||||
echo "Cleaning release state on issue #$NUM"
|
||||
gh issue edit "$NUM" \
|
||||
--remove-label "released-dev" \
|
||||
--remove-label "pending-release" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
|
||||
HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0")
|
||||
if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then
|
||||
gh issue close "$NUM" \
|
||||
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
|
||||
--repo "${{ github.repository }}" || true
|
||||
fi
|
||||
done
|
||||
@@ -1,15 +1,12 @@
|
||||
name: Sync Dev After Main Release
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
sync-dev:
|
||||
# Only run if Release workflow succeeded on main branch
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
|
||||
if: ${{ !github.event.release.prerelease && github.event.release.target_commitish == 'main' && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ CCS is a Bun + TypeScript CLI with a React dashboard. This guide is the shortest
|
||||
- Open PRs against `dev`.
|
||||
- Use conventional commits.
|
||||
- If you change user-facing behavior, update the docs that describe it.
|
||||
- Suspected security vulnerabilities do not go through public issues. Use [SECURITY.md](./SECURITY.md).
|
||||
|
||||
If you are new to the project, start with a docs fix, a focused bug fix, or an issue labeled `good first issue`.
|
||||
|
||||
@@ -191,10 +192,21 @@ Releases are automated with semantic-release.
|
||||
- Merges to `main` publish the `@latest` channel.
|
||||
- Do not manually bump versions, create tags, or run manual `npm publish`.
|
||||
|
||||
## Security Reporting
|
||||
|
||||
If you think you found a security vulnerability, do not open a public GitHub issue.
|
||||
|
||||
Use the private reporting path in [SECURITY.md](./SECURITY.md):
|
||||
|
||||
- https://github.com/kaitranntt/ccs/security/advisories/new
|
||||
|
||||
Public issues are fine for normal bugs, regressions, docs problems, and feature requests. They are not fine for exploit details, leaked credentials, or anything that could put users at risk before a fix ships.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Bugs and features: https://github.com/kaitranntt/ccs/issues
|
||||
- Questions and discussion: https://github.com/kaitranntt/ccs/discussions
|
||||
- Questions: https://github.com/kaitranntt/ccs/issues/new/choose
|
||||
- Security reports: https://github.com/kaitranntt/ccs/security/advisories/new
|
||||
- Hosted docs: https://docs.ccs.kaitran.ca
|
||||
- User-facing docs: [README.md](./README.md)
|
||||
- Internal architecture notes: [docs/](./docs)
|
||||
|
||||
@@ -32,6 +32,13 @@ Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, withou
|
||||
Looking for the full setup guide, command reference, provider guides, or troubleshooting?
|
||||
Start at **https://docs.ccs.kaitran.ca**.
|
||||
|
||||
## Contribute And Report Safely
|
||||
|
||||
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
- Starter work: [good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue), [help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted)
|
||||
- Questions: [open a question issue](https://github.com/kaitranntt/ccs/issues/new/choose)
|
||||
- Security reports: [SECURITY.md](./SECURITY.md) and the [private advisory form](https://github.com/kaitranntt/ccs/security/advisories/new)
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
@@ -149,6 +156,8 @@ The dashboard provides visual management for all account types:
|
||||
|
||||
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
|
||||
|
||||
> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later.
|
||||
|
||||
> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles.
|
||||
|
||||
**Powered by:**
|
||||
@@ -665,7 +674,7 @@ bun remove -g @kaitranntt/ccs
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md). For suspected vulnerabilities, use [SECURITY.md](./SECURITY.md) instead of a public issue.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -683,6 +692,6 @@ MIT License - see [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | **[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca)** | [Report Issues](https://github.com/kaitranntt/ccs/issues) | [Star on GitHub](https://github.com/kaitranntt/ccs)
|
||||
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | **[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca)** | [Issue Templates](https://github.com/kaitranntt/ccs/issues/new/choose) | [Private Security Report](https://github.com/kaitranntt/ccs/security/advisories/new) | [Star on GitHub](https://github.com/kaitranntt/ccs)
|
||||
|
||||
</div>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest supported release line and the active development branch.
|
||||
|
||||
| Branch / channel | Status |
|
||||
| --- | --- |
|
||||
| `main` / `@latest` | Supported |
|
||||
| `dev` / `@dev` | Supported |
|
||||
| Older releases | Best effort only |
|
||||
|
||||
## Report A Vulnerability
|
||||
|
||||
Do not open public GitHub issues for suspected security vulnerabilities.
|
||||
|
||||
Use GitHub Private Vulnerability Reporting instead:
|
||||
|
||||
- https://github.com/kaitranntt/ccs/security/advisories/new
|
||||
|
||||
Include:
|
||||
|
||||
- A short description of the issue
|
||||
- Affected version, branch, or install method
|
||||
- Reproduction steps or proof of concept
|
||||
- Impact assessment if you have one
|
||||
|
||||
Please avoid posting tokens, cookies, private configs, or exploit details in public issues, discussions, or screenshots.
|
||||
|
||||
## What To Expect
|
||||
|
||||
- Initial acknowledgement target: within 3 business days
|
||||
- Triage and severity assessment after reproduction
|
||||
- A coordinated fix and release when the report is confirmed
|
||||
|
||||
If you already opened a public issue by mistake, edit it down to a minimal note and ask for a private reporting path instead of posting more detail.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
Last Updated: 2026-03-17
|
||||
Last Updated: 2026-03-23
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
@@ -10,6 +10,8 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent
|
||||
|
||||
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
|
||||
|
||||
When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth.
|
||||
|
||||
## Account Context Modes (Related Feature)
|
||||
|
||||
Dashboard auth and account context metadata are separate:
|
||||
@@ -180,7 +182,8 @@ dashboard_auth:
|
||||
1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage
|
||||
2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript)
|
||||
3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes)
|
||||
4. **File permissions**: Config file is created with 0o600 permissions
|
||||
4. **Fail-closed remote writes**: When auth is disabled, sensitive management routes allow localhost only
|
||||
5. **File permissions**: Config file is created with 0o600 permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-03-19
|
||||
Last Updated: 2026-03-23
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes.
|
||||
- **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests.
|
||||
- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles.
|
||||
- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback.
|
||||
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
|
||||
|
||||
@@ -361,6 +361,10 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
|
||||
|
||||
**Providers**: GitHub Copilot (ghcp)
|
||||
|
||||
Provider identity note:
|
||||
- Providers that do not expose a reliable email no longer require a manual nickname during first auth.
|
||||
- CCS derives a stable internal account identifier from the token/cache context and still allows the user to rename the account later.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| OAuth - Device Code Flow (No Port Needed) |
|
||||
@@ -432,6 +436,8 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
|
||||
- Device Code method uses /start route (no callback port)
|
||||
- Callback/social methods use /start-url + status polling
|
||||
- Some management flows return state first, auth_url later
|
||||
- Manual nicknames are optional when the upstream provider does not return an email
|
||||
- Account storage uses a stable internal identifier so reauth/update flows do not depend on dashboard list order
|
||||
```
|
||||
|
||||
### API Key Profiles (GLM, Kimi)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.56.0",
|
||||
"version": "7.56.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+102
-50
@@ -26,8 +26,8 @@ log_info() { echo -e "${GREEN}[i]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[X]${NC} $1"; }
|
||||
|
||||
# Ensure we have the latest tags
|
||||
git fetch --tags origin main
|
||||
# Ensure we have the latest tags and branch refs
|
||||
git fetch --tags origin main dev
|
||||
|
||||
# Get latest stable tag from main (exclude prereleases like -dev, -beta, -rc)
|
||||
# Match only clean semver tags: vX.Y.Z
|
||||
@@ -39,58 +39,100 @@ if [ -z "$STABLE_TAG" ]; then
|
||||
fi
|
||||
|
||||
STABLE=${STABLE_TAG#v}
|
||||
CURRENT_VERSION=$(jq -r '.version' package.json)
|
||||
HEAD_SUBJECT=$(git log -1 --pretty=%s 2>/dev/null || echo "")
|
||||
DEV_VERSION_REGEX="^${STABLE//./\\.}-dev\\.[0-9]+$"
|
||||
PREVIOUS_DEV_TAG=""
|
||||
RECOVERY_MODE=false
|
||||
|
||||
log_info "Current stable version: ${STABLE}"
|
||||
|
||||
# Find latest dev tag for this stable version
|
||||
LATEST_DEV=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | head -1 || echo "")
|
||||
if [[ "$CURRENT_VERSION" =~ $DEV_VERSION_REGEX ]] && [[ "$HEAD_SUBJECT" == "chore(release): ${CURRENT_VERSION} [skip ci]" ]]; then
|
||||
VERSION="$CURRENT_VERSION"
|
||||
CURRENT_TAG="v${VERSION}"
|
||||
PREVIOUS_DEV_TAG=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | grep -vx "$CURRENT_TAG" | head -1 || echo "")
|
||||
RECOVERY_MODE=true
|
||||
log_warn "Recovery mode for ${VERSION}"
|
||||
|
||||
# Calculate next dev number
|
||||
if [ -z "$LATEST_DEV" ]; then
|
||||
DEV_NUM=1
|
||||
log_info "No existing dev tags for ${STABLE}, starting at dev.1"
|
||||
if git rev-parse "${CURRENT_TAG}" >/dev/null 2>&1; then
|
||||
TAG_COMMIT=$(git rev-parse "${CURRENT_TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then
|
||||
log_error "Tag ${CURRENT_TAG} exists but does not point to HEAD"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Reusing existing tag ${CURRENT_TAG}"
|
||||
else
|
||||
git tag "${CURRENT_TAG}"
|
||||
log_warn "Recreated missing tag ${CURRENT_TAG} on release commit"
|
||||
fi
|
||||
else
|
||||
DEV_NUM=$(echo "$LATEST_DEV" | sed 's/.*dev\.\([0-9]*\)/\1/')
|
||||
DEV_NUM=$((DEV_NUM + 1))
|
||||
log_info "Latest dev tag: ${LATEST_DEV}, incrementing to dev.${DEV_NUM}"
|
||||
# Find latest dev tag for this stable version
|
||||
LATEST_DEV=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | head -1 || echo "")
|
||||
PREVIOUS_DEV_TAG="$LATEST_DEV"
|
||||
|
||||
# Calculate next dev number
|
||||
if [ -z "$LATEST_DEV" ]; then
|
||||
DEV_NUM=1
|
||||
log_info "No existing dev tags for ${STABLE}, starting at dev.1"
|
||||
else
|
||||
DEV_NUM=$(echo "$LATEST_DEV" | sed 's/.*dev\.\([0-9]*\)/\1/')
|
||||
DEV_NUM=$((DEV_NUM + 1))
|
||||
log_info "Latest dev tag: ${LATEST_DEV}, incrementing to dev.${DEV_NUM}"
|
||||
fi
|
||||
|
||||
VERSION="${STABLE}-dev.${DEV_NUM}"
|
||||
CURRENT_TAG="v${VERSION}"
|
||||
log_info "New version: ${VERSION}"
|
||||
|
||||
# Check if tag already exists (safety check)
|
||||
if git rev-parse "${CURRENT_TAG}" >/dev/null 2>&1; then
|
||||
log_error "Tag ${CURRENT_TAG} already exists!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update package.json
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
log_info "Updated package.json to ${VERSION}"
|
||||
|
||||
# Configure git for GitHub Actions
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Commit version change
|
||||
git add package.json
|
||||
git commit -m "chore(release): ${VERSION} [skip ci]"
|
||||
log_info "Created release commit"
|
||||
|
||||
# Create tag
|
||||
git tag "${CURRENT_TAG}"
|
||||
log_info "Created tag ${CURRENT_TAG}"
|
||||
fi
|
||||
|
||||
VERSION="${STABLE}-dev.${DEV_NUM}"
|
||||
log_info "New version: ${VERSION}"
|
||||
|
||||
# Check if tag already exists (safety check)
|
||||
if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
|
||||
log_error "Tag v${VERSION} already exists!"
|
||||
exit 1
|
||||
PACKAGE_NAME=$(jq -r '.name' package.json)
|
||||
if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then
|
||||
log_info "npm already has ${PACKAGE_NAME}@${VERSION}, skipping publish"
|
||||
else
|
||||
npm publish --tag dev
|
||||
log_info "Published to npm with @dev tag"
|
||||
fi
|
||||
|
||||
# Update package.json
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
log_info "Updated package.json to ${VERSION}"
|
||||
if git merge-base --is-ancestor HEAD origin/dev >/dev/null 2>&1; then
|
||||
log_info "origin/dev already contains release commit"
|
||||
else
|
||||
git push origin HEAD:dev
|
||||
log_info "Pushed release commit to origin/dev"
|
||||
fi
|
||||
|
||||
# Configure git for GitHub Actions
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Commit version change
|
||||
git add package.json
|
||||
git commit -m "chore(release): ${VERSION} [skip ci]"
|
||||
log_info "Created release commit"
|
||||
|
||||
# Create tag
|
||||
git tag "v${VERSION}"
|
||||
log_info "Created tag v${VERSION}"
|
||||
|
||||
# Push commit and tag
|
||||
git push origin dev
|
||||
git push origin "v${VERSION}"
|
||||
log_info "Pushed to origin"
|
||||
|
||||
# Publish to npm with dev tag
|
||||
npm publish --tag dev
|
||||
log_info "Published to npm with @dev tag"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${CURRENT_TAG}" >/dev/null 2>&1; then
|
||||
log_info "Remote tag ${CURRENT_TAG} already exists"
|
||||
else
|
||||
git push origin "${CURRENT_TAG}"
|
||||
log_info "Pushed tag ${CURRENT_TAG}"
|
||||
fi
|
||||
|
||||
# Generate release notes from commits since last tag
|
||||
PREV_TAG=$(git tag -l "v*" --sort=-v:refname | sed -n '2p' || echo "")
|
||||
PREV_TAG="${PREVIOUS_DEV_TAG:-$STABLE_TAG}"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
# Get commits between previous tag and the one before our release commit
|
||||
NOTES=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD~1" 2>/dev/null | grep -v "chore(release):" | head -15 || echo "- Dev release")
|
||||
@@ -99,12 +141,16 @@ else
|
||||
fi
|
||||
|
||||
# Create GitHub prerelease
|
||||
gh release create "v${VERSION}" \
|
||||
--title "v${VERSION}" \
|
||||
--notes "${NOTES}" \
|
||||
--prerelease
|
||||
if gh release view "${CURRENT_TAG}" >/dev/null 2>&1; then
|
||||
log_info "GitHub prerelease ${CURRENT_TAG} already exists"
|
||||
else
|
||||
gh release create "${CURRENT_TAG}" \
|
||||
--title "${CURRENT_TAG}" \
|
||||
--notes "${NOTES}" \
|
||||
--prerelease
|
||||
|
||||
log_info "Created GitHub prerelease"
|
||||
log_info "Created GitHub prerelease"
|
||||
fi
|
||||
|
||||
# Save release info for Discord notification
|
||||
# This file is read by send-discord-release.cjs for dev releases
|
||||
@@ -117,7 +163,13 @@ EOF
|
||||
log_info "Saved release info for Discord notification"
|
||||
|
||||
# Output for GitHub Actions
|
||||
echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"
|
||||
echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}"
|
||||
{
|
||||
echo "current_tag=${CURRENT_TAG}"
|
||||
echo "previous_dev_tag=${PREVIOUS_DEV_TAG}"
|
||||
echo "recovery_mode=${RECOVERY_MODE}"
|
||||
echo "released=true"
|
||||
echo "stable_tag=${STABLE_TAG}"
|
||||
echo "version=${VERSION}"
|
||||
} >> "${GITHUB_OUTPUT:-/dev/null}"
|
||||
|
||||
log_info "Dev release ${VERSION} complete!"
|
||||
@@ -12,11 +12,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import {
|
||||
isUnifiedMode,
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { validateApiName } from './validation-service';
|
||||
import { listApiProfiles } from './profile-reader';
|
||||
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
|
||||
@@ -64,17 +60,17 @@ function writeJsonObjectAtomically(filePath: string, value: unknown): void {
|
||||
|
||||
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (config.profiles[name] && !force) {
|
||||
throw new Error(`API profile already exists: ${name}`);
|
||||
}
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.profiles[name] && !force) {
|
||||
throw new Error(`API profile already exists: ${name}`);
|
||||
}
|
||||
|
||||
config.profiles[name] = {
|
||||
type: 'api',
|
||||
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
config.profiles[name] = {
|
||||
type: 'api',
|
||||
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { validateApiName } from './validation-service';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||
@@ -198,13 +194,13 @@ function createApiProfileUnified(
|
||||
// Inject WebSearch hooks into profile settings
|
||||
ensureProfileHooks(name);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.profiles[name] = {
|
||||
type: 'api',
|
||||
settings: `~/.ccs/${settingsFile}`,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.profiles[name] = {
|
||||
type: 'api',
|
||||
settings: `~/.ccs/${settingsFile}`,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new API profile */
|
||||
@@ -308,17 +304,17 @@ export function updateApiProfileTarget(
|
||||
): UpdateApiProfileTargetResult {
|
||||
try {
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (!config.profiles[name]) {
|
||||
return { success: false, error: `API profile not found: ${name}` };
|
||||
}
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.profiles[name]) {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
}
|
||||
|
||||
if (target === 'claude') {
|
||||
delete config.profiles[name].target;
|
||||
} else {
|
||||
config.profiles[name].target = target;
|
||||
}
|
||||
saveUnifiedConfig(config);
|
||||
if (target === 'claude') {
|
||||
delete config.profiles[name].target;
|
||||
} else {
|
||||
config.profiles[name].target = target;
|
||||
}
|
||||
});
|
||||
return { success: true, target };
|
||||
}
|
||||
|
||||
@@ -357,30 +353,26 @@ export function updateApiProfileTarget(
|
||||
|
||||
/** Remove API profile from unified config */
|
||||
function removeApiProfileUnified(name: string): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const profile = config.profiles[name];
|
||||
mutateUnifiedConfig((config) => {
|
||||
const profile = config.profiles[name];
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
}
|
||||
|
||||
// Delete the settings file if it exists.
|
||||
// Uses expandPath() for cross-platform path handling.
|
||||
if (profile.settings) {
|
||||
const settingsPath = expandPath(profile.settings);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
if (!profile) {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
delete config.profiles[name];
|
||||
if (profile.settings) {
|
||||
const settingsPath = expandPath(profile.settings);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear default if it was the deleted profile
|
||||
if (config.default === name) {
|
||||
config.default = undefined;
|
||||
}
|
||||
delete config.profiles[name];
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
if (config.default === name) {
|
||||
config.default = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove API profile from legacy config */
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from 'path';
|
||||
import { ProfileMetadata } from '../types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../config/unified-config-loader';
|
||||
import type { AccountConfig } from '../config/unified-config-types';
|
||||
@@ -313,74 +313,72 @@ export class ProfileRegistry {
|
||||
* Create account in unified config (config.yaml)
|
||||
*/
|
||||
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (config.accounts[name]) {
|
||||
throw new Error(`Account already exists: ${name}`);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
created: new Date().toISOString(),
|
||||
last_used: null,
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
bare: metadata.bare,
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.accounts[name]) {
|
||||
throw new Error(`Account already exists: ${name}`);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
created: new Date().toISOString(),
|
||||
last_used: null,
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
bare: metadata.bare,
|
||||
});
|
||||
});
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account metadata in unified config
|
||||
*/
|
||||
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
...config.accounts[name],
|
||||
...updates,
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||
...config.accounts[name],
|
||||
...updates,
|
||||
});
|
||||
});
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove account from unified config
|
||||
*/
|
||||
removeAccountUnified(name: string): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
delete config.accounts[name];
|
||||
// Clear default if it was the deleted account
|
||||
if (config.default === name) {
|
||||
config.default = undefined;
|
||||
}
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
delete config.accounts[name];
|
||||
if (config.default === name) {
|
||||
config.default = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default profile in unified config
|
||||
*/
|
||||
setDefaultUnified(name: string): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
// Check if exists in accounts, profiles, or cliproxy variants
|
||||
const exists =
|
||||
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
|
||||
if (!exists) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
}
|
||||
config.default = name;
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
const exists =
|
||||
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
|
||||
if (!exists) {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
}
|
||||
config.default = name;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear default profile in unified config (restore original CCS behavior)
|
||||
*/
|
||||
clearDefaultUnified(): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.default = undefined;
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.default = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,13 +416,13 @@ export class ProfileRegistry {
|
||||
* Update account last_used in unified config
|
||||
*/
|
||||
touchAccountUnified(name: string): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
config.accounts[name].last_used = new Date().toISOString();
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.accounts[name]) {
|
||||
throw new Error(`Account not found: ${name}`);
|
||||
}
|
||||
config.accounts[name].last_used = new Date().toISOString();
|
||||
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -28,8 +28,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
loadAccountsRegistry,
|
||||
saveAccountsRegistry,
|
||||
|
||||
@@ -21,8 +21,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
} from './token-file-ops';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { AccountInfo } from './types';
|
||||
import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry } from './registry';
|
||||
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
||||
|
||||
/**
|
||||
* Get all accounts for a provider
|
||||
@@ -14,10 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry
|
||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Sync with actual token files (removes stale entries)
|
||||
if (syncRegistryWithTokenFiles(registry)) {
|
||||
saveAccountsRegistry(registry);
|
||||
}
|
||||
// Sync in-memory view with actual token files without mutating disk on read.
|
||||
syncRegistryWithTokenFiles(registry);
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
@@ -67,12 +65,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Partial match on nickname or email prefix
|
||||
const partialMatch = accounts.find(
|
||||
const partialMatches = accounts.filter(
|
||||
(a) =>
|
||||
a.nickname?.toLowerCase().startsWith(lowerQuery) ||
|
||||
a.email?.toLowerCase().startsWith(lowerQuery)
|
||||
);
|
||||
return partialMatch || null;
|
||||
return partialMatches.length === 1 ? partialMatches[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+321
-348
@@ -5,15 +5,18 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
|
||||
import { getAuthDir } from '../config-generator';
|
||||
import { getAuthDir, getCliproxyDir } from '../config-generator';
|
||||
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
|
||||
import {
|
||||
getAccountsRegistryPath,
|
||||
getPausedDir,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
hasAccountNameConflict,
|
||||
validateNickname,
|
||||
moveTokenToPaused,
|
||||
moveTokenFromPaused,
|
||||
@@ -21,37 +24,65 @@ import {
|
||||
} from './token-file-ops';
|
||||
|
||||
/** Default registry structure */
|
||||
const DEFAULT_REGISTRY: AccountsRegistry = {
|
||||
version: 1,
|
||||
providers: {},
|
||||
};
|
||||
function createDefaultRegistry(): AccountsRegistry {
|
||||
return {
|
||||
version: 1,
|
||||
providers: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
*/
|
||||
export function loadAccountsRegistry(): AccountsRegistry {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
function withAccountsRegistryLock<T>(callback: () => T): T {
|
||||
const lockTarget = getCliproxyDir();
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
if (!fs.existsSync(lockTarget)) {
|
||||
fs.mkdirSync(lockTarget, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(registryPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return {
|
||||
version: data.version || 1,
|
||||
providers: data.providers || {},
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
||||
return callback();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save accounts registry
|
||||
*/
|
||||
export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
function readAccountsRegistryFromDisk(): AccountsRegistry {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return createDefaultRegistry();
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(registryPath, 'utf-8');
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Accounts registry is corrupted: expected object');
|
||||
}
|
||||
|
||||
const parsed = data as { version?: unknown; providers?: unknown };
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : 1,
|
||||
providers:
|
||||
parsed.providers && typeof parsed.providers === 'object'
|
||||
? (parsed.providers as AccountsRegistry['providers'])
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
function writeAccountsRegistryToDisk(registry: AccountsRegistry): void {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
const dir = path.dirname(registryPath);
|
||||
|
||||
@@ -59,9 +90,39 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', {
|
||||
const tempPath = `${registryPath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(registry, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tempPath, registryPath);
|
||||
}
|
||||
|
||||
function mutateAccountsRegistry<T>(mutator: (registry: AccountsRegistry) => T): T {
|
||||
return withAccountsRegistryLock(() => {
|
||||
const registry = readAccountsRegistryFromDisk();
|
||||
const initialSnapshot = JSON.stringify(registry);
|
||||
const result = mutator(registry);
|
||||
if (JSON.stringify(registry) !== initialSnapshot) {
|
||||
writeAccountsRegistryToDisk(registry);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
*/
|
||||
export function loadAccountsRegistry(): AccountsRegistry {
|
||||
return readAccountsRegistryFromDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save accounts registry
|
||||
*/
|
||||
export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
withAccountsRegistryLock(() => {
|
||||
writeAccountsRegistryToDisk(registry);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,8 +176,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
|
||||
* Called after successful OAuth to record the account
|
||||
*
|
||||
* For providers without email (kiro, ghcp):
|
||||
* - nickname is REQUIRED and used as accountId
|
||||
* - Uniqueness is enforced to prevent overwriting
|
||||
* - internal accountId is derived from token metadata
|
||||
* - nickname is optional metadata
|
||||
*
|
||||
* For providers with email:
|
||||
* - email is used as accountId
|
||||
@@ -129,110 +190,103 @@ export function registerAccount(
|
||||
nickname?: string,
|
||||
projectId?: string
|
||||
): AccountInfo {
|
||||
const registry = loadAccountsRegistry();
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
syncRegistryWithTokenFiles(registry);
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) {
|
||||
throw new Error('Failed to initialize provider accounts');
|
||||
}
|
||||
|
||||
// Determine account ID based on provider type
|
||||
let accountId: string;
|
||||
let accountNickname: string;
|
||||
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
// For kiro/ghcp: nickname is REQUIRED and used as accountId
|
||||
if (!nickname || nickname === 'default') {
|
||||
throw new Error(
|
||||
`Nickname is required when adding ${provider} accounts. ` +
|
||||
`Use --nickname <name> or provide a nickname in the UI.`
|
||||
);
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Validate nickname format
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) {
|
||||
throw new Error('Failed to initialize provider accounts');
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
|
||||
if (existingId.toLowerCase() === nickname.toLowerCase()) {
|
||||
throw new Error(
|
||||
`An account with nickname "${nickname}" already exists for ${provider}. ` +
|
||||
`Choose a different nickname.`
|
||||
);
|
||||
let accountId: string;
|
||||
let accountNickname: string;
|
||||
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
accountId = email
|
||||
? extractAccountIdFromTokenFile(tokenFile, email)
|
||||
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
|
||||
if (nickname) {
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
|
||||
id,
|
||||
nickname: account.nickname,
|
||||
}));
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
|
||||
throw new Error(
|
||||
`An account with nickname "${nickname}" already exists for ${provider}. ` +
|
||||
`Choose a different nickname.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accountNickname =
|
||||
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
||||
} else {
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
accountNickname = nickname || generateNickname(email);
|
||||
}
|
||||
|
||||
accountId = nickname;
|
||||
accountNickname = nickname;
|
||||
} else {
|
||||
// For other providers: use email as accountId, fallback to filename extraction
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
accountNickname = nickname || generateNickname(email);
|
||||
}
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
createdAt: existingAccount?.createdAt || new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
if (provider === 'agy' && projectId) {
|
||||
accountMeta.projectId = projectId;
|
||||
}
|
||||
|
||||
// Create or update account
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
|
||||
// Include projectId for Antigravity accounts
|
||||
if (provider === 'agy' && projectId) {
|
||||
accountMeta.projectId = projectId;
|
||||
}
|
||||
if (isFirstAccount) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
|
||||
// Set as default if first account
|
||||
if (isFirstAccount) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
provider,
|
||||
isDefault: accountId === providerAccounts.default,
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
createdAt: providerAccounts.accounts[accountId].createdAt,
|
||||
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
|
||||
projectId: providerAccounts.accounts[accountId].projectId,
|
||||
};
|
||||
return {
|
||||
id: accountId,
|
||||
provider,
|
||||
isDefault: accountId === providerAccounts.default,
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
createdAt: providerAccounts.accounts[accountId].createdAt,
|
||||
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
|
||||
projectId: providerAccounts.accounts[accountId].projectId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default account for a provider
|
||||
*/
|
||||
export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.default = accountId;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
providerAccounts.default = accountId;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,27 +294,26 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
|
||||
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it
|
||||
*/
|
||||
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
if (accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if already paused (idempotent)
|
||||
if (accountMeta.paused) {
|
||||
if (!moveTokenToPaused(accountMeta.tokenFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = true;
|
||||
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move token file to paused directory (if it exists in auth dir)
|
||||
moveTokenToPaused(accountMeta.tokenFile);
|
||||
|
||||
providerAccounts.accounts[accountId].paused = true;
|
||||
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,55 +321,53 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
|
||||
*/
|
||||
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
if (!accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if already active (idempotent)
|
||||
if (!accountMeta.paused) {
|
||||
if (!moveTokenFromPaused(accountMeta.tokenFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = false;
|
||||
providerAccounts.accounts[accountId].pausedAt = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move token file back from paused directory (if it exists in paused dir)
|
||||
moveTokenFromPaused(accountMeta.tokenFile);
|
||||
|
||||
providerAccounts.accounts[accountId].paused = false;
|
||||
providerAccounts.accounts[accountId].pausedAt = undefined;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account
|
||||
*/
|
||||
export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete token file from both auth and paused directories
|
||||
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
|
||||
deleteTokenFile(tokenFile);
|
||||
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
|
||||
if (!deleteTokenFile(tokenFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete providerAccounts.accounts[accountId];
|
||||
delete providerAccounts.accounts[accountId];
|
||||
|
||||
// Update default if needed
|
||||
const remainingAccounts = Object.keys(providerAccounts.accounts);
|
||||
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
|
||||
providerAccounts.default = remainingAccounts[0];
|
||||
}
|
||||
const remainingAccounts = Object.keys(providerAccounts.accounts);
|
||||
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
|
||||
providerAccounts.default = remainingAccounts[0];
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,36 +383,36 @@ export function renameAccount(
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if nickname is already used by another account
|
||||
for (const [id, account] of Object.entries(providerAccounts.accounts)) {
|
||||
if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) {
|
||||
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
|
||||
id,
|
||||
nickname: account.nickname,
|
||||
}));
|
||||
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
|
||||
throw new Error(`Nickname "${newNickname}" is already used by another account`);
|
||||
}
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].nickname = newNickname;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
providerAccounts.accounts[accountId].nickname = newNickname;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last used timestamp for an account
|
||||
*/
|
||||
export function touchAccount(provider: CLIProxyProvider, accountId: string): void {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (providerAccounts?.accounts[accountId]) {
|
||||
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
}
|
||||
mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (providerAccounts?.accounts[accountId]) {
|
||||
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,16 +423,16 @@ export function setAccountTier(
|
||||
accountId: string,
|
||||
tier: 'free' | 'pro' | 'ultra' | 'unknown'
|
||||
): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
return mutateAccountsRegistry((registry) => {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].tier = tier;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
providerAccounts.accounts[accountId].tier = tier;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,181 +450,103 @@ export function discoverExistingAccounts(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
const files = fs.readdirSync(authDir);
|
||||
mutateAccountsRegistry((registry) => {
|
||||
syncRegistryWithTokenFiles(registry);
|
||||
|
||||
// Track whether any accounts were discovered (to avoid saving empty registry)
|
||||
let discoveredCount = 0;
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const filePath = path.join(authDir, file);
|
||||
|
||||
const filePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (!data.type) continue;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Skip if no type field
|
||||
if (!data.type) continue;
|
||||
|
||||
// Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider)
|
||||
// e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro"
|
||||
const typeValue = data.type.toLowerCase();
|
||||
let provider: CLIProxyProvider | undefined;
|
||||
for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
|
||||
if (typeValues.includes(typeValue)) {
|
||||
provider = prov as CLIProxyProvider;
|
||||
break;
|
||||
const typeValue = data.type.toLowerCase();
|
||||
let provider: CLIProxyProvider | undefined;
|
||||
for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
|
||||
if (typeValues.includes(typeValue)) {
|
||||
provider = prov as CLIProxyProvider;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if unknown provider type
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email if available, fallback to filename-based ID
|
||||
let email = data.email || undefined;
|
||||
|
||||
// Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json")
|
||||
if (!email && file.includes('@')) {
|
||||
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
|
||||
if (match) {
|
||||
email = match[1];
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
let email = data.email || undefined;
|
||||
if (!email && file.includes('@')) {
|
||||
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
|
||||
if (match) {
|
||||
email = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
|
||||
if (existingTokenFiles.includes(file)) {
|
||||
const projectIdValue =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
if (provider === 'agy' && projectIdValue) {
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, meta]) => meta.tokenFile === file
|
||||
);
|
||||
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
|
||||
existingEntry[1].projectId = projectIdValue;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const accountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
||||
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
|
||||
: extractAccountIdFromTokenFile(file, email);
|
||||
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: email ? generateNickname(email) : accountId,
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: lastModified.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
// Skip if token file already registered (under any accountId)
|
||||
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
|
||||
if (existingTokenFiles.includes(file)) {
|
||||
// Token file exists - check if we need to update projectId for agy accounts
|
||||
const projectIdValue =
|
||||
const discoveredProjectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
if (provider === 'agy' && projectIdValue) {
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, meta]) => meta.tokenFile === file
|
||||
);
|
||||
// Update if missing or changed
|
||||
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
|
||||
existingEntry[1].projectId = projectIdValue;
|
||||
discoveredCount++; // Count projectId updates as changes
|
||||
}
|
||||
if (provider === 'agy' && discoveredProjectId) {
|
||||
accountMeta.projectId = discoveredProjectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine accountId based on provider type
|
||||
let accountId: string;
|
||||
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) {
|
||||
// For kiro/ghcp without email: extract from filename or generate unique
|
||||
// Pattern: kiro-github-ABC123.json -> github-ABC123
|
||||
const filenameId = extractAccountIdFromTokenFile(file, undefined);
|
||||
|
||||
if (filenameId !== 'default') {
|
||||
accountId = filenameId;
|
||||
} else {
|
||||
// Generate unique ID: provider + incrementing index
|
||||
let index = 1;
|
||||
while (providerAccounts.accounts[`${provider}-${index}`]) {
|
||||
index++;
|
||||
}
|
||||
accountId = `${provider}-${index}`;
|
||||
}
|
||||
} else {
|
||||
// For providers with email: use email or filename extraction
|
||||
accountId = extractAccountIdFromTokenFile(file, email);
|
||||
}
|
||||
|
||||
// Skip if account already registered
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set as default if first account
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
// Get file stats for creation time
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// Register account with auto-generated nickname
|
||||
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
|
||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: generateNickname(email),
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: lastModified.toISOString(),
|
||||
};
|
||||
|
||||
// Read project_id for Antigravity accounts (read-only field from auth token)
|
||||
const discoveredProjectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
if (provider === 'agy' && discoveredProjectId) {
|
||||
accountMeta.projectId = discoveredProjectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
discoveredCount++;
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Only save if at least one account was discovered or updated
|
||||
// This prevents creating accounts.json with empty provider sections
|
||||
if (discoveredCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload-merge pattern: reduce race condition with concurrent OAuth registration
|
||||
// Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts)
|
||||
const freshRegistry = loadAccountsRegistry();
|
||||
for (const [providerName, discovered] of Object.entries(registry.providers)) {
|
||||
if (!discovered) continue;
|
||||
// Skip empty provider sections (no accounts discovered)
|
||||
if (Object.keys(discovered.accounts).length === 0) continue;
|
||||
|
||||
const prov = providerName as CLIProxyProvider;
|
||||
if (!freshRegistry.providers[prov]) {
|
||||
freshRegistry.providers[prov] = discovered;
|
||||
} else {
|
||||
// Merge accounts, preferring fresh registry's existing entries but updating projectId
|
||||
const freshProviderAccounts = freshRegistry.providers[prov];
|
||||
if (!freshProviderAccounts) continue;
|
||||
for (const [id, meta] of Object.entries(discovered.accounts)) {
|
||||
if (!freshProviderAccounts.accounts[id]) {
|
||||
freshProviderAccounts.accounts[id] = meta;
|
||||
// Set default if none exists
|
||||
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
|
||||
freshProviderAccounts.default = id;
|
||||
}
|
||||
} else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) {
|
||||
// Update existing account with projectId if discovered from auth file
|
||||
freshProviderAccounts.accounts[id].projectId = meta.projectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveAccountsRegistry(freshRegistry);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCliproxyDir, getAuthDir } from '../config-generator';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from './types';
|
||||
|
||||
/**
|
||||
@@ -92,16 +93,17 @@ export function moveTokenFromPaused(tokenFile: string): boolean {
|
||||
* Delete token file from both auth and paused directories
|
||||
* Idempotent
|
||||
*/
|
||||
export function deleteTokenFile(tokenFile: string): void {
|
||||
export function deleteTokenFile(tokenFile: string): boolean {
|
||||
const tokenPath = path.join(getAuthDir(), tokenFile);
|
||||
const pausedPath = path.join(getPausedDir(), tokenFile);
|
||||
let success = true;
|
||||
|
||||
// Delete from auth directory
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tokenPath);
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +112,11 @@ export function deleteTokenFile(tokenFile: string): void {
|
||||
try {
|
||||
fs.unlinkSync(pausedPath);
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,6 +147,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string):
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a collision-safe internal account ID for providers that may not expose email.
|
||||
* Reuses the existing entry when the token file is already known, otherwise prefers the
|
||||
* filename-derived ID before falling back to provider-scoped sequential IDs.
|
||||
*/
|
||||
export function deriveNoEmailProviderAccountId(
|
||||
provider: CLIProxyProvider,
|
||||
tokenFile: string,
|
||||
existingAccounts: Record<string, Pick<AccountInfo, 'tokenFile' | 'nickname'>>
|
||||
): string {
|
||||
const existingEntries = Object.entries(existingAccounts);
|
||||
const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile);
|
||||
if (existingEntry) {
|
||||
return existingEntry[0];
|
||||
}
|
||||
|
||||
const extractedId = extractAccountIdFromTokenFile(tokenFile);
|
||||
const lowerExtractedId = extractedId.toLowerCase();
|
||||
|
||||
if (
|
||||
extractedId !== 'default' &&
|
||||
!existingEntries.some(
|
||||
([existingId, account]) =>
|
||||
existingId.toLowerCase() === lowerExtractedId ||
|
||||
account.nickname?.toLowerCase() === lowerExtractedId
|
||||
)
|
||||
) {
|
||||
return extractedId;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
while (
|
||||
existingEntries.some(
|
||||
([existingId, account]) =>
|
||||
existingId.toLowerCase() === `${provider}-${index}` ||
|
||||
account.nickname?.toLowerCase() === `${provider}-${index}`
|
||||
)
|
||||
) {
|
||||
index++;
|
||||
}
|
||||
|
||||
return `${provider}-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nickname from email
|
||||
* Takes prefix before @ symbol, sanitizes whitespace
|
||||
@@ -180,3 +228,44 @@ export function validateNickname(nickname: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a nickname would collide with any existing account ID or nickname.
|
||||
*/
|
||||
export function hasAccountNameConflict(
|
||||
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
candidateName: string,
|
||||
excludeAccountId?: string
|
||||
): boolean {
|
||||
const normalizedCandidate = candidateName.toLowerCase();
|
||||
const normalizedExcludedId = excludeAccountId?.toLowerCase();
|
||||
|
||||
return accounts.some((account) => {
|
||||
if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
account.id.toLowerCase() === normalizedCandidate ||
|
||||
account.nickname?.toLowerCase() === normalizedCandidate
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the existing account that already owns the supplied id/nickname.
|
||||
*/
|
||||
export function findAccountNameMatch(
|
||||
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
candidateName: string
|
||||
): Pick<AccountInfo, 'id' | 'nickname'> | null {
|
||||
const normalizedCandidate = candidateName.toLowerCase();
|
||||
|
||||
return (
|
||||
accounts.find(
|
||||
(account) =>
|
||||
account.id.toLowerCase() === normalizedCandidate ||
|
||||
account.nickname?.toLowerCase() === normalizedCandidate
|
||||
) || null
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator';
|
||||
@@ -21,6 +22,41 @@ type FamilyEntriesMap = {
|
||||
|
||||
export type FamilyEntries<F extends AiProviderFamilyId> = FamilyEntriesMap[F];
|
||||
|
||||
type EntryWithOptionalId = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function createFamilyEntryId(family: AiProviderFamilyId): string {
|
||||
return `${family}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
function ensureStableEntryIds<F extends AiProviderFamilyId>(
|
||||
family: F,
|
||||
entries: FamilyEntries<F>
|
||||
): { entries: FamilyEntries<F>; changed: boolean } {
|
||||
const seenIds = new Set<string>();
|
||||
let changed = false;
|
||||
|
||||
const normalizedEntries = entries.map((entry) => {
|
||||
const rawId = (entry as EntryWithOptionalId).id;
|
||||
const normalizedId = typeof rawId === 'string' && rawId.trim().length > 0 ? rawId.trim() : null;
|
||||
const nextId =
|
||||
normalizedId && !seenIds.has(normalizedId) ? normalizedId : createFamilyEntryId(family);
|
||||
|
||||
if (normalizedId !== nextId) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
seenIds.add(nextId);
|
||||
return {
|
||||
...entry,
|
||||
id: nextId,
|
||||
};
|
||||
}) as FamilyEntries<F>;
|
||||
|
||||
return { entries: normalizedEntries, changed };
|
||||
}
|
||||
|
||||
function ensureLocalConfigPath(): string {
|
||||
if (!configExists()) {
|
||||
regenerateConfig();
|
||||
@@ -91,7 +127,12 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
|
||||
|
||||
if (!target.isRemote) {
|
||||
const config = readLocalConfig();
|
||||
return (config[family] || []) as FamilyEntries<F>;
|
||||
const entries = (Array.isArray(config[family]) ? config[family] : []) as FamilyEntries<F>;
|
||||
const normalized = ensureStableEntryIds(family, entries);
|
||||
if (normalized.changed) {
|
||||
writeLocalFamilySection(family, normalized.entries);
|
||||
}
|
||||
return normalized.entries;
|
||||
}
|
||||
|
||||
const client = createManagementClient({
|
||||
@@ -102,17 +143,29 @@ export async function readFamilyEntries<F extends AiProviderFamilyId>(
|
||||
auth_token: target.authToken,
|
||||
});
|
||||
|
||||
return client.getSection<FamilyEntries<F>[number]>(family) as Promise<FamilyEntries<F>>;
|
||||
const entries = (await client.getSection<FamilyEntries<F>[number]>(family)) as FamilyEntries<F>;
|
||||
const normalized = ensureStableEntryIds(
|
||||
family,
|
||||
Array.isArray(entries) ? entries : ([] as unknown as FamilyEntries<F>)
|
||||
);
|
||||
if (normalized.changed) {
|
||||
await client.putSection<FamilyEntries<F>[number]>(
|
||||
family,
|
||||
normalized.entries as FamilyEntries<F>[number][]
|
||||
);
|
||||
}
|
||||
return normalized.entries;
|
||||
}
|
||||
|
||||
export async function writeFamilyEntries<F extends AiProviderFamilyId>(
|
||||
family: F,
|
||||
entries: FamilyEntries<F>
|
||||
): Promise<void> {
|
||||
const normalized = ensureStableEntryIds(family, entries);
|
||||
const target = getProxyTarget();
|
||||
|
||||
if (!target.isRemote) {
|
||||
writeLocalFamilySection(family, entries);
|
||||
writeLocalFamilySection(family, normalized.entries);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,5 +177,8 @@ export async function writeFamilyEntries<F extends AiProviderFamilyId>(
|
||||
auth_token: target.authToken,
|
||||
});
|
||||
|
||||
await client.putSection<FamilyEntries<F>[number]>(family, entries as FamilyEntries<F>[number][]);
|
||||
await client.putSection<FamilyEntries<F>[number]>(
|
||||
family,
|
||||
normalized.entries as FamilyEntries<F>[number][]
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ function buildApiKeyEntryView(
|
||||
index: number
|
||||
): AiProviderEntryView {
|
||||
return {
|
||||
id: `${family}:${index}`,
|
||||
id: entry.id || `${family}:${index}`,
|
||||
index,
|
||||
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
|
||||
baseUrl: entry['base-url']?.trim() || undefined,
|
||||
@@ -67,7 +67,7 @@ function buildApiKeyEntryView(
|
||||
|
||||
function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView {
|
||||
return {
|
||||
id: `openai-compatibility:${index}`,
|
||||
id: entry.id || `openai-compatibility:${index}`,
|
||||
index,
|
||||
name: entry.name,
|
||||
label: entry.name,
|
||||
@@ -131,6 +131,7 @@ function toApiKeyEntry(
|
||||
: existing?.['api-key'] || '';
|
||||
|
||||
return {
|
||||
id: existing?.id,
|
||||
'api-key': nextSecret,
|
||||
'base-url': input.baseUrl?.trim() || undefined,
|
||||
'proxy-url': input.proxyUrl?.trim() || undefined,
|
||||
@@ -155,6 +156,7 @@ function toOpenAiCompatEntry(
|
||||
: (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']);
|
||||
|
||||
return {
|
||||
id: existing?.id,
|
||||
name: input.name?.trim() || existing?.name || 'connector',
|
||||
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
|
||||
headers: normalizeHeaders(input.headers),
|
||||
@@ -163,8 +165,41 @@ function toOpenAiCompatEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function assertIndex(entries: unknown[], index: number): void {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= entries.length) {
|
||||
function resolveEntryIndex(entries: Array<{ id?: string }>, entryId: string): number {
|
||||
const normalizedEntryId = entryId.trim();
|
||||
const matchedIndex = entries.findIndex((entry) => entry.id === normalizedEntryId);
|
||||
if (matchedIndex !== -1) {
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
const legacyIndex = Number.parseInt(normalizedEntryId, 10);
|
||||
if (
|
||||
Number.isInteger(legacyIndex) &&
|
||||
legacyIndex >= 0 &&
|
||||
legacyIndex < entries.length &&
|
||||
String(legacyIndex) === normalizedEntryId
|
||||
) {
|
||||
return legacyIndex;
|
||||
}
|
||||
|
||||
if (normalizedEntryId.startsWith('openai-compatibility:') || normalizedEntryId.includes(':')) {
|
||||
const legacySuffix = normalizedEntryId.split(':').at(-1) || '';
|
||||
const legacySuffixIndex = Number.parseInt(legacySuffix, 10);
|
||||
if (
|
||||
Number.isInteger(legacySuffixIndex) &&
|
||||
legacySuffixIndex >= 0 &&
|
||||
legacySuffixIndex < entries.length &&
|
||||
String(legacySuffixIndex) === legacySuffix
|
||||
) {
|
||||
return legacySuffixIndex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
function assertEntryId(entryId: string): void {
|
||||
if (!entryId.trim()) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
}
|
||||
@@ -208,12 +243,14 @@ export async function createAiProviderEntry(
|
||||
|
||||
export async function updateAiProviderEntry(
|
||||
family: AiProviderFamilyId,
|
||||
index: number,
|
||||
entryId: string,
|
||||
input: UpsertAiProviderEntryInput
|
||||
): Promise<void> {
|
||||
assertEntryId(entryId);
|
||||
|
||||
if (family === 'openai-compatibility') {
|
||||
const entries = await readFamilyEntries(family);
|
||||
assertIndex(entries, index);
|
||||
const index = resolveEntryIndex(entries, entryId);
|
||||
validateFamilyInput(family, input);
|
||||
entries[index] = toOpenAiCompatEntry(input, entries[index]);
|
||||
await writeFamilyEntries(family, entries);
|
||||
@@ -221,7 +258,7 @@ export async function updateAiProviderEntry(
|
||||
}
|
||||
|
||||
const entries = await readFamilyEntries(family);
|
||||
assertIndex(entries, index);
|
||||
const index = resolveEntryIndex(entries, entryId);
|
||||
validateFamilyInput(family, input);
|
||||
entries[index] = toApiKeyEntry(input, entries[index]);
|
||||
await writeFamilyEntries(family, entries);
|
||||
@@ -229,18 +266,20 @@ export async function updateAiProviderEntry(
|
||||
|
||||
export async function deleteAiProviderEntry(
|
||||
family: AiProviderFamilyId,
|
||||
index: number
|
||||
entryId: string
|
||||
): Promise<void> {
|
||||
assertEntryId(entryId);
|
||||
|
||||
if (family === 'openai-compatibility') {
|
||||
const entries = await readFamilyEntries(family);
|
||||
assertIndex(entries, index);
|
||||
const index = resolveEntryIndex(entries, entryId);
|
||||
entries.splice(index, 1);
|
||||
await writeFamilyEntries(family, entries);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await readFamilyEntries(family);
|
||||
assertIndex(entries, index);
|
||||
const index = resolveEntryIndex(entries, entryId);
|
||||
entries.splice(index, 1);
|
||||
await writeFamilyEntries(family, entries);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface AiProviderModelAlias {
|
||||
}
|
||||
|
||||
export interface AiProviderApiKeyEntry {
|
||||
id?: string;
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
'proxy-url'?: string;
|
||||
@@ -29,6 +30,7 @@ export interface OpenAICompatApiKeyEntry {
|
||||
}
|
||||
|
||||
export interface OpenAICompatEntry {
|
||||
id?: string;
|
||||
name: string;
|
||||
'base-url': string;
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
|
||||
/**
|
||||
@@ -87,19 +87,17 @@ export function getEffectiveManagementSecret(): string {
|
||||
* @param apiKey - New API key (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalApiKey(apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete config.cliproxy.auth.api_key;
|
||||
} else {
|
||||
config.cliproxy.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
if (apiKey === undefined) {
|
||||
delete config.cliproxy.auth.api_key;
|
||||
} else {
|
||||
config.cliproxy.auth.api_key = apiKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,19 +107,17 @@ export function setGlobalApiKey(apiKey: string | undefined): void {
|
||||
* @param secret - New management secret (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalManagementSecret(secret: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (secret === undefined) {
|
||||
delete config.cliproxy.auth.management_secret;
|
||||
} else {
|
||||
config.cliproxy.auth.management_secret = secret;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
if (secret === undefined) {
|
||||
delete config.cliproxy.auth.management_secret;
|
||||
} else {
|
||||
config.cliproxy.auth.management_secret = secret;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,28 +128,26 @@ export function setGlobalManagementSecret(secret: string | undefined): void {
|
||||
* @param apiKey - New API key (or undefined to remove override)
|
||||
*/
|
||||
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
mutateUnifiedConfig((config) => {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
|
||||
if (!variant) {
|
||||
throw new Error(`Variant '${variantName}' not found`);
|
||||
}
|
||||
|
||||
if (!variant.auth) {
|
||||
variant.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete variant.auth.api_key;
|
||||
// Clean up empty auth object
|
||||
if (Object.keys(variant.auth).length === 0) {
|
||||
delete variant.auth;
|
||||
if (!variant) {
|
||||
throw new Error(`Variant '${variantName}' not found`);
|
||||
}
|
||||
} else {
|
||||
variant.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
if (!variant.auth) {
|
||||
variant.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete variant.auth.api_key;
|
||||
if (Object.keys(variant.auth).length === 0) {
|
||||
delete variant.auth;
|
||||
}
|
||||
} else {
|
||||
variant.auth.api_key = apiKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,20 +155,16 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined
|
||||
* Removes cliproxy.auth and all variant auth overrides.
|
||||
*/
|
||||
export function resetAuthToDefaults(): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
delete config.cliproxy.auth;
|
||||
|
||||
// Remove global auth
|
||||
delete config.cliproxy.auth;
|
||||
|
||||
// Remove all variant auth overrides
|
||||
for (const variantName of Object.keys(config.cliproxy.variants)) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant.auth) {
|
||||
delete variant.auth;
|
||||
for (const variantName of Object.keys(config.cliproxy.variants)) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant.auth) {
|
||||
delete variant.auth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
getProviderAccounts,
|
||||
getDefaultAccount,
|
||||
touchAccount,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../account-manager';
|
||||
@@ -88,6 +90,28 @@ export async function requestPasteCallbackStart(
|
||||
return (await response.json()) as PasteCallbackStartData;
|
||||
}
|
||||
|
||||
export function getCliAuthNicknameError(
|
||||
provider: CLIProxyProvider,
|
||||
nickname: string | undefined,
|
||||
existingAccounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
allowExistingAccountId?: string
|
||||
): string | null {
|
||||
if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
|
||||
return `Nickname "${nickname}" is already in use. Choose a different one.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for account nickname (required for kiro/ghcp)
|
||||
* Returns null if user cancels
|
||||
*/
|
||||
async function promptNickname(
|
||||
provider: CLIProxyProvider,
|
||||
existingAccounts: AccountInfo[]
|
||||
): Promise<string | null> {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const existingNicknames = existingAccounts.map(
|
||||
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
|
||||
if (existingNicknames.length > 0) {
|
||||
console.log(` Existing: ${existingNicknames.join(', ')}`);
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
// Handle Ctrl+C gracefully (only if not already resolved)
|
||||
rl.on('close', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
const askForNickname = () => {
|
||||
rl.question('[?] Enter a nickname for this account: ', (answer) => {
|
||||
const nickname = answer.trim();
|
||||
|
||||
if (!nickname) {
|
||||
console.log(fail('Nickname cannot be empty'));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
console.log(fail(validationError));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingNicknames.includes(nickname.toLowerCase())) {
|
||||
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
resolved = true;
|
||||
rl.close();
|
||||
resolve(nickname);
|
||||
});
|
||||
};
|
||||
|
||||
askForNickname();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-flight OAuth checks
|
||||
*/
|
||||
@@ -350,7 +306,8 @@ async function handlePasteCallbackMode(
|
||||
oauthConfig: ProviderOAuthConfig,
|
||||
verbose: boolean,
|
||||
tokenDir: string,
|
||||
nickname?: string
|
||||
nickname?: string,
|
||||
expectedAccountId?: string
|
||||
): Promise<AccountInfo | null> {
|
||||
// Resolve CLIProxyAPI target (local or remote based on config)
|
||||
const target = getProxyTarget();
|
||||
@@ -474,7 +431,13 @@ async function handlePasteCallbackMode(
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
||||
const account = registerAccountFromToken(
|
||||
provider,
|
||||
tokenDir,
|
||||
nickname,
|
||||
verbose,
|
||||
expectedAccountId
|
||||
);
|
||||
|
||||
// Account safety: check for cross-provider conflicts
|
||||
if (account?.email) {
|
||||
@@ -509,7 +472,7 @@ export async function triggerOAuth(
|
||||
warnOAuthBanRisk(provider);
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
const acceptAgyRisk = options.acceptAgyRisk === true;
|
||||
let { nickname } = options;
|
||||
const { nickname } = options;
|
||||
const resolvedKiroMethod =
|
||||
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
|
||||
|
||||
@@ -533,21 +496,26 @@ export async function triggerOAuth(
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const nicknameError = !fromUI
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
|
||||
: null;
|
||||
if (nicknameError) {
|
||||
console.log(fail(nicknameError));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle paste-callback mode
|
||||
if (options.pasteCallback) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
}
|
||||
|
||||
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
|
||||
const promptedNickname = await promptNickname(provider, existingAccounts);
|
||||
if (!promptedNickname) {
|
||||
console.log(info('Cancelled'));
|
||||
return null;
|
||||
}
|
||||
nickname = promptedNickname;
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||
@@ -555,7 +523,7 @@ export async function triggerOAuth(
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const success = await importKiroToken(verbose);
|
||||
if (success) {
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -589,12 +557,26 @@ export async function triggerOAuth(
|
||||
// Non-interactive environment (piped input) - default to paste mode
|
||||
if (!process.stdin.isTTY) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
const mode = await promptOAuthModeChoice(callbackPort);
|
||||
if (mode === 'paste') {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
// mode === 'forward' continues to existing port-forwarding flow below
|
||||
}
|
||||
@@ -678,6 +660,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
isCLI,
|
||||
nickname,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
});
|
||||
|
||||
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface OAuthProcessOptions {
|
||||
verbose: boolean;
|
||||
isCLI: boolean;
|
||||
nickname?: string;
|
||||
expectedAccountId?: string;
|
||||
}
|
||||
|
||||
/** Internal state for OAuth process */
|
||||
@@ -276,6 +277,7 @@ async function handleTokenNotFound(
|
||||
callbackPort: number | null,
|
||||
tokenDir: string,
|
||||
nickname: string | undefined,
|
||||
expectedAccountId: string | undefined,
|
||||
verbose: boolean,
|
||||
failureReason?: string
|
||||
): Promise<AccountInfo | null> {
|
||||
@@ -289,7 +291,7 @@ async function handleTokenNotFound(
|
||||
if (result.success) {
|
||||
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
|
||||
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId);
|
||||
}
|
||||
|
||||
console.log(fail(`Auto-import failed: ${result.error}`));
|
||||
@@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
headless,
|
||||
verbose,
|
||||
nickname,
|
||||
expectedAccountId,
|
||||
} = options;
|
||||
|
||||
const log = (msg: string) => {
|
||||
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
|
||||
}
|
||||
|
||||
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
||||
resolve(
|
||||
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
|
||||
);
|
||||
} else {
|
||||
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
|
||||
|
||||
@@ -556,6 +561,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
callbackPort,
|
||||
tokenDir,
|
||||
nickname,
|
||||
expectedAccountId,
|
||||
verbose,
|
||||
failureReason || undefined
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||
import {
|
||||
AuthStatus,
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
@@ -202,50 +203,98 @@ export function registerAccountFromToken(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string,
|
||||
nickname?: string,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
expectedAccountId?: string
|
||||
): import('../account-manager').AccountInfo | null {
|
||||
const { registerAccount, generateNickname } = require('../account-manager');
|
||||
type TokenCandidate = {
|
||||
file: string;
|
||||
filePath: string;
|
||||
email?: string;
|
||||
projectId?: string;
|
||||
accountId: string;
|
||||
mtimeMs: number;
|
||||
alreadyRegistered: boolean;
|
||||
};
|
||||
|
||||
const { registerAccount } = require('../account-manager');
|
||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||
try {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const candidates: TokenCandidate[] = jsonFiles
|
||||
.map((file): TokenCandidate | null => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) return null;
|
||||
|
||||
let newestFile: string | null = null;
|
||||
let newestMtime = 0;
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
file,
|
||||
filePath,
|
||||
email,
|
||||
projectId,
|
||||
accountId,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is TokenCandidate => candidate !== null)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) continue;
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.mtimeMs > newestMtime) {
|
||||
newestMtime = stats.mtimeMs;
|
||||
newestFile = file;
|
||||
}
|
||||
if (expectedAccountId) {
|
||||
selectedCandidate =
|
||||
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
|
||||
candidates.find((candidate) => {
|
||||
const existingAccount = existingAccounts.find(
|
||||
(account) => account.id === expectedAccountId
|
||||
);
|
||||
return !!existingAccount && existingAccount.tokenFile === candidate.file;
|
||||
}) ||
|
||||
null;
|
||||
} else {
|
||||
selectedCandidate = candidates[0] || null;
|
||||
}
|
||||
|
||||
if (!newestFile) {
|
||||
if (!selectedCandidate) {
|
||||
if (verbose && expectedAccountId) {
|
||||
console.error(
|
||||
`[auth] No token matched the expected account ${expectedAccountId}; refusing ambiguous registration`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenPath = path.join(tokenDir, newestFile);
|
||||
const content = fs.readFileSync(tokenPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
|
||||
const account = registerAccount(
|
||||
provider,
|
||||
newestFile,
|
||||
email,
|
||||
nickname || generateNickname(email),
|
||||
projectId
|
||||
selectedCandidate.file,
|
||||
selectedCandidate.email,
|
||||
nickname,
|
||||
selectedCandidate.projectId
|
||||
);
|
||||
|
||||
// Upload token to remote server if configured (async, don't block)
|
||||
uploadTokenToRemoteAsync(tokenPath, verbose);
|
||||
|
||||
uploadTokenToRemoteAsync(selectedCandidate.filePath, verbose);
|
||||
return account;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (verbose) {
|
||||
console.error(`[auth] Failed to register token-backed account: ${message}`);
|
||||
}
|
||||
|
||||
if (selectedCandidate && !selectedCandidate.alreadyRegistered && !expectedAccountId) {
|
||||
deleteTokenFile(selectedCandidate.file);
|
||||
}
|
||||
|
||||
if (expectedAccountId && verbose) {
|
||||
console.error(
|
||||
`[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname>" to switch accounts`);
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../config/unified-config-types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
@@ -171,21 +171,20 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
}
|
||||
|
||||
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((unifiedConfig) => {
|
||||
if (!unifiedConfig.cliproxy) {
|
||||
unifiedConfig.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
};
|
||||
}
|
||||
if (!unifiedConfig.cliproxy.variants) {
|
||||
unifiedConfig.cliproxy.variants = {};
|
||||
}
|
||||
|
||||
if (!unifiedConfig.cliproxy) {
|
||||
unifiedConfig.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
};
|
||||
}
|
||||
if (!unifiedConfig.cliproxy.variants) {
|
||||
unifiedConfig.cliproxy.variants = {};
|
||||
}
|
||||
|
||||
unifiedConfig.cliproxy.variants[name] = config;
|
||||
saveUnifiedConfig(unifiedConfig);
|
||||
unifiedConfig.cliproxy.variants[name] = config;
|
||||
});
|
||||
}
|
||||
|
||||
export function saveVariantUnified(
|
||||
@@ -196,28 +195,26 @@ export function saveVariantUnified(
|
||||
port?: number,
|
||||
target: TargetType = 'claude'
|
||||
): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cliproxy) {
|
||||
config.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
};
|
||||
}
|
||||
if (!config.cliproxy.variants) {
|
||||
config.cliproxy.variants = {};
|
||||
}
|
||||
|
||||
if (!config.cliproxy) {
|
||||
config.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
config.cliproxy.variants[name] = {
|
||||
provider,
|
||||
account,
|
||||
settings: settingsPath,
|
||||
port,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
}
|
||||
if (!config.cliproxy.variants) {
|
||||
config.cliproxy.variants = {};
|
||||
}
|
||||
|
||||
config.cliproxy.variants[name] = {
|
||||
provider,
|
||||
account,
|
||||
settings: settingsPath,
|
||||
port,
|
||||
...(target !== 'claude' && { target }),
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveVariantLegacy(
|
||||
@@ -268,18 +265,22 @@ export function saveVariantLegacy(
|
||||
}
|
||||
|
||||
export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
let removedVariant: CLIProxyVariantConfig | CompositeVariantConfig | null = null;
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
|
||||
removedVariant = config.cliproxy.variants[name];
|
||||
delete config.cliproxy.variants[name];
|
||||
});
|
||||
|
||||
if (!removedVariant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const variant = config.cliproxy.variants[name];
|
||||
delete config.cliproxy.variants[name];
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
if ('type' in variant && variant.type === 'composite') {
|
||||
const composite = variant as CompositeVariantConfig;
|
||||
const composite = removedVariant as CompositeVariantConfig;
|
||||
if (composite.type === 'composite') {
|
||||
return {
|
||||
provider: composite.tiers[composite.default_tier].provider,
|
||||
settings: composite.settings,
|
||||
@@ -290,7 +291,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
|
||||
tiers: composite.tiers,
|
||||
};
|
||||
}
|
||||
const singleVariant = variant as CLIProxyVariantConfig;
|
||||
const singleVariant = removedVariant as CLIProxyVariantConfig;
|
||||
return {
|
||||
provider: singleVariant.provider,
|
||||
settings: singleVariant.settings,
|
||||
|
||||
@@ -11,17 +11,7 @@
|
||||
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
|
||||
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
|
||||
import { detectRunningProxy } from '../../cliproxy/proxy-detector';
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||
* Falls back to default port when unset/invalid.
|
||||
*/
|
||||
export function resolveLifecyclePort(): number {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
import { resolveLifecyclePort } from './resolve-lifecycle-port';
|
||||
|
||||
export async function handleStart(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||
* Falls back to default port when unset/invalid.
|
||||
*/
|
||||
export function resolveLifecyclePort(): number {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
@@ -5,11 +5,7 @@
|
||||
*/
|
||||
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { initUI, header, ok, info, warn, dim } from '../../utils/ui';
|
||||
|
||||
/**
|
||||
@@ -57,15 +53,14 @@ export async function handleDisable(): Promise<void> {
|
||||
}
|
||||
|
||||
// Disable auth
|
||||
const fullConfig = loadOrCreateUnifiedConfig();
|
||||
fullConfig.dashboard_auth = {
|
||||
enabled: false,
|
||||
username: fullConfig.dashboard_auth?.username ?? '',
|
||||
password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
|
||||
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(fullConfig);
|
||||
mutateUnifiedConfig((fullConfig) => {
|
||||
fullConfig.dashboard_auth = {
|
||||
enabled: false,
|
||||
username: fullConfig.dashboard_auth?.username ?? '',
|
||||
password_hash: fullConfig.dashboard_auth?.password_hash ?? '',
|
||||
session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(ok('Dashboard authentication disabled'));
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui';
|
||||
import type { AuthSetupResult } from './types';
|
||||
|
||||
@@ -99,24 +99,23 @@ export async function handleSetup(): Promise<AuthSetupResult> {
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
// Load existing config and update
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.dashboard_auth = {
|
||||
enabled: true,
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
|
||||
// Save config
|
||||
saveUnifiedConfig(config);
|
||||
const config = mutateUnifiedConfig((currentConfig) => {
|
||||
currentConfig.dashboard_auth = {
|
||||
enabled: true,
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
session_timeout_hours: currentConfig.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(ok('Dashboard authentication configured'));
|
||||
console.log('');
|
||||
console.log(info('Settings saved to ~/.ccs/config.yaml'));
|
||||
console.log(info(`Username: ${username}`));
|
||||
console.log(info(`Session timeout: ${config.dashboard_auth.session_timeout_hours} hours`));
|
||||
console.log(
|
||||
info(`Session timeout: ${config.dashboard_auth?.session_timeout_hours ?? 24} hours`)
|
||||
);
|
||||
console.log('');
|
||||
console.log(dim(' Start dashboard: ccs config'));
|
||||
console.log(dim(' Show status: ccs config auth show'));
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
normalizeCopilotConfigWithWarnings,
|
||||
} from '../copilot';
|
||||
import type { CopilotModel } from '../copilot';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
|
||||
import { ok, fail, info, color, warn } from '../utils/ui';
|
||||
import { normalizeCopilotSubcommand } from '../copilot/constants';
|
||||
@@ -361,14 +361,13 @@ async function handleStop(): Promise<number> {
|
||||
* Handle enable subcommand.
|
||||
*/
|
||||
async function handleEnable(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.copilot) {
|
||||
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
|
||||
}
|
||||
|
||||
if (!config.copilot) {
|
||||
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
|
||||
}
|
||||
|
||||
config.copilot.enabled = true;
|
||||
saveUnifiedConfig(config);
|
||||
config.copilot.enabled = true;
|
||||
});
|
||||
|
||||
console.log(ok('Copilot integration enabled'));
|
||||
console.log('');
|
||||
@@ -384,12 +383,11 @@ async function handleEnable(): Promise<number> {
|
||||
* Handle disable subcommand.
|
||||
*/
|
||||
async function handleDisable(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (config.copilot) {
|
||||
config.copilot.enabled = false;
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.copilot) {
|
||||
config.copilot.enabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(ok('Copilot integration disabled'));
|
||||
|
||||
|
||||
@@ -15,11 +15,7 @@ import {
|
||||
getAvailableModels,
|
||||
getDefaultModel,
|
||||
} from '../cursor';
|
||||
import {
|
||||
getCursorConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
|
||||
import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display';
|
||||
import { ok, fail, info } from '../utils/ui';
|
||||
@@ -239,14 +235,13 @@ async function handleStop(): Promise<number> {
|
||||
* Handle enable subcommand.
|
||||
*/
|
||||
async function handleEnable(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cursor) {
|
||||
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
|
||||
}
|
||||
|
||||
if (!config.cursor) {
|
||||
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
|
||||
}
|
||||
|
||||
config.cursor.enabled = true;
|
||||
saveUnifiedConfig(config);
|
||||
config.cursor.enabled = true;
|
||||
});
|
||||
|
||||
console.log(ok('Cursor integration enabled'));
|
||||
console.log('');
|
||||
@@ -262,12 +257,11 @@ async function handleEnable(): Promise<number> {
|
||||
* Handle disable subcommand.
|
||||
*/
|
||||
async function handleDisable(): Promise<number> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (config.cursor) {
|
||||
config.cursor.enabled = false;
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cursor) {
|
||||
config.cursor.enabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(ok('Cursor integration disabled'));
|
||||
return 0;
|
||||
|
||||
@@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'Show auth URL and prompt for callback paste (cross-browser)',
|
||||
],
|
||||
['ccs <provider> --accounts', 'List all accounts'],
|
||||
['ccs <provider> --use <name>', 'Switch to account'],
|
||||
['ccs <provider> --use <nickname-or-id>', 'Switch to account'],
|
||||
['ccs <provider> --config', 'Change model (agy, gemini)'],
|
||||
[
|
||||
'ccs agy --accept-agr-risk',
|
||||
|
||||
@@ -19,7 +19,7 @@ import { initUI, header, ok, info, warn } from '../utils/ui';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
loadUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
hasUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
|
||||
@@ -377,9 +377,10 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
|
||||
console.log(' After creating, edit the settings file to add your API key.');
|
||||
}
|
||||
|
||||
// Save config
|
||||
config.setup_completed = true;
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((currentConfig) => {
|
||||
currentConfig.setup_completed = true;
|
||||
currentConfig.cliproxy_server = config.cliproxy_server;
|
||||
});
|
||||
|
||||
// Final summary
|
||||
console.log('');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import {
|
||||
@@ -64,65 +65,73 @@ function getLockFilePath(): string {
|
||||
|
||||
/**
|
||||
* Acquire lockfile for config write operations.
|
||||
* Returns true if lock acquired, false if already locked by another process.
|
||||
* Returns a lock token if acquired, null if already locked by another process.
|
||||
* Cleans up stale locks (older than LOCK_STALE_MS).
|
||||
*/
|
||||
|
||||
function acquireLock(): boolean {
|
||||
function acquireLock(): string | null {
|
||||
const lockPath = getLockFilePath();
|
||||
const lockData = `${process.pid}\n${Date.now()}`;
|
||||
const lockToken = crypto.randomUUID();
|
||||
const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`;
|
||||
|
||||
try {
|
||||
// Check if lock exists
|
||||
if (fs.existsSync(lockPath)) {
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const [pidStr, timestampStr] = content.trim().split('\n');
|
||||
const timestamp = parseInt(timestampStr, 10);
|
||||
const pid = Number.parseInt(pidStr, 10);
|
||||
const timestamp = Number.parseInt(timestampStr, 10);
|
||||
const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid);
|
||||
const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS;
|
||||
|
||||
// Check if lock is stale
|
||||
if (Date.now() - timestamp > LOCK_STALE_MS) {
|
||||
// Stale lock - remove and acquire
|
||||
if (hasLiveOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isStale || !hasLiveOwner) {
|
||||
fs.unlinkSync(lockPath);
|
||||
} else {
|
||||
// Check if process still exists
|
||||
try {
|
||||
process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists
|
||||
// Process exists - lock is valid
|
||||
return false;
|
||||
} catch {
|
||||
// Process doesn't exist - remove stale lock
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire lock
|
||||
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
|
||||
return true;
|
||||
return lockToken;
|
||||
} catch (error) {
|
||||
// EEXIST means another process acquired the lock between our check and write
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lockfile after config write operation.
|
||||
*/
|
||||
|
||||
function releaseLock(): void {
|
||||
function releaseLock(lockToken: string): void {
|
||||
const lockPath = getLockFilePath();
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const fileToken = content.trim().split('\n')[2];
|
||||
if (fileToken === lockToken) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
function processExists(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unified config.yaml exists
|
||||
*/
|
||||
@@ -152,7 +161,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
|
||||
|
||||
/**
|
||||
* Load unified config from YAML file.
|
||||
* Returns null if file doesn't exist or format check fails.
|
||||
* Returns null if file doesn't exist.
|
||||
* Auto-upgrades config if version is outdated (regenerates comments).
|
||||
*/
|
||||
export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
@@ -168,8 +177,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
const parsed = yaml.load(content);
|
||||
|
||||
if (!isUnifiedConfig(parsed)) {
|
||||
console.error(`[!] Invalid config format in ${yamlPath}`);
|
||||
return null;
|
||||
throw new Error(`Invalid config format in ${yamlPath}`);
|
||||
}
|
||||
|
||||
// Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
|
||||
@@ -208,7 +216,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[X] Failed to load config: ${error}`);
|
||||
}
|
||||
return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,16 +816,17 @@ function withConfigWriteLock<T>(callback: () => T): T {
|
||||
// Acquire lock (retry for up to 1 second)
|
||||
const maxRetries = 10;
|
||||
const retryDelayMs = 100;
|
||||
let lockAcquired = false;
|
||||
let lockToken: string | null = null;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (acquireLock()) {
|
||||
lockAcquired = true;
|
||||
const acquiredToken = acquireLock();
|
||||
if (acquiredToken) {
|
||||
lockToken = acquiredToken;
|
||||
break;
|
||||
}
|
||||
sleepSync(retryDelayMs);
|
||||
}
|
||||
|
||||
if (!lockAcquired) {
|
||||
if (!lockToken) {
|
||||
throw new Error('Config file is locked by another process. Wait a moment and try again.');
|
||||
}
|
||||
|
||||
@@ -825,7 +834,7 @@ function withConfigWriteLock<T>(callback: () => T): T {
|
||||
return callback();
|
||||
} finally {
|
||||
// Always release lock
|
||||
releaseLock();
|
||||
releaseLock(lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,12 +145,8 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
|
||||
// Use appropriate config based on unified mode
|
||||
const { isUnifiedMode } = require('../config/unified-config-loader');
|
||||
if (isUnifiedMode()) {
|
||||
const {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} = require('../config/unified-config-loader');
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
saveUnifiedConfig(config);
|
||||
const { mutateUnifiedConfig } = require('../config/unified-config-loader');
|
||||
mutateUnifiedConfig(() => {});
|
||||
return { success: true, message: 'Created/updated config.yaml' };
|
||||
}
|
||||
const configPath = getConfigPath();
|
||||
|
||||
@@ -132,3 +132,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
// Unauthorized
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
export function isLoopbackRemoteAddress(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().replace(/^\[|\]$/g, '');
|
||||
return (
|
||||
normalized === '::1' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized.startsWith('127.') ||
|
||||
normalized === '::ffff:127.0.0.1' ||
|
||||
normalized.startsWith('::ffff:127.')
|
||||
);
|
||||
}
|
||||
|
||||
export function requireLocalAccessWhenAuthDisabled(
|
||||
req: Request,
|
||||
res: Response,
|
||||
error = 'This endpoint requires localhost access when dashboard auth is disabled.'
|
||||
): boolean {
|
||||
if (getDashboardAuthConfig().enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
res.status(403).json({ error });
|
||||
return false;
|
||||
}
|
||||
@@ -8,9 +8,22 @@ import {
|
||||
type AiProviderFamilyId,
|
||||
type UpsertAiProviderEntryInput,
|
||||
} from '../../cliproxy/ai-providers';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'AI provider endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
function isAiProviderFamilyId(value: string): value is AiProviderFamilyId {
|
||||
return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId);
|
||||
}
|
||||
@@ -24,13 +37,13 @@ function parseFamily(req: Request, res: Response): AiProviderFamilyId | null {
|
||||
return family;
|
||||
}
|
||||
|
||||
function parseIndex(req: Request, res: Response): number | null {
|
||||
const index = Number.parseInt(req.params.index || '', 10);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
res.status(400).json({ error: 'Invalid entry index' });
|
||||
function parseEntryId(req: Request, res: Response): string | null {
|
||||
const entryId = req.params.entryId?.trim();
|
||||
if (!entryId) {
|
||||
res.status(400).json({ error: 'Invalid entry id' });
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
return entryId;
|
||||
}
|
||||
|
||||
function parseInput(body: unknown): UpsertAiProviderEntryInput {
|
||||
@@ -95,14 +108,14 @@ router.post('/:family', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:family/:index', async (req: Request, res: Response) => {
|
||||
router.put('/:family/:entryId', async (req: Request, res: Response) => {
|
||||
const family = parseFamily(req, res);
|
||||
if (!family) return;
|
||||
const index = parseIndex(req, res);
|
||||
if (index === null) return;
|
||||
const entryId = parseEntryId(req, res);
|
||||
if (!entryId) return;
|
||||
|
||||
try {
|
||||
await updateAiProviderEntry(family, index, parseInput(req.body));
|
||||
await updateAiProviderEntry(family, entryId, parseInput(req.body));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
@@ -110,14 +123,14 @@ router.put('/:family/:index', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:family/:index', async (req: Request, res: Response) => {
|
||||
router.delete('/:family/:entryId', async (req: Request, res: Response) => {
|
||||
const family = parseFamily(req, res);
|
||||
if (!family) return;
|
||||
const index = parseIndex(req, res);
|
||||
if (index === null) return;
|
||||
const entryId = parseEntryId(req, res);
|
||||
if (!entryId) return;
|
||||
|
||||
try {
|
||||
await deleteAiProviderEntry(family, index);
|
||||
await deleteAiProviderEntry(family, entryId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
pauseAccount as pauseAccountFn,
|
||||
resumeAccount as resumeAccountFn,
|
||||
touchAccount,
|
||||
hasAccountNameConflict,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../../cliproxy/account-manager';
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
||||
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
|
||||
import {
|
||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||
@@ -51,14 +52,70 @@ import {
|
||||
isAntigravityResponsibilityBypassEnabled,
|
||||
} from '../../cliproxy/antigravity-responsibility';
|
||||
import { createRouteErrorHelpers } from './route-helpers';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const pendingManualAuthState = new Map<
|
||||
string,
|
||||
{ nickname?: string; expectedAccountId?: string; createdAt: number }
|
||||
>();
|
||||
|
||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||
|
||||
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'CLIProxy auth endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
function pruneExpiredManualAuthState(now = Date.now()): void {
|
||||
for (const [state, pending] of pendingManualAuthState.entries()) {
|
||||
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
|
||||
pendingManualAuthState.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberManualAuthState(
|
||||
state: string,
|
||||
pending: { nickname?: string; expectedAccountId?: string }
|
||||
): void {
|
||||
pruneExpiredManualAuthState();
|
||||
pendingManualAuthState.set(state, {
|
||||
...pending,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function getManualAuthState(
|
||||
state: string | undefined
|
||||
): { nickname?: string; expectedAccountId?: string } | null {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pruneExpiredManualAuthState();
|
||||
const pending = pendingManualAuthState.get(state);
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: pending.nickname,
|
||||
expectedAccountId: pending.expectedAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
@@ -104,6 +161,41 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
|
||||
return 'Authentication failed or was cancelled';
|
||||
}
|
||||
|
||||
function getManualCallbackRegistrationError(provider: CLIProxyProvider): string {
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.';
|
||||
}
|
||||
return 'Authenticated token could not be registered. Retry the flow.';
|
||||
}
|
||||
|
||||
export function getStartAuthNicknameError(
|
||||
provider: CLIProxyProvider,
|
||||
nickname: string | undefined,
|
||||
existingAccounts: Array<{ id: string; nickname?: string }>,
|
||||
allowExistingAccountId?: string
|
||||
): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null {
|
||||
if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
return {
|
||||
error: validationError,
|
||||
code: 'INVALID_NICKNAME',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
|
||||
return {
|
||||
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
|
||||
code: 'NICKNAME_EXISTS',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
@@ -430,37 +522,15 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
// For kiro/ghcp: nickname is required
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
|
||||
if (!nickname) {
|
||||
res.status(400).json({
|
||||
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
|
||||
code: 'NICKNAME_REQUIRED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
res.status(400).json({
|
||||
error: validationError,
|
||||
code: 'INVALID_NICKNAME',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const existingNicknames = existingAccounts.map(
|
||||
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
|
||||
);
|
||||
if (existingNicknames.includes(nickname.toLowerCase())) {
|
||||
res.status(400).json({
|
||||
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
|
||||
code: 'NICKNAME_EXISTS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Kiro no-incognito setting from config (or request body)
|
||||
@@ -625,7 +695,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
|
||||
// Check remote mode
|
||||
@@ -668,6 +743,17 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authUrlProvider =
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||
@@ -696,19 +782,26 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
method?: string;
|
||||
};
|
||||
const authUrl = data.url || data.auth_url;
|
||||
const oauthState = data.state || parseAuthUrlState(authUrl);
|
||||
|
||||
// Some upstream flows return state first and provide auth_url in subsequent status polling.
|
||||
if (!authUrl && !data.state) {
|
||||
if (!authUrl && !oauthState) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthState) {
|
||||
rememberManualAuthState(oauthState, {
|
||||
nickname: nickname || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authUrl: authUrl || null,
|
||||
state: data.state || null,
|
||||
state: oauthState,
|
||||
method: data.method || null,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -768,6 +861,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthUrlState(url: string | null | undefined): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).searchParams.get('state');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
|
||||
* For cross-browser OAuth flows where callback cannot redirect directly
|
||||
@@ -800,6 +905,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
|
||||
return;
|
||||
}
|
||||
const pendingAuth = getManualAuthState(parsed.state);
|
||||
|
||||
try {
|
||||
const callbackProvider =
|
||||
@@ -824,7 +930,34 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
const account = registerAccountFromToken(
|
||||
provider as CLIProxyProvider,
|
||||
getProviderTokenDir(provider as CLIProxyProvider),
|
||||
pendingAuth?.nickname,
|
||||
false,
|
||||
pendingAuth?.expectedAccountId
|
||||
);
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
res.status(409).json({
|
||||
error: getManualCallbackRegistrationError(provider as CLIProxyProvider),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
nickname: account.nickname,
|
||||
provider: account.provider,
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
getDeniedModelIdReasonForProvider,
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -66,6 +67,18 @@ interface QuotaRateLimitEntry {
|
||||
|
||||
const quotaRateLimits = new Map<string, QuotaRateLimitEntry>();
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'CLIProxy management endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
function buildQuotaRateLimitKey(req: Request, provider: string): string {
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return `${clientIp}:${provider}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import {
|
||||
generateSyncPayload,
|
||||
generateSyncPreview,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
syncToLocalConfig,
|
||||
getLocalSyncStatus,
|
||||
} from '../../cliproxy/sync';
|
||||
import { saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -130,18 +129,13 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update config
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (!config.cliproxy) {
|
||||
// Should not happen as loadOrCreate initializes it, but handle gracefully
|
||||
res.status(500).json({ error: 'CLIProxy config not initialized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config
|
||||
try {
|
||||
config.cliproxy.auto_sync = enabled;
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cliproxy) {
|
||||
throw new Error('CLIProxy config not initialized');
|
||||
}
|
||||
config.cliproxy.auto_sync = enabled;
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` });
|
||||
return;
|
||||
|
||||
@@ -9,11 +9,7 @@ import { promises as fsp } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
getCursorConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig, getCursorConfig } from '../../config/unified-config-loader';
|
||||
import type { CursorConfig } from '../../config/unified-config-types';
|
||||
|
||||
const router = Router();
|
||||
@@ -184,36 +180,38 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const normalizedModel = parseRequiredModel(updates.model);
|
||||
const config = mutateUnifiedConfig((currentConfig) => {
|
||||
currentConfig.cursor = {
|
||||
enabled: updates.enabled ?? currentConfig.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
|
||||
port: updates.port ?? currentConfig.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
|
||||
auto_start:
|
||||
updates.auto_start ??
|
||||
currentConfig.cursor?.auto_start ??
|
||||
DEFAULT_CURSOR_CONFIG.auto_start,
|
||||
ghost_mode:
|
||||
updates.ghost_mode ??
|
||||
currentConfig.cursor?.ghost_mode ??
|
||||
DEFAULT_CURSOR_CONFIG.ghost_mode,
|
||||
model: normalizedModel ?? currentConfig.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
|
||||
opus_model:
|
||||
'opus_model' in updates
|
||||
? parseOptionalModel(updates.opus_model)
|
||||
: currentConfig.cursor?.opus_model,
|
||||
sonnet_model:
|
||||
'sonnet_model' in updates
|
||||
? parseOptionalModel(updates.sonnet_model)
|
||||
: currentConfig.cursor?.sonnet_model,
|
||||
haiku_model:
|
||||
'haiku_model' in updates
|
||||
? parseOptionalModel(updates.haiku_model)
|
||||
: currentConfig.cursor?.haiku_model,
|
||||
};
|
||||
});
|
||||
|
||||
// Merge updates with existing config
|
||||
// Only known fields are merged — unknown properties are ignored
|
||||
config.cursor = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
|
||||
port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
|
||||
auto_start:
|
||||
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
|
||||
ghost_mode:
|
||||
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
|
||||
model: normalizedModel ?? config.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
|
||||
opus_model:
|
||||
'opus_model' in updates
|
||||
? parseOptionalModel(updates.opus_model)
|
||||
: config.cursor?.opus_model,
|
||||
sonnet_model:
|
||||
'sonnet_model' in updates
|
||||
? parseOptionalModel(updates.sonnet_model)
|
||||
: config.cursor?.sonnet_model,
|
||||
haiku_model:
|
||||
'haiku_model' in updates
|
||||
? parseOptionalModel(updates.haiku_model)
|
||||
: config.cursor?.haiku_model,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
await syncRawSettingsFromCursorConfig(config.cursor);
|
||||
res.json({ success: true, cursor: config.cursor });
|
||||
const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG;
|
||||
await syncRawSettingsFromCursorConfig(cursorConfig);
|
||||
res.json({ success: true, cursor: cursorConfig });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
@@ -295,19 +293,19 @@ router.put('/raw', (req: Request, res: Response): void => {
|
||||
|
||||
// Keep unified config aligned with raw settings edits (parity with Copilot raw editor).
|
||||
const parsedPort = parseLocalCursorPort(settings);
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const env = (settings as { env?: Record<string, unknown> }).env ?? {};
|
||||
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model;
|
||||
mutateUnifiedConfig((config) => {
|
||||
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model;
|
||||
|
||||
config.cursor = {
|
||||
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
|
||||
...(parsedPort !== null ? { port: parsedPort } : {}),
|
||||
...(model ? { model } : {}),
|
||||
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
|
||||
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
|
||||
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
config.cursor = {
|
||||
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
|
||||
...(parsedPort !== null ? { port: parsedPort } : {}),
|
||||
...(model ? { model } : {}),
|
||||
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
|
||||
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
|
||||
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
|
||||
};
|
||||
});
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
res.json({ success: true, mtime: stat.mtimeMs });
|
||||
|
||||
@@ -8,8 +8,6 @@ import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
getGlobalEnvConfig,
|
||||
getThinkingConfig,
|
||||
@@ -24,9 +22,22 @@ import {
|
||||
THINKING_OFF_VALUES,
|
||||
} from '../../cliproxy';
|
||||
import { validateFilePath } from './route-helpers';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'Local configuration endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export function resolveThinkingProviderOverridesForSave(
|
||||
currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined,
|
||||
updatesProviderOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> | undefined,
|
||||
@@ -304,14 +315,10 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const shouldClearOverride = clearOverrideFlag === true || updates.override === null;
|
||||
const shouldClearProviderOverrides =
|
||||
clearProviderOverridesFlag === true || updates.provider_overrides === null;
|
||||
let normalizedOverride: string | number | undefined = config.thinking?.override as
|
||||
| string
|
||||
| number
|
||||
| undefined;
|
||||
let normalizedOverride: string | number | undefined;
|
||||
let normalizedProviderOverrides:
|
||||
| Record<string, Partial<ThinkingConfig['tier_defaults']>>
|
||||
| undefined;
|
||||
@@ -438,28 +445,32 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
||||
Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined;
|
||||
}
|
||||
|
||||
// Update thinking section
|
||||
config.thinking = {
|
||||
mode: updates.mode ?? config.thinking?.mode ?? 'auto',
|
||||
override: shouldClearOverride
|
||||
? undefined
|
||||
: updates.override !== undefined
|
||||
? normalizedOverride
|
||||
: config.thinking?.override,
|
||||
tier_defaults: {
|
||||
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high',
|
||||
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium',
|
||||
haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low',
|
||||
},
|
||||
provider_overrides: resolveThinkingProviderOverridesForSave(
|
||||
config.thinking?.provider_overrides,
|
||||
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
|
||||
shouldClearProviderOverrides
|
||||
),
|
||||
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
const config = mutateUnifiedConfig((currentConfig) => {
|
||||
currentConfig.thinking = {
|
||||
mode: updates.mode ?? currentConfig.thinking?.mode ?? 'auto',
|
||||
override: shouldClearOverride
|
||||
? undefined
|
||||
: updates.override !== undefined
|
||||
? normalizedOverride
|
||||
: currentConfig.thinking?.override,
|
||||
tier_defaults: {
|
||||
opus:
|
||||
updates.tier_defaults?.opus ?? currentConfig.thinking?.tier_defaults?.opus ?? 'high',
|
||||
sonnet:
|
||||
updates.tier_defaults?.sonnet ??
|
||||
currentConfig.thinking?.tier_defaults?.sonnet ??
|
||||
'medium',
|
||||
haiku:
|
||||
updates.tier_defaults?.haiku ?? currentConfig.thinking?.tier_defaults?.haiku ?? 'low',
|
||||
},
|
||||
provider_overrides: resolveThinkingProviderOverridesForSave(
|
||||
currentConfig.thinking?.provider_overrides,
|
||||
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
|
||||
shouldClearProviderOverrides
|
||||
),
|
||||
show_warnings: updates.show_warnings ?? currentConfig.thinking?.show_warnings ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
// W4: Return new mtime for subsequent requests
|
||||
let newMtime: number | undefined;
|
||||
|
||||
@@ -17,9 +17,22 @@ import {
|
||||
CliproxyServerConfig,
|
||||
} from '../../config/unified-config-types';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'CLIProxy server endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy-server - Get proxy configuration
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
||||
import { listVariants } from '../../cliproxy/services/variant-service';
|
||||
@@ -20,11 +21,8 @@ import {
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
@@ -43,40 +41,25 @@ const MODEL_ENV_KEYS = [
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
|
||||
const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
|
||||
|
||||
const { logRouteError, respondInternalError } = createRouteErrorHelpers('settings-routes');
|
||||
|
||||
function isLoopbackAddress(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().replace(/^\[|\]$/g, '');
|
||||
return (
|
||||
normalized === '::1' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized.startsWith('127.') ||
|
||||
normalized === '::ffff:127.0.0.1' ||
|
||||
normalized.startsWith('::ffff:127.')
|
||||
);
|
||||
function resolvePathWithin(basePath: string, targetPath: string): string {
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) {
|
||||
throw new Error('Invalid settings path');
|
||||
}
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
||||
const dashboardAuth = getDashboardAuthConfig();
|
||||
if (dashboardAuth.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use only socket-level address for security decisions.
|
||||
// X-Forwarded-For is trivially spoofable and must NOT be trusted
|
||||
// without an explicit trust proxy configuration.
|
||||
const candidateAddress = req.socket.remoteAddress;
|
||||
|
||||
if (isLoopbackAddress(candidateAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
res.status(403).json({
|
||||
error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.',
|
||||
});
|
||||
return false;
|
||||
return requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'Sensitive settings endpoints require localhost access when dashboard auth is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
|
||||
@@ -100,18 +83,26 @@ function classifyConfigSaveFailure(error: unknown): { statusCode: number; messag
|
||||
* Variants have settings paths in config, regular profiles use {name}.settings.json
|
||||
*/
|
||||
function resolveSettingsPath(profileOrVariant: string): string {
|
||||
if (!SETTINGS_IDENTIFIER_PATTERN.test(profileOrVariant)) {
|
||||
throw new Error('Invalid profile name');
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const resolvedCcsDir = path.resolve(ccsDir);
|
||||
|
||||
// Check if this is a variant
|
||||
const variants = listVariants();
|
||||
const variant = variants[profileOrVariant];
|
||||
if (variant?.settings) {
|
||||
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
|
||||
return variant.settings.replace(/^~/, os.homedir());
|
||||
return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir()));
|
||||
}
|
||||
|
||||
// Regular profile settings
|
||||
return path.join(ccsDir, `${profileOrVariant}.settings.json`);
|
||||
return resolvePathWithin(
|
||||
resolvedCcsDir,
|
||||
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null {
|
||||
@@ -261,11 +252,29 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
|
||||
}
|
||||
|
||||
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
}
|
||||
|
||||
function withSettingsFileLock<T>(settingsPath: string, callback: () => T): T {
|
||||
const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath);
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
||||
return callback();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadCanonicalProfileSettings(
|
||||
profileOrVariant: string,
|
||||
settingsPath: string,
|
||||
@@ -339,6 +348,8 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
* GET /api/settings/:profile/raw - Get full settings (for editing)
|
||||
*/
|
||||
router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
@@ -379,6 +390,8 @@ function checkRequiredEnvVars(settings: Settings): string[] {
|
||||
* PUT /api/settings/:profile - Update settings with conflict detection and backup
|
||||
*/
|
||||
router.put('/:profile', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { settings, expectedMtime } = req.body;
|
||||
@@ -407,53 +420,56 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
const missingFields = checkRequiredEnvVars(normalizedSettings);
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
const fileExists = fs.existsSync(settingsPath);
|
||||
|
||||
// Only check conflict if file exists and expectedMtime was provided
|
||||
if (fileExists && expectedMtime) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup only if file exists AND content actually changed
|
||||
let backupPath: string | undefined;
|
||||
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
|
||||
if (fileExists) {
|
||||
const existingContent = fs.readFileSync(settingsPath, 'utf8');
|
||||
// Only create backup if content differs
|
||||
if (existingContent !== newContent) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
let created = false;
|
||||
let newMtime = 0;
|
||||
|
||||
withSettingsFileLock(settingsPath, () => {
|
||||
const fileExists = fs.existsSync(settingsPath);
|
||||
|
||||
if (fileExists && expectedMtime) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
|
||||
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
|
||||
if (fileExists) {
|
||||
const existingContent = fs.readFileSync(settingsPath, 'utf8');
|
||||
if (existingContent !== newContent) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
} else {
|
||||
created = true;
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
}
|
||||
|
||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, newContent);
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
newMtime = fs.statSync(settingsPath).mtime.getTime();
|
||||
});
|
||||
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure directory exists for new files
|
||||
if (!fileExists) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
}
|
||||
|
||||
// Write new settings atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, newContent);
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
const newStat = fs.statSync(settingsPath);
|
||||
res.json({
|
||||
profile,
|
||||
mtime: newStat.mtime.getTime(),
|
||||
mtime: newMtime,
|
||||
backupPath,
|
||||
created: !fileExists,
|
||||
created,
|
||||
// Include warning if fields missing (runtime will use defaults)
|
||||
...(missingFields.length > 0 && {
|
||||
warning: `Missing fields will use defaults: ${missingFields.join(', ')}`,
|
||||
@@ -491,6 +507,8 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
|
||||
* POST /api/settings/:profile/presets - Create a new preset
|
||||
*/
|
||||
router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
|
||||
@@ -502,56 +520,65 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
let persistedPreset:
|
||||
| {
|
||||
name: string;
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||
settings.presets = settings.presets || [];
|
||||
withSettingsFileLock(settingsPath, () => {
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||
settings.presets = settings.presets || [];
|
||||
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePresetModel = (modelId: string): string =>
|
||||
canonicalizeProfileModelId(profile, modelId, settings);
|
||||
|
||||
for (const modelId of [
|
||||
defaultModel,
|
||||
opus || defaultModel,
|
||||
sonnet || defaultModel,
|
||||
haiku || defaultModel,
|
||||
]) {
|
||||
const deniedReason = findDeniedProfileModel(profile, modelId, settings);
|
||||
if (deniedReason) {
|
||||
res.status(400).json({ error: deniedReason });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: normalizePresetModel(defaultModel),
|
||||
opus: normalizePresetModel(opus || defaultModel),
|
||||
sonnet: normalizePresetModel(sonnet || defaultModel),
|
||||
haiku: normalizePresetModel(haiku || defaultModel),
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||
persistedPreset =
|
||||
canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset;
|
||||
});
|
||||
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePresetModel = (modelId: string): string =>
|
||||
canonicalizeProfileModelId(profile, modelId, settings);
|
||||
|
||||
for (const modelId of [
|
||||
defaultModel,
|
||||
opus || defaultModel,
|
||||
sonnet || defaultModel,
|
||||
haiku || defaultModel,
|
||||
]) {
|
||||
const deniedReason = findDeniedProfileModel(profile, modelId, settings);
|
||||
if (deniedReason) {
|
||||
res.status(400).json({ error: deniedReason });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedDefaultModel = normalizePresetModel(defaultModel);
|
||||
const normalizedOpusModel = normalizePresetModel(opus || defaultModel);
|
||||
const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel);
|
||||
const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel);
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: normalizedDefaultModel,
|
||||
opus: normalizedOpusModel,
|
||||
sonnet: normalizedSonnetModel,
|
||||
haiku: normalizedHaikuModel,
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||
|
||||
const persistedPreset =
|
||||
canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset;
|
||||
res.status(201).json({ preset: persistedPreset });
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -562,25 +589,33 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
* DELETE /api/settings/:profile/presets/:name - Delete a preset
|
||||
*/
|
||||
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { profile, name } = req.params;
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
withSettingsFileLock(settingsPath, () => {
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||
});
|
||||
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadCanonicalProfileSettings(profile, settingsPath, false);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
const canonicalizedSettings = canonicalizeProfileSettings(profile, settings);
|
||||
writeSettingsAtomically(settingsPath, canonicalizedSettings);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -642,6 +677,8 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
|
||||
* GET /api/settings/auth/tokens - Get current auth token status (masked)
|
||||
*/
|
||||
router.get('/auth/tokens', (_req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(_req, res)) return;
|
||||
|
||||
try {
|
||||
const summary = getAuthSummary();
|
||||
|
||||
@@ -692,6 +729,8 @@ router.get('/auth/tokens/raw', (req: Request, res: Response): void => {
|
||||
* PUT /api/settings/auth/tokens - Update auth tokens
|
||||
*/
|
||||
router.put('/auth/tokens', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { apiKey, managementSecret } = req.body;
|
||||
|
||||
@@ -728,6 +767,8 @@ router.put('/auth/tokens', (req: Request, res: Response): void => {
|
||||
* POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret
|
||||
*/
|
||||
router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(_req, res)) return;
|
||||
|
||||
try {
|
||||
const newSecret = generateSecureToken(32);
|
||||
setGlobalManagementSecret(newSecret);
|
||||
@@ -752,6 +793,8 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo
|
||||
* POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults
|
||||
*/
|
||||
router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(_req, res)) return;
|
||||
|
||||
try {
|
||||
resetAuthToDefaults();
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
loadUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
getWebSearchConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader';
|
||||
import type { WebSearchConfig } from '../../config/unified-config-types';
|
||||
import {
|
||||
getWebSearchReadiness,
|
||||
@@ -52,59 +48,45 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Load existing config and update websearch section
|
||||
const existingConfig = loadUnifiedConfig();
|
||||
if (!existingConfig) {
|
||||
res.status(500).json({ error: 'Failed to load config' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge updates - supports Gemini CLI and Grok CLI
|
||||
existingConfig.websearch = {
|
||||
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
||||
providers: providers
|
||||
? {
|
||||
gemini: {
|
||||
enabled:
|
||||
providers.gemini?.enabled ??
|
||||
existingConfig.websearch?.providers?.gemini?.enabled ??
|
||||
true,
|
||||
model:
|
||||
providers.gemini?.model ??
|
||||
existingConfig.websearch?.providers?.gemini?.model ??
|
||||
'gemini-2.5-flash',
|
||||
timeout:
|
||||
providers.gemini?.timeout ??
|
||||
existingConfig.websearch?.providers?.gemini?.timeout ??
|
||||
55,
|
||||
},
|
||||
grok: {
|
||||
enabled:
|
||||
providers.grok?.enabled ??
|
||||
existingConfig.websearch?.providers?.grok?.enabled ??
|
||||
false,
|
||||
timeout:
|
||||
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled:
|
||||
providers.opencode?.enabled ??
|
||||
existingConfig.websearch?.providers?.opencode?.enabled ??
|
||||
false,
|
||||
model:
|
||||
providers.opencode?.model ??
|
||||
existingConfig.websearch?.providers?.opencode?.model ??
|
||||
'opencode/grok-code',
|
||||
timeout:
|
||||
providers.opencode?.timeout ??
|
||||
existingConfig.websearch?.providers?.opencode?.timeout ??
|
||||
60,
|
||||
},
|
||||
}
|
||||
: existingConfig.websearch?.providers,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(existingConfig);
|
||||
const existingConfig = mutateUnifiedConfig((config) => {
|
||||
config.websearch = {
|
||||
enabled: enabled ?? config.websearch?.enabled ?? true,
|
||||
providers: providers
|
||||
? {
|
||||
gemini: {
|
||||
enabled:
|
||||
providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true,
|
||||
model:
|
||||
providers.gemini?.model ??
|
||||
config.websearch?.providers?.gemini?.model ??
|
||||
'gemini-2.5-flash',
|
||||
timeout:
|
||||
providers.gemini?.timeout ?? config.websearch?.providers?.gemini?.timeout ?? 55,
|
||||
},
|
||||
grok: {
|
||||
enabled:
|
||||
providers.grok?.enabled ?? config.websearch?.providers?.grok?.enabled ?? false,
|
||||
timeout:
|
||||
providers.grok?.timeout ?? config.websearch?.providers?.grok?.timeout ?? 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled:
|
||||
providers.opencode?.enabled ??
|
||||
config.websearch?.providers?.opencode?.enabled ??
|
||||
false,
|
||||
model:
|
||||
providers.opencode?.model ??
|
||||
config.websearch?.providers?.opencode?.model ??
|
||||
'opencode/grok-code',
|
||||
timeout:
|
||||
providers.opencode?.timeout ??
|
||||
config.websearch?.providers?.opencode?.timeout ??
|
||||
60,
|
||||
},
|
||||
}
|
||||
: config.websearch?.providers,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -10,9 +10,22 @@ import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||
import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware';
|
||||
|
||||
export const sharedRoutes = Router();
|
||||
|
||||
sharedRoutes.use((req: Request, res: Response, next) => {
|
||||
if (
|
||||
requireLocalAccessWhenAuthDisabled(
|
||||
req,
|
||||
res,
|
||||
'Shared-content endpoints require localhost access when dashboard auth is disabled.'
|
||||
)
|
||||
) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
||||
const MAX_DESCRIPTION_LENGTH = 140;
|
||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegistryModule() {
|
||||
return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('account registry integrity', () => {
|
||||
it('does not create accounts.json during no-op discovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { discoverExistingAccounts } = await loadRegistryModule();
|
||||
discoverExistingAccounts();
|
||||
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write accounts.json during provider account reads', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
expect(getProviderAccounts('kiro')).toEqual([]);
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes stale accounts before choosing the next default during registration', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, 'kiro-github-ABC123.json'), JSON.stringify({ type: 'kiro' }));
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
providers: {
|
||||
kiro: {
|
||||
default: 'github-OLD999',
|
||||
accounts: {
|
||||
'github-OLD999': {
|
||||
nickname: 'old',
|
||||
tokenFile: 'kiro-github-OLD999.json',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
lastUsedAt: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
const account = registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
const registry = loadAccountsRegistry();
|
||||
const kiroAccounts = registry.providers.kiro;
|
||||
|
||||
expect(account.id).toBe('github-ABC123');
|
||||
expect(kiroAccounts?.default).toBe('github-ABC123');
|
||||
expect(kiroAccounts?.accounts['github-OLD999']).toBeUndefined();
|
||||
expect(Object.keys(kiroAccounts?.accounts ?? {})).toEqual(['github-ABC123']);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed on corrupted accounts.json', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, '{not-valid-json', 'utf8');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
expect(() => loadAccountsRegistry()).toThrow(/corrupted/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`);
|
||||
}
|
||||
|
||||
function writeTokenFile(homeDir: string, tokenFile: string): void {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), '{}', 'utf8');
|
||||
}
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(testDir, () => fn(testDir));
|
||||
} finally {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerAccount optional nickname flow', () => {
|
||||
it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => {
|
||||
const account = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
});
|
||||
|
||||
expect(account.id).toBe('github-ABC123');
|
||||
expect(account.nickname).toBe('github-ABC123');
|
||||
});
|
||||
|
||||
it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => {
|
||||
const { first, second } = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-nomail.json');
|
||||
writeTokenFile(homeDir, 'kiro-second.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return {
|
||||
first: registerAccount('kiro', 'kiro-nomail.json'),
|
||||
second: registerAccount('kiro', 'kiro-second.json'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(first.id).toBe('kiro-1');
|
||||
expect(first.nickname).toBe('kiro-1');
|
||||
expect(second.id).toBe('kiro-2');
|
||||
expect(second.nickname).toBe('kiro-2');
|
||||
});
|
||||
|
||||
it('keeps user nicknames optional metadata separate from internal ids', async () => {
|
||||
const account = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'ghcp-amazon-XYZ789.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work');
|
||||
});
|
||||
|
||||
expect(account.id).toBe('amazon-XYZ789');
|
||||
expect(account.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('preserves an existing custom nickname when the same token file is re-registered', async () => {
|
||||
const reauthenticated = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work');
|
||||
return registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
});
|
||||
|
||||
expect(reauthenticated.id).toBe('github-ABC123');
|
||||
expect(reauthenticated.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('rejects nickname collisions against existing account ids and nicknames', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-NEW123.json');
|
||||
const { registerAccount, renameAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal');
|
||||
|
||||
expect(() =>
|
||||
registerAccount('kiro', 'kiro-google-NEW123.json', undefined, 'github-ABC123')
|
||||
).toThrow(/already exists/i);
|
||||
expect(() => renameAccount('kiro', second.id, 'github-ABC123')).toThrow(/already used/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('avoids auto-generated ids that would collide with an existing nickname', async () => {
|
||||
const added = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789');
|
||||
return registerAccount('kiro', 'kiro-google-XYZ789.json');
|
||||
});
|
||||
|
||||
expect(added.id).toBe('kiro-1');
|
||||
expect(added.nickname).toBe('kiro-1');
|
||||
});
|
||||
|
||||
it('does not resolve ambiguous nickname prefixes to the first generated account', async () => {
|
||||
const match = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-github-DEF456.json');
|
||||
const { registerAccount, findAccountByQuery } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
registerAccount('kiro', 'kiro-github-DEF456.json');
|
||||
return findAccountByQuery('kiro', 'github');
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
function getCliproxyConfigPath(homeDir: string): string {
|
||||
return path.join(homeDir, '.ccs', 'cliproxy', 'config.yaml');
|
||||
}
|
||||
|
||||
function writeCliproxyConfig(homeDir: string, value: Record<string, unknown>): void {
|
||||
const configPath = getCliproxyConfigPath(homeDir);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, yaml.dump(value), 'utf8');
|
||||
}
|
||||
|
||||
function readCliproxyConfig(homeDir: string): Record<string, any> {
|
||||
return (
|
||||
(yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record<string, any>) || {}
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAiProviderService() {
|
||||
return import(`../../../src/cliproxy/ai-providers/service?stable-id=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('ai-provider service stable ids', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-ids-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('backfills and persists stable ids for api-key provider entries', async () => {
|
||||
const { listAiProviders } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [{ 'api-key': 'alpha' }, { 'api-key': 'beta' }],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const family = listed.families.find((entry) => entry.id === 'gemini-api-key');
|
||||
|
||||
expect(family).toBeDefined();
|
||||
expect(family?.entries).toHaveLength(2);
|
||||
expect(family?.entries[0]?.id).toBeTruthy();
|
||||
expect(family?.entries[1]?.id).toBeTruthy();
|
||||
expect(family?.entries[0]?.id).not.toBe(family?.entries[1]?.id);
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe(family?.entries[0]?.id);
|
||||
expect(persisted[1]?.id).toBe(family?.entries[1]?.id);
|
||||
});
|
||||
|
||||
it('updates and deletes api-key entries by stable id while preserving the id', async () => {
|
||||
const { updateAiProviderEntry, deleteAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [
|
||||
{ 'api-key': 'alpha', id: 'gemini-a' },
|
||||
{ 'api-key': 'beta', id: 'gemini-b' },
|
||||
],
|
||||
});
|
||||
|
||||
await updateAiProviderEntry('gemini-api-key', 'gemini-a', {
|
||||
apiKey: 'gamma',
|
||||
baseUrl: 'https://example.test/gemini',
|
||||
});
|
||||
|
||||
let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('gamma');
|
||||
expect(persisted[0]?.['base-url']).toBe('https://example.test/gemini');
|
||||
|
||||
await deleteAiProviderEntry('gemini-api-key', 'gemini-a');
|
||||
|
||||
persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted[0]?.id).toBe('gemini-b');
|
||||
});
|
||||
|
||||
it('keeps legacy numeric index updates working during the route transition', async () => {
|
||||
const { updateAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [{ 'api-key': 'alpha', id: 'gemini-a' }],
|
||||
});
|
||||
|
||||
await updateAiProviderEntry('gemini-api-key', '0', {
|
||||
apiKey: 'legacy-index-update',
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('legacy-index-update');
|
||||
});
|
||||
|
||||
it('backfills and preserves stable ids for openai-compatible connectors', async () => {
|
||||
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'openai-compatibility': [
|
||||
{
|
||||
name: 'openrouter',
|
||||
'base-url': 'https://openrouter.ai/api/v1',
|
||||
'api-key-entries': [{ 'api-key': 'sk-openrouter' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const family = listed.families.find((entry) => entry.id === 'openai-compatibility');
|
||||
const connectorId = family?.entries[0]?.id;
|
||||
|
||||
expect(connectorId).toBeTruthy();
|
||||
|
||||
await updateAiProviderEntry('openai-compatibility', connectorId!, {
|
||||
name: 'openrouter',
|
||||
baseUrl: 'https://router.example/v1',
|
||||
preserveSecrets: true,
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['openai-compatibility'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe(connectorId);
|
||||
expect(persisted[0]?.['base-url']).toBe('https://router.example/v1');
|
||||
expect((persisted[0]?.['api-key-entries'] as Array<Record<string, unknown>>)[0]?.['api-key']).toBe(
|
||||
'sk-openrouter'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -102,3 +102,54 @@ describe('resolvePasteCallbackAuthUrl', () => {
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliAuthNicknameError', () => {
|
||||
it('allows omitted nicknames for no-email providers', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-empty=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', undefined, [])).toBeNull();
|
||||
expect(getCliAuthNicknameError('ghcp', undefined, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid supplied nicknames before OAuth starts', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-invalid=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'bad nickname', [])).toBe(
|
||||
'Nickname cannot contain whitespace'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects supplied nicknames that collide with existing ids or nicknames', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-conflict=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'ghcp-2', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getCliAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toBe(
|
||||
'Nickname "github-ABC123" is already in use. Choose a different one.'
|
||||
);
|
||||
expect(getCliAuthNicknameError('ghcp', 'work', existingAccounts)).toBe(
|
||||
'Nickname "work" is already in use. Choose a different one.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows reauth when the supplied nickname already belongs to the same account', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-reauth=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'amazon-XYZ789', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand';
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||
|
||||
let tempDir: string;
|
||||
type MockUnifiedConfig = {
|
||||
cliproxy_server?: {
|
||||
local?: {
|
||||
port?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function writeUnifiedConfig(localPort: number): void {
|
||||
const configPath = path.join(tempDir, 'config.yaml');
|
||||
const yaml = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
cliproxy_server:
|
||||
local:
|
||||
port: ${localPort}
|
||||
`;
|
||||
fs.writeFileSync(configPath, yaml, 'utf8');
|
||||
function mockUnifiedConfig(config: MockUnifiedConfig): void {
|
||||
mock.module('../../../src/config/unified-config-loader', () => ({
|
||||
loadOrCreateUnifiedConfig: () => config,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadResolveLifecyclePort() {
|
||||
const mod = await import(
|
||||
`../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
return mod.resolveLifecyclePort;
|
||||
}
|
||||
|
||||
describe('resolveLifecyclePort', () => {
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('uses configured cliproxy_server.local.port', async () => {
|
||||
writeUnifiedConfig(9456);
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(9456);
|
||||
mockUnifiedConfig({
|
||||
cliproxy_server: {
|
||||
local: {
|
||||
port: 9456,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
||||
expect(resolveLifecyclePort()).toBe(9456);
|
||||
});
|
||||
|
||||
it('falls back to default port when configured local port is invalid', async () => {
|
||||
writeUnifiedConfig(70000);
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
mockUnifiedConfig({
|
||||
cliproxy_server: {
|
||||
local: {
|
||||
port: 70000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('falls back to default port when config file is missing', async () => {
|
||||
await runWithScopedConfigDir(tempDir, () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
mockUnifiedConfig({});
|
||||
|
||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import express from 'express';
|
||||
import type { Server } from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const listCalls: string[] = [];
|
||||
const updateCalls: Array<{ family: string; entryId: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
mock.module('../../../src/cliproxy/ai-providers', () => ({
|
||||
AI_PROVIDER_FAMILY_DEFINITIONS: {
|
||||
'gemini-api-key': {
|
||||
id: 'gemini-api-key',
|
||||
displayName: 'Gemini',
|
||||
description: 'Mock Gemini family',
|
||||
authMode: 'hybrid',
|
||||
supportsNamedEntries: false,
|
||||
routePath: '/api/provider/gemini',
|
||||
},
|
||||
'openai-compatibility': {
|
||||
id: 'openai-compatibility',
|
||||
displayName: 'OpenAI-Compatible',
|
||||
description: 'Mock connector family',
|
||||
authMode: 'connector',
|
||||
supportsNamedEntries: true,
|
||||
routePath: '/api/provider/openai-compat',
|
||||
},
|
||||
},
|
||||
AI_PROVIDER_FAMILY_IDS: ['gemini-api-key', 'openai-compatibility'],
|
||||
listAiProviders: async () => {
|
||||
listCalls.push('list');
|
||||
return {
|
||||
source: {
|
||||
mode: 'local',
|
||||
label: 'Local CLIProxy',
|
||||
target: 'http://127.0.0.1:8317',
|
||||
managementAuth: 'configured',
|
||||
},
|
||||
families: [],
|
||||
};
|
||||
},
|
||||
createAiProviderEntry: async () => {},
|
||||
updateAiProviderEntry: async (family: string, entryId: string, data: Record<string, unknown>) => {
|
||||
updateCalls.push({ family, entryId, data });
|
||||
},
|
||||
deleteAiProviderEntry: async () => {},
|
||||
}));
|
||||
|
||||
describe('ai-provider-routes', () => {
|
||||
let router: typeof import('../../../src/web-server/routes/ai-provider-routes').default;
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let forcedRemoteAddress = '127.0.0.1';
|
||||
let tempHome = '';
|
||||
let originalDashboardAuthEnabled: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ default: router } = await import(
|
||||
`../../../src/web-server/routes/ai-provider-routes?ai-provider-routes=${Date.now()}`
|
||||
));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use('/api/cliproxy/ai-providers', router);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-routes-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
listCalls.length = 0;
|
||||
updateCalls.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDashboardAuthEnabled !== undefined) {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||
} else {
|
||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
}
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks remote access when dashboard auth is disabled', async () => {
|
||||
forcedRemoteAddress = '10.10.0.24';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'AI provider endpoints require localhost access when dashboard auth is disabled.',
|
||||
});
|
||||
expect(listCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows non-local access when dashboard auth is enabled', async () => {
|
||||
forcedRemoteAddress = '10.10.0.24';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(listCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes stable entry ids through update routes', async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/cliproxy/ai-providers/gemini-api-key/entry-alpha-123`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: 'sk-test' }),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateCalls).toEqual([
|
||||
{
|
||||
family: 'gemini-api-key',
|
||||
entryId: 'entry-alpha-123',
|
||||
data: {
|
||||
apiKey: 'sk-test',
|
||||
apiKeys: undefined,
|
||||
baseUrl: undefined,
|
||||
excludedModels: undefined,
|
||||
headers: undefined,
|
||||
models: undefined,
|
||||
name: undefined,
|
||||
prefix: undefined,
|
||||
preserveSecrets: false,
|
||||
proxyUrl: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
import { restoreFetch, mockFetch } from '../../mocks';
|
||||
|
||||
describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/cliproxy/auth', cliproxyAuthRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-manual-callback-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function postJson(route: string, body: Record<string, unknown>) {
|
||||
return await new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const url = new URL(`${baseUrl}${route}`);
|
||||
const request = http.request(
|
||||
{
|
||||
method: 'POST',
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let responseBody = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
status: response.statusCode || 0,
|
||||
body: responseBody ? JSON.parse(responseBody) : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
request.write(payload);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-123',
|
||||
state: 'state-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/oauth-callback$/,
|
||||
method: 'POST',
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
|
||||
nickname: 'work',
|
||||
kiroMethod: 'google',
|
||||
});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tokenDir, 'kiro-github-ABC123.json'),
|
||||
JSON.stringify({ type: 'kiro' }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
|
||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-123',
|
||||
});
|
||||
|
||||
expect(callbackResponse.status).toBe(200);
|
||||
|
||||
const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json');
|
||||
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
|
||||
providers: {
|
||||
kiro: {
|
||||
accounts: Record<string, { nickname?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('returns 409 when callback completes upstream but no account can be registered locally', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-409',
|
||||
state: 'state-409',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/oauth-callback$/,
|
||||
method: 'POST',
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
|
||||
nickname: 'work',
|
||||
kiroMethod: 'google',
|
||||
});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
|
||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-409',
|
||||
});
|
||||
|
||||
expect(callbackResponse.status).toBe(409);
|
||||
expect(callbackResponse.body).toEqual({
|
||||
error:
|
||||
'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getStartAuthFailureMessage,
|
||||
getStartAuthNicknameError,
|
||||
getStartUrlUnsupportedReason,
|
||||
} from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
|
||||
@@ -43,3 +44,44 @@ describe('cliproxy-auth-routes start failure messaging', () => {
|
||||
expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cliproxy-auth-routes nickname validation', () => {
|
||||
it('allows Kiro and GHCP start requests without a nickname', () => {
|
||||
expect(getStartAuthNicknameError('kiro', undefined, [])).toBeNull();
|
||||
expect(getStartAuthNicknameError('ghcp', undefined, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid supplied nicknames for no-email providers', () => {
|
||||
expect(getStartAuthNicknameError('kiro', 'bad nickname', [])).toEqual({
|
||||
error: 'Nickname cannot contain whitespace',
|
||||
code: 'INVALID_NICKNAME',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects nicknames that collide with an existing account id or nickname', () => {
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'ghcp-2', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getStartAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toEqual({
|
||||
error: 'Nickname "github-ABC123" is already in use. Choose a different one.',
|
||||
code: 'NICKNAME_EXISTS',
|
||||
});
|
||||
|
||||
expect(getStartAuthNicknameError('ghcp', 'work', existingAccounts)).toEqual({
|
||||
error: 'Nickname "work" is already in use. Choose a different one.',
|
||||
code: 'NICKNAME_EXISTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows reauth when the nickname already belongs to the same account', () => {
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'ghcp-2', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroAuthMethodOption,
|
||||
isDeviceCodeProvider,
|
||||
isNicknameRequiredProvider,
|
||||
KIRO_AUTH_METHOD_OPTIONS,
|
||||
} from '@/lib/provider-config';
|
||||
import type { KiroAuthMethod } from '@/lib/provider-config';
|
||||
@@ -89,7 +88,6 @@ export function AddAccountDialog({
|
||||
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
|
||||
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const requiresNickname = isNicknameRequiredProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
|
||||
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||
@@ -266,10 +264,6 @@ export function AddAccountDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requiresNickname && !nicknameTrimmed) {
|
||||
setLocalError(`Nickname is required for ${displayName} accounts.`);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
@@ -394,11 +388,7 @@ export function AddAccountDialog({
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">
|
||||
{requiresNickname
|
||||
? t('addAccountDialog.nicknameRequired')
|
||||
: t('addAccountDialog.nicknameOptional')}
|
||||
</Label>
|
||||
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -414,9 +404,7 @@ export function AddAccountDialog({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{requiresNickname
|
||||
? t('addAccountDialog.nicknameRequiredHint')
|
||||
: t('addAccountDialog.nicknameOptionalHint')}
|
||||
{t('addAccountDialog.nicknameOptionalHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -554,7 +542,6 @@ export function AddAccountDialog({
|
||||
disabled={
|
||||
isPending ||
|
||||
isAgyBypassStatePending ||
|
||||
(requiresNickname && !nicknameTrimmed) ||
|
||||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
|
||||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,92 @@
|
||||
/**
|
||||
* OpenRouter Quick Start Card
|
||||
* Prominent CTA for new users to create OpenRouter profile
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
CloudCog,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OpenRouterQuickStartProps {
|
||||
hasProfiles: boolean;
|
||||
profileCount: number;
|
||||
onOpenRouterClick: () => void;
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
}
|
||||
|
||||
interface QuickStartCardProps {
|
||||
badge: string;
|
||||
badgeClassName?: string;
|
||||
className?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visual: ReactNode;
|
||||
highlights: Array<{ icon: ReactNode; label: string }>;
|
||||
actionLabel: string;
|
||||
actionClassName: string;
|
||||
onAction: () => void;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
function QuickStartCard({
|
||||
badge,
|
||||
badgeClassName,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
visual,
|
||||
highlights,
|
||||
actionLabel,
|
||||
actionClassName,
|
||||
onAction,
|
||||
footer,
|
||||
}: QuickStartCardProps) {
|
||||
return (
|
||||
<Card className={cn('flex h-full flex-col border shadow-sm', className)}>
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{visual}
|
||||
<Badge variant="secondary" className={badgeClassName}>
|
||||
{badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription className="text-sm leading-6">{description}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex flex-1 flex-col gap-4 pt-0">
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
{highlights.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={onAction} className={actionClassName}>
|
||||
{actionLabel}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{footer ? <div className="text-xs text-muted-foreground">{footer}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
hasProfiles,
|
||||
profileCount,
|
||||
onOpenRouterClick,
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
@@ -34,185 +94,151 @@ export function OpenRouterQuickStart({
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { modelCount, isLoading } = useOpenRouterReady();
|
||||
const modelCountLabel = isLoading ? '300+' : `${modelCount}+`;
|
||||
const profileSummaryLabel = hasProfiles
|
||||
? t('openrouterQuickStart.profileCount', { count: profileCount })
|
||||
: t('openrouterQuickStart.recommended');
|
||||
const summaryTitle = hasProfiles
|
||||
? t('openrouterQuickStart.selectProfileTitle')
|
||||
: t('apiProfiles.noProfilesYet');
|
||||
const summaryDescription = hasProfiles
|
||||
? t('openrouterQuickStart.summaryDescriptionWithProfiles', { count: profileCount })
|
||||
: t('openrouterQuickStart.summaryDescriptionNoProfiles');
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20 p-8">
|
||||
<div className="max-w-lg w-full space-y-6">
|
||||
{/* Main OpenRouter Card */}
|
||||
<Card className="border-accent/30 dark:border-accent/40 bg-gradient-to-br from-accent/5 to-background dark:from-accent/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-accent/10 dark:bg-accent/20">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-6 h-6" />
|
||||
<div className="flex h-full min-h-0 flex-col overflow-auto bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<Card className="border-dashed bg-background/90 shadow-sm">
|
||||
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{profileSummaryLabel}</Badge>
|
||||
<Badge variant="outline">
|
||||
{t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
|
||||
>
|
||||
{t('openrouterQuickStart.recommended')}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">{t('openrouterQuickStart.title')}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{t('openrouterQuickStart.description', {
|
||||
modelCountLabel: isLoading ? '300+' : `${modelCount}+`,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Key Features */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Zap className="w-4 h-4 text-accent" />
|
||||
<span>{t('openrouterQuickStart.featureOneApi')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span>{t('openrouterQuickStart.featureTierMapping')}</span>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">{summaryTitle}</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{summaryDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onOpenRouterClick}
|
||||
className="w-full bg-accent hover:bg-accent/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
{t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
<Button variant="outline" onClick={onCustomClick} className="shrink-0">
|
||||
{t('openrouterQuickStart.createCustomProfile')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alibaba Coding Plan Card */}
|
||||
<Card className="border-orange-500/30 dark:border-orange-500/40 bg-gradient-to-br from-orange-500/5 to-background dark:from-orange-500/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-orange-500/10 dark:bg-orange-500/20">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.recommended')}
|
||||
title={t('openrouterQuickStart.title')}
|
||||
description={t('openrouterQuickStart.description', { modelCountLabel })}
|
||||
visual={
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <Zap className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureOneApi'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureTierMapping'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
actionClassName="w-full bg-accent text-white hover:bg-accent/90"
|
||||
onAction={onOpenRouterClick}
|
||||
footer={
|
||||
<>
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.runtimeProviderBadge')}
|
||||
badgeClassName="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
title={t('openrouterQuickStart.runtimeProviderTitle')}
|
||||
description={t('openrouterQuickStart.runtimeProviderDescription')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label: t('openrouterQuickStart.runtimeProviderFeatureConnectors'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label: t('openrouterQuickStart.runtimeProviderFeatureSecrets'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.runtimeProviderTitle')}
|
||||
actionClassName="w-full bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onAction={onCliproxyClick}
|
||||
footer={<span>{t('openrouterQuickStart.runtimeProviderFooter')}</span>}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('alibabaCodingPlanQuickStart.recommended')}
|
||||
badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
className="lg:col-span-2"
|
||||
title={t('alibabaCodingPlanQuickStart.title')}
|
||||
description={t('alibabaCodingPlanQuickStart.description')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<img
|
||||
src="/assets/providers/alibabacloud-color.svg"
|
||||
alt="Alibaba Coding Plan"
|
||||
className="w-6 h-6"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
>
|
||||
{t('alibabaCodingPlanQuickStart.recommended')}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">{t('alibabaCodingPlanQuickStart.title')}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{t('alibabaCodingPlanQuickStart.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CloudCog className="w-4 h-4 text-orange-600" />
|
||||
<span>{t('alibabaCodingPlanQuickStart.featureEndpoint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="w-4 h-4 text-orange-600" />
|
||||
<span>{t('alibabaCodingPlanQuickStart.featureKeyFormat')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onAlibabaCodingPlanClick}
|
||||
className="w-full bg-orange-600 hover:bg-orange-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
{t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-700 dark:text-orange-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-500/30 dark:border-emerald-500/40 bg-gradient-to-br from-emerald-500/5 to-background dark:from-emerald-500/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
||||
<SlidersHorizontal className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
>
|
||||
Configure in AI Providers
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Manage CLIProxy AI providers</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors directly in
|
||||
the dedicated CLIProxy AI Providers page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<SlidersHorizontal className="w-4 h-4 text-emerald-600" />
|
||||
<span>Dedicated /cliproxy/ai-providers workspace</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="w-4 h-4 text-emerald-600" />
|
||||
<span>Manage provider secrets outside API Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onCliproxyClick}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
Open AI Providers
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Keep runtime provider configuration in CLIProxy, then create API Profiles only when
|
||||
you need standalone Anthropic-compatible endpoints.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">{t('openrouterQuickStart.or')}</span>
|
||||
<Separator className="flex-1" />
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <CloudCog className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureEndpoint'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureKeyFormat'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90"
|
||||
onAction={onAlibabaCodingPlanClick}
|
||||
footer={
|
||||
<>
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-700 hover:underline dark:text-orange-400"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Option */}
|
||||
<Button variant="outline" onClick={onCustomClick} className="w-full">
|
||||
{t('openrouterQuickStart.createCustomProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,13 +42,13 @@ export function useUpdateCliproxyAiProviderEntry() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
family,
|
||||
index,
|
||||
entryId,
|
||||
data,
|
||||
}: {
|
||||
family: AiProviderFamilyId;
|
||||
index: number;
|
||||
entryId: string;
|
||||
data: UpsertAiProviderEntryInput;
|
||||
}) => api.cliproxy.aiProviders.update(family, index, data),
|
||||
}) => api.cliproxy.aiProviders.update(family, entryId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success('Provider entry updated');
|
||||
@@ -63,8 +63,8 @@ export function useDeleteCliproxyAiProviderEntry() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ family, index }: { family: AiProviderFamilyId; index: number }) =>
|
||||
api.cliproxy.aiProviders.delete(family, index),
|
||||
mutationFn: ({ family, entryId }: { family: AiProviderFamilyId; entryId: string }) =>
|
||||
api.cliproxy.aiProviders.delete(family, entryId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success('Provider entry removed');
|
||||
|
||||
@@ -41,6 +41,8 @@ interface StartAuthOptions {
|
||||
const POLL_INTERVAL = 3000;
|
||||
/** Maximum polling duration (5 minutes) */
|
||||
const MAX_POLL_DURATION = 5 * 60 * 1000;
|
||||
/** Fail visibly after repeated poll transport errors instead of retrying forever */
|
||||
const MAX_POLL_FAILURES = 3;
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
|
||||
const text = await response.text();
|
||||
@@ -69,18 +71,26 @@ const INITIAL_STATE: AuthFlowState = {
|
||||
export function useCliproxyAuthFlow() {
|
||||
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
|
||||
|
||||
const attemptIdRef = useRef(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollStartRef = useRef<number>(0);
|
||||
const pollFailureCountRef = useRef(0);
|
||||
const openedAuthUrlRef = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isActiveAttempt = useCallback(
|
||||
(attemptId: number) => attemptId === attemptIdRef.current,
|
||||
[]
|
||||
);
|
||||
|
||||
// Clear polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
pollFailureCountRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
@@ -94,15 +104,21 @@ export function useCliproxyAuthFlow() {
|
||||
|
||||
// Poll OAuth status
|
||||
const pollStatus = useCallback(
|
||||
async (provider: string, oauthState: string) => {
|
||||
async (provider: string, oauthState: string, attemptId: number) => {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) {
|
||||
stopPolling();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication timed out. Please try again.',
|
||||
}));
|
||||
if (isActiveAttempt(attemptId)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication timed out. Please try again.',
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,6 +126,10 @@ export function useCliproxyAuthFlow() {
|
||||
const response = await fetch(
|
||||
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
|
||||
);
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
status?: string;
|
||||
error?: string;
|
||||
@@ -118,6 +138,7 @@ export function useCliproxyAuthFlow() {
|
||||
verification_url?: string;
|
||||
user_code?: string;
|
||||
};
|
||||
pollFailureCountRef.current = 0;
|
||||
|
||||
if (data.status === 'ok') {
|
||||
stopPolling();
|
||||
@@ -161,11 +182,30 @@ export function useCliproxyAuthFlow() {
|
||||
}));
|
||||
}
|
||||
// status === 'wait' (or pending) means continue polling
|
||||
} catch {
|
||||
// Network error - continue polling
|
||||
} catch (error) {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollFailureCountRef.current += 1;
|
||||
if (pollFailureCountRef.current < MAX_POLL_FAILURES) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
const message =
|
||||
error instanceof Error && error.message.trim().length > 0
|
||||
? error.message
|
||||
: 'Lost contact with the auth status endpoint';
|
||||
toast.error(message);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticating: false,
|
||||
error: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[queryClient, stopPolling]
|
||||
[isActiveAttempt, queryClient, stopPolling]
|
||||
);
|
||||
|
||||
const startAuth = useCallback(
|
||||
@@ -182,9 +222,12 @@ export function useCliproxyAuthFlow() {
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
pollFailureCountRef.current = 0;
|
||||
|
||||
// Create fresh controller and capture locally to avoid race with cancelAuth
|
||||
const controller = new AbortController();
|
||||
const attemptId = attemptIdRef.current + 1;
|
||||
attemptIdRef.current = attemptId;
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
const flowType =
|
||||
@@ -219,7 +262,13 @@ export function useCliproxyAuthFlow() {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const data = await parseResponseBody(response);
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const success = data.success === true;
|
||||
if (response.ok && success) {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
@@ -240,6 +289,9 @@ export function useCliproxyAuthFlow() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// Cancelled - state already reset by cancelAuth
|
||||
return;
|
||||
@@ -261,8 +313,14 @@ export function useCliproxyAuthFlow() {
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await parseResponseBody(response);
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const success = data.success === true;
|
||||
|
||||
if (!response.ok || !success) {
|
||||
@@ -290,11 +348,14 @@ export function useCliproxyAuthFlow() {
|
||||
if (oauthState) {
|
||||
pollStartRef.current = Date.now();
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
pollStatus(provider, oauthState);
|
||||
void pollStatus(provider, oauthState, attemptId);
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
openedAuthUrlRef.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
@@ -309,11 +370,12 @@ export function useCliproxyAuthFlow() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[pollStatus, stopPolling, queryClient]
|
||||
[isActiveAttempt, pollStatus, stopPolling, queryClient]
|
||||
);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
const currentProvider = state.provider;
|
||||
attemptIdRef.current += 1;
|
||||
abortControllerRef.current?.abort();
|
||||
stopPolling();
|
||||
openedAuthUrlRef.current = false;
|
||||
@@ -329,37 +391,53 @@ export function useCliproxyAuthFlow() {
|
||||
const submitCallback = useCallback(
|
||||
async (redirectUrl: string) => {
|
||||
if (!state.provider) return;
|
||||
const attemptId = attemptIdRef.current;
|
||||
const currentProvider = state.provider;
|
||||
|
||||
setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, {
|
||||
const response = await fetch(`/api/cliproxy/auth/${currentProvider}/submit-callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirectUrl }),
|
||||
});
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await parseResponseBody(response);
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const success = data.success === true;
|
||||
const hasAccount = typeof data.account === 'object' && data.account !== null;
|
||||
|
||||
if (response.ok && success) {
|
||||
if (response.ok && success && hasAccount) {
|
||||
stopPolling();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
toast.success(`${state.provider} authentication successful`);
|
||||
toast.success(`${currentProvider} authentication successful`);
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
const errorMsg =
|
||||
typeof data.error === 'string' ? data.error : 'Callback submission failed';
|
||||
typeof data.error === 'string'
|
||||
? data.error
|
||||
: success
|
||||
? 'Authenticated account could not be registered'
|
||||
: 'Callback submission failed';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to submit callback';
|
||||
toast.error(message);
|
||||
setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message }));
|
||||
}
|
||||
},
|
||||
[state.provider, queryClient, stopPolling]
|
||||
[isActiveAttempt, state.provider, queryClient, stopPolling]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
|
||||
@@ -854,15 +854,21 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (family: AiProviderFamilyId, index: number, data: UpsertAiProviderEntryInput) =>
|
||||
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (family: AiProviderFamilyId, index: number) =>
|
||||
request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
update: (family: AiProviderFamilyId, entryId: string, data: UpsertAiProviderEntryInput) =>
|
||||
request(
|
||||
`/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
),
|
||||
delete: (family: AiProviderFamilyId, entryId: string) =>
|
||||
request(
|
||||
`/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
// Config YAML for Config tab
|
||||
|
||||
+77
-4
@@ -241,6 +241,25 @@ const resources = {
|
||||
'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.',
|
||||
featureOneApi: 'One API, all providers',
|
||||
featureTierMapping: 'Model tier mapping',
|
||||
profileCount: '{{count}} profile',
|
||||
profileCount_other: '{{count}} profiles',
|
||||
selectProfileTitle: 'Select an API profile',
|
||||
summaryDescriptionWithProfiles:
|
||||
'You already have {{count}} profile in this workspace. Select one from the left rail to edit it, or create another profile from here.',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'You already have {{count}} profiles in this workspace. Select one from the left rail to edit it, or create another profile from here.',
|
||||
summaryDescriptionNoProfiles:
|
||||
'Use API Profiles for Anthropic-compatible endpoints. Use AI Providers for Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors.',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} OpenRouter models',
|
||||
runtimeProviderBadge: 'Runtime provider setup',
|
||||
runtimeProviderTitle: 'Open AI Providers',
|
||||
runtimeProviderDescription:
|
||||
'Manage shared provider keys and runtime connectors before creating standalone API Profiles.',
|
||||
runtimeProviderFeatureConnectors:
|
||||
'Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors',
|
||||
runtimeProviderFeatureSecrets: 'Shared secrets stay outside individual API profiles',
|
||||
runtimeProviderFooter:
|
||||
'Best when multiple profiles should share one provider configuration.',
|
||||
createOpenRouterProfile: 'Create OpenRouter Profile',
|
||||
getApiKeyAt: 'Get your API key at',
|
||||
or: 'or',
|
||||
@@ -473,7 +492,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'Required for this provider. Use a unique friendly name (e.g., work, personal).',
|
||||
nicknameOptionalHint:
|
||||
'A friendly name to identify this account. Auto-generated from email if left empty.',
|
||||
'A friendly name to identify this account. Leave blank to use a safe generated identifier.',
|
||||
waitingForAuth: 'Waiting for authentication...',
|
||||
deviceCodeHint:
|
||||
'A verification code dialog will appear shortly. Enter the code on the provider website.',
|
||||
@@ -1420,6 +1439,22 @@ const resources = {
|
||||
'通过一个 API 即可访问来自 OpenAI、Anthropic、Google、Meta 等的 {{modelCountLabel}} 个模型。',
|
||||
featureOneApi: '一个 API,接入全部提供商',
|
||||
featureTierMapping: '模型档位映射',
|
||||
profileCount: '{{count}} 个 API 配置',
|
||||
profileCount_other: '{{count}} 个 API 配置',
|
||||
selectProfileTitle: '选择一个 API 配置',
|
||||
summaryDescriptionWithProfiles:
|
||||
'此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。',
|
||||
summaryDescriptionNoProfiles:
|
||||
'API 配置适用于 Anthropic 兼容端点。AI Providers 适用于 Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器。',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} 个 OpenRouter 模型',
|
||||
runtimeProviderBadge: '运行时提供商设置',
|
||||
runtimeProviderTitle: '打开 AI Providers',
|
||||
runtimeProviderDescription: '先管理共享的提供商密钥和运行时连接器,再创建独立的 API 配置。',
|
||||
runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器',
|
||||
runtimeProviderFeatureSecrets: '共享密钥与单个 API 配置分离存放',
|
||||
runtimeProviderFooter: '当多个配置需要共享同一提供商设置时最适合使用。',
|
||||
createOpenRouterProfile: '创建 OpenRouter 配置',
|
||||
getApiKeyAt: '在此获取 API Key:',
|
||||
or: '或',
|
||||
@@ -1632,7 +1667,7 @@ const resources = {
|
||||
nicknameOptional: '昵称(选填)',
|
||||
nicknamePlaceholder: '例如:工作、个人',
|
||||
nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。',
|
||||
nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。',
|
||||
nicknameOptionalHint: '用于区分账号的友好名称。留空将自动生成安全标识。',
|
||||
waitingForAuth: '等待认证中...',
|
||||
deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。',
|
||||
browserHint: '在浏览器中完成认证后,本对话框将自动关闭。',
|
||||
@@ -2566,6 +2601,25 @@ const resources = {
|
||||
'Truy cập các mô hình {{modelCountLabel}} từ OpenAI, Anthropic, Google, Meta, v.v. - tất cả đều thông qua một API.',
|
||||
featureOneApi: 'Một API cho mọi nhà cung cấp',
|
||||
featureTierMapping: 'Ánh xạ tầng mô hình',
|
||||
profileCount: '{{count}} hồ sơ',
|
||||
profileCount_other: '{{count}} hồ sơ',
|
||||
selectProfileTitle: 'Chọn một hồ sơ API',
|
||||
summaryDescriptionWithProfiles:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.',
|
||||
summaryDescriptionNoProfiles:
|
||||
'Dùng API Profiles cho các endpoint tương thích Anthropic. Dùng AI Providers cho Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI.',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} mô hình OpenRouter',
|
||||
runtimeProviderBadge: 'Thiết lập nhà cung cấp runtime',
|
||||
runtimeProviderTitle: 'Mở AI Providers',
|
||||
runtimeProviderDescription:
|
||||
'Quản lý khóa nhà cung cấp dùng chung và các connector runtime trước khi tạo API Profiles độc lập.',
|
||||
runtimeProviderFeatureConnectors:
|
||||
'Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI',
|
||||
runtimeProviderFeatureSecrets: 'Khóa dùng chung tách biệt khỏi từng API Profile',
|
||||
runtimeProviderFooter:
|
||||
'Phù hợp nhất khi nhiều hồ sơ cần dùng chung một cấu hình nhà cung cấp.',
|
||||
createOpenRouterProfile: 'Tạo hồ sơ OpenRouter',
|
||||
getApiKeyAt: 'Nhận khóa API của bạn tại',
|
||||
or: 'hoặc',
|
||||
@@ -2804,7 +2858,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).',
|
||||
nicknameOptionalHint:
|
||||
'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.',
|
||||
'Tên thân thiện để nhận biết tài khoản này. Để trống để dùng mã nhận dạng an toàn do hệ thống tạo.',
|
||||
waitingForAuth: 'Đang chờ xác thực...',
|
||||
deviceCodeHint:
|
||||
'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.',
|
||||
@@ -3767,6 +3821,25 @@ const resources = {
|
||||
'OpenAI、Anthropic、Google、Meta などの {{modelCountLabel}} モデルを、1 つの API で利用できます。',
|
||||
featureOneApi: '1 つの API で全プロバイダー',
|
||||
featureTierMapping: 'モデルティアマッピング',
|
||||
profileCount: '{{count}} 個の API プロファイル',
|
||||
profileCount_other: '{{count}} 個の API プロファイル',
|
||||
selectProfileTitle: 'API プロファイルを選択',
|
||||
summaryDescriptionWithProfiles:
|
||||
'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。',
|
||||
summaryDescriptionWithProfiles_other:
|
||||
'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。',
|
||||
summaryDescriptionNoProfiles:
|
||||
'API Profiles は Anthropic 互換エンドポイント向けです。AI Providers は Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ向けです。',
|
||||
openrouterModelsBadge: '{{modelCountLabel}} 個の OpenRouter モデル',
|
||||
runtimeProviderBadge: 'ランタイムプロバイダー設定',
|
||||
runtimeProviderTitle: 'AI Providers を開く',
|
||||
runtimeProviderDescription:
|
||||
'個別の API プロファイルを作成する前に、共有プロバイダーキーとランタイムコネクタを管理します。',
|
||||
runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ',
|
||||
runtimeProviderFeatureSecrets:
|
||||
'共有シークレットを個別の API プロファイルから切り離して管理',
|
||||
runtimeProviderFooter:
|
||||
'複数のプロファイルで 1 つのプロバイダー設定を共有したい場合に最適です。',
|
||||
createOpenRouterProfile: 'OpenRouter プロファイルを作成',
|
||||
getApiKeyAt: 'API キー取得:',
|
||||
or: 'または',
|
||||
@@ -4004,7 +4077,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。',
|
||||
nicknameOptionalHint:
|
||||
'このアカウントを識別しやすい名前です。空欄ならメールアドレスから自動生成されます。',
|
||||
'このアカウントを識別しやすい名前です。空欄の場合は安全な識別子を自動生成します。',
|
||||
waitingForAuth: '認証を待機中...',
|
||||
deviceCodeHint:
|
||||
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
|
||||
|
||||
@@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
|
||||
return 'Complete the authorization in your browser.';
|
||||
}
|
||||
|
||||
/** Providers that require nickname because token payload may not include email. */
|
||||
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
|
||||
|
||||
/** Check if provider requires user-supplied nickname in auth flow */
|
||||
export function isNicknameRequiredProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
@@ -208,7 +208,7 @@ export function ApiPage() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<OpenRouterBanner onCreateClick={() => setCreateDialogOpen(true)} />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -262,7 +262,7 @@ export function ApiPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{t('apiProfiles.loadingProfiles')}
|
||||
@@ -354,7 +354,7 @@ export function ApiPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{selectedProfileData ? (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
|
||||
@@ -387,6 +387,8 @@ export function ApiPage() {
|
||||
</>
|
||||
) : (
|
||||
<OpenRouterQuickStart
|
||||
hasProfiles={profiles.length > 0}
|
||||
profileCount={profiles.length}
|
||||
onCliproxyClick={() => {
|
||||
navigate('/cliproxy/ai-providers');
|
||||
}}
|
||||
|
||||
@@ -1759,7 +1759,7 @@ export function CliproxyAiProvidersPage() {
|
||||
onSave={async (payload) => {
|
||||
await updateMutation.mutateAsync({
|
||||
family: selectedFamily,
|
||||
index: selectedEntry.index,
|
||||
entryId: selectedEntry.id,
|
||||
data: payload,
|
||||
});
|
||||
void refetch();
|
||||
@@ -1793,7 +1793,7 @@ export function CliproxyAiProvidersPage() {
|
||||
if (editingEntry) {
|
||||
await updateMutation.mutateAsync({
|
||||
family: selectedFamily,
|
||||
index: editingEntry.index,
|
||||
entryId: editingEntry.id,
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
@@ -1820,7 +1820,7 @@ export function CliproxyAiProvidersPage() {
|
||||
if (!deleteEntry) return;
|
||||
await deleteMutation.mutateAsync({
|
||||
family: selectedFamily,
|
||||
index: deleteEntry.index,
|
||||
entryId: deleteEntry.id,
|
||||
});
|
||||
setDeleteEntry(null);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
useOpenRouterReady: () => ({
|
||||
modelCount: 318,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('OpenRouterQuickStart', () => {
|
||||
const props = {
|
||||
onOpenRouterClick: vi.fn(),
|
||||
onAlibabaCodingPlanClick: vi.fn(),
|
||||
onCliproxyClick: vi.fn(),
|
||||
onCustomClick: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
it('prompts the user to select an existing API profile instead of showing the empty-state copy', () => {
|
||||
render(<OpenRouterQuickStart hasProfiles profileCount={1} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Select an API profile' })).toBeInTheDocument();
|
||||
expect(screen.getByText('1 profile')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You already have 1 profile in this workspace\./)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No API profiles yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the original empty-state title when no API profiles exist', () => {
|
||||
render(<OpenRouterQuickStart hasProfiles={false} profileCount={0} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Recommended')).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toast } from 'sonner';
|
||||
import { AllProviders } from '../../setup/test-utils';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AllProviders>{children}</AllProviders>
|
||||
);
|
||||
|
||||
describe('useCliproxyAuthFlow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores stale poll completions from a superseded auth attempt', async () => {
|
||||
const firstPoll = createDeferred<Response>();
|
||||
let startCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/start-url')) {
|
||||
startCount += 1;
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: `https://auth.example/${startCount}`,
|
||||
state: `state-${startCount}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=state-1')) {
|
||||
return firstPoll.promise;
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=state-2')) {
|
||||
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
|
||||
});
|
||||
|
||||
expect(result.current.oauthState).toBe('state-1');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
|
||||
});
|
||||
|
||||
expect(result.current.oauthState).toBe('state-2');
|
||||
expect(result.current.isAuthenticating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
firstPoll.resolve(createJsonResponse({ status: 'ok' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.oauthState).toBe('state-2');
|
||||
expect(result.current.isAuthenticating).toBe(true);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces repeated poll transport failures instead of retrying forever', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/start-url')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: 'https://auth.example/fail',
|
||||
state: 'state-fail',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=state-fail')) {
|
||||
return Promise.reject(new Error('poll failed'));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.error).toBe('poll failed');
|
||||
expect(result.current.isAuthenticating).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith('poll failed');
|
||||
});
|
||||
|
||||
it('treats callback responses without an account as failures', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/start-url')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: 'https://auth.example/callback',
|
||||
state: 'state-callback',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/submit-callback')) {
|
||||
return Promise.resolve(createJsonResponse({ success: true, account: null }));
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=state-callback')) {
|
||||
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('gemini', { startEndpoint: 'start-url' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitCallback(
|
||||
'http://localhost/callback?code=abc123&state=state-callback'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Authenticated account could not be registered');
|
||||
expect(result.current.isSubmittingCallback).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith('Authenticated account could not be registered');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user