From 185f7f469e62a0a86aebf1d9fe98aaa101a3a19b Mon Sep 17 00:00:00 2001
From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com>
Date: Mon, 23 Mar 2026 09:51:33 -0400
Subject: [PATCH 01/12] docs(repo): add security reporting and issue routing
(#769)
---
.github/CODE_OF_CONDUCT.md | 4 +-
.github/ISSUE_TEMPLATE/bug-report.yml | 2 +
.github/ISSUE_TEMPLATE/config.yml | 11 ++--
.github/ISSUE_TEMPLATE/feature-request.yml | 2 +-
.github/ISSUE_TEMPLATE/question.yml | 58 ++++++++++++++++++++++
CONTRIBUTING.md | 14 +++++-
README.md | 11 +++-
SECURITY.md | 36 ++++++++++++++
8 files changed, 128 insertions(+), 10 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/question.yml
create mode 100644 SECURITY.md
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
index 37b1b341..2ca670e5 100644
--- a/.github/CODE_OF_CONDUCT.md
+++ b/.github/CODE_OF_CONDUCT.md
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index 24edae15..1c0fccb2 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -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:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 312d0210..f6e89631 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 57c89761..5c8e79c4 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -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.
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 00000000..fea0c90b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e469fbdd..cd160d2f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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)
diff --git a/README.md b/README.md
index dd0d0dd4..6e60745d 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -665,7 +672,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.
@@ -683,6 +690,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)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..9c4782b7
--- /dev/null
+++ b/SECURITY.md
@@ -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.
From 5c1553e6f5722e5f61780a389d4729428529fdc3 Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 23 Mar 2026 09:52:59 -0400
Subject: [PATCH 02/12] fix(ci): publish docker image from releases
---
.github/workflows/docker-release.yml | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 6d84b530..ca9a94fb 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -1,9 +1,9 @@
name: Publish Docker Image
on:
- push:
- tags:
- - 'v*.*.*'
+ release:
+ types:
+ - published
concurrency:
group: docker-release-${{ github.ref }}
@@ -11,27 +11,32 @@ concurrency:
jobs:
publish:
- if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
+ if: ${{ !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+
steps:
- name: Checkout release tag
uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.release.tag_name }}
- name: Validate stable semver tag
id: tag
run: |
- if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ if [[ "${RELEASE_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 ${RELEASE_TAG}"
- name: Set up QEMU
if: steps.tag.outputs.publish == 'true'
@@ -45,7 +50,7 @@ jobs:
if: steps.tag.outputs.publish == 'true'
id: meta
run: |
- VERSION="${GITHUB_REF_NAME#v}"
+ VERSION="${RELEASE_TAG#v}"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
From 3a7e778bec13869bf03d3c4c39d2934473eb9fce Mon Sep 17 00:00:00 2001
From: Max Loo
Date: Mon, 23 Mar 2026 21:53:17 +0800
Subject: [PATCH 03/12] fix(dashboard): remove the unnecessary blank on api
config page
---
ui/src/pages/api.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx
index b1474d57..47cdaf84 100644
--- a/ui/src/pages/api.tsx
+++ b/ui/src/pages/api.tsx
@@ -206,7 +206,7 @@ export function ApiPage() {
};
return (
-
+
setCreateDialogOpen(true)} />
From 5616c68471c052e68e65578ac3a48c8998036c86 Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 23 Mar 2026 10:53:33 -0400
Subject: [PATCH 04/12] fix(ci): harden release automation workflows
---
.github/workflows/dev-release.yml | 42 +++--
.github/workflows/docker-release.yml | 66 +++++---
.github/workflows/label-pending-release.yml | 13 +-
.github/workflows/release.yml | 60 ++++----
.github/workflows/sync-dev-after-release.yml | 9 +-
scripts/dev-release.sh | 152 +++++++++++++------
6 files changed, 223 insertions(+), 119 deletions(-)
diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml
index 1c12c76e..c13926d3 100644
--- a/.github/workflows/dev-release.yml
+++ b/.github/workflows/dev-release.yml
@@ -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:
@@ -65,12 +64,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 +81,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 | \
+ COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
+ ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
+ 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" | \
+ grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
+ 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
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index ca9a94fb..6fd5c00a 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -4,39 +4,57 @@ on:
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: ${{ !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }}
+ if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
- env:
- RELEASE_TAG: ${{ github.event.release.tag_name }}
-
steps:
- - name: Checkout release tag
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.release.tag_name }}
+ - 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 [[ "${RELEASE_TAG}" =~ ^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 ${RELEASE_TAG}"
+ 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'
@@ -49,18 +67,34 @@ jobs:
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
id: meta
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- VERSION="${RELEASE_TAG#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}
+ ${IMAGE}:${MINOR}
+ ${IMAGE}:${MAJOR}"
+
+ if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
+ TAGS="${TAGS}
+ ${IMAGE}:latest"
+ fi
{
echo "version=${VERSION}"
echo "minor=${MINOR}"
echo "major=${MAJOR}"
echo "image=${IMAGE}"
+ echo "revision=${REVISION}"
+ echo "tags<> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
@@ -79,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
diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml
index a966d113..8a5c604a 100644
--- a/.github/workflows/label-pending-release.yml
+++ b/.github/workflows/label-pending-release.yml
@@ -2,7 +2,7 @@ name: Label Pending Release
on:
pull_request:
- types: [opened, reopened]
+ types: [opened, reopened, edited, synchronize]
branches: [dev]
jobs:
@@ -10,17 +10,22 @@ jobs:
runs-on: ubuntu-latest
permissions:
issues: write
+ pull-requests: read
steps:
- name: Label linked issues as pending-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
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"
+ # Extract issue numbers from PR title/body plus current PR commits.
+ # Using env vars prevents shell injection from backticks in markdown.
+ COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true)
+ PR_TEXT="$PR_TITLE
+ $PR_BODY
+ $COMMIT_TEXT"
ISSUES=$(echo "$PR_TEXT" | grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | grep -oE "#[0-9]+" | sort -u || true)
if [[ -z "$ISSUES" ]]; then
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3bec763a..cd5a4eec 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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,31 @@ 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 "")
+ RELEASE_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \
+ perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
+ grep -oE '#[0-9]+' | tr -d '#' | sort -u || true)
+ RESOLVED_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \
+ perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
+ grep -oE '#[0-9]+' | tr -d '#' | sort -u || true)
- # 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 "")
+ if [[ -z "$RELEASE_ISSUES" ]]; then
+ echo "No release-scoped issues found for v${VERSION}"
+ exit 0
+ fi
- 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
- 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 "")
-
- 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
-
- # 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 "")
-
- 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
diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml
index b89d1cdf..55ec94b5 100644
--- a/.github/workflows/sync-dev-after-release.yml
+++ b/.github/workflows/sync-dev-after-release.yml
@@ -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:
diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh
index 1fb74bc3..5b372216 100755
--- a/scripts/dev-release.sh
+++ b/scripts/dev-release.sh
@@ -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!"
From e06e13ad0db514ef6b3ef3af2062cc2ee28a2170 Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 23 Mar 2026 10:57:24 -0400
Subject: [PATCH 05/12] fix(ci): close release automation edge cases
---
.github/workflows/dev-release.yml | 9 +--
.github/workflows/docker-release.yml | 6 +-
.github/workflows/label-pending-release.yml | 73 ++++++++++++++-------
.github/workflows/release.yml | 50 ++++++++++++--
4 files changed, 102 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml
index c13926d3..e048677e 100644
--- a/.github/workflows/dev-release.yml
+++ b/.github/workflows/dev-release.yml
@@ -25,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 }}
@@ -101,8 +102,8 @@ jobs:
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
- grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
- grep -oE "#[0-9]+" || true)
+ 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]+\\)" | \
@@ -117,8 +118,8 @@ jobs:
done
ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
- grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
- grep -oE "#[0-9]+" || true)
+ 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)
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 6fd5c00a..ad46ad84 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -77,12 +77,12 @@ jobs:
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs-dashboard"
REVISION=$(git rev-parse HEAD)
- TAGS="${IMAGE}:${VERSION}
- ${IMAGE}:${MINOR}
- ${IMAGE}:${MAJOR}"
+ TAGS="${IMAGE}:${VERSION}"
if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then
TAGS="${TAGS}
+ ${IMAGE}:${MINOR}
+ ${IMAGE}:${MAJOR}
${IMAGE}:latest"
fi
diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml
index 8a5c604a..dffe8a2d 100644
--- a/.github/workflows/label-pending-release.yml
+++ b/.github/workflows/label-pending-release.yml
@@ -13,36 +13,59 @@ jobs:
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_NUMBER: ${{ github.event.pull_request.number }}
- PR_TITLE: ${{ github.event.pull_request.title }}
- PR_BODY: ${{ github.event.pull_request.body }}
run: |
- # Extract issue numbers from PR title/body plus current PR commits.
- # Using env vars prevents shell injection from backticks in markdown.
- COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true)
- PR_TEXT="$PR_TITLE
- $PR_BODY
- $COMMIT_TEXT"
- 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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cd5a4eec..5b0e2220 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -93,12 +93,54 @@ jobs:
run: |
VERSION=$(jq -r '.version' package.json)
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
- RELEASE_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \
+ 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
+
+ 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)
+
+ 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
+
+ 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 '#' | sort -u || true)
- RESOLVED_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \
+ 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)
+
+ 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 '#' | sort -u || true)
+ 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)
if [[ -z "$RELEASE_ISSUES" ]]; then
echo "No release-scoped issues found for v${VERSION}"
From bdb7ac2937d144dc2564d09b01e717e8a4bc44eb Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 23 Mar 2026 09:16:52 -0400
Subject: [PATCH 06/12] feat(cliproxy): allow optional provider nicknames
- stop requiring manual unique nicknames for no-email providers by default
- preserve explicit nickname validation and same-account reauth semantics
- persist optional nicknames through dashboard manual callback flows
---
README.md | 2 +
src/cliproxy/account-manager.ts | 3 +
src/cliproxy/accounts/index.ts | 3 +
src/cliproxy/accounts/query.ts | 4 +-
src/cliproxy/accounts/registry.ts | 95 ++++-----
src/cliproxy/accounts/token-file-ops.ts | 86 ++++++++
src/cliproxy/auth/oauth-handler.ts | 153 +++++++-------
src/cliproxy/auth/oauth-process.ts | 10 +-
src/cliproxy/auth/token-manager.ts | 38 ++--
src/cliproxy/executor/index.ts | 2 +-
src/commands/help-command.ts | 2 +-
src/web-server/routes/cliproxy-auth-routes.ts | 186 ++++++++++++++----
.../components/account/add-account-dialog.tsx | 17 +-
ui/src/lib/i18n.ts | 8 +-
ui/src/lib/provider-config.ts | 9 -
15 files changed, 398 insertions(+), 220 deletions(-)
diff --git a/README.md b/README.md
index 6e60745d..828919e2 100644
--- a/README.md
+++ b/README.md
@@ -156,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:**
diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts
index a49fd640..bb1b8a07 100644
--- a/src/cliproxy/account-manager.ts
+++ b/src/cliproxy/account-manager.ts
@@ -28,8 +28,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
+ deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
+ hasAccountNameConflict,
+ findAccountNameMatch,
tokenFileExists,
loadAccountsRegistry,
saveAccountsRegistry,
diff --git a/src/cliproxy/accounts/index.ts b/src/cliproxy/accounts/index.ts
index 621d5562..9408bea7 100644
--- a/src/cliproxy/accounts/index.ts
+++ b/src/cliproxy/accounts/index.ts
@@ -21,8 +21,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
+ deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
+ hasAccountNameConflict,
+ findAccountNameMatch,
tokenFileExists,
} from './token-file-ops';
diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts
index c91f9f45..c1189129 100644
--- a/src/cliproxy/accounts/query.ts
+++ b/src/cliproxy/accounts/query.ts
@@ -67,12 +67,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;
}
/**
diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts
index 553864d5..6287d50d 100644
--- a/src/cliproxy/accounts/registry.ts
+++ b/src/cliproxy/accounts/registry.ts
@@ -13,7 +13,9 @@ import {
getAccountsRegistryPath,
getPausedDir,
extractAccountIdFromTokenFile,
+ deriveNoEmailProviderAccountId,
generateNickname,
+ hasAccountNameConflict,
validateNickname,
moveTokenToPaused,
moveTokenFromPaused,
@@ -21,10 +23,12 @@ 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
@@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) {
- return { ...DEFAULT_REGISTRY };
+ return createDefaultRegistry();
}
try {
@@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
providers: data.providers || {},
};
} catch {
- return { ...DEFAULT_REGISTRY };
+ return createDefaultRegistry();
}
}
@@ -115,8 +119,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
@@ -149,23 +153,22 @@ export function registerAccount(
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 or provide a nickname in the UI.`
- );
- }
+ accountId = email
+ ? extractAccountIdFromTokenFile(tokenFile, email)
+ : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
+ const existingAccount = providerAccounts.accounts[accountId];
- // Validate nickname format
- const validationError = validateNickname(nickname);
- if (validationError) {
- throw new Error(validationError);
- }
+ if (nickname) {
+ const validationError = validateNickname(nickname);
+ if (validationError) {
+ throw new Error(validationError);
+ }
- // Check uniqueness
- for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
- if (existingId.toLowerCase() === nickname.toLowerCase()) {
+ 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.`
@@ -173,8 +176,8 @@ export function registerAccount(
}
}
- accountId = nickname;
- accountNickname = nickname;
+ accountNickname =
+ nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
// For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email);
@@ -184,11 +187,12 @@ export function registerAccount(
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
+ const existingAccount = providerAccounts.accounts[accountId];
const accountMeta: Omit = {
email,
nickname: accountNickname,
tokenFile,
- createdAt: new Date().toISOString(),
+ createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
@@ -339,11 +343,12 @@ export function renameAccount(
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()) {
- throw new Error(`Nickname "${newNickname}" is already used by another account`);
- }
+ 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;
@@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void {
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);
- }
+ const accountId =
+ PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
+ ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
+ : extractAccountIdFromTokenFile(file, email);
// Skip if account already registered
if (providerAccounts.accounts[accountId]) {
@@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void {
const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit = {
email,
- nickname: generateNickname(email),
+ nickname: email ? generateNickname(email) : accountId,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts
index d06f4df7..2f35d95d 100644
--- a/src/cliproxy/accounts/token-file-ops.ts
+++ b/src/cliproxy/accounts/token-file-ops.ts
@@ -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';
/**
@@ -143,6 +144,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 {
+ 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 +225,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>,
+ 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>,
+ candidateName: string
+): Pick | null {
+ const normalizedCandidate = candidateName.toLowerCase();
+
+ return (
+ accounts.find(
+ (account) =>
+ account.id.toLowerCase() === normalizedCandidate ||
+ account.nickname?.toLowerCase() === normalizedCandidate
+ ) || null
+ );
+}
diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts
index e989ae56..b30baf09 100644
--- a/src/cliproxy/auth/oauth-handler.ts
+++ b/src/cliproxy/auth/oauth-handler.ts
@@ -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>,
+ 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 {
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 {
- 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((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 {
// 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)
diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts
index d3458a18..8fcdfa60 100644
--- a/src/cliproxy/auth/oauth-process.ts
+++ b/src/cliproxy/auth/oauth-process.ts
@@ -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 {
@@ -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 {
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise f.endsWith('.json'));
- let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
@@ -233,19 +235,33 @@ export function registerAccountFromToken(
const email = data.email || undefined;
const projectId = data.project_id || undefined;
- const account = registerAccount(
- provider,
- newestFile,
- email,
- nickname || generateNickname(email),
- projectId
- );
+ const account = registerAccount(provider, newestFile, email, nickname, projectId);
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, 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 (typeof newestFile === 'string') {
+ const hasExistingRegistration = getProviderAccounts(provider).some(
+ (account) => account.tokenFile === newestFile
+ );
+ if (!hasExistingRegistration) {
+ deleteTokenFile(newestFile);
+ }
+ }
+
+ if (expectedAccountId && verbose) {
+ console.error(
+ `[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token`
+ );
+ }
+
return null;
}
}
diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts
index 9945ef4d..ec772b15 100644
--- a/src/cliproxy/executor/index.ts
+++ b/src/cliproxy/executor/index.ts
@@ -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 " to switch accounts`);
+ console.log(`\n Use "ccs ${provider} --use " to switch accounts`);
}
process.exit(0);
}
diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts
index 41d365aa..acb6adbe 100644
--- a/src/commands/help-command.ts
+++ b/src/commands/help-command.ts
@@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'Show auth URL and prompt for callback paste (cross-browser)',
],
['ccs --accounts', 'List all accounts'],
- ['ccs --use ', 'Switch to account'],
+ ['ccs --use ', 'Switch to account'],
['ccs --config', 'Change model (agy, gemini)'],
[
'ccs agy --accept-agr-risk',
diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts
index af838c5b..0072fc67 100644
--- a/src/web-server/routes/cliproxy-auth-routes.ts
+++ b/src/web-server/routes/cliproxy-auth-routes.ts
@@ -22,6 +22,8 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
+ hasAccountNameConflict,
+ findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
@@ -33,7 +35,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,
@@ -53,12 +55,55 @@ import {
import { createRouteErrorHelpers } from './route-helpers';
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');
+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 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled';
}
+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 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise 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 existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
+ const nicknameError = getStartAuthNicknameError(
+ provider as CLIProxyProvider,
+ nickname,
+ existingAccounts,
+ existingNameMatch?.id
+ );
+ if (nicknameError) {
+ res.status(400).json(nicknameError);
+ return;
}
// Check Kiro no-incognito setting from config (or request body)
@@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise => {
const { provider } = req.params;
- const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
+ const requestBody =
+ req.body && typeof req.body === 'object' ? (req.body as Record) : {};
+ 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 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
+ const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
+ const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
+ const nicknameError = getStartAuthNicknameError(
+ provider as CLIProxyProvider,
+ nickname,
+ existingAccounts,
+ existingNameMatch?.id
+ );
+ if (nicknameError) {
+ res.status(400).json(nicknameError);
+ return;
+ }
+
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
@@ -696,19 +767,27 @@ 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,
+ expectedAccountId: existingNameMatch?.id,
+ });
+ }
+
res.json({
success: true,
authUrl: authUrl || null,
- state: data.state || null,
+ state: oauthState,
method: data.method || null,
});
} catch (error) {
@@ -768,6 +847,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 +891,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 +916,29 @@ 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);
+ }
+
+ res.json({
+ success: true,
+ account: account
+ ? {
+ id: account.id,
+ email: account.email,
+ nickname: account.nickname,
+ provider: account.provider,
+ isDefault: account.isDefault,
+ }
+ : null,
+ });
} catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx
index bdb2e90a..6ec2d361 100644
--- a/ui/src/components/account/add-account-dialog.tsx
+++ b/ui/src/components/account/add-account-dialog.tsx
@@ -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 && (
);
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts
index 038d5ee0..a411699b 100644
--- a/ui/src/lib/i18n.ts
+++ b/ui/src/lib/i18n.ts
@@ -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',
@@ -1420,6 +1439,24 @@ 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: '或',
@@ -2566,6 +2603,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',
@@ -3767,6 +3823,26 @@ 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: 'または',
diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx
index 47cdaf84..3fb37b33 100644
--- a/ui/src/pages/api.tsx
+++ b/ui/src/pages/api.tsx
@@ -206,9 +206,9 @@ export function ApiPage() {
};
return (
-
+
setCreateDialogOpen(true)} />
-
+
@@ -262,7 +262,7 @@ export function ApiPage() {
-
+
{isLoading ? (
{t('apiProfiles.loadingProfiles')}
@@ -354,7 +354,7 @@ export function ApiPage() {
/>
-
+
{selectedProfileData ? (
<>
@@ -387,6 +387,8 @@ export function ApiPage() {
>
) : (
0}
+ profileCount={profiles.length}
onCliproxyClick={() => {
navigate('/cliproxy/ai-providers');
}}
From 10f284585f1473f3198288bc0419c58b058442b3 Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 23 Mar 2026 11:43:13 -0400
Subject: [PATCH 10/12] test(dashboard): cover api profile quick start states
---
.../profiles/openrouter-quick-start.test.tsx | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx
diff --git a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx
new file mode 100644
index 00000000..2fdd1394
--- /dev/null
+++ b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx
@@ -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();
+
+ 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();
+
+ expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument();
+ expect(screen.getAllByText('Recommended')).not.toHaveLength(0);
+ });
+});
From 89a4d5669eb50057d76ea27517b6aeea67da3ed4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 23 Mar 2026 15:45:48 +0000
Subject: [PATCH 11/12] chore(release): 7.56.0-dev.1 [skip ci]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 4858ecc9..88de3b28 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "7.56.0",
+ "version": "7.56.0-dev.1",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
From 19c1e1da4f56afce466903d884ea49cf52b75255 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 23 Mar 2026 16:05:44 +0000
Subject: [PATCH 12/12] chore(release): 7.56.0-dev.2 [skip ci]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 88de3b28..c353c70a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "7.56.0-dev.1",
+ "version": "7.56.0-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",