Compare commits
21 Commits
@@ -0,0 +1,177 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- "scripts/publish.ps1"
|
||||
- "scripts/publish.sh"
|
||||
- "scripts/docker/Dockerfile.release"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
env:
|
||||
GITEA_SERVER_URL: https://git.pchuan.top
|
||||
RUST_IMAGE: docker.m.daocloud.io/library/rust:1-bookworm
|
||||
CARGO_REGISTRY: sparse+https://rsproxy.cn/index/
|
||||
APT_MIRROR: https://mirrors.ustc.edu.cn/debian
|
||||
CARGO_TERM_COLOR: always
|
||||
HTTP_PROXY: http://172.17.0.1:1082
|
||||
HTTPS_PROXY: http://172.17.0.1:1082
|
||||
ALL_PROXY: http://172.17.0.1:1082
|
||||
NO_PROXY: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
|
||||
http_proxy: http://172.17.0.1:1082
|
||||
https_proxy: http://172.17.0.1:1082
|
||||
all_proxy: http://172.17.0.1:1082
|
||||
no_proxy: localhost,127.0.0.1,::1,172.17.0.1,git.pchuan.top,.pchuan.top
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
name: Prepare release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Configure network proxy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config --global http.proxy "${HTTPS_PROXY}"
|
||||
git config --global https.proxy "${HTTPS_PROXY}"
|
||||
git config --global http.noProxy "${NO_PROXY}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create or update Gitea release
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
current_tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
version="${current_tag#v}"
|
||||
tag_target="$(git rev-list -n 1 "${current_tag}^{}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_tag}^{}^" 2>/dev/null || true)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
{
|
||||
echo "## Changes"
|
||||
echo
|
||||
echo "Built from commit: \`${GITHUB_SHA}\`"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
echo "Changes since \`${previous_tag}\`:"
|
||||
echo
|
||||
git log --no-merges --pretty=format:'- %s (%h)' "${previous_tag}..${current_tag}"
|
||||
else
|
||||
echo "Initial release changes:"
|
||||
echo
|
||||
git log --no-merges --pretty=format:'- %s (%h)' "${current_tag}^{}"
|
||||
fi
|
||||
echo
|
||||
echo
|
||||
echo "## Artifacts"
|
||||
echo
|
||||
echo "- \`cdxs-${version}-windows-x64.exe\`"
|
||||
echo "- \`cdxs-${version}-linux-x64\`"
|
||||
echo "- \`cdxs-${version}-linux-arm64\`"
|
||||
} > RELEASE_NOTES.md
|
||||
|
||||
body="$(python3 -c 'import json, pathlib; print(json.dumps(pathlib.Path("RELEASE_NOTES.md").read_text()))')"
|
||||
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":%s,"draft":false,"prerelease":false}' \
|
||||
"${current_tag}" "${tag_target}" "${current_tag}" "${body}")"
|
||||
|
||||
status="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_base}/releases/tags/${current_tag}")"
|
||||
|
||||
if [[ "${status}" == "200" ]]; then
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${api_base}/releases/${release_id}" >/dev/null
|
||||
elif [[ "${status}" == "404" ]]; then
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${api_base}/releases" >/dev/null
|
||||
else
|
||||
cat release.json
|
||||
echo "Unexpected Gitea release lookup status: ${status}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release-assets:
|
||||
name: Build release assets
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Configure network proxy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config --global http.proxy "${HTTPS_PROXY}"
|
||||
git config --global https.proxy "${HTTPS_PROXY}"
|
||||
git config --global http.noProxy "${NO_PROXY}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
|
||||
release_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
release_tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
echo "RELEASE_TAG=${release_tag}" >> "${GITHUB_ENV}"
|
||||
echo "VERSION=${release_tag#v}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release assets
|
||||
shell: bash
|
||||
run: bash scripts/publish.sh --version "${VERSION}" --rust-image "${RUST_IMAGE}" --cargo-registry "${CARGO_REGISTRY}" --apt-mirror "${APT_MIRROR}" --clean
|
||||
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${RELEASE_TAG}" > release.json
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/${release_id}/assets" > assets.json
|
||||
|
||||
for file in dist/*; do
|
||||
name="$(basename "${file}")"
|
||||
existing_asset_id="$(python3 -c 'import json, sys; target = sys.argv[1]; print(next((str(asset["id"]) for asset in json.load(open("assets.json")) if asset.get("name") == target), ""))' "${name}")"
|
||||
if [[ -n "${existing_asset_id}" ]]; then
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api_base}/releases/${release_id}/assets/${existing_asset_id}"
|
||||
fi
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
done
|
||||
Generated
+192
-224
@@ -8,18 +8,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
@@ -190,11 +178,11 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -244,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cdxs"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -253,7 +241,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"hex",
|
||||
"rand 0.8.6",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -278,6 +266,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -358,6 +357,22 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -366,9 +381,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -384,43 +399,43 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -474,6 +489,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -522,16 +543,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -568,26 +579,27 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -598,11 +610,11 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -662,6 +674,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -717,9 +738,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -940,9 +963,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -1102,7 +1125,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1123,7 +1146,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1164,35 +1187,25 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1205,15 +1218,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -1224,14 +1228,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1287,10 +1297,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
@@ -1298,6 +1318,7 @@ dependencies = [
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1415,11 +1436,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1436,9 +1457,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -1479,6 +1500,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -1529,12 +1562,24 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1543,18 +1588,7 @@ version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1644,44 +1678,42 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -1832,12 +1864,6 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -2030,6 +2056,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -2048,22 +2085,13 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2075,67 +2103,34 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -2148,48 +2143,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2198,12 +2169,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cdxs"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
description = "Codex account switcher CLI"
|
||||
|
||||
@@ -10,16 +10,16 @@ axum = "0.8"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
dirs = "5"
|
||||
dirs = "6"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate", "zstd"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rand = "0.10.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "system-proxy", "gzip", "brotli", "deflate", "zstd"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
|
||||
toml = "0.8"
|
||||
toml = "1.1.2"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
@@ -1,339 +1,55 @@
|
||||
# cdxs
|
||||
|
||||
`cdxs` 是一个 Codex 账号与 `CODEX_HOME` 切换工具。它可以保存多个 Codex OAuth 账号或 API Key 账号,把指定账号写入 Codex 的 `auth.json`,并提供配额查询、会话查看/回收/修复、多 `CODEX_HOME` 管理和简单的配置同步服务。
|
||||
`cdxs` 是一个面向 Codex 的账号、home、会话和账号状态同步管理工具。
|
||||
|
||||
## 功能概览
|
||||
它把多个 Codex OAuth / API Key 账号保存在本地,再按需写回 Codex 使用的 `auth.json`。同时,它可以管理多个独立 `CODEX_HOME`,避免手动搬运认证文件和会话状态。
|
||||
|
||||
- 保存和切换多个 Codex 账号。
|
||||
- 支持从已有 `auth.json` 导入 OAuth 或 API Key 认证信息。
|
||||
- 支持通过 OpenAI OAuth 登录并保存 token。
|
||||
- 支持直接添加 API Key 账号。
|
||||
- 支持为不同项目创建独立的 `CODEX_HOME`,并绑定不同账号。
|
||||
- 支持用指定账号或 home 启动外部命令,例如 `codex`。
|
||||
- 支持查询 OAuth 账号的 Codex 使用配额。
|
||||
- 支持查看 Codex 会话、统计 token、移入垃圾箱、恢复和修复可见性。
|
||||
- 支持把本地 `cdxs.toml` 推送到自建同步服务,或从同步服务拉取。
|
||||
## 简介
|
||||
|
||||
## 安装与构建
|
||||
Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力:
|
||||
|
||||
本项目是 Rust CLI。需要先安装 Rust 工具链。
|
||||
- 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。
|
||||
- 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地。
|
||||
- Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。
|
||||
- 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`。
|
||||
- 会话管理:查看会话、统计 token 和文件信息、移入可恢复垃圾箱、恢复会话、修复缺失的会话索引。
|
||||
- 线程同步:在多个受管理 home 之间补齐缺失的会话线程。
|
||||
- 状态同步:运行轻量 HTTP 服务,在多台机器之间推送或拉取账号状态。
|
||||
|
||||
```powershell
|
||||
cargo build --release
|
||||
```
|
||||
管理 Codex 登录信息本地文件。
|
||||
|
||||
构建后的可执行文件位于:
|
||||
## 存储
|
||||
|
||||
```text
|
||||
target\release\cdxs.exe
|
||||
```
|
||||
Codex home 的解析顺序:
|
||||
|
||||
开发时可直接运行:
|
||||
|
||||
```powershell
|
||||
cargo run -- --help
|
||||
```
|
||||
|
||||
如果希望全局使用,可以把 `target\release` 加入 `PATH`,或者把 `cdxs.exe` 复制到已有的命令目录。
|
||||
|
||||
## 数据文件
|
||||
|
||||
默认读取当前 `CODEX_HOME` 环境变量;如果没有设置,则使用用户目录下的 `.codex`:
|
||||
|
||||
```text
|
||||
%USERPROFILE%\.codex
|
||||
```
|
||||
1. `--codex-home`
|
||||
2. `CODEX_HOME`
|
||||
3. `~/.codex`
|
||||
|
||||
主要文件:
|
||||
|
||||
- `auth.json`:Codex 原生认证文件,`cdxs switch` 会写入这里。
|
||||
- `cdxs.toml`:`cdxs` 自己的配置文件,保存账号、home、同步信息。
|
||||
- `cdxs-backups\`:写入 `auth.json`、`cdxs.toml`、会话索引或状态库前的备份目录。
|
||||
- `cdxs-trash\`:被 `cdxs session trash` 移入垃圾箱的会话。
|
||||
|
||||
多数命令支持通过 `CODEX_HOME` 控制配置位置;部分命令还提供 `--codex-home` 参数指定目标 Codex home。
|
||||
|
||||
## 快速开始
|
||||
|
||||
从当前 Codex 的 `auth.json` 导入账号:
|
||||
|
||||
```powershell
|
||||
cdxs import auth
|
||||
```
|
||||
|
||||
导入后立即切换为当前账号:
|
||||
|
||||
```powershell
|
||||
cdxs import auth --switch
|
||||
```
|
||||
|
||||
查看已保存账号:
|
||||
|
||||
```powershell
|
||||
cdxs list
|
||||
```
|
||||
|
||||
切换账号:
|
||||
|
||||
```powershell
|
||||
cdxs switch <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
用指定账号启动 Codex:
|
||||
|
||||
```powershell
|
||||
cdxs run --account <账号ID或邮箱前缀> -- codex
|
||||
```
|
||||
|
||||
## 账号管理
|
||||
|
||||
OAuth 登录:
|
||||
|
||||
```powershell
|
||||
cdxs login oauth
|
||||
```
|
||||
|
||||
默认会监听 `127.0.0.1:1455` 等待浏览器回调。可指定端口:
|
||||
|
||||
```powershell
|
||||
cdxs login oauth --port 1456
|
||||
```
|
||||
|
||||
如果不能自动接收回调,可以手动粘贴回调 URL:
|
||||
|
||||
```powershell
|
||||
cdxs login oauth --manual
|
||||
```
|
||||
|
||||
添加 API Key 账号:
|
||||
|
||||
```powershell
|
||||
cdxs account add-api-key --key sk-...
|
||||
```
|
||||
|
||||
使用自定义 API base URL:
|
||||
|
||||
```powershell
|
||||
cdxs account add-api-key --key sk-... --base-url https://example.com/v1
|
||||
```
|
||||
|
||||
常用账号命令:
|
||||
|
||||
```powershell
|
||||
cdxs account list
|
||||
cdxs account current
|
||||
cdxs account show <账号ID或邮箱前缀>
|
||||
cdxs account remove <账号ID或邮箱前缀>
|
||||
cdxs refresh-token <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
支持 JSON 输出的命令:
|
||||
|
||||
```powershell
|
||||
cdxs list --json
|
||||
cdxs account current --json
|
||||
cdxs account show <账号> --json
|
||||
```
|
||||
|
||||
说明:`switch --apply-fingerprint` 参数目前只会输出提示,实际不会应用设备指纹。
|
||||
|
||||
## 配额查询
|
||||
|
||||
查询当前账号或第一个账号的 Codex 配额:
|
||||
|
||||
```powershell
|
||||
cdxs quota
|
||||
```
|
||||
|
||||
查询指定账号:
|
||||
|
||||
```powershell
|
||||
cdxs quota <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
查询所有账号:
|
||||
|
||||
```powershell
|
||||
cdxs quota --all
|
||||
```
|
||||
|
||||
JSON 输出:
|
||||
|
||||
```powershell
|
||||
cdxs quota --all --json
|
||||
```
|
||||
|
||||
注意:配额查询调用的是 ChatGPT/Codex OAuth 后端接口,只支持 OAuth 账号;API Key 账号不支持该配额查询。
|
||||
|
||||
## 多 CODEX_HOME 管理
|
||||
|
||||
创建一个独立 home:
|
||||
|
||||
```powershell
|
||||
cdxs home create work --path D:\codex-homes\work
|
||||
```
|
||||
|
||||
创建时绑定账号,并把账号写入该 home 的 `auth.json`:
|
||||
|
||||
```powershell
|
||||
cdxs home create work --path D:\codex-homes\work --account <账号>
|
||||
```
|
||||
|
||||
绑定已有 home 到账号:
|
||||
|
||||
```powershell
|
||||
cdxs home bind work <账号>
|
||||
```
|
||||
|
||||
查看 home:
|
||||
|
||||
```powershell
|
||||
cdxs home list
|
||||
cdxs home path work
|
||||
```
|
||||
|
||||
用某个 home 启动命令:
|
||||
|
||||
```powershell
|
||||
cdxs run --home work -- codex
|
||||
```
|
||||
|
||||
删除 home 记录:
|
||||
|
||||
```powershell
|
||||
cdxs home remove work
|
||||
```
|
||||
|
||||
说明:`home remove` 只删除 `cdxs.toml` 里的 home 记录,不会删除实际目录;`default` home 不能删除。
|
||||
|
||||
## 会话管理
|
||||
|
||||
列出默认 home 的 Codex 会话:
|
||||
|
||||
```powershell
|
||||
cdxs session list
|
||||
```
|
||||
|
||||
列出所有受管理 home 的会话:
|
||||
|
||||
```powershell
|
||||
cdxs session list --all-homes
|
||||
```
|
||||
|
||||
查看某个会话的统计信息:
|
||||
|
||||
```powershell
|
||||
cdxs session stats <session_id>
|
||||
```
|
||||
|
||||
移入 `cdxs` 垃圾箱,并从 Codex 会话索引和 SQLite 状态库中隐藏:
|
||||
|
||||
```powershell
|
||||
cdxs session trash <session_id>
|
||||
```
|
||||
|
||||
查看垃圾箱:
|
||||
|
||||
```powershell
|
||||
cdxs session trash-list
|
||||
```
|
||||
|
||||
恢复会话:
|
||||
|
||||
```powershell
|
||||
cdxs session restore <session_id>
|
||||
```
|
||||
|
||||
检查会话可见性问题:
|
||||
|
||||
```powershell
|
||||
cdxs session visibility check
|
||||
```
|
||||
|
||||
自动修复可见性问题:
|
||||
|
||||
```powershell
|
||||
cdxs session visibility repair
|
||||
```
|
||||
|
||||
把缺失的会话线程复制到其他受管理 home:
|
||||
|
||||
```powershell
|
||||
cdxs session sync-threads --all-homes
|
||||
```
|
||||
|
||||
预览同步动作,不实际写入:
|
||||
|
||||
```powershell
|
||||
cdxs session sync-threads --all-homes --dry-run
|
||||
```
|
||||
|
||||
会话相关命令会读取 Codex 的 `state_5.sqlite`、`session_index.jsonl` 以及 `sessions` / `archived_sessions` 下的 rollout 文件。写入前会尽量在 `cdxs-backups` 中备份相关文件。
|
||||
- `auth.json`:Codex 认证文件,由账号切换或运行命令时写入。
|
||||
- `cdxs.toml`:`cdxs` 的本地状态,保存账号、home、同步配置和元数据。
|
||||
- `state_5.sqlite`:Codex 会话数据库。
|
||||
- `session_index.jsonl`:Codex 会话列表索引。
|
||||
- `sessions/`:Codex rollout JSONL 文件。
|
||||
- `cdxs-trash/`:会话垃圾箱,用于可恢复删除。
|
||||
|
||||
会话修复、隐藏、恢复和同步可能修改 Codex 会话状态;写入前会备份相关状态文件。
|
||||
|
||||
## 常用快捷命令
|
||||
|
||||
- `cdxs list`:列出账号,并自动刷新过期配额缓存。
|
||||
- `cdxs show`:列出账号,但不请求配额接口。
|
||||
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
|
||||
- `cdxs switch <账号>`:切换到指定账号。
|
||||
- `cdxs login api --key <key> --base-url <url> --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
|
||||
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。
|
||||
- `cdxs pull`:从同步服务拉取账号状态,等价于 `cdxs sync pull`。
|
||||
- `cdxs push`:推送账号状态到同步服务,等价于 `cdxs sync push`。
|
||||
|
||||
## 同步服务
|
||||
|
||||
`cdxs` 内置一个简单同步服务,用于在多台机器之间同步 `cdxs.toml` 中的账号、home 等便携状态。
|
||||
内置同步服务只保存每个用户的账号状态,并提供登录、拉取、推送接口。它同步的是可迁移的 `cdxs` 账号状态,不同步整个 Codex home。
|
||||
|
||||
在服务端添加用户:
|
||||
|
||||
```powershell
|
||||
cdxs server user add alice --password your-password
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```powershell
|
||||
cdxs server run --bind 127.0.0.1:8765
|
||||
```
|
||||
|
||||
客户端登录:
|
||||
|
||||
```powershell
|
||||
cdxs sync login --server http://127.0.0.1:8765 --user alice --password your-password
|
||||
```
|
||||
|
||||
推送本地状态到服务端:
|
||||
|
||||
```powershell
|
||||
cdxs sync push
|
||||
```
|
||||
|
||||
从服务端拉取状态到本地:
|
||||
|
||||
```powershell
|
||||
cdxs sync pull
|
||||
```
|
||||
|
||||
查看同步配置:
|
||||
|
||||
```powershell
|
||||
cdxs sync status
|
||||
```
|
||||
|
||||
说明:服务端会保存用户密码哈希和登录 session;客户端拉取/推送的状态会排除服务端用户和同步 token。同步会覆盖账号、home 和 meta 状态,使用前建议先备份当前 `.codex\cdxs.toml`。
|
||||
|
||||
## 常用命令速查
|
||||
|
||||
```powershell
|
||||
cdxs --help
|
||||
cdxs list
|
||||
cdxs import auth --switch
|
||||
cdxs login oauth --switch
|
||||
cdxs account add-api-key --key sk-... --switch
|
||||
cdxs switch <账号>
|
||||
cdxs run --account <账号> -- codex
|
||||
cdxs quota --all
|
||||
cdxs home list
|
||||
cdxs session list --all-homes
|
||||
cdxs session visibility check --all-homes
|
||||
```
|
||||
|
||||
## 开发验证
|
||||
|
||||
当前代码没有单元测试,但可以运行:
|
||||
|
||||
```powershell
|
||||
cargo test
|
||||
```
|
||||
|
||||
当前实际结果为 0 个测试通过,命令本身成功完成。
|
||||
项目包含 `Dockerfile` 和 `compose.yml`。默认容器监听 `8765`,数据目录为 `/data`。
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
# 账号管理
|
||||
|
||||
本文说明 `cdxs` 当前账号管理功能的实现方式、调用机制和运行过程。
|
||||
|
||||
## 功能范围
|
||||
|
||||
账号管理主要覆盖以下能力:
|
||||
|
||||
- 从 Codex 原生 `auth.json` 导入账号。
|
||||
- 添加 API Key 账号。
|
||||
- 列出所有已保存账号。
|
||||
- 查看当前账号。
|
||||
- 查看指定账号详情。
|
||||
- 删除指定账号。
|
||||
- 将指定账号切换为当前账号并写入目标 `auth.json`。
|
||||
|
||||
## 核心文件
|
||||
|
||||
- `src/main.rs`:CLI 入口,负责把命令分发到具体模块。
|
||||
- `src/cli.rs`:定义账号相关命令和参数。
|
||||
- `src/account.rs`:账号管理主逻辑。
|
||||
- `src/config_store.rs`:`cdxs.toml` 的数据模型、加载、保存和查询。
|
||||
- `src/auth_file.rs`:Codex 原生 `auth.json` 的读取和写入。
|
||||
- `src/paths.rs`:解析 `CODEX_HOME`、`auth.json`、`cdxs.toml` 路径。
|
||||
- `src/atomic.rs`:写入配置或认证文件前备份,并使用原子写入。
|
||||
- `src/jwt.rs`:从 OAuth `id_token` 中解析邮箱、计划、组织等账号元数据。
|
||||
- `src/token.rs`:切换 OAuth 账号前按需刷新 token。
|
||||
|
||||
## 数据保存方式
|
||||
|
||||
`cdxs` 自己的账号状态保存在当前 `CODEX_HOME` 下的 `cdxs.toml`:
|
||||
|
||||
```text
|
||||
<CODEX_HOME>\cdxs.toml
|
||||
```
|
||||
|
||||
如果没有设置 `CODEX_HOME`,默认使用:
|
||||
|
||||
```text
|
||||
%USERPROFILE%\.codex
|
||||
```
|
||||
|
||||
账号列表保存在 `Store.accounts` 中,当前账号 ID 保存在:
|
||||
|
||||
```text
|
||||
Store.meta.current_account_id
|
||||
```
|
||||
|
||||
每个账号使用 `Account` 结构保存,关键字段包括:
|
||||
|
||||
- `id`:稳定账号 ID。
|
||||
- `email`:显示用邮箱或 API Key 虚拟邮箱。
|
||||
- `auth_mode`:`oauth` 或 `api_key`。
|
||||
- `tokens`:OAuth 账号的 token。
|
||||
- `openai_api_key`:API Key 账号的 key。
|
||||
- `api_base_url`:API Key 账号的可选 base URL。
|
||||
- `plan_type`:账号套餐类型。
|
||||
- `account_id`:OAuth 账号 ID。
|
||||
- `organization_id`:OAuth 组织 ID。
|
||||
- `quota`:最近一次配额查询结果。
|
||||
- `requires_reauth`:OAuth refresh 失败后标记是否需要重新登录。
|
||||
|
||||
## 账号 ID 生成原理
|
||||
|
||||
账号 ID 由 `account.rs` 中的 `stable_id` 生成。
|
||||
|
||||
OAuth 账号使用以下信息生成稳定 ID:
|
||||
|
||||
- 固定前缀:`oauth`
|
||||
- 邮箱
|
||||
- `account_id`
|
||||
- `organization_id`
|
||||
|
||||
API Key 账号使用以下信息生成稳定 ID:
|
||||
|
||||
- 固定前缀:`apikey`
|
||||
- API Key
|
||||
- `base_url`
|
||||
|
||||
生成方式是对这些字段做 SHA-256,然后取前 16 位十六进制字符串:
|
||||
|
||||
```text
|
||||
oauth_xxxxxxxxxxxxxxxx
|
||||
apikey_xxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
这样重复导入同一个账号时,会更新原有记录,而不是创建重复账号。
|
||||
|
||||
## 命令调用机制
|
||||
|
||||
账号相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发到 `src/account.rs`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户执行 cdxs 命令] --> B[Cli::parse]
|
||||
B --> C[src/main.rs match Commands]
|
||||
C --> D{账号相关命令}
|
||||
D -->|cdxs import auth| E[account::import_auth]
|
||||
D -->|cdxs account add-api-key| F[account::add_api_key]
|
||||
D -->|cdxs list / account list| G[account::list_accounts]
|
||||
D -->|cdxs account current| H[account::current_account]
|
||||
D -->|cdxs account show| I[account::show_account]
|
||||
D -->|cdxs account remove| J[account::remove_account]
|
||||
D -->|cdxs switch| K[account::switch_account]
|
||||
```
|
||||
|
||||
## 导入 auth.json 运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs import auth
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
cdxs import auth --file <auth.json路径>
|
||||
cdxs import auth --codex-home <路径>
|
||||
cdxs import auth --switch
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 解析主配置 home,也就是保存 `cdxs.toml` 的位置。
|
||||
2. 解析来源 home,用于定位要导入的 `auth.json`。
|
||||
3. 调用 `auth_file::read_auth_file` 读取并解析 `auth.json`。
|
||||
4. 调用 `account_from_auth` 判断是 OAuth 账号还是 API Key 账号。
|
||||
5. OAuth 账号会解析 token,并从 `id_token` 中读取邮箱、计划、账号 ID、组织 ID。
|
||||
6. API Key 账号会读取 `OPENAI_API_KEY` 和可选 base URL。
|
||||
7. 生成稳定账号 ID。
|
||||
8. 调用 `Store::upsert_account` 写入或更新账号。
|
||||
9. 如果带 `--switch`,同时写入目标 home 的 `auth.json`,并设置当前账号。
|
||||
10. 调用 `Store::save` 保存 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant M as main.rs
|
||||
participant A as account.rs
|
||||
participant P as paths.rs
|
||||
participant AF as auth_file.rs
|
||||
participant S as config_store.rs
|
||||
participant J as jwt.rs
|
||||
|
||||
U->>M: cdxs import auth
|
||||
M->>A: import_auth(file, codex_home, switch)
|
||||
A->>P: codex_home(None)
|
||||
P-->>A: 配置 home
|
||||
A->>P: codex_home(codex_home)
|
||||
P-->>A: 来源 home
|
||||
A->>AF: read_auth_file(auth_path)
|
||||
AF-->>A: CodexAuthFile
|
||||
A->>A: account_from_auth
|
||||
alt OAuth 账号
|
||||
A->>J: decode_payload(id_token)
|
||||
J-->>A: email / plan / account_id / organization_id
|
||||
A->>A: oauth_account
|
||||
else API Key 账号
|
||||
A->>AF: extract_api_key / api_base_url
|
||||
AF-->>A: key / base_url
|
||||
A->>A: api_key_account
|
||||
end
|
||||
A->>S: Store::load
|
||||
S-->>A: Store
|
||||
A->>S: upsert_account
|
||||
opt --switch
|
||||
A->>AF: write_account_to_auth
|
||||
A->>A: 设置 current_account_id
|
||||
end
|
||||
A->>S: save
|
||||
S-->>U: 导入完成
|
||||
```
|
||||
|
||||
## 添加 API Key 账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs account add-api-key --key sk-...
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
cdxs account add-api-key --key sk-... --base-url https://example.com/v1
|
||||
cdxs account add-api-key --key sk-... --switch
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 解析当前 `CODEX_HOME`。
|
||||
2. 加载 `cdxs.toml`。
|
||||
3. 校验 API Key 非空。
|
||||
4. 根据 API Key 和 base URL 生成稳定账号 ID。
|
||||
5. 生成显示用邮箱,格式类似 `api-key-xxxxxxxx`。
|
||||
6. 调用 `Store::upsert_account` 保存账号。
|
||||
7. 如果带 `--switch`,写入当前 home 的 `auth.json`。
|
||||
8. 保存 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs account add-api-key] --> B[paths::codex_home]
|
||||
B --> C[Store::load]
|
||||
C --> D[api_key_account]
|
||||
D --> E[stable_id]
|
||||
E --> F[Store::upsert_account]
|
||||
F --> G{是否 --switch}
|
||||
G -->|是| H[auth_file::write_account_to_auth]
|
||||
G -->|否| I[跳过 auth.json 写入]
|
||||
H --> J[Store::save]
|
||||
I --> J
|
||||
```
|
||||
|
||||
## 列出账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs list
|
||||
cdxs account list
|
||||
```
|
||||
|
||||
JSON 输出:
|
||||
|
||||
```powershell
|
||||
cdxs list --json
|
||||
cdxs account list --json
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 解析当前 `CODEX_HOME`。
|
||||
2. 加载 `cdxs.toml`。
|
||||
3. 如果 `--json`,直接输出 `store.accounts` 的 JSON。
|
||||
4. 如果不是 JSON,按表格输出账号 ID、邮箱、认证模式、套餐和配额。
|
||||
5. 当前账号会用 `*` 标记。
|
||||
|
||||
## 查看当前账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs account current
|
||||
```
|
||||
|
||||
JSON 输出:
|
||||
|
||||
```powershell
|
||||
cdxs account current --json
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载 `cdxs.toml`。
|
||||
2. 读取 `Store.meta.current_account_id`。
|
||||
3. 如果没有当前账号,输出未设置账号。
|
||||
4. 如果当前账号 ID 不存在,返回错误。
|
||||
5. 找到账号后输出详情或 JSON。
|
||||
|
||||
## 查看指定账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs account show <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
查找账号使用 `Store::find_account`,支持三种匹配方式:
|
||||
|
||||
- 完整账号 ID。
|
||||
- 完整邮箱。
|
||||
- 邮箱前缀。
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载 `cdxs.toml`。
|
||||
2. 调用 `find_account` 查找账号。
|
||||
3. 找不到则返回错误。
|
||||
4. 找到后输出详情或 JSON。
|
||||
|
||||
## 删除账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs account remove <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载 `cdxs.toml`。
|
||||
2. 用账号 ID、邮箱或邮箱前缀查找账号。
|
||||
3. 从 `Store.accounts` 中删除该账号。
|
||||
4. 如果该账号是当前账号,清空 `current_account_id`。
|
||||
5. 如果有 home 绑定该账号,清空对应 `bound_account_id`。
|
||||
6. 保存 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs account remove] --> B[Store::load]
|
||||
B --> C[Store::find_account]
|
||||
C --> D{是否存在}
|
||||
D -->|否| E[返回账号不存在错误]
|
||||
D -->|是| F[accounts.retain 删除账号]
|
||||
F --> G{是否当前账号}
|
||||
G -->|是| H[清空 current_account_id]
|
||||
G -->|否| I[保持 current_account_id]
|
||||
H --> J[清空 homes 中相关 bound_account_id]
|
||||
I --> J
|
||||
J --> K[Store::save]
|
||||
```
|
||||
|
||||
## 切换账号运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs switch <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
可选指定目标 home:
|
||||
|
||||
```powershell
|
||||
cdxs switch <账号> --codex-home <路径>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载主配置 home 的 `cdxs.toml`。
|
||||
2. 解析目标 home,用于确定要写入哪个 `auth.json`。
|
||||
3. 用账号 ID、邮箱或邮箱前缀查找账号。
|
||||
4. 如果是 OAuth 账号,调用 `token::refresh_account_if_needed` 检查 access token 是否即将过期。
|
||||
5. 如果 token 需要刷新,则先刷新并更新 `cdxs.toml` 中的账号 token。
|
||||
6. 调用 `auth_file::write_account_to_auth` 把账号写入目标 home 的 `auth.json`。
|
||||
7. 更新该账号的 `last_used_at`。
|
||||
8. 设置 `Store.meta.current_account_id`。
|
||||
9. 保存 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant M as main.rs
|
||||
participant A as account.rs
|
||||
participant S as config_store.rs
|
||||
participant T as token.rs
|
||||
participant AF as auth_file.rs
|
||||
|
||||
U->>M: cdxs switch <account>
|
||||
M->>A: switch_account
|
||||
A->>S: Store::load
|
||||
S-->>A: Store
|
||||
A->>S: find_account
|
||||
S-->>A: Account
|
||||
A->>T: refresh_account_if_needed
|
||||
alt OAuth token 即将过期
|
||||
T->>T: refresh_account
|
||||
T-->>A: 已更新 tokens
|
||||
else 不需要刷新或 API Key
|
||||
T-->>A: 无需刷新
|
||||
end
|
||||
A->>AF: write_account_to_auth
|
||||
AF-->>A: 写入 auth.json
|
||||
A->>A: 更新 last_used_at/current_account_id
|
||||
A->>S: save
|
||||
S-->>U: 切换完成
|
||||
```
|
||||
|
||||
## auth.json 写入规则
|
||||
|
||||
账号切换或带 `--switch` 保存账号时,会调用 `auth_file::write_account_to_auth`。
|
||||
|
||||
OAuth 账号写入格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "...",
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"account_id": "..."
|
||||
},
|
||||
"last_refresh": "..."
|
||||
}
|
||||
```
|
||||
|
||||
API Key 账号写入格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
## 写入安全机制
|
||||
|
||||
`cdxs.toml` 和 `auth.json` 写入时都使用同一套安全机制:
|
||||
|
||||
1. 如果目标文件已存在,先备份到:
|
||||
|
||||
```text
|
||||
<CODEX_HOME>\cdxs-backups\
|
||||
```
|
||||
|
||||
2. 写入时先写到同目录临时文件:
|
||||
|
||||
```text
|
||||
.<原文件名>.tmp
|
||||
```
|
||||
|
||||
3. 再用 rename 替换目标文件。
|
||||
|
||||
这样可以降低写入中断导致配置文件损坏的风险。
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
- `switch --apply-fingerprint` 当前只输出提示,实际不会应用设备指纹。
|
||||
- API Key 账号没有 OAuth token,也不会参与 token refresh。
|
||||
- 账号查找支持邮箱前缀,但如果多个邮箱前缀相同,当前实现会返回第一个匹配项。
|
||||
- `remove_account` 只删除 `cdxs.toml` 中的账号记录,不会主动清理已经写入某个 home 的 `auth.json`。
|
||||
@@ -1,454 +0,0 @@
|
||||
# 认证与 Token、配额查询
|
||||
|
||||
本文说明 `cdxs` 当前“认证与 Token、配额查询”功能的实现方式、调用机制和运行过程。
|
||||
|
||||
## 功能范围
|
||||
|
||||
本功能主要覆盖以下能力:
|
||||
|
||||
- 通过 OpenAI OAuth PKCE 流程登录 Codex 账号。
|
||||
- 支持浏览器本地回调和手动粘贴回调 URL 两种 OAuth 完成方式。
|
||||
- 将 OAuth 返回的 token 保存为 `cdxs` 账号。
|
||||
- 将已保存账号写入 Codex 原生 `auth.json`。
|
||||
- 在切换、运行、配额查询前按需刷新 OAuth access token。
|
||||
- 手动刷新指定 OAuth 账号 token。
|
||||
- 解码 JWT payload,用于读取邮箱、套餐、账号 ID、组织 ID 和过期时间。
|
||||
- 查询 OAuth 账号 Codex 配额,并缓存最近一次配额结果。
|
||||
|
||||
## 核心文件
|
||||
|
||||
- `src/main.rs`:CLI 入口,负责把认证、刷新和配额命令分发到具体模块。
|
||||
- `src/cli.rs`:定义 `login oauth`、`refresh-token`、`quota` 等命令参数。
|
||||
- `src/oauth.rs`:实现 OAuth PKCE 登录、回调解析和授权码换 token。
|
||||
- `src/token.rs`:实现 OAuth token 过期检查和 refresh token 刷新。
|
||||
- `src/quota.rs`:实现 Codex 配额接口调用、配额解析和展示。
|
||||
- `src/auth_file.rs`:负责 Codex 原生 `auth.json` 的读取和写入。
|
||||
- `src/jwt.rs`:本地解码 JWT payload,提取账号元数据和过期时间。
|
||||
- `src/config_store.rs`:定义账号、token、quota 的持久化模型,并读写 `cdxs.toml`。
|
||||
|
||||
## 数据保存方式
|
||||
|
||||
`cdxs` 自己管理的账号和 token 保存在当前 `CODEX_HOME` 下的 `cdxs.toml`:
|
||||
|
||||
```text
|
||||
<CODEX_HOME>\cdxs.toml
|
||||
```
|
||||
|
||||
OAuth 账号的 token 保存在 `Account.tokens`:
|
||||
|
||||
- `id_token`:主要用于本地解析账号元数据。
|
||||
- `access_token`:用于调用 ChatGPT/Codex 后端接口。
|
||||
- `refresh_token`:用于刷新 access token。
|
||||
|
||||
配额结果保存在 `Account.quota`:
|
||||
|
||||
- `primary_remaining_percent`:主窗口剩余百分比。
|
||||
- `primary_reset_time`:主窗口重置时间戳,可为空。
|
||||
- `secondary_remaining_percent`:次窗口剩余百分比。
|
||||
- `secondary_reset_time`:次窗口重置时间戳,可为空。
|
||||
- `updated_at`:本地更新时间戳。
|
||||
|
||||
Codex CLI 实际读取的认证文件仍然是原生 `auth.json`:
|
||||
|
||||
```text
|
||||
<CODEX_HOME>\auth.json
|
||||
```
|
||||
|
||||
`cdxs` 在切换账号或导入登录时,只负责把选中的账号转换成 Codex 兼容格式写入该文件。
|
||||
|
||||
## 命令调用机制
|
||||
|
||||
认证与配额相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户执行 cdxs 命令] --> B[Cli::parse]
|
||||
B --> C[src/main.rs match Commands]
|
||||
C --> D{认证与配额命令}
|
||||
D -->|cdxs login oauth| E[oauth::login_oauth]
|
||||
D -->|cdxs refresh-token| F[token::refresh_token_command]
|
||||
D -->|cdxs quota| G[quota::quota_command]
|
||||
D -->|cdxs switch| H[account::switch_account]
|
||||
D -->|cdxs run| I[run_cmd::run_with_account_or_home]
|
||||
H --> J[token::refresh_account_if_needed]
|
||||
I --> J
|
||||
G --> J
|
||||
```
|
||||
|
||||
## OAuth 登录实现原理
|
||||
|
||||
`cdxs login oauth` 使用 PKCE 授权码流程,不需要本地保存 client secret。
|
||||
|
||||
关键常量在 `src/oauth.rs` 中定义:
|
||||
|
||||
- `CLIENT_ID`:Codex 使用的 OAuth client id。
|
||||
- `AUTH_ENDPOINT`:`https://auth.openai.com/oauth/authorize`。
|
||||
- `TOKEN_ENDPOINT`:`https://auth.openai.com/oauth/token`。
|
||||
- `SCOPES`:`openid profile email offline_access`。
|
||||
- `ORIGINATOR`:`codex_vscode`。
|
||||
|
||||
运行时会生成三类临时值:
|
||||
|
||||
- `code_verifier`:随机 32 字节 base64url 字符串。
|
||||
- `code_challenge`:对 `code_verifier` 做 SHA-256 后 base64url 编码。
|
||||
- `state`:随机字符串,用来校验回调是否属于本次登录。
|
||||
|
||||
然后拼出授权 URL,用户在浏览器打开 URL 完成登录。登录成功后,OpenAI 会把浏览器重定向到:
|
||||
|
||||
```text
|
||||
http://localhost:<port>/auth/callback?code=...&state=...
|
||||
```
|
||||
|
||||
默认端口是 `1455`。
|
||||
|
||||
## OAuth 登录运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs login oauth
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
cdxs login oauth --manual
|
||||
cdxs login oauth --port 1455
|
||||
cdxs login oauth --switch
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 生成 `code_verifier`、`code_challenge` 和 `state`。
|
||||
2. 构造 OpenAI OAuth 授权 URL 并打印到终端。
|
||||
3. 如果没有 `--manual`,在 `127.0.0.1:<port>` 启动一次性 HTTP listener 等待回调。
|
||||
4. 如果使用 `--manual`,从标准输入读取用户粘贴的完整回调 URL 或查询字符串。
|
||||
5. 调用 `parse_callback_code` 校验回调路径必须是 `/auth/callback`。
|
||||
6. 校验回调中的 `state` 必须和本次登录生成的 `state` 一致。
|
||||
7. 提取授权码 `code`。
|
||||
8. 调用 token endpoint,用 `authorization_code`、`client_id`、`redirect_uri`、`code_verifier` 换取 token。
|
||||
9. 要求响应中必须包含 `id_token` 和 `access_token`,`refresh_token` 可选。
|
||||
10. 调用 `account::upsert_oauth_tokens` 保存或更新账号。
|
||||
11. 如果带 `--switch`,保存账号后会切换到该账号。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant M as main.rs
|
||||
participant O as oauth.rs
|
||||
participant B as 浏览器
|
||||
participant OA as OpenAI OAuth
|
||||
participant A as account.rs
|
||||
participant S as cdxs.toml
|
||||
|
||||
U->>M: cdxs login oauth
|
||||
M->>O: login_oauth(manual, port, switch)
|
||||
O->>O: 生成 verifier/challenge/state
|
||||
O-->>U: 打印授权 URL
|
||||
U->>B: 打开授权 URL
|
||||
B->>OA: 登录并授权
|
||||
OA-->>B: redirect 到 localhost callback
|
||||
alt 自动回调模式
|
||||
B->>O: GET /auth/callback?code&state
|
||||
O->>O: 校验 path/state 并提取 code
|
||||
else --manual 模式
|
||||
U->>O: 粘贴回调 URL 或 query
|
||||
O->>O: 校验 path/state 并提取 code
|
||||
end
|
||||
O->>OA: POST /oauth/token grant_type=authorization_code
|
||||
OA-->>O: id_token/access_token/refresh_token
|
||||
O->>A: upsert_oauth_tokens(tokens, None, switch)
|
||||
A->>S: 保存账号和 token
|
||||
opt --switch
|
||||
A->>A: 写入 auth.json 并设置当前账号
|
||||
end
|
||||
```
|
||||
|
||||
## 回调解析机制
|
||||
|
||||
`parse_callback_code` 支持三种输入形式:
|
||||
|
||||
- 完整 URL,例如 `http://localhost:1455/auth/callback?code=...&state=...`。
|
||||
- 路径和查询,例如 `/auth/callback?code=...&state=...`。
|
||||
- 纯查询字符串,例如 `?code=...&state=...` 或 `code=...&state=...`。
|
||||
|
||||
当前实现会拒绝以下情况:
|
||||
|
||||
- URL 格式无效。
|
||||
- 路径不是 `/auth/callback`。
|
||||
- `state` 不匹配。
|
||||
- 缺少 `code` 或 `code` 为空。
|
||||
|
||||
自动回调模式最多等待 300 秒,超时后返回错误。
|
||||
|
||||
## auth.json 写入机制
|
||||
|
||||
`auth_file::write_account_to_auth` 是 `cdxs` 和 Codex 原生认证文件之间的兼容边界。
|
||||
|
||||
OAuth 账号写入格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "...",
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"account_id": "..."
|
||||
},
|
||||
"last_refresh": "..."
|
||||
}
|
||||
```
|
||||
|
||||
API Key 账号写入格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
写入前会调用 `atomic::backup_if_exists` 备份已有文件,然后通过 `atomic::write_atomic` 原子替换目标文件。
|
||||
|
||||
## JWT 解析机制
|
||||
|
||||
`src/jwt.rs` 只解码 JWT payload,不验证签名。签名有效性由 OpenAI 服务端在 token 被使用时校验。
|
||||
|
||||
本地解析的字段包括:
|
||||
|
||||
- `email`:账号邮箱。
|
||||
- `sub`:JWT subject。
|
||||
- `exp`:过期时间戳。
|
||||
- `https://api.openai.com/auth.chatgpt_plan_type`:套餐类型。
|
||||
- `https://api.openai.com/auth.account_id`:ChatGPT/Codex 账号 ID。
|
||||
- `https://api.openai.com/auth.organization_id`:组织 ID。
|
||||
|
||||
`token_expired` 使用 `exp` 判断 access token 是否过期。当前刷新提前量是 300 秒,也就是 token 距离过期不足 5 分钟时会被视为需要刷新。无法解析的 token 会被视为已过期。
|
||||
|
||||
## Token 刷新实现原理
|
||||
|
||||
`src/token.rs` 提供两类刷新入口:
|
||||
|
||||
- `refresh_token_command`:用户显式执行 `cdxs refresh-token <account>`。
|
||||
- `refresh_account_if_needed`:切换、运行、配额查询等流程内部按需调用。
|
||||
|
||||
只支持 OAuth 账号刷新。API Key 账号没有 OAuth token,`refresh_account_if_needed` 对 API Key 返回无需刷新,显式刷新 API Key 账号会返回错误。
|
||||
|
||||
刷新请求使用:
|
||||
|
||||
```text
|
||||
POST https://auth.openai.com/oauth/token
|
||||
grant_type=refresh_token
|
||||
refresh_token=<refresh_token>
|
||||
client_id=<CLIENT_ID>
|
||||
```
|
||||
|
||||
刷新响应必须包含新的 `access_token`。如果响应没有新的 `id_token`,当前实现会沿用旧的 `id_token`,因为旧 `id_token` 仍可用于本地展示账号元数据。
|
||||
|
||||
如果响应没有新的 `refresh_token`,当前实现会继续保存旧的 `refresh_token`。
|
||||
|
||||
## 手动刷新 Token 运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs refresh-token <账号ID或邮箱前缀>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 解析当前 `CODEX_HOME`。
|
||||
2. 加载 `cdxs.toml`。
|
||||
3. 通过账号 ID、完整邮箱或邮箱前缀查找账号。
|
||||
4. 校验账号必须是 OAuth 账号。
|
||||
5. 校验账号必须保存了 `refresh_token`。
|
||||
6. 调用 OpenAI token endpoint 刷新 token。
|
||||
7. 更新账号的 `tokens`、`updated_at`、`plan_type`、`account_id`、`organization_id`。
|
||||
8. 将 `requires_reauth` 设置为 `false`。
|
||||
9. 保存 `cdxs.toml`。
|
||||
|
||||
如果刷新失败,当前实现会把该账号的 `requires_reauth` 标记为 `true`,并返回错误。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs refresh-token account] --> B[Store::load]
|
||||
B --> C[Store::find_account]
|
||||
C --> D{OAuth 账号?}
|
||||
D -->|否| E[返回 API Key 不支持刷新]
|
||||
D -->|是| F{有 refresh_token?}
|
||||
F -->|否| G[返回需要重新登录]
|
||||
F -->|是| H[POST OAuth token endpoint]
|
||||
H --> I{刷新成功?}
|
||||
I -->|是| J[更新 tokens 和账号元数据]
|
||||
I -->|否| K[标记 requires_reauth=true]
|
||||
J --> L[Store::save]
|
||||
K --> L
|
||||
```
|
||||
|
||||
## 按需刷新运行过程
|
||||
|
||||
切换账号、运行命令、查询配额前会尽量避免写入或使用即将过期的 access token。
|
||||
|
||||
按需刷新逻辑:
|
||||
|
||||
1. 找到目标账号。
|
||||
2. 如果账号不是 OAuth,直接返回无需刷新。
|
||||
3. 如果 OAuth 账号缺少 `tokens`,返回错误。
|
||||
4. 用 `jwt::token_expired(access_token, 300)` 判断 access token 是否即将过期。
|
||||
5. 如果没有过期,返回无需刷新。
|
||||
6. 如果即将过期,调用 `refresh_account` 刷新并更新 store。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as switch/run/quota
|
||||
participant T as token.rs
|
||||
participant J as jwt.rs
|
||||
participant OA as OpenAI OAuth
|
||||
participant S as cdxs.toml
|
||||
|
||||
Caller->>T: refresh_account_if_needed(store, account_id)
|
||||
T->>T: 查找账号并检查 auth_mode
|
||||
alt API Key 账号
|
||||
T-->>Caller: false
|
||||
else OAuth 账号
|
||||
T->>J: token_expired(access_token, 300)
|
||||
alt 未过期
|
||||
J-->>T: false
|
||||
T-->>Caller: false
|
||||
else 即将过期或无法解析
|
||||
J-->>T: true
|
||||
T->>OA: POST grant_type=refresh_token
|
||||
OA-->>T: 新 tokens
|
||||
T->>S: 更新内存 store,调用方之后保存
|
||||
T-->>Caller: true
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 配额查询实现原理
|
||||
|
||||
配额查询由 `src/quota.rs` 实现,只支持 OAuth 账号。API Key 账号会被拒绝,因为配额接口依赖 ChatGPT/Codex 账号上下文。
|
||||
|
||||
当前调用的后端接口是:
|
||||
|
||||
```text
|
||||
GET https://chatgpt.com/backend-api/wham/usage
|
||||
```
|
||||
|
||||
请求头包括:
|
||||
|
||||
- `Authorization: Bearer <access_token>`。
|
||||
- `Accept: application/json`。
|
||||
- `ChatGPT-Account-Id: <account_id>`,仅当账号保存了非空 `account_id` 时添加。
|
||||
|
||||
`ChatGPT-Account-Id` 用于多账号或组织场景,避免后端选择错误账号上下文。
|
||||
|
||||
接口响应中的 `used_percent` 会被转换成剩余百分比:
|
||||
|
||||
```text
|
||||
remaining_percent = 100 - clamp(used_percent, 0, 100)
|
||||
```
|
||||
|
||||
主窗口来自 `rate_limit.primary_window`,次窗口来自 `rate_limit.secondary_window`。重置时间优先使用接口返回的 `reset_at`;如果没有 `reset_at`,则用当前时间加 `reset_after_seconds` 计算。
|
||||
|
||||
## 配额查询运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs quota
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
cdxs quota <账号ID或邮箱前缀>
|
||||
cdxs quota --all
|
||||
cdxs quota --json
|
||||
```
|
||||
|
||||
账号选择顺序:
|
||||
|
||||
1. 如果传入 `--all`,查询所有保存账号。
|
||||
2. 如果传入指定账号,查询该账号。
|
||||
3. 如果存在 `Store.meta.current_account_id`,查询当前账号。
|
||||
4. 否则查询保存的第一个账号。
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 解析当前 `CODEX_HOME`。
|
||||
2. 加载 `cdxs.toml`。
|
||||
3. 根据参数选择要查询的账号 ID 列表。
|
||||
4. 对每个账号调用 `refresh_one_quota`。
|
||||
5. 查询前先调用 `token::refresh_account_if_needed`。
|
||||
6. 校验账号必须是 OAuth 账号。
|
||||
7. 使用 access token 调用 usage 接口。
|
||||
8. 如果请求失败且错误文本包含 `401`、`token_invalidated` 或 `authentication token has been invalidated`,强制刷新一次 token 后重试。
|
||||
9. 解析配额并更新账号的 `plan_type`、`quota`、`updated_at`。
|
||||
10. 保存 `cdxs.toml`。
|
||||
11. 根据 `--json` 决定输出 JSON 或表格。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant M as main.rs
|
||||
participant Q as quota.rs
|
||||
participant T as token.rs
|
||||
participant API as ChatGPT Usage API
|
||||
participant S as cdxs.toml
|
||||
|
||||
U->>M: cdxs quota [account|--all|--json]
|
||||
M->>Q: quota_command(account, all, json)
|
||||
Q->>S: Store::load
|
||||
Q->>Q: 选择账号列表
|
||||
loop 每个账号
|
||||
Q->>T: refresh_account_if_needed
|
||||
T-->>Q: 已刷新或无需刷新
|
||||
Q->>Q: 校验 OAuth 账号和 tokens
|
||||
Q->>API: GET /backend-api/wham/usage
|
||||
alt token 失效类错误
|
||||
API-->>Q: 401/token_invalidated
|
||||
Q->>T: refresh_account
|
||||
T-->>Q: 新 tokens
|
||||
Q->>API: 重新 GET usage
|
||||
end
|
||||
API-->>Q: usage JSON
|
||||
Q->>Q: parse_quota
|
||||
Q->>S: 更新账号 quota/plan/updated_at
|
||||
end
|
||||
Q->>S: Store::save
|
||||
Q-->>U: 输出表格或 JSON
|
||||
```
|
||||
|
||||
## 配额输出格式
|
||||
|
||||
默认表格输出字段:
|
||||
|
||||
- `ID`:账号 ID,过长会被截断显示。
|
||||
- `Email`:账号邮箱,过长会被截断显示。
|
||||
- `Plan`:套餐类型。
|
||||
- `5h`:主窗口剩余百分比。
|
||||
- `Weekly`:次窗口剩余百分比。
|
||||
- `Status`:`ok` 或错误信息。
|
||||
|
||||
JSON 输出会输出数组,每个元素包括:
|
||||
|
||||
- `id`
|
||||
- `email`
|
||||
- `plan_type`
|
||||
- `quota`
|
||||
- `error`
|
||||
|
||||
如果查询多个账号时存在失败项,非 JSON 和 JSON 输出都会先展示可用结果,最后返回“部分账号配额刷新失败”的错误。
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
- OAuth 登录不会自动打开浏览器,只打印授权 URL。
|
||||
- 自动 OAuth 回调只监听 `127.0.0.1:<port>`,并且只处理一次回调请求。
|
||||
- 自动 OAuth 回调等待时间是 300 秒。
|
||||
- `id_token` 只做本地 payload 解码,不做签名校验。
|
||||
- 显式 `refresh-token` 只支持 OAuth 账号,不支持 API Key 账号。
|
||||
- 配额查询只支持 OAuth 账号,不支持 API Key 账号。
|
||||
- 配额接口错误信息不会输出完整响应体,只输出 HTTP status 和 body 长度,避免泄露响应内容。
|
||||
- 如果 token 刷新失败,账号会被标记为 `requires_reauth=true`,但不会自动重新发起 OAuth 登录。
|
||||
@@ -1,319 +0,0 @@
|
||||
# 多 CODEX_HOME 管理与会话
|
||||
|
||||
本文说明当前代码中“多 CODEX_HOME 管理”和“会话管理”的实现方式、调用机制与运行过程。相关入口主要在 `src/main.rs`、`src/cli.rs`,实现分布在 `src/account.rs`、`src/run_cmd.rs`、`src/session.rs`、`src/config_store.rs`、`src/paths.rs`、`src/atomic.rs`。
|
||||
|
||||
## 功能边界
|
||||
|
||||
当前实现把 `CODEX_HOME` 当作 Codex 原生状态目录使用,每个 home 下可以有自己的 `auth.json`、`state_5.sqlite`、`session_index.jsonl`、`sessions/`、`archived_sessions/` 等文件。`cdxs` 额外在主 home 的 `cdxs.toml` 中保存已管理 home 列表,并支持把账号写入指定 home、用指定 home 启动子命令,以及检查、修复、回收、恢复和跨 home 补齐会话。
|
||||
|
||||
当前代码没有实现 `current_home` 的切换命令;`Store.meta.current_home` 只是配置模型字段,`list_homes` 会用它标记当前 home,但现有命令不会修改它。
|
||||
|
||||
## 数据模型
|
||||
|
||||
`src/config_store.rs` 中的 `Store` 是 `cdxs.toml` 的内存模型,其中和本功能相关的字段是:
|
||||
|
||||
- `meta.current_home`:默认值为 `default`,当前没有命令写入它。
|
||||
- `homes`:已管理的 home 列表,每项是 `Home { name, path, bound_account_id }`。
|
||||
- `accounts`:账号列表,home 绑定账号时通过 `bound_account_id` 引用这里的账号。
|
||||
|
||||
配置加载逻辑会自动保证存在一个名为 `default` 的 home。这个默认 home 的路径来自 `paths::codex_home(None)`,也就是优先使用环境变量 `CODEX_HOME`,否则使用用户目录下的 `.codex`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[paths::codex_home(None)] --> B{CODEX_HOME 是否存在}
|
||||
B -->|存在且非空| C[展开并使用 CODEX_HOME]
|
||||
B -->|不存在| D[使用用户目录/.codex]
|
||||
C --> E[Store::load]
|
||||
D --> E
|
||||
E --> F{cdxs.toml 是否存在}
|
||||
F -->|不存在或为空| G[Store::with_default_home]
|
||||
F -->|存在| H[解析 TOML]
|
||||
G --> I[ensure_default_home]
|
||||
H --> I
|
||||
I --> J[得到 homes 列表]
|
||||
```
|
||||
|
||||
路径处理由 `src/paths.rs` 完成:
|
||||
|
||||
- `codex_home(override_path)`:命令显式传入路径时优先使用;否则读取 `CODEX_HOME`;再否则使用 `~/.codex`。
|
||||
- `config_path(home)`:返回 `<home>/cdxs.toml`。
|
||||
- `auth_path(home)`:返回 `<home>/auth.json`。
|
||||
- `expand_home(path)`:支持把 `~`、`~/`、`~\` 展开为用户主目录。
|
||||
|
||||
写配置和关键状态文件时,`src/atomic.rs` 提供两类基础能力:
|
||||
|
||||
- `write_atomic`:先写同目录临时文件,再 rename 到目标路径,避免半写入。
|
||||
- `backup_if_exists`:把已有文件复制到 `<codex_home>/cdxs-backups/`,文件名带时间戳。
|
||||
|
||||
## 多 CODEX_HOME 管理
|
||||
|
||||
### 命令入口
|
||||
|
||||
`src/cli.rs` 定义 `cdxs home` 子命令,`src/main.rs` 负责分发到 `account.rs`:
|
||||
|
||||
- `cdxs home list [--json]` -> `account::list_homes`
|
||||
- `cdxs home create <name> --path <path> [--account <account>]` -> `account::create_home`
|
||||
- `cdxs home bind <name> <account>` -> `account::bind_home`
|
||||
- `cdxs home path <name>` -> `account::home_path`
|
||||
- `cdxs home remove <name>` -> `account::remove_home`
|
||||
|
||||
### 创建 home
|
||||
|
||||
`create_home` 总是加载主 home 下的 `cdxs.toml`,不会把新 home 当作配置源。它会检查名称是否重复,解析并展开目标路径,创建目录,然后把新记录写入 `Store.homes`。
|
||||
|
||||
如果创建时传入 `--account`,代码会先在主配置的账号列表里解析账号,拿到稳定账号 ID,随后立即把该账号写入新 home 的 `auth.json`。这样新 home 创建完成后已经具备 Codex 可读取的认证文件。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CLI as cdxs home create
|
||||
participant Main as main.rs
|
||||
participant Account as account::create_home
|
||||
participant Store as cdxs.toml Store
|
||||
participant FS as 文件系统
|
||||
participant Auth as auth.json
|
||||
|
||||
CLI->>Main: HomeCommands::Create
|
||||
Main->>Account: create_home(name, path, account)
|
||||
Account->>Store: Store::load(main_home)
|
||||
Account->>Store: 检查 home 名称是否已存在
|
||||
Account->>FS: expand_home(path) 并 create_dir_all
|
||||
alt 传入 --account
|
||||
Account->>Store: find_account(account)
|
||||
Account->>Auth: write_account_to_auth(new_home/auth.json)
|
||||
end
|
||||
Account->>Store: push Home{name,path,bound_account_id}
|
||||
Account->>Store: save(main_home)
|
||||
```
|
||||
|
||||
### 绑定、查看和删除 home
|
||||
|
||||
`bind_home` 只修改 `cdxs.toml` 中指定 home 的 `bound_account_id`,不会立即改写该 home 的 `auth.json`。真正运行命令时,`run` 会根据绑定账号刷新 token 并写入目标 home 的 `auth.json`。
|
||||
|
||||
`home_path` 只查找并打印配置中记录的路径。
|
||||
|
||||
`remove_home` 只从 `Store.homes` 中删除记录,不删除磁盘上的 home 目录。代码明确禁止删除名为 `default` 的 home。
|
||||
|
||||
## 用指定账号或 home 运行命令
|
||||
|
||||
### 命令入口
|
||||
|
||||
`cdxs run` 的参数定义在 `RunArgs`:
|
||||
|
||||
- `--account <account>`:指定账号,在目标 `CODEX_HOME` 中准备 `auth.json`。
|
||||
- `--home <home>`:指定已管理 home,如果该 home 绑定了账号,则先准备认证。
|
||||
- `--codex-home <path>`:和 `--account` 一起使用,用作目标 home 路径。
|
||||
- `-- <command...>`:实际启动的子命令,例如 `codex`。
|
||||
|
||||
`--account` 与 `--home` 互斥。没有命令参数时会报错。
|
||||
|
||||
### 运行机制
|
||||
|
||||
`src/run_cmd.rs` 的 `run_with_account_or_home` 只做三件事:
|
||||
|
||||
1. 解析目标 home。
|
||||
2. 必要时调用 `account::prepare_account_in_home` 写入目标 home 的 `auth.json`。
|
||||
3. 启动子进程,并给子进程设置 `CODEX_HOME=<target_home>`。
|
||||
|
||||
子进程环境被设置后,Codex 自己会从这个 home 读取 `auth.json` 和会话状态文件。父进程的 shell 环境不会被永久修改。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs run] --> B{使用 --home?}
|
||||
B -->|是| C[resolve_home_for_run]
|
||||
C --> D{home 是否绑定账号}
|
||||
D -->|是| E[prepare_account_in_home]
|
||||
D -->|否| F[使用 home.path]
|
||||
E --> F
|
||||
B -->|否| G{是否有 --account}
|
||||
G -->|否| H[报错: run 需要 --account 或 --home]
|
||||
G -->|是| I[paths::codex_home(--codex-home)]
|
||||
I --> J[prepare_account_in_home]
|
||||
J --> F
|
||||
F --> K[Command::new]
|
||||
K --> L[child.env CODEX_HOME=target_home]
|
||||
L --> M[启动子命令并等待退出]
|
||||
```
|
||||
|
||||
`prepare_account_in_home` 的流程是:从主 home 加载 `cdxs.toml`,按 ID、邮箱或邮箱前缀查找账号,调用 `token::refresh_account_if_needed` 按需刷新 OAuth token,然后把账号写入目标 home 的 `auth.json`,更新账号 `last_used_at`,最后保存主配置。
|
||||
|
||||
这里的设计重点是:账号库只存在于主 home 的 `cdxs.toml`,而运行时认证材料会被物化到目标 home 的 `auth.json`。
|
||||
|
||||
## 会话管理
|
||||
|
||||
### Codex 会话状态来源
|
||||
|
||||
`src/session.rs` 认为 Codex 会话可见性由三类文件共同决定:
|
||||
|
||||
- `<home>/state_5.sqlite`:SQLite 数据库,读取 `threads` 表。
|
||||
- `<home>/session_index.jsonl`:会话选择器索引,每行 JSON 包含会话 ID 等信息。
|
||||
- `<home>/sessions/` 和 `<home>/archived_sessions/`:rollout JSONL 文件目录。
|
||||
|
||||
`SessionSummary` 由 SQLite 的 `threads` 行转换而来,包含 home 名称、home 路径、会话 ID、标题、cwd、更新时间、token 数、rollout 路径和 archived 标记。
|
||||
|
||||
### home 选择规则
|
||||
|
||||
会话命令都有 `--all-homes` 选项。底层通过 `homes(all_homes)` 选择扫描范围:
|
||||
|
||||
- 不传 `--all-homes`:只扫描当前解析出来的默认 home,名称固定为 `default`。
|
||||
- 传 `--all-homes`:加载主 home 的 `cdxs.toml`,遍历 `Store.homes`,展开每个路径,并按路径去重。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[session 命令] --> B{--all-homes?}
|
||||
B -->|否| C[只返回 paths::codex_home(None)]
|
||||
B -->|是| D[Store::load(default_home)]
|
||||
D --> E[遍历 store.homes]
|
||||
E --> F[expand_home]
|
||||
F --> G[按路径去重]
|
||||
C --> H[扫描会话]
|
||||
G --> H
|
||||
```
|
||||
|
||||
### 会话命令入口
|
||||
|
||||
`src/cli.rs` 定义 `cdxs session` 子命令,`src/main.rs` 分发到 `session.rs`:
|
||||
|
||||
- `cdxs session list [--all-homes] [--json]`:列出会话。
|
||||
- `cdxs session stats <session_id> [--all-homes] [--json]`:显示单个会话的 token 和文件统计。
|
||||
- `cdxs session trash <session_ids...> [--all-homes]`:把会话移入 `cdxs` 自己的垃圾箱。
|
||||
- `cdxs session trash-list [--all-homes] [--json]`:列出垃圾箱条目。
|
||||
- `cdxs session restore <session_ids...> [--all-homes]`:从垃圾箱恢复会话。
|
||||
- `cdxs session visibility check [--all-homes] [--json]`:检查 SQLite、索引、rollout 的一致性。
|
||||
- `cdxs session visibility repair [--all-homes] [--json]`:修复可见性问题。
|
||||
- `cdxs session sync-threads [--all-homes] [--dry-run] [--json]`:在多个 home 之间补齐缺失线程。
|
||||
|
||||
### 列表与统计
|
||||
|
||||
`list_sessions` 遍历选中的 home,调用 `read_sessions_for_home` 打开 `<home>/state_5.sqlite`,读取 `threads` 表,然后按 `updated_at_ms` 倒序输出。若数据库不存在,该 home 返回空列表,不报错。
|
||||
|
||||
`session_stats` 先用 `find_thread` 查找指定会话,再解析对应 rollout 文件:
|
||||
|
||||
- 文件存在时读取大小和行数。
|
||||
- 遍历 JSONL 行,递归查找最后一个 `total_token_usage`,提取 total、input、output token。
|
||||
- SQLite 中的 `tokens_used` 和 rollout 中的 token 统计会分别显示。
|
||||
|
||||
### 垃圾箱与恢复
|
||||
|
||||
`trash_sessions` 对每个会话调用 `trash_one`。垃圾箱目录位于 `<home>/cdxs-trash/<timestamp>-<session_id>-<home_name>/`。
|
||||
|
||||
移动到垃圾箱时会保存三类可恢复材料:
|
||||
|
||||
- rollout 文件副本,如果原 rollout 存在。
|
||||
- 从 `session_index.jsonl` 删除的原始行。
|
||||
- SQLite `threads` 原始行,写入 `manifest.json`。
|
||||
|
||||
随后代码会从 SQLite `threads` 表删除该会话,并删除原 rollout 文件。这样 Codex 不再能看到该会话。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Cmd as cdxs session trash
|
||||
participant S as session.rs
|
||||
participant DB as state_5.sqlite
|
||||
participant IDX as session_index.jsonl
|
||||
participant Rollout as rollout jsonl
|
||||
participant Trash as cdxs-trash
|
||||
|
||||
Cmd->>S: trash_sessions(ids)
|
||||
S->>DB: find_thread(session_id)
|
||||
S->>Trash: 创建垃圾箱目录
|
||||
S->>Rollout: 复制 rollout 到垃圾箱
|
||||
S->>IDX: 删除匹配 session_id 的索引行
|
||||
S->>Trash: 写 manifest.json
|
||||
S->>DB: DELETE FROM threads
|
||||
S->>Rollout: 删除原 rollout 文件
|
||||
```
|
||||
|
||||
`restore_sessions` 会扫描垃圾箱 manifest,找到匹配会话后调用 `restore_one`:
|
||||
|
||||
1. 把 manifest 中保存的 `ThreadRowData` 插回 SQLite。
|
||||
2. 把备份 rollout 复制回原始 rollout 路径。
|
||||
3. 把保存的 `session_index.jsonl` 行追加回索引,已有同 ID 时不会重复追加。
|
||||
4. 删除对应垃圾箱目录。
|
||||
|
||||
### 可见性检查与修复
|
||||
|
||||
`visibility_check` 会同时读取 SQLite thread、`session_index.jsonl` 和 rollout 文件,报告以下问题:
|
||||
|
||||
- `missing_rollout`:SQLite 中有线程,但 `rollout_path` 指向的文件不存在。
|
||||
- `missing_index`:SQLite 中有线程,但 `session_index.jsonl` 没有该 ID。
|
||||
- `orphan_index`:索引里有 ID,但 SQLite 没有线程。
|
||||
- `orphan_rollout`:rollout 文件里有 `session_meta`,但 SQLite 没有线程。
|
||||
|
||||
rollout 扫描只处理 `sessions/` 和 `archived_sessions/` 下的 `.jsonl` 文件,并只读取每个文件前 25 行查找 `type == "session_meta"` 的记录。
|
||||
|
||||
`visibility_repair` 的修复策略是以 SQLite 现有线程为优先来源:
|
||||
|
||||
- 如果 SQLite 线程的 rollout 路径不存在,但扫描到了同 ID 的 rollout,则更新 SQLite 中的 `rollout_path`。
|
||||
- 如果 SQLite 线程缺少索引行,则根据 thread 生成紧凑 JSONL 索引并追加。
|
||||
- 如果存在 orphan rollout,则从 rollout 的 `session_meta` 构造一个最小 `ThreadRowData` 并插入 SQLite,同时补索引。
|
||||
|
||||
修复前会备份 `state_5.sqlite` 和 `session_index.jsonl` 到 `<home>/cdxs-backups/`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[visibility repair] --> B[读取 threads]
|
||||
B --> C[读取 session_index.jsonl]
|
||||
C --> D[扫描 sessions 和 archived_sessions]
|
||||
D --> E{线程 rollout 缺失但找到同 ID rollout?}
|
||||
E -->|是| F[备份状态文件并更新 rollout_path]
|
||||
E -->|否| G[继续]
|
||||
F --> G
|
||||
G --> H{线程缺少索引?}
|
||||
H -->|是| I[备份并追加索引行]
|
||||
H -->|否| J[继续]
|
||||
I --> J
|
||||
J --> K{rollout 没有 SQLite 线程?}
|
||||
K -->|是| L[从 session_meta 构造最小 thread 并插入]
|
||||
K -->|否| M[结束]
|
||||
L --> M
|
||||
```
|
||||
|
||||
### 跨 home 补齐会话
|
||||
|
||||
`sync_threads` 用于在多个已管理 home 之间复制缺失的会话线程。它要求选中 home 数量至少为 2;通常需要配合 `--all-homes` 使用,否则只会选到默认 home 并报错。
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 读取每个 home 的 SQLite threads 和索引 ID。
|
||||
2. 按 session ID 建立全局映射,第一次看到的线程作为源。
|
||||
3. 对每个目标 home,检查是否缺少某个 session ID。
|
||||
4. 如果源 rollout 不存在,则记录 `skip`。
|
||||
5. 如果不是 `--dry-run`,则备份目标状态文件,复制 rollout,插入 SQLite thread,并按需追加索引行。
|
||||
|
||||
当目标路径上已经存在同名 rollout 时,`portable_rollout_path` 会把目标路径改为 `sessions/cdxs-sync/<source_home>/<file_name>`,避免覆盖目标 home 的已有文件。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Cmd as cdxs session sync-threads
|
||||
participant S as session.rs
|
||||
participant Src as 源 home
|
||||
participant Tgt as 目标 home
|
||||
|
||||
Cmd->>S: sync_threads(all_homes, dry_run, json)
|
||||
S->>S: homes(all_homes) 并要求至少两个
|
||||
S->>Src: 读取 threads 和 session_index
|
||||
S->>Tgt: 读取 threads 和 session_index
|
||||
S->>S: 第一次看到的 session 作为源
|
||||
alt 目标缺少 session 且源 rollout 存在
|
||||
S->>Tgt: 备份 state_5.sqlite 和 session_index.jsonl
|
||||
S->>Tgt: 复制 rollout
|
||||
S->>Tgt: INSERT OR REPLACE thread
|
||||
S->>Tgt: 必要时追加 index
|
||||
else dry-run
|
||||
S->>S: 只记录 would_sync
|
||||
else 源 rollout 缺失
|
||||
S->>S: 记录 skip
|
||||
end
|
||||
```
|
||||
|
||||
## 安全性与持久化细节
|
||||
|
||||
配置保存和会话修复会尽量避免直接覆盖关键文件:
|
||||
|
||||
- `Store::save` 保存 `cdxs.toml` 前会先备份旧文件,再原子写入新文件。
|
||||
- `remove_session_index_entries` 修改 `session_index.jsonl` 前会备份旧索引。
|
||||
- `restore_session_index_entries` 恢复索引前会备份旧索引。
|
||||
- `backup_state_files` 会备份 `state_5.sqlite` 和 `session_index.jsonl`,供修复和同步使用。
|
||||
|
||||
需要注意的是,`trash_one` 删除 SQLite 行和 rollout 文件本身不是一个数据库事务加文件事务的整体原子操作;它通过先写 manifest 和备份 rollout 来保证后续可以用 `restore` 尝试恢复。
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
# 配置同步/服务端
|
||||
|
||||
本文说明 `cdxs` 当前配置同步和同步服务端功能的实现方式、调用机制和运行过程。
|
||||
|
||||
## 功能范围
|
||||
|
||||
配置同步/服务端主要覆盖以下能力:
|
||||
|
||||
- 启动一个最小 HTTP 同步服务。
|
||||
- 在服务端配置文件中添加或更新登录用户。
|
||||
- 客户端使用用户名和密码登录同步服务。
|
||||
- 客户端把本地可移植配置推送到服务端。
|
||||
- 客户端从服务端拉取可移植配置并覆盖本地状态。
|
||||
- 查看当前客户端同步配置状态。
|
||||
- 使用 Docker 或 docker-compose 运行同步服务。
|
||||
|
||||
当前同步的数据只包含 `cdxs.toml` 中的可移植状态:
|
||||
|
||||
- `meta`
|
||||
- `accounts`
|
||||
- `homes`
|
||||
|
||||
不会同步客户端本地的 `sync` 配置,也不会把服务端的 `server.users`、`server.sessions` 下发给客户端。
|
||||
|
||||
## 核心文件
|
||||
|
||||
- `src/main.rs`:CLI 入口,负责把 `server` 和 `sync` 命令分发到具体模块。
|
||||
- `src/cli.rs`:定义同步服务端和客户端命令参数。
|
||||
- `src/server.rs`:HTTP 同步服务端实现,包含用户管理、登录、鉴权、状态读写。
|
||||
- `src/sync_client.rs`:客户端同步逻辑,包含登录、拉取、推送和状态查看。
|
||||
- `src/config_store.rs`:`cdxs.toml` 数据模型、加载、保存和默认 home 初始化。
|
||||
- `Dockerfile`:构建并运行 `cdxs server run` 的容器镜像。
|
||||
- `docker-compose.yml`:使用具名 volume 持久化 `/data/cdxs.toml` 并暴露 `8765` 端口。
|
||||
|
||||
## 数据保存方式
|
||||
|
||||
同步功能复用 `Store` 结构,也就是 `cdxs.toml` 的配置模型。
|
||||
|
||||
客户端默认读取当前 `CODEX_HOME` 下的配置:
|
||||
|
||||
```text
|
||||
<CODEX_HOME>\cdxs.toml
|
||||
```
|
||||
|
||||
如果没有设置 `CODEX_HOME`,默认使用:
|
||||
|
||||
```text
|
||||
%USERPROFILE%\.codex\cdxs.toml
|
||||
```
|
||||
|
||||
服务端有两种数据路径:
|
||||
|
||||
- 如果 `server run --data <路径>` 被指定,服务端直接使用这个路径保存数据。
|
||||
- 如果未指定 `--data`,服务端使用当前 `CODEX_HOME` 下的 `cdxs.toml`。
|
||||
|
||||
`server user add` 也使用同一套路径规则,因此添加用户和启动服务必须指向同一个数据文件,才能让服务端读取到该用户。
|
||||
|
||||
## Store 中的相关字段
|
||||
|
||||
`Store` 中和同步相关的字段包括:
|
||||
|
||||
- `meta`:当前账号、当前 home 等元数据。
|
||||
- `accounts`:账号列表。
|
||||
- `homes`:受管理的 `CODEX_HOME` 列表。
|
||||
- `server`:服务端用户和 bearer session,仅服务端使用。
|
||||
- `sync`:客户端保存的服务端地址、用户名、token、最近拉取/推送时间。
|
||||
|
||||
服务端用户保存在:
|
||||
|
||||
```text
|
||||
Store.server.users
|
||||
```
|
||||
|
||||
服务端登录 session 保存在:
|
||||
|
||||
```text
|
||||
Store.server.sessions
|
||||
```
|
||||
|
||||
客户端同步配置保存在:
|
||||
|
||||
```text
|
||||
Store.sync
|
||||
```
|
||||
|
||||
## 服务端用户与 Token 原理
|
||||
|
||||
添加用户时,`server::add_user` 会生成随机 salt,并把密码保存为哈希值。
|
||||
|
||||
密码哈希方式:
|
||||
|
||||
```text
|
||||
sha256(salt + ":" + password)
|
||||
```
|
||||
|
||||
登录成功后,服务端生成一个随机 bearer token,并只保存 token 的哈希值。
|
||||
|
||||
token 哈希方式:
|
||||
|
||||
```text
|
||||
sha256("cdxs-token:" + token)
|
||||
```
|
||||
|
||||
原始 token 只在 `/v1/login` 响应中返回一次,由客户端保存到本地 `Store.sync.token`。后续 `pull` 和 `push` 使用:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
服务端收到请求后,对请求中的 token 重新计算哈希,并检查是否存在于 `Store.server.sessions`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[server user add] --> B[生成 salt]
|
||||
B --> C[计算 password_hash]
|
||||
C --> D[写入 Store.server.users]
|
||||
E[sync login] --> F[校验 username/password]
|
||||
F --> G[生成 bearer token]
|
||||
G --> H[保存 token_hash 到 Store.server.sessions]
|
||||
H --> I[原始 token 返回给客户端]
|
||||
J[sync pull/push] --> K[Authorization Bearer token]
|
||||
K --> L[服务端计算 token_hash]
|
||||
L --> M{sessions 中是否存在}
|
||||
M -->|是| N[允许访问 /v1/state]
|
||||
M -->|否| O[返回 401]
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
|
||||
同步服务端使用 `axum` 提供三个接口:
|
||||
|
||||
- `GET /health`:健康检查,返回 `ok`。
|
||||
- `POST /v1/login`:使用用户名和密码登录,返回 bearer token。
|
||||
- `GET /v1/state`:鉴权后返回服务端保存的可移植状态。
|
||||
- `PUT /v1/state`:鉴权后用客户端提交的可移植状态替换服务端状态。
|
||||
|
||||
`/v1/state` 的读写都会经过 `authorize` 鉴权。
|
||||
|
||||
服务端返回给客户端前会调用 `sanitized_for_client`,把以下字段清空:
|
||||
|
||||
- `server`
|
||||
- `sync`
|
||||
|
||||
这样客户端不会拿到服务端用户、密码哈希、已签发 session,也不会拿到服务端自己的同步配置。
|
||||
|
||||
## 命令调用机制
|
||||
|
||||
同步相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户执行 cdxs 命令] --> B[Cli::parse]
|
||||
B --> C[src/main.rs match Commands]
|
||||
C --> D{命令类型}
|
||||
D -->|cdxs server run| E[server::run_server]
|
||||
D -->|cdxs server user add| F[server::add_user]
|
||||
D -->|cdxs sync login| G[sync_client::login]
|
||||
D -->|cdxs sync pull| H[sync_client::pull]
|
||||
D -->|cdxs sync push| I[sync_client::push]
|
||||
D -->|cdxs sync status| J[sync_client::status]
|
||||
```
|
||||
|
||||
## 添加服务端用户运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs server user add <username> --password <password>
|
||||
```
|
||||
|
||||
可选指定服务端数据文件:
|
||||
|
||||
```powershell
|
||||
cdxs server user add <username> --password <password> --data <cdxs.toml路径>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 调用 `resolve_data_path` 解析服务端数据文件路径。
|
||||
2. 调用 `Store::load_from_path` 加载服务端 `cdxs.toml`,文件不存在时生成默认 store。
|
||||
3. 校验用户名和密码不能为空。
|
||||
4. 生成随机 salt。
|
||||
5. 使用 salt 和密码计算 `password_hash`。
|
||||
6. 如果用户已存在,替换原用户记录。
|
||||
7. 如果用户不存在,追加到 `Store.server.users`。
|
||||
8. 调用 `Store::save_to_path` 保存服务端配置。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant M as main.rs
|
||||
participant S as server.rs
|
||||
participant C as config_store.rs
|
||||
|
||||
U->>M: cdxs server user add alice --password ***
|
||||
M->>S: add_user(data, username, password)
|
||||
S->>S: resolve_data_path
|
||||
S->>C: Store::load_from_path
|
||||
C-->>S: Store
|
||||
S->>S: random_token 生成 salt
|
||||
S->>S: hash_secret
|
||||
S->>S: upsert ServerUser
|
||||
S->>C: save_to_path
|
||||
C-->>U: 用户已添加/更新
|
||||
```
|
||||
|
||||
## 启动服务端运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs server run
|
||||
```
|
||||
|
||||
默认监听:
|
||||
|
||||
```text
|
||||
127.0.0.1:8765
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
cdxs server run --bind 0.0.0.0:8765
|
||||
cdxs server run --data <cdxs.toml路径>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 调用 `resolve_data_path` 解析服务端数据文件和默认 home。
|
||||
2. 解析 `--bind` 为 `SocketAddr`。
|
||||
3. 创建 `AppState`,保存数据路径、默认 home 和一个异步 `Mutex`。
|
||||
4. 注册 `/health`、`/v1/login`、`/v1/state` 路由。
|
||||
5. 绑定 TCP listener。
|
||||
6. 调用 `axum::serve` 持续处理请求。
|
||||
|
||||
服务端在每个会读写配置文件的请求中都会先获取 `AppState.lock`。这个锁用于串行化文件读写,避免并发请求同时读写同一个 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs server run] --> B[resolve_data_path]
|
||||
B --> C[解析 bind 地址]
|
||||
C --> D[创建 AppState]
|
||||
D --> E[注册 axum Router]
|
||||
E --> F[绑定 TcpListener]
|
||||
F --> G[axum::serve]
|
||||
G --> H[处理 /health /v1/login /v1/state]
|
||||
```
|
||||
|
||||
## 客户端登录运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs sync login --server http://127.0.0.1:8765 --user alice --password <password>
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 客户端规范化 server URL,去掉末尾 `/`。
|
||||
2. 向 `<server>/v1/login` 发送 JSON 请求。
|
||||
3. 服务端加载 `cdxs.toml`。
|
||||
4. 服务端按用户名查找 `Store.server.users`。
|
||||
5. 服务端校验密码哈希。
|
||||
6. 登录成功后生成 bearer token。
|
||||
7. 服务端保存 token 哈希到 `Store.server.sessions`。
|
||||
8. 客户端解析响应中的原始 token。
|
||||
9. 客户端加载本地 `cdxs.toml`。
|
||||
10. 客户端写入 `Store.sync.server_url`、`Store.sync.username`、`Store.sync.token`。
|
||||
11. 客户端保存本地配置。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant C as sync_client.rs
|
||||
participant H as HTTP
|
||||
participant S as server.rs
|
||||
participant FS as cdxs.toml
|
||||
|
||||
U->>C: cdxs sync login
|
||||
C->>H: POST /v1/login username/password
|
||||
H->>S: login_handler
|
||||
S->>FS: 加载服务端 Store
|
||||
S->>S: 校验 password_hash
|
||||
S->>S: 生成 bearer token 并保存 token_hash
|
||||
S->>FS: 保存服务端 Store
|
||||
S-->>C: token
|
||||
C->>FS: 加载本地 Store
|
||||
C->>FS: 保存 sync.server_url / username / token
|
||||
C-->>U: sync login 成功
|
||||
```
|
||||
|
||||
## 拉取配置运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs sync pull
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载本地 `cdxs.toml`。
|
||||
2. 从 `Store.sync` 读取 server URL 和 token。
|
||||
3. 如果未登录或缺少 token,返回错误。
|
||||
4. 向 `<server>/v1/state` 发送 `GET` 请求,并附带 `Authorization` header。
|
||||
5. 服务端校验 bearer token。
|
||||
6. 服务端加载服务端 `Store`。
|
||||
7. 服务端清空 `server` 和 `sync` 字段后返回 JSON。
|
||||
8. 客户端解析远端 `Store`。
|
||||
9. 客户端用远端 `meta`、`accounts`、`homes` 覆盖本地对应字段。
|
||||
10. 客户端保留本地 `sync` 配置,并更新 `last_pull_at`。
|
||||
11. 保存本地 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[cdxs sync pull] --> B[加载本地 Store]
|
||||
B --> C[sync_endpoint 读取 server/token]
|
||||
C --> D[GET /v1/state]
|
||||
D --> E[服务端 authorize]
|
||||
E --> F[服务端 sanitized_for_client]
|
||||
F --> G[返回远端 Store]
|
||||
G --> H[覆盖本地 meta/accounts/homes]
|
||||
H --> I[更新 last_pull_at]
|
||||
I --> J[保存本地 cdxs.toml]
|
||||
```
|
||||
|
||||
## 推送配置运行过程
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs sync push
|
||||
```
|
||||
|
||||
运行过程:
|
||||
|
||||
1. 加载本地 `cdxs.toml`。
|
||||
2. 从 `Store.sync` 读取 server URL 和 token。
|
||||
3. 克隆本地 `Store` 作为上传 payload。
|
||||
4. 上传前把 payload 中的 `server` 和 `sync` 字段清空。
|
||||
5. 向 `<server>/v1/state` 发送 `PUT` 请求,并附带 `Authorization` header。
|
||||
6. 服务端校验 bearer token。
|
||||
7. 服务端加载原服务端 `Store`。
|
||||
8. 服务端只用 payload 中的 `meta`、`accounts`、`homes` 替换服务端状态。
|
||||
9. 服务端把 `sync` 置为默认值,保留原服务端的 `server.users` 和 `server.sessions`。
|
||||
10. 服务端保存配置。
|
||||
11. 客户端更新本地 `last_push_at`。
|
||||
12. 客户端保存本地 `cdxs.toml`。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as sync_client.rs
|
||||
participant S as server.rs
|
||||
participant FS as cdxs.toml
|
||||
|
||||
C->>FS: 加载本地 Store
|
||||
C->>C: payload.server/default, payload.sync/default
|
||||
C->>S: PUT /v1/state + Bearer token
|
||||
S->>FS: 加载服务端 Store
|
||||
S->>S: authorize
|
||||
S->>S: 替换 meta/accounts/homes
|
||||
S->>FS: 保存服务端 Store
|
||||
S-->>C: sanitized Store
|
||||
C->>FS: 更新 last_push_at 并保存
|
||||
```
|
||||
|
||||
## 查看同步状态
|
||||
|
||||
命令:
|
||||
|
||||
```powershell
|
||||
cdxs sync status
|
||||
```
|
||||
|
||||
输出内容来自本地 `Store.sync`:
|
||||
|
||||
- `server`
|
||||
- `user`
|
||||
- `token`
|
||||
- `last_pull_at`
|
||||
- `last_push_at`
|
||||
|
||||
如果本地保存了 token,输出只显示:
|
||||
|
||||
```text
|
||||
<stored>
|
||||
```
|
||||
|
||||
不会打印 token 明文。
|
||||
|
||||
## Docker 运行方式
|
||||
|
||||
`Dockerfile` 使用两阶段构建:
|
||||
|
||||
1. 使用 `rust:1-bookworm` 构建 release 版本 `cdxs`。
|
||||
2. 使用 `debian:bookworm-slim` 作为运行镜像。
|
||||
3. 安装 `ca-certificates`。
|
||||
4. 把 `cdxs` 复制到 `/usr/local/bin/cdxs`。
|
||||
5. 暴露 `8765` 端口。
|
||||
6. 声明 `/data` volume。
|
||||
|
||||
容器默认命令:
|
||||
|
||||
```text
|
||||
cdxs server run --bind 0.0.0.0:8765 --data /data/cdxs.toml
|
||||
```
|
||||
|
||||
`docker-compose.yml` 会:
|
||||
|
||||
- 构建当前目录镜像。
|
||||
- 将容器命名为 `cdxs-server`。
|
||||
- 设置 `restart: unless-stopped`。
|
||||
- 映射宿主机 `8765` 到容器 `8765`。
|
||||
- 使用具名 volume `cdxs-data` 挂载到 `/data`。
|
||||
|
||||
## 写入安全机制
|
||||
|
||||
服务端和客户端保存 `cdxs.toml` 都调用 `Store::save` 或 `Store::save_to_path`。
|
||||
|
||||
保存时会:
|
||||
|
||||
1. 如果目标文件已存在,先备份到对应 home 下的 `cdxs-backups`。
|
||||
2. 将 `Store` 序列化为 TOML。
|
||||
3. 写入同目录临时文件。
|
||||
4. 使用 rename 替换目标文件。
|
||||
|
||||
服务端请求还会使用 `tokio::sync::Mutex` 串行化文件读写,降低并发请求导致状态覆盖或文件损坏的风险。
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
- 同步没有字段级合并或冲突解决,`pull` 会用远端 `meta/accounts/homes` 覆盖本地,`push` 会用本地 `meta/accounts/homes` 覆盖服务端。
|
||||
- bearer session 当前没有过期时间和撤销命令。
|
||||
- 服务端用户只有添加/更新命令,没有删除、列表或改密命令。
|
||||
- 服务端没有多租户隔离;所有通过鉴权的用户访问同一份服务端状态。
|
||||
- HTTP 服务没有在代码中直接配置 TLS,需要由外部反向代理或部署环境处理。
|
||||
- `sync push` 不上传客户端本地 `sync` token,也不上传客户端本地 `server` 配置。
|
||||
- `sync pull` 不会写入远端 `server` 或远端 `sync` 字段,只更新本地可移植状态并保留本地同步登录信息。
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- 保存和管理多个 Codex OAuth 账号。
|
||||
- 保存和管理多个 Codex API Key 账号。
|
||||
- 通过 `login api` 保存 API Key 账号,可绑定自定义 API base URL。
|
||||
- 从现有 Codex `auth.json` 导入账号。
|
||||
- 通过 OpenAI OAuth 登录并保存 Codex token。
|
||||
- 将指定账号切换写入 Codex `auth.json`。
|
||||
|
||||
@@ -2,23 +2,63 @@ ARG RUST_IMAGE=rust:1-bookworm
|
||||
FROM --platform=$BUILDPLATFORM ${RUST_IMAGE} AS builder
|
||||
|
||||
ARG RUST_TARGET=x86_64-unknown-linux-gnu
|
||||
ARG CARGO_REGISTRY=sparse+https://rsproxy.cn/index/
|
||||
ARG APT_MIRROR=https://mirrors.ustc.edu.cn/debian
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc6-dev-arm64-cross \
|
||||
RUN mkdir -p /usr/local/cargo \
|
||||
&& printf '[source.crates-io]\nreplace-with = "mirror"\n\n[source.mirror]\nregistry = "%s"\n' "${CARGO_REGISTRY}" > /usr/local/cargo/config.toml
|
||||
|
||||
RUN find /etc/apt/sources.list.d -type f \( -name "*.list" -o -name "*.sources" \) -print0 2>/dev/null \
|
||||
| xargs -0 -r sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
&& if [ -f /etc/apt/sources.list ]; then \
|
||||
sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|https://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|https://security.debian.org/debian-security|${APT_MIRROR}-security|g" \
|
||||
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
-e "s|https://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& case "${RUST_TARGET}" in \
|
||||
aarch64-unknown-linux-gnu) \
|
||||
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross ;; \
|
||||
x86_64-pc-windows-gnu) \
|
||||
apt-get install -y --no-install-recommends binutils-mingw-w64-x86-64 gcc-mingw-w64-x86-64-posix mingw-w64-x86-64-dev ;; \
|
||||
esac \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup target add ${RUST_TARGET}
|
||||
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR=x86_64-w64-mingw32-ar
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release --target ${RUST_TARGET} \
|
||||
&& mkdir -p /out \
|
||||
&& cp target/${RUST_TARGET}/release/cdxs /out/cdxs
|
||||
&& if [ "${RUST_TARGET}" = "x86_64-pc-windows-gnu" ]; then \
|
||||
cp target/${RUST_TARGET}/release/cdxs.exe /out/cdxs.exe; \
|
||||
else \
|
||||
cp target/${RUST_TARGET}/release/cdxs /out/cdxs; \
|
||||
fi
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = "dist",
|
||||
[string]$RustImage = "rust:1-bookworm",
|
||||
[switch]$SkipDockerLinux,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Version) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
}
|
||||
|
||||
$dist = Join-Path $repoRoot $OutputDir
|
||||
if ($Clean -and (Test-Path $dist)) {
|
||||
Remove-Item -LiteralPath $dist -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
function Copy-ReleaseFile {
|
||||
param(
|
||||
[string]$SourceFile,
|
||||
[string]$OutputName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
throw "Missing release binary: $SourceFile"
|
||||
}
|
||||
|
||||
$outputPath = Join-Path $dist $OutputName
|
||||
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
|
||||
Write-Host "Wrote $outputPath"
|
||||
}
|
||||
|
||||
Write-Host "Building Windows x64"
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
|
||||
if (-not $SkipDockerLinux) {
|
||||
$linuxTargets = @(
|
||||
@{ Name = "linux-x64"; RustTarget = "x86_64-unknown-linux-gnu"; Platform = "linux/amd64" },
|
||||
@{ Name = "linux-arm64"; RustTarget = "aarch64-unknown-linux-gnu"; Platform = "linux/arm64" }
|
||||
)
|
||||
|
||||
foreach ($target in $linuxTargets) {
|
||||
$containerName = "cdxs-build-$($target.Name)-$([guid]::NewGuid().ToString('N'))"
|
||||
$imageTag = "cdxs-build:$($target.Name)"
|
||||
$tmpOutDir = Join-Path $dist ".tmp-$($target.Name)"
|
||||
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
|
||||
|
||||
Write-Host "Building $($target.Name) with Docker platform $($target.Platform)"
|
||||
$buildArgs = @(
|
||||
"buildx", "build",
|
||||
"--platform", $target.Platform,
|
||||
"--target", "builder",
|
||||
"--build-arg", "RUST_TARGET=$($target.RustTarget)",
|
||||
"--build-arg", "RUST_IMAGE=$RustImage",
|
||||
"--load",
|
||||
"-t", $imageTag,
|
||||
"-f", "scripts/docker/Dockerfile.release",
|
||||
"."
|
||||
)
|
||||
docker @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker build failed for $($target.Name)"
|
||||
}
|
||||
docker image inspect $imageTag | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker image was not created: $imageTag"
|
||||
}
|
||||
$containerId = docker create --name $containerName $imageTag
|
||||
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
|
||||
throw "Docker create failed for $imageTag"
|
||||
}
|
||||
try {
|
||||
docker cp "${containerName}:/out/cdxs" (Join-Path $tmpOutDir "cdxs")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker cp failed for $($target.Name)"
|
||||
}
|
||||
} finally {
|
||||
docker rm -f $containerName | Out-Null
|
||||
}
|
||||
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $tmpOutDir "cdxs") `
|
||||
-OutputName "cdxs-$Version-$($target.Name)"
|
||||
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Binary release artifacts are in $dist"
|
||||
@@ -1,48 +0,0 @@
|
||||
param(
|
||||
[string]$Registry = "docker.pchuan.top",
|
||||
[string]$ImageName = "cdxs",
|
||||
[string]$Tag = "",
|
||||
[switch]$NoLatest,
|
||||
[string]$Platform = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Tag) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Tag = $Matches[1]
|
||||
}
|
||||
|
||||
$image = "$Registry/$ImageName"
|
||||
$versionTag = "${image}:$Tag"
|
||||
$latestTag = "${image}:latest"
|
||||
|
||||
Write-Host "Building $versionTag"
|
||||
$buildArgs = @("build", "-t", $versionTag)
|
||||
if (-not $NoLatest) {
|
||||
$buildArgs += @("-t", $latestTag)
|
||||
}
|
||||
if ($Platform) {
|
||||
$buildArgs += @("--platform", $Platform)
|
||||
}
|
||||
$buildArgs += "."
|
||||
docker @buildArgs
|
||||
|
||||
Write-Host "Pushing $versionTag"
|
||||
docker push $versionTag
|
||||
|
||||
if (-not $NoLatest) {
|
||||
Write-Host "Pushing $latestTag"
|
||||
docker push $latestTag
|
||||
}
|
||||
|
||||
Write-Host "Published $versionTag"
|
||||
if (-not $NoLatest) {
|
||||
Write-Host "Published $latestTag"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = "dist",
|
||||
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
|
||||
[string]$Target = "all",
|
||||
[string]$RustImage = "docker.m.daocloud.io/library/rust:1-bookworm",
|
||||
[string]$CargoRegistry = "sparse+https://rsproxy.cn/index/",
|
||||
[string]$AptMirror = "https://mirrors.ustc.edu.cn/debian",
|
||||
[switch]$DockerWindows,
|
||||
[switch]$Clean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $Version) {
|
||||
$cargoToml = Get-Content -Raw -Path "Cargo.toml"
|
||||
if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') {
|
||||
throw "Cannot read package version from Cargo.toml"
|
||||
}
|
||||
$Version = $Matches[1]
|
||||
}
|
||||
|
||||
$dist = Join-Path $repoRoot $OutputDir
|
||||
if ($Clean -and (Test-Path $dist)) {
|
||||
Remove-Item -LiteralPath $dist -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
function Copy-ReleaseFile {
|
||||
param(
|
||||
[string]$SourceFile,
|
||||
[string]$OutputName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
throw "Missing release binary: $SourceFile"
|
||||
}
|
||||
|
||||
$outputPath = Join-Path $dist $OutputName
|
||||
Copy-Item -LiteralPath $SourceFile -Destination $outputPath -Force
|
||||
Write-Host "Wrote $outputPath"
|
||||
}
|
||||
|
||||
function Build-DockerRelease {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$RustTarget,
|
||||
[string]$Platform,
|
||||
[string]$ContainerBinary = "cdxs",
|
||||
[string]$OutputName = "cdxs-$Version-$Name"
|
||||
)
|
||||
|
||||
$safeRunId = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { [guid]::NewGuid().ToString("N") }
|
||||
$imageTag = "cdxs-build:$Name-$safeRunId"
|
||||
$containerName = "cdxs-build-$Name-$safeRunId"
|
||||
|
||||
Write-Host "Building $Name with Docker platform $Platform"
|
||||
docker buildx build `
|
||||
--platform $Platform `
|
||||
--target builder `
|
||||
--build-arg "RUST_TARGET=$RustTarget" `
|
||||
--build-arg "RUST_IMAGE=$RustImage" `
|
||||
--build-arg "CARGO_REGISTRY=$CargoRegistry" `
|
||||
--build-arg "APT_MIRROR=$AptMirror" `
|
||||
--load `
|
||||
-t $imageTag `
|
||||
-f "scripts/docker/Dockerfile.release" `
|
||||
.
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker build failed for $Name"
|
||||
}
|
||||
|
||||
$containerId = docker create --name $containerName $imageTag
|
||||
if ($LASTEXITCODE -ne 0 -or -not $containerId) {
|
||||
throw "Docker create failed for $imageTag"
|
||||
}
|
||||
|
||||
try {
|
||||
$tmpOutDir = Join-Path $dist ".tmp-$Name"
|
||||
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
|
||||
docker cp "${containerName}:/out/$ContainerBinary" (Join-Path $tmpOutDir $ContainerBinary)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker cp failed for $Name"
|
||||
}
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $tmpOutDir $ContainerBinary) `
|
||||
-OutputName $OutputName
|
||||
} finally {
|
||||
docker rm -f $containerName | Out-Null
|
||||
if (Test-Path $tmpOutDir) {
|
||||
Remove-Item -LiteralPath $tmpOutDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "win") {
|
||||
if ($DockerWindows) {
|
||||
Build-DockerRelease `
|
||||
-Name "windows-x64" `
|
||||
-RustTarget "x86_64-pc-windows-gnu" `
|
||||
-Platform "linux/amd64" `
|
||||
-ContainerBinary "cdxs.exe" `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
} else {
|
||||
Write-Host "Building Windows x64"
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows build failed"
|
||||
}
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $repoRoot "target/x86_64-pc-windows-msvc/release/cdxs.exe") `
|
||||
-OutputName "cdxs-$Version-windows-x64.exe"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "linux-x64") {
|
||||
Build-DockerRelease `
|
||||
-Name "linux-x64" `
|
||||
-RustTarget "x86_64-unknown-linux-gnu" `
|
||||
-Platform "linux/amd64"
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "linux-arm64") {
|
||||
Build-DockerRelease `
|
||||
-Name "linux-arm64" `
|
||||
-RustTarget "aarch64-unknown-linux-gnu" `
|
||||
-Platform "linux/arm64"
|
||||
}
|
||||
|
||||
Write-Host "Release artifacts are in $dist"
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version=""
|
||||
output_dir="dist"
|
||||
target="all"
|
||||
rust_image="${RUST_IMAGE:-docker.m.daocloud.io/library/rust:1-bookworm}"
|
||||
cargo_registry="${CARGO_REGISTRY:-sparse+https://rsproxy.cn/index/}"
|
||||
apt_mirror="${APT_MIRROR:-https://mirrors.ustc.edu.cn/debian}"
|
||||
clean=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/publish.sh [options]
|
||||
|
||||
Build release binaries with Docker buildx.
|
||||
|
||||
Options:
|
||||
--version VERSION Version used in output filenames.
|
||||
--output-dir DIR Output directory. Default: dist
|
||||
--target TARGET all, win, linux-x64, linux-arm64. Default: all
|
||||
--rust-image IMAGE Rust Docker image.
|
||||
--cargo-registry URL Cargo registry mirror.
|
||||
--apt-mirror URL Debian apt mirror.
|
||||
--clean Remove output directory before building.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
version="${2:?missing value for --version}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
output_dir="${2:?missing value for --output-dir}"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
target="${2:?missing value for --target}"
|
||||
shift 2
|
||||
;;
|
||||
--rust-image)
|
||||
rust_image="${2:?missing value for --rust-image}"
|
||||
shift 2
|
||||
;;
|
||||
--cargo-registry)
|
||||
cargo_registry="${2:?missing value for --cargo-registry}"
|
||||
shift 2
|
||||
;;
|
||||
--apt-mirror)
|
||||
apt_mirror="${2:?missing value for --apt-mirror}"
|
||||
shift 2
|
||||
;;
|
||||
--clean)
|
||||
clean=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${target}" in
|
||||
all|win|linux-x64|linux-arm64) ;;
|
||||
*)
|
||||
echo "Invalid --target: ${target}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
if [[ -z "${version}" ]]; then
|
||||
version="$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
|
||||
if [[ -z "${version}" ]]; then
|
||||
echo "Cannot read package version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${clean}" -eq 1 ]]; then
|
||||
rm -rf "${output_dir}"
|
||||
fi
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
build_asset() {
|
||||
local target_name="$1"
|
||||
local rust_target="$2"
|
||||
local platform="$3"
|
||||
local container_binary="$4"
|
||||
local output_name="$5"
|
||||
local run_id="${GITHUB_RUN_ID:-$(date +%s)-$$}"
|
||||
local image_tag="cdxs-build:${target_name}-${run_id}"
|
||||
local container_name="cdxs-build-${target_name}-${run_id}"
|
||||
|
||||
echo "Building ${target_name} with Docker platform ${platform}"
|
||||
|
||||
local proxy_build_args=()
|
||||
local proxy_var
|
||||
for proxy_var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy; do
|
||||
if [[ -n "${!proxy_var:-}" ]]; then
|
||||
proxy_build_args+=(--build-arg "${proxy_var}=${!proxy_var}")
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx build \
|
||||
--platform "${platform}" \
|
||||
--target builder \
|
||||
--build-arg "RUST_TARGET=${rust_target}" \
|
||||
--build-arg "RUST_IMAGE=${rust_image}" \
|
||||
--build-arg "CARGO_REGISTRY=${cargo_registry}" \
|
||||
--build-arg "APT_MIRROR=${apt_mirror}" \
|
||||
"${proxy_build_args[@]}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
|
||||
local container_id
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' RETURN
|
||||
docker cp "${container_id}:/out/${container_binary}" "${output_dir}/${output_name}"
|
||||
docker rm -f "${container_name}" >/dev/null
|
||||
trap - RETURN
|
||||
echo "Wrote ${output_dir}/${output_name}"
|
||||
}
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "win" ]]; then
|
||||
build_asset windows-x64 x86_64-pc-windows-gnu linux/amd64 cdxs.exe "cdxs-${version}-windows-x64.exe"
|
||||
fi
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "linux-x64" ]]; then
|
||||
build_asset linux-x64 x86_64-unknown-linux-gnu linux/amd64 cdxs "cdxs-${version}-linux-x64"
|
||||
fi
|
||||
|
||||
if [[ "${target}" == "all" || "${target}" == "linux-arm64" ]]; then
|
||||
build_asset linux-arm64 aarch64-unknown-linux-gnu linux/arm64 cdxs "cdxs-${version}-linux-arm64"
|
||||
fi
|
||||
|
||||
echo "Release artifacts are in ${output_dir}"
|
||||
+202
-26
@@ -12,7 +12,9 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::auth_file;
|
||||
use crate::config_store::{self, Account, AuthMode, Home, Store, Tokens};
|
||||
use crate::{jwt, paths, token};
|
||||
use crate::{codex_config, jwt, paths, token};
|
||||
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: bool) -> Result<()> {
|
||||
// Store imported credentials in the main cdxs config, even when the source
|
||||
@@ -30,6 +32,7 @@ pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: b
|
||||
store.meta.current_account_id = Some(id.clone());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?;
|
||||
codex_config::apply_account_provider(&source_home, account)?;
|
||||
}
|
||||
store.save(&config_home)?;
|
||||
println!("已导入账号: {email} ({id})");
|
||||
@@ -47,6 +50,7 @@ pub fn add_api_key(key: String, base_url: Option<String>, switch: bool) -> Resul
|
||||
store.meta.current_account_id = Some(id.clone());
|
||||
let account = store.find_account(&id).expect("just inserted account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
|
||||
codex_config::apply_account_provider(&home, account)?;
|
||||
}
|
||||
store.save(&home)?;
|
||||
println!("已保存 API Key 账号: {email} ({id})");
|
||||
@@ -79,6 +83,16 @@ pub async fn list_accounts(json: bool, force: bool) -> Result<()> {
|
||||
if quota_report.changed {
|
||||
store.save(&home)?;
|
||||
}
|
||||
print_accounts(&store, json)
|
||||
}
|
||||
|
||||
pub fn show_accounts(json: bool) -> Result<()> {
|
||||
let home = paths::codex_home(None)?;
|
||||
let store = Store::load(&home)?;
|
||||
print_accounts(&store, json)
|
||||
}
|
||||
|
||||
fn print_accounts(store: &Store, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
|
||||
return Ok(());
|
||||
@@ -87,36 +101,36 @@ pub async fn list_accounts(json: bool, force: bool) -> Result<()> {
|
||||
println!("没有保存的账号。可使用 `cdxs import auth` 导入。");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{:<3} {:<22} {:<34} {:<10} {:<12} {}",
|
||||
"", "ID", "Email", "Mode", "Plan", "Quota"
|
||||
);
|
||||
for account in &store.accounts {
|
||||
print_account_table_border();
|
||||
print_account_table_row("", "ID", "Email", "Mode", "Plan", "5h", "Week");
|
||||
print_account_table_border();
|
||||
let current_account_id = store.meta.current_account_id.as_deref();
|
||||
let mut accounts = store.accounts.iter().collect::<Vec<_>>();
|
||||
accounts.sort_by_key(|account| {
|
||||
if Some(account.id.as_str()) == current_account_id {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
for account in accounts {
|
||||
let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) {
|
||||
"*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let quota = account
|
||||
.quota
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
format!(
|
||||
"5h={}%, weekly={}%",
|
||||
q.primary_remaining_percent, q.secondary_remaining_percent
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
println!(
|
||||
"{:<3} {:<22} {:<34} {:<10} {:<12} {}",
|
||||
let (primary_quota, secondary_quota) = format_quota_cells(account);
|
||||
print_account_table_row(
|
||||
current,
|
||||
shorten(&account.id, 22),
|
||||
shorten(&account.email, 34),
|
||||
&account.id,
|
||||
account_email_display(account),
|
||||
auth_file::account_auth_mode_name(account),
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
quota
|
||||
account_plan_display(account),
|
||||
&primary_quota,
|
||||
&secondary_quota,
|
||||
);
|
||||
}
|
||||
print_account_table_border();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -237,6 +251,7 @@ async fn switch_account_id(
|
||||
.find_account(account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(target_home), target_home, account)?;
|
||||
codex_config::apply_account_provider(target_home, account)?;
|
||||
if let Some(account) = store.find_account_mut(account_id) {
|
||||
account.last_used_at = Utc::now().timestamp();
|
||||
}
|
||||
@@ -295,6 +310,7 @@ pub fn create_home(name: &str, path: PathBuf, account: Option<String>) -> Result
|
||||
if let Some(account_id) = bound_account_id.as_deref() {
|
||||
let account = store.find_account(account_id).expect("checked account");
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?;
|
||||
codex_config::apply_account_provider(&path, account)?;
|
||||
}
|
||||
store.homes.push(Home {
|
||||
name: name.to_string(),
|
||||
@@ -367,6 +383,7 @@ pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result
|
||||
.find_account(&account_id)
|
||||
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
|
||||
auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?;
|
||||
codex_config::apply_account_provider(&codex_home, account)?;
|
||||
(account.id.clone(), account.email.clone())
|
||||
};
|
||||
if let Some(account) = store.find_account_mut(&account_id) {
|
||||
@@ -478,14 +495,15 @@ fn api_key_account(key: String, base_url: Option<String>) -> Result<Account> {
|
||||
if key.is_empty() {
|
||||
return Err(anyhow!("API Key 不能为空"));
|
||||
}
|
||||
let base_url = normalize_api_base_url(base_url);
|
||||
let id = stable_id("apikey", key, base_url.as_deref(), None);
|
||||
let email = format!("api-key-{}", &id[id.len().saturating_sub(8)..]);
|
||||
let email = api_base_url_display(base_url.as_deref()).to_string();
|
||||
let now = Utc::now().timestamp();
|
||||
Ok(Account {
|
||||
id,
|
||||
email,
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
plan_type: Some("API_KEY".to_string()),
|
||||
plan_type: None,
|
||||
account_id: None,
|
||||
organization_id: None,
|
||||
tokens: None,
|
||||
@@ -623,6 +641,125 @@ fn format_quota(account: &Account) -> String {
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn account_plan_display(account: &Account) -> &str {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
"-"
|
||||
} else if account.requires_reauth {
|
||||
"reauth"
|
||||
} else {
|
||||
account.plan_type.as_deref().unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
fn account_email_display(account: &Account) -> &str {
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
api_base_url_display(account.api_base_url.as_deref())
|
||||
} else {
|
||||
&account.email
|
||||
}
|
||||
}
|
||||
|
||||
fn api_base_url_display(base_url: Option<&str>) -> &str {
|
||||
base_url.unwrap_or(DEFAULT_API_BASE_URL)
|
||||
}
|
||||
|
||||
fn format_quota_cells(account: &Account) -> (String, String) {
|
||||
let Some(quota) = account.quota.as_ref() else {
|
||||
return ("-".to_string(), "-".to_string());
|
||||
};
|
||||
(
|
||||
format_quota_cell(
|
||||
quota.primary_remaining_percent,
|
||||
quota.primary_reset_time,
|
||||
format_reset_time,
|
||||
),
|
||||
format_quota_cell(
|
||||
quota.secondary_remaining_percent,
|
||||
quota.secondary_reset_time,
|
||||
format_week_reset_time,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_quota_cell(
|
||||
remaining_percent: i32,
|
||||
reset_time: Option<i64>,
|
||||
format_reset: fn(i64) -> String,
|
||||
) -> String {
|
||||
format!(
|
||||
"{:>3}% / {}",
|
||||
remaining_percent,
|
||||
reset_time
|
||||
.map(format_reset)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn format_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
let minutes = (seconds % 3_600) / 60;
|
||||
if days > 0 {
|
||||
format!("{days}d{hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h{minutes:02}m")
|
||||
} else {
|
||||
format!("{minutes}m")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_week_reset_time(reset_time: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
let seconds = reset_time.saturating_sub(now);
|
||||
if seconds <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
format!("{days}d{hours:02}h")
|
||||
}
|
||||
|
||||
fn print_account_table_border() {
|
||||
println!(
|
||||
"+{}+{}+{}+{}+{}+{}+{}+",
|
||||
"-".repeat(3),
|
||||
"-".repeat(24),
|
||||
"-".repeat(30),
|
||||
"-".repeat(10),
|
||||
"-".repeat(8),
|
||||
"-".repeat(14),
|
||||
"-".repeat(14)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_account_table_row(
|
||||
marker: &str,
|
||||
id: &str,
|
||||
email: &str,
|
||||
mode: &str,
|
||||
plan: &str,
|
||||
primary_quota: &str,
|
||||
secondary_quota: &str,
|
||||
) {
|
||||
println!(
|
||||
"| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |",
|
||||
shorten(marker, 1),
|
||||
shorten(id, 22),
|
||||
shorten(email, 28),
|
||||
shorten(mode, 8),
|
||||
shorten(plan, 6),
|
||||
shorten(primary_quota, 12),
|
||||
shorten(secondary_quota, 12)
|
||||
);
|
||||
}
|
||||
|
||||
fn shorten(value: &str, width: usize) -> String {
|
||||
if value.chars().count() <= width {
|
||||
return value.to_string();
|
||||
@@ -637,9 +774,9 @@ fn shorten(value: &str, width: usize) -> String {
|
||||
|
||||
fn print_account(account: &Account) {
|
||||
println!("id: {}", account.id);
|
||||
println!("email: {}", account.email);
|
||||
println!("email: {}", account_email_display(account));
|
||||
println!("auth_mode: {}", auth_file::account_auth_mode_name(account));
|
||||
println!("plan_type: {}", account.plan_type.as_deref().unwrap_or("-"));
|
||||
println!("plan_type: {}", account_plan_display(account));
|
||||
println!(
|
||||
"account_id: {}",
|
||||
account.account_id.as_deref().unwrap_or("-")
|
||||
@@ -648,5 +785,44 @@ fn print_account(account: &Account) {
|
||||
"organization_id: {}",
|
||||
account.organization_id.as_deref().unwrap_or("-")
|
||||
);
|
||||
if account.auth_mode == AuthMode::ApiKey {
|
||||
println!(
|
||||
"api_base_url: {}",
|
||||
api_base_url_display(account.api_base_url.as_deref())
|
||||
);
|
||||
println!(
|
||||
"openai_api_key: {}",
|
||||
account
|
||||
.openai_api_key
|
||||
.as_deref()
|
||||
.map(mask_api_key)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
}
|
||||
println!("requires_reauth: {}", account.requires_reauth);
|
||||
}
|
||||
|
||||
fn normalize_api_base_url(base_url: Option<String>) -> Option<String> {
|
||||
base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn mask_api_key(key: &str) -> String {
|
||||
let chars = key.chars().collect::<Vec<_>>();
|
||||
if chars.len() <= 8 {
|
||||
return "<stored>".to_string();
|
||||
}
|
||||
let prefix = chars.iter().take(3).collect::<String>();
|
||||
let suffix = chars
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
+32
@@ -22,6 +22,29 @@ pub enum Commands {
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
/// List saved accounts without refreshing quota.
|
||||
Show {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Pull account state from the sync server.
|
||||
Pull {
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Push account state to the sync server.
|
||||
Push {
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Remove a saved account.
|
||||
Remove {
|
||||
#[arg(
|
||||
value_name = "ACCOUNT_ID_OR_EMAIL",
|
||||
help = "Account id, exact email, or email prefix"
|
||||
)]
|
||||
account: String,
|
||||
},
|
||||
/// Switch Codex auth.json to a saved account.
|
||||
Switch {
|
||||
#[arg(
|
||||
@@ -109,6 +132,15 @@ pub enum LoginCommands {
|
||||
#[arg(long)]
|
||||
switch: bool,
|
||||
},
|
||||
/// Add an OpenAI API key account.
|
||||
Api {
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
#[arg(long)]
|
||||
base_url: Option<String>,
|
||||
#[arg(long)]
|
||||
switch: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Codex `config.toml` helpers for account-specific provider settings.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
use toml::map::Map;
|
||||
use toml::Value;
|
||||
|
||||
use crate::config_store::{Account, AuthMode};
|
||||
use crate::{atomic, paths};
|
||||
|
||||
const MANAGED_PROVIDER_PREFIX: &str = "cdxs_api_";
|
||||
|
||||
pub fn apply_account_provider(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
match account.auth_mode {
|
||||
AuthMode::Oauth => clear_managed_provider(codex_home),
|
||||
AuthMode::ApiKey => apply_api_key_provider(codex_home, account),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_api_key_provider(codex_home: &Path, account: &Account) -> Result<()> {
|
||||
let path = paths::codex_config_path(codex_home);
|
||||
let mut config = read_config(&path)?;
|
||||
let mut changed = remove_previous_managed_provider(&mut config);
|
||||
|
||||
if let Some(base_url) = account.api_base_url.as_deref().and_then(normalize_base_url) {
|
||||
let provider_id = provider_id(account, &base_url);
|
||||
config.insert(
|
||||
"model_provider".to_string(),
|
||||
Value::String(provider_id.clone()),
|
||||
);
|
||||
|
||||
let providers = table_entry(&mut config, "model_providers");
|
||||
let mut provider = Map::new();
|
||||
provider.insert("name".to_string(), Value::String("cdxs api".to_string()));
|
||||
provider.insert("base_url".to_string(), Value::String(base_url));
|
||||
provider.insert("requires_openai_auth".to_string(), Value::Boolean(true));
|
||||
providers.insert(provider_id, Value::Table(provider));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
write_config(&path, codex_home, &config)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_managed_provider(codex_home: &Path) -> Result<()> {
|
||||
let path = paths::codex_config_path(codex_home);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut config = read_config(&path)?;
|
||||
if remove_previous_managed_provider(&mut config) {
|
||||
write_config(&path, codex_home, &config)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> Result<Map<String, Value>> {
|
||||
if !path.exists() {
|
||||
return Ok(Map::new());
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("读取 Codex config.toml 失败: {}", path.display()))?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(Map::new());
|
||||
}
|
||||
let value: Value = toml::from_str(&content)
|
||||
.with_context(|| format!("解析 Codex config.toml 失败: {}", path.display()))?;
|
||||
Ok(value.as_table().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn write_config(path: &Path, codex_home: &Path, config: &Map<String, Value>) -> Result<()> {
|
||||
atomic::backup_if_exists(path, codex_home, "config.toml")?;
|
||||
let content = toml::to_string_pretty(config).context("序列化 Codex config.toml 失败")?;
|
||||
atomic::write_atomic(path, &content)
|
||||
}
|
||||
|
||||
fn remove_previous_managed_provider(config: &mut Map<String, Value>) -> bool {
|
||||
let mut changed = false;
|
||||
let managed_current = config
|
||||
.get("model_provider")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|provider| provider.starts_with(MANAGED_PROVIDER_PREFIX))
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
if managed_current.is_some() {
|
||||
config.remove("model_provider");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if let Some(Value::Table(providers)) = config.get_mut("model_providers") {
|
||||
let before = providers.len();
|
||||
providers.retain(|key, _| !key.starts_with(MANAGED_PROVIDER_PREFIX));
|
||||
changed |= providers.len() != before;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn table_entry<'a>(config: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
|
||||
let needs_table = !matches!(config.get(key), Some(Value::Table(_)));
|
||||
if needs_table {
|
||||
config.insert(key.to_string(), Value::Table(Map::new()));
|
||||
}
|
||||
config
|
||||
.get_mut(key)
|
||||
.and_then(Value::as_table_mut)
|
||||
.expect("table entry was just inserted")
|
||||
}
|
||||
|
||||
fn provider_id(account: &Account, base_url: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(account.id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(base_url.as_bytes());
|
||||
let hex = hex::encode(hasher.finalize());
|
||||
format!("{MANAGED_PROVIDER_PREFIX}{}", &hex[..12])
|
||||
}
|
||||
|
||||
fn normalize_base_url(value: &str) -> Option<String> {
|
||||
let value = value.trim().trim_end_matches('/');
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.context("创建 HTTP client 失败")
|
||||
}
|
||||
+12
-1
@@ -7,7 +7,9 @@ mod account;
|
||||
mod atomic;
|
||||
mod auth_file;
|
||||
mod cli;
|
||||
mod codex_config;
|
||||
mod config_store;
|
||||
mod http_client;
|
||||
mod jwt;
|
||||
mod oauth;
|
||||
mod paths;
|
||||
@@ -39,6 +41,11 @@ async fn main() -> Result<()> {
|
||||
port,
|
||||
switch,
|
||||
}) => oauth::login_oauth(manual, port, switch).await,
|
||||
Some(LoginCommands::Api {
|
||||
key,
|
||||
base_url,
|
||||
switch,
|
||||
}) => account::add_api_key(key, base_url, switch),
|
||||
None => oauth::login_oauth(args.manual, args.port, args.switch).await,
|
||||
},
|
||||
Commands::Import(args) => match args.command {
|
||||
@@ -50,13 +57,17 @@ async fn main() -> Result<()> {
|
||||
None => account::import_auth(args.file, args.codex_home, args.switch),
|
||||
},
|
||||
Commands::List { json, force } => account::list_accounts(json, force).await,
|
||||
Commands::Show { json } => account::show_accounts(json),
|
||||
Commands::Pull { force } => sync_client::pull(force).await,
|
||||
Commands::Push { force } => sync_client::push(force).await,
|
||||
Commands::Remove { account } => account::remove_account(&account),
|
||||
Commands::Switch {
|
||||
account,
|
||||
auto,
|
||||
codex_home,
|
||||
apply_fingerprint,
|
||||
} => {
|
||||
if auto {
|
||||
if auto || account.is_none() {
|
||||
account::switch_auto(codex_home, apply_fingerprint).await
|
||||
} else {
|
||||
account::switch_account(
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ use std::io::{self, Write};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use rand::RngCore;
|
||||
use rand::RngExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -61,7 +61,7 @@ fn build_auth_url(redirect_uri: &str, code_challenge: &str, state: &str) -> Stri
|
||||
|
||||
fn random_base64url_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
rand::rng().fill(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ async fn exchange_code_for_token(code: &str, code_verifier: &str, port: u16) ->
|
||||
// Store the refresh token when the provider returns one so cdxs can refresh
|
||||
// access tokens before switch/run/quota operations.
|
||||
let redirect_uri = format!("http://localhost:{port}/auth/callback");
|
||||
let response = reqwest::Client::new()
|
||||
let response = crate::http_client::client()?
|
||||
.post(TOKEN_ENDPOINT)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
|
||||
@@ -27,6 +27,10 @@ pub fn auth_path(codex_home: &std::path::Path) -> PathBuf {
|
||||
codex_home.join("auth.json")
|
||||
}
|
||||
|
||||
pub fn codex_config_path(codex_home: &std::path::Path) -> PathBuf {
|
||||
codex_home.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn expand_home(path: PathBuf) -> PathBuf {
|
||||
// PathBuf does not expand ~ on Windows or Unix, so handle the common cases.
|
||||
let raw = path.to_string_lossy();
|
||||
|
||||
+24
-4
@@ -29,7 +29,8 @@ struct RateLimitInfo {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct UsageResponse {
|
||||
plan_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_plan_type")]
|
||||
plan_type: Option<Option<String>>,
|
||||
rate_limit: Option<RateLimitInfo>,
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
|
||||
.find_account_mut(account_id)
|
||||
.expect("quota result references an existing account");
|
||||
if let Some(plan) = quota.plan_type {
|
||||
account.plan_type = Some(plan);
|
||||
account.plan_type = plan;
|
||||
}
|
||||
account.quota = Some(quota.quota);
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
@@ -326,7 +327,7 @@ fn apply_quota_result(store: &mut Store, account_id: &str, quota: FetchQuotaResu
|
||||
|
||||
struct FetchQuotaResult {
|
||||
quota: Quota,
|
||||
plan_type: Option<String>,
|
||||
plan_type: Option<Option<String>>,
|
||||
}
|
||||
|
||||
async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result<FetchQuotaResult> {
|
||||
@@ -346,7 +347,7 @@ async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result<Fet
|
||||
);
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
let response = crate::http_client::client()?
|
||||
.get(USAGE_URL)
|
||||
.headers(headers)
|
||||
.send()
|
||||
@@ -388,6 +389,25 @@ fn parse_quota(usage: &UsageResponse) -> Quota {
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_plan_type<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
Ok(match value {
|
||||
None => None,
|
||||
Some(serde_json::Value::Null) => Some(None),
|
||||
Some(serde_json::Value::String(plan)) => Some(Some(plan)),
|
||||
Some(value) => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected plan_type to be string or null, got {value}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remaining_percent(window: &WindowInfo) -> i32 {
|
||||
100 - window.used_percent.unwrap_or(0).clamp(0, 100)
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::Utc;
|
||||
use rand::RngCore;
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -314,7 +314,7 @@ fn normalize_username(username: &str) -> Result<String> {
|
||||
|
||||
fn random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
rand::rng().fill(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
|
||||
+27
-12
@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config_store::{Account, AccountSyncState, Store};
|
||||
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginRequest<'a> {
|
||||
username: &'a str,
|
||||
@@ -35,7 +37,7 @@ pub async fn login(server: &str, user: &str, password: &str) -> Result<()> {
|
||||
if server.is_empty() {
|
||||
return Err(anyhow!("server URL 不能为空"));
|
||||
}
|
||||
let response = reqwest::Client::new()
|
||||
let response = crate::http_client::client()?
|
||||
.post(format!("{server}/v1/login"))
|
||||
.json(&LoginRequest {
|
||||
username: user,
|
||||
@@ -90,12 +92,6 @@ pub async fn push(force: bool) -> Result<()> {
|
||||
let home = crate::paths::codex_home(None)?;
|
||||
let mut local = Store::load(&home)?;
|
||||
let (server, token) = sync_endpoint(&local)?;
|
||||
if !force {
|
||||
let remote_state = fetch_remote_state(&server, &token).await?;
|
||||
let remote_revision = remote_state.revision;
|
||||
local.accounts = merge_accounts(local.accounts.clone(), remote_state.accounts);
|
||||
local.sync.last_remote_revision = Some(remote_revision);
|
||||
}
|
||||
let payload = PutStateRequest {
|
||||
expected_revision: local.sync.last_remote_revision.or(Some(0)),
|
||||
force,
|
||||
@@ -105,7 +101,7 @@ pub async fn push(force: bool) -> Result<()> {
|
||||
accounts: local.accounts.clone(),
|
||||
},
|
||||
};
|
||||
let response = reqwest::Client::new()
|
||||
let response = crate::http_client::client()?
|
||||
.put(format!("{server}/v1/state"))
|
||||
.headers(auth_headers(&token)?)
|
||||
.json(&payload)
|
||||
@@ -126,7 +122,7 @@ pub async fn push(force: bool) -> Result<()> {
|
||||
"sync push 完成: accounts={}, revision={}, mode={}",
|
||||
local.accounts.len(),
|
||||
remote_state.revision,
|
||||
if force { "force" } else { "merge" }
|
||||
if force { "force" } else { "normal" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -169,9 +165,9 @@ pub async fn remote(json: bool) -> Result<()> {
|
||||
println!(
|
||||
"{:<22} {:<34} {:<10} {:<12} {}",
|
||||
shorten(&account.id, 22),
|
||||
shorten(&account.email, 34),
|
||||
shorten(account_email_display(account), 34),
|
||||
account_auth_mode_name(account),
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
account_plan_display(account),
|
||||
quota
|
||||
);
|
||||
}
|
||||
@@ -250,7 +246,7 @@ fn auth_headers(token: &str) -> Result<HeaderMap> {
|
||||
}
|
||||
|
||||
async fn fetch_remote_state(server: &str, token: &str) -> Result<AccountSyncState> {
|
||||
let remote = reqwest::Client::new()
|
||||
let remote = crate::http_client::client()?
|
||||
.get(format!("{server}/v1/state"))
|
||||
.headers(auth_headers(token)?)
|
||||
.send()
|
||||
@@ -293,6 +289,25 @@ fn account_auth_mode_name(account: &Account) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn account_email_display(account: &Account) -> &str {
|
||||
if account.auth_mode == crate::config_store::AuthMode::ApiKey {
|
||||
account
|
||||
.api_base_url
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_API_BASE_URL)
|
||||
} else {
|
||||
&account.email
|
||||
}
|
||||
}
|
||||
|
||||
fn account_plan_display(account: &Account) -> &str {
|
||||
if account.auth_mode == crate::config_store::AuthMode::ApiKey {
|
||||
"-"
|
||||
} else {
|
||||
account.plan_type.as_deref().unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
fn shorten(value: &str, width: usize) -> String {
|
||||
if value.chars().count() <= width {
|
||||
return value.to_string();
|
||||
|
||||
+9
-13
@@ -69,24 +69,20 @@ pub async fn refresh_account(store: &mut Store, account_id: &str) -> Result<()>
|
||||
account.tokens = Some(tokens);
|
||||
account.requires_reauth = false;
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
if let Some(plan) = auth
|
||||
.as_ref()
|
||||
.and_then(|auth| auth.chatgpt_plan_type.clone())
|
||||
{
|
||||
account.plan_type = Some(plan);
|
||||
}
|
||||
if account.account_id.is_none() {
|
||||
account.account_id = auth.as_ref().and_then(|auth| auth.account_id.clone());
|
||||
}
|
||||
if account.organization_id.is_none() {
|
||||
account.organization_id =
|
||||
auth.as_ref().and_then(|auth| auth.organization_id.clone());
|
||||
if let Some(auth) = auth {
|
||||
account.plan_type = auth.chatgpt_plan_type;
|
||||
account.account_id = auth.account_id;
|
||||
account.organization_id = auth.organization_id;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(account) = store.find_account_mut(account_id) {
|
||||
account.requires_reauth = true;
|
||||
account.plan_type = None;
|
||||
account.account_id = None;
|
||||
account.organization_id = None;
|
||||
account.quota = None;
|
||||
account.updated_at = Utc::now().timestamp();
|
||||
}
|
||||
Err(error)
|
||||
@@ -100,7 +96,7 @@ pub async fn refresh_access_token(
|
||||
) -> Result<Tokens> {
|
||||
// Some refresh responses omit id_token; keep the previous one when possible
|
||||
// because it still contains useful local metadata.
|
||||
let response = reqwest::Client::new()
|
||||
let response = crate::http_client::client()?
|
||||
.post(TOKEN_ENDPOINT)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
|
||||
Reference in New Issue
Block a user