Compare commits
11 Commits
+76
-135
@@ -2,6 +2,13 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- "scripts/publish.ps1"
|
||||
- "scripts/publish.sh"
|
||||
- "scripts/docker/Dockerfile.release"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
@@ -9,13 +16,31 @@ 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:
|
||||
@@ -28,14 +53,21 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current_tag="$(git describe --tags --exact-match)"
|
||||
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
|
||||
@@ -56,7 +88,7 @@ jobs:
|
||||
|
||||
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}" "${GITHUB_SHA}" "${current_tag}" "${body}")"
|
||||
"${current_tag}" "${tag_target}" "${current_tag}" "${body}")"
|
||||
|
||||
status="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
@@ -82,155 +114,64 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-windows-x64:
|
||||
name: Build windows-x64
|
||||
runs-on: windows-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust target
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.cargo" | Out-Null
|
||||
@"
|
||||
[source.crates-io]
|
||||
replace-with = "mirror"
|
||||
|
||||
[source.mirror]
|
||||
registry = "$env:CARGO_REGISTRY"
|
||||
"@ | Set-Content -Path "$env:USERPROFILE\.cargo\config.toml" -Encoding utf8
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build release binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = git describe --tags --exact-match
|
||||
$version = $tag -replace '^v', ''
|
||||
./scripts/publish-binaries.ps1 -Version $version -Target win -Clean
|
||||
|
||||
- name: Upload release asset
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
$tag = git describe --tags --exact-match
|
||||
$release = Invoke-RestMethod `
|
||||
-Headers @{ Authorization = "token $env:GITEA_TOKEN" } `
|
||||
-Uri "$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/tags/$tag"
|
||||
$file = Get-ChildItem -Path dist -File | Select-Object -First 1
|
||||
curl.exe -fsS -X POST `
|
||||
-H "Authorization: token $env:GITEA_TOKEN" `
|
||||
-F "attachment=@$($file.FullName)" `
|
||||
"$env:GITEA_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$($release.id)/assets?name=$($file.Name)"
|
||||
|
||||
build-linux-x64:
|
||||
name: Build linux-x64
|
||||
build-release-assets:
|
||||
name: Build release assets
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build release binary
|
||||
- name: Configure network proxy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
version="${tag#v}"
|
||||
target_name="linux-x64"
|
||||
rust_target="x86_64-unknown-linux-gnu"
|
||||
platform="linux/amd64"
|
||||
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
git config --global http.proxy "${HTTPS_PROXY}"
|
||||
git config --global https.proxy "${HTTPS_PROXY}"
|
||||
git config --global http.noProxy "${NO_PROXY}"
|
||||
|
||||
mkdir -p dist
|
||||
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}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- name: Upload release asset
|
||||
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
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
|
||||
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"])')"
|
||||
file="$(find dist -maxdepth 1 -type f | head -n 1)"
|
||||
name="$(basename "${file}")"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/${release_id}/assets" > assets.json
|
||||
|
||||
build-linux-arm64:
|
||||
name: Build linux-arm64
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build release binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
version="${tag#v}"
|
||||
target_name="linux-arm64"
|
||||
rust_target="aarch64-unknown-linux-gnu"
|
||||
platform="linux/arm64"
|
||||
image_tag="cdxs-build:${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
container_name="cdxs-build-${target_name}-${GITHUB_RUN_ID:-manual}"
|
||||
|
||||
mkdir -p dist
|
||||
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}" \
|
||||
--load \
|
||||
-t "${image_tag}" \
|
||||
-f scripts/docker/Dockerfile.release \
|
||||
.
|
||||
|
||||
container_id="$(docker create --name "${container_name}" "${image_tag}")"
|
||||
trap 'docker rm -f "${container_name}" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "${container_id}:/out/cdxs" "dist/cdxs-${version}-${target_name}"
|
||||
|
||||
- name: Upload release asset
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="$(git describe --tags --exact-match)"
|
||||
api_base="${GITEA_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token ${GITEA_TOKEN}" "${api_base}/releases/tags/${tag}" > release.json
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
|
||||
file="$(find dist -maxdepth 1 -type f | head -n 1)"
|
||||
name="$(basename "${file}")"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${name}"
|
||||
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
+1
-1
@@ -232,7 +232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cdxs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cdxs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
description = "Codex account switcher CLI"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力:
|
||||
|
||||
- 账号管理:导入已有 `auth.json`,通过 OAuth 登录,添加 API Key 账号,切换当前账号,并在需要时刷新 OAuth token。
|
||||
- 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。
|
||||
- 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地。
|
||||
- Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。
|
||||
- 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`。
|
||||
@@ -43,6 +43,7 @@ Codex home 的解析顺序:
|
||||
- `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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -3,26 +3,62 @@ 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 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 apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
gcc-aarch64-linux-gnu \
|
||||
libc6-dev-arm64-cross \
|
||||
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,107 +0,0 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = "dist",
|
||||
[ValidateSet("all", "win", "linux-x64", "linux-arm64")]
|
||||
[string]$Target = "all",
|
||||
[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
|
||||
|
||||
if ($SkipDockerLinux) {
|
||||
$Target = "win"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
if ($Target -eq "all" -or $Target -eq "win") {
|
||||
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"
|
||||
}
|
||||
|
||||
$linuxTargets = @(
|
||||
@{ Name = "linux-x64"; RustTarget = "x86_64-unknown-linux-gnu"; Platform = "linux/amd64" },
|
||||
@{ Name = "linux-arm64"; RustTarget = "aarch64-unknown-linux-gnu"; Platform = "linux/arm64" }
|
||||
) | Where-Object { $Target -eq "all" -or $Target -eq $_.Name }
|
||||
|
||||
foreach ($linuxTarget in $linuxTargets) {
|
||||
$containerName = "cdxs-build-$($linuxTarget.Name)-$([guid]::NewGuid().ToString('N'))"
|
||||
$imageTag = "cdxs-build:$($linuxTarget.Name)"
|
||||
$tmpOutDir = Join-Path $dist ".tmp-$($linuxTarget.Name)"
|
||||
New-Item -ItemType Directory -Force -Path $tmpOutDir | Out-Null
|
||||
|
||||
Write-Host "Building $($linuxTarget.Name) with Docker platform $($linuxTarget.Platform)"
|
||||
$buildArgs = @(
|
||||
"buildx", "build",
|
||||
"--platform", $linuxTarget.Platform,
|
||||
"--target", "builder",
|
||||
"--build-arg", "RUST_TARGET=$($linuxTarget.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 $($linuxTarget.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 $($linuxTarget.Name)"
|
||||
}
|
||||
} finally {
|
||||
docker rm -f $containerName | Out-Null
|
||||
}
|
||||
|
||||
Copy-ReleaseFile `
|
||||
-SourceFile (Join-Path $tmpOutDir "cdxs") `
|
||||
-OutputName "cdxs-$Version-$($linuxTarget.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}"
|
||||
+79
-10
@@ -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})");
|
||||
@@ -119,9 +123,9 @@ fn print_accounts(store: &Store, json: bool) -> Result<()> {
|
||||
print_account_table_row(
|
||||
current,
|
||||
&account.id,
|
||||
&account.email,
|
||||
account_email_display(account),
|
||||
auth_file::account_auth_mode_name(account),
|
||||
account.plan_type.as_deref().unwrap_or("-"),
|
||||
account_plan_display(account),
|
||||
&primary_quota,
|
||||
&secondary_quota,
|
||||
);
|
||||
@@ -247,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();
|
||||
}
|
||||
@@ -305,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(),
|
||||
@@ -377,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) {
|
||||
@@ -488,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,
|
||||
@@ -633,6 +641,28 @@ 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());
|
||||
@@ -702,7 +732,7 @@ fn print_account_table_border() {
|
||||
"-".repeat(3),
|
||||
"-".repeat(24),
|
||||
"-".repeat(30),
|
||||
"-".repeat(8),
|
||||
"-".repeat(10),
|
||||
"-".repeat(8),
|
||||
"-".repeat(14),
|
||||
"-".repeat(14)
|
||||
@@ -719,11 +749,11 @@ fn print_account_table_row(
|
||||
secondary_quota: &str,
|
||||
) {
|
||||
println!(
|
||||
"| {:<1} | {:<22} | {:<28} | {:<6} | {:<6} | {:<12} | {:<12} |",
|
||||
"| {:<1} | {:<22} | {:<28} | {:<8} | {:<6} | {:<12} | {:<12} |",
|
||||
shorten(marker, 1),
|
||||
shorten(id, 22),
|
||||
shorten(email, 28),
|
||||
shorten(mode, 6),
|
||||
shorten(mode, 8),
|
||||
shorten(plan, 6),
|
||||
shorten(primary_quota, 12),
|
||||
shorten(secondary_quota, 12)
|
||||
@@ -744,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("-")
|
||||
@@ -755,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}")
|
||||
}
|
||||
|
||||
@@ -132,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())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod account;
|
||||
mod atomic;
|
||||
mod auth_file;
|
||||
mod cli;
|
||||
mod codex_config;
|
||||
mod config_store;
|
||||
mod http_client;
|
||||
mod jwt;
|
||||
@@ -40,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+23
-3
@@ -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> {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+23
-2
@@ -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,
|
||||
@@ -163,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
|
||||
);
|
||||
}
|
||||
@@ -287,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();
|
||||
|
||||
+8
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user