21 files changed
+480
-517
No files matched your search
@@ -1,85 +0,0 @@
|
||||
name: Mirror GitHub release to GitCode
|
||||
|
||||
run-name: >-
|
||||
GitCode mirror ${{ inputs.release_tag || github.event.workflow_run.display_title }}
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Build portable releases
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing GitHub release tag to mirror
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: gitcode-mirror-${{ inputs.release_tag || github.event.workflow_run.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
name: Mirror release assets
|
||||
if: >-
|
||||
vars.GITCODE_REPOSITORY != ''
|
||||
&& (
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| github.event.workflow_run.conclusion == 'success'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Resolve release tag
|
||||
id: release
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
RUN_TITLE: ${{ github.event.workflow_run.display_title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_tag="$INPUT_RELEASE_TAG"
|
||||
if [ -z "$release_tag" ]; then
|
||||
case "$RUN_TITLE" in
|
||||
"Portable release "*) release_tag="${RUN_TITLE#Portable release }" ;;
|
||||
*) echo "Cannot resolve a release tag from workflow title: $RUN_TITLE" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
case "$release_tag" in
|
||||
v*) ;;
|
||||
*) release_tag="v$release_tag" ;;
|
||||
esac
|
||||
if ! node scripts/app-version.mjs "${release_tag#v}" >/dev/null 2>&1; then
|
||||
if [ -n "$INPUT_RELEASE_TAG" ]; then
|
||||
echo "Invalid release tag: $release_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Skipping GitCode mirror because the completed run did not publish a release"
|
||||
echo "should_mirror=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
|
||||
echo "should_mirror=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Download GitHub release assets
|
||||
if: steps.release.outputs.should_mirror == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh release download "${{ steps.release.outputs.tag }}" --dir artifacts
|
||||
- name: Publish GitCode mirror
|
||||
if: steps.release.outputs.should_mirror == 'true'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ARTIFACT_DIRECTORY: artifacts
|
||||
GITCODE_ACCESS_TOKEN: ${{ secrets.GITCODE_ACCESS_TOKEN }}
|
||||
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
|
||||
GITCODE_TARGET_COMMITISH: ${{ vars.GITCODE_TARGET_COMMITISH || 'main' }}
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
run: node scripts/publish-gitcode-release.mjs
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
source_version="$(node scripts/app-version.mjs)"
|
||||
source_version="$(node scripts/version.mjs)"
|
||||
release_tag="$REQUESTED_VERSION"
|
||||
if [ -z "$release_tag" ]; then
|
||||
release_tag="v$source_version"
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
esac
|
||||
fi
|
||||
|
||||
version="$(node scripts/app-version.mjs "${release_tag#v}")"
|
||||
version="$(node scripts/version.mjs "${release_tag#v}")"
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Apply release version
|
||||
run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}"
|
||||
run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}"
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
bun-version: 1.3.14
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Apply release version
|
||||
run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}"
|
||||
run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}"
|
||||
- name: Resolve release package name
|
||||
id: package
|
||||
shell: bash
|
||||
@@ -166,7 +166,8 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build GUI
|
||||
env:
|
||||
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
|
||||
GITCODE_GUI_REPOSITORY: ${{ vars.GITCODE_GUI_REPOSITORY }}
|
||||
GITCODE_CORE_REPOSITORY: ${{ vars.GITCODE_CORE_REPOSITORY }}
|
||||
RUSTFLAGS: -D warnings
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
@@ -174,7 +175,7 @@ jobs:
|
||||
shell: bash
|
||||
- name: Prepare portable directory
|
||||
run: >-
|
||||
node scripts/prepare-portable.mjs
|
||||
node scripts/portable.mjs
|
||||
--binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}"
|
||||
--output "release/${{ steps.package.outputs.stem }}"
|
||||
--os "${{ matrix.os }}"
|
||||
@@ -414,11 +415,11 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.version.outputs.tag }}
|
||||
run: >-
|
||||
node scripts/generate-portable-update-manifest.mjs
|
||||
node scripts/manifest.mjs
|
||||
--directory artifacts
|
||||
--output artifacts/portable-update-windows.json
|
||||
--repository "${{ github.repository }}"
|
||||
--gitcode-repository "${{ vars.GITCODE_REPOSITORY }}"
|
||||
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}"
|
||||
--tag "$RELEASE_TAG"
|
||||
- name: Publish release
|
||||
shell: bash
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
param(
|
||||
[int]$BuildJobs,
|
||||
|
||||
[string]$GitCodeRepository = 'lzt404/EasyCLIProxyAPI'
|
||||
[string]$GitCodeGuiRepository = 'lzt404/EasyCLIProxyAPI',
|
||||
|
||||
[string]$GitCodeCoreRepository = 'lzt404/CLIProxyAPI'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -12,7 +14,7 @@ $RootDir = $PSScriptRoot
|
||||
$AppBin = Join-Path $RootDir 'src-tauri\target\release\cpa-gui.exe'
|
||||
$BinDir = Join-Path $RootDir 'bin-work'
|
||||
$BinOut = Join-Path $BinDir 'EasyCLIProxyAPI.exe'
|
||||
$PreparePortable = Join-Path $RootDir 'scripts\prepare-portable.mjs'
|
||||
$PortableScript = Join-Path $RootDir 'scripts\portable.mjs'
|
||||
|
||||
Set-Location -LiteralPath $RootDir
|
||||
|
||||
@@ -27,8 +29,11 @@ if (-not $PSBoundParameters.ContainsKey('BuildJobs')) {
|
||||
if ($BuildJobs -lt 1 -or $BuildJobs -gt 256) {
|
||||
throw 'BuildJobs must be between 1 and 256.'
|
||||
}
|
||||
if ($GitCodeRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
|
||||
throw 'GitCodeRepository must use the owner/repository format.'
|
||||
if ($GitCodeGuiRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
|
||||
throw 'GitCodeGuiRepository must use the owner/repository format.'
|
||||
}
|
||||
if ($GitCodeCoreRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
|
||||
throw 'GitCodeCoreRepository must use the owner/repository format.'
|
||||
}
|
||||
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
@@ -36,7 +41,8 @@ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
}
|
||||
|
||||
Write-Host "Cargo build jobs: $BuildJobs"
|
||||
Write-Host "GitCode fallback repository: $GitCodeRepository"
|
||||
Write-Host "GitCode GUI fallback repository: $GitCodeGuiRepository"
|
||||
Write-Host "GitCode core fallback repository: $GitCodeCoreRepository"
|
||||
|
||||
& bun install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -44,10 +50,12 @@ if ($LASTEXITCODE -ne 0) {
|
||||
}
|
||||
|
||||
$PreviousBuildJobs = $env:CARGO_BUILD_JOBS
|
||||
$PreviousGitCodeRepository = $env:GITCODE_REPOSITORY
|
||||
$PreviousGitCodeGuiRepository = $env:GITCODE_GUI_REPOSITORY
|
||||
$PreviousGitCodeCoreRepository = $env:GITCODE_CORE_REPOSITORY
|
||||
try {
|
||||
$env:CARGO_BUILD_JOBS = [string]$BuildJobs
|
||||
$env:GITCODE_REPOSITORY = $GitCodeRepository
|
||||
$env:GITCODE_GUI_REPOSITORY = $GitCodeGuiRepository
|
||||
$env:GITCODE_CORE_REPOSITORY = $GitCodeCoreRepository
|
||||
& bun tauri build --no-bundle
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Tauri build failed with exit code $LASTEXITCODE."
|
||||
@@ -60,11 +68,17 @@ finally {
|
||||
else {
|
||||
$env:CARGO_BUILD_JOBS = $PreviousBuildJobs
|
||||
}
|
||||
if ($null -eq $PreviousGitCodeRepository) {
|
||||
Remove-Item Env:GITCODE_REPOSITORY -ErrorAction SilentlyContinue
|
||||
if ($null -eq $PreviousGitCodeGuiRepository) {
|
||||
Remove-Item Env:GITCODE_GUI_REPOSITORY -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:GITCODE_REPOSITORY = $PreviousGitCodeRepository
|
||||
$env:GITCODE_GUI_REPOSITORY = $PreviousGitCodeGuiRepository
|
||||
}
|
||||
if ($null -eq $PreviousGitCodeCoreRepository) {
|
||||
Remove-Item Env:GITCODE_CORE_REPOSITORY -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:GITCODE_CORE_REPOSITORY = $PreviousGitCodeCoreRepository
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +86,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
|
||||
throw "Build finished, but executable not found: $AppBin"
|
||||
}
|
||||
|
||||
& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
|
||||
& bun $PortableScript --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Portable preparation failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
BUILD_JOBS="${CARGO_BUILD_JOBS:-20}"
|
||||
GITCODE_REPOSITORY="${GITCODE_REPOSITORY:-lzt404/EasyCLIProxyAPI}"
|
||||
GITCODE_GUI_REPOSITORY="${GITCODE_GUI_REPOSITORY:-lzt404/EasyCLIProxyAPI}"
|
||||
GITCODE_CORE_REPOSITORY="${GITCODE_CORE_REPOSITORY:-lzt404/CLIProxyAPI}"
|
||||
APP_BIN="$ROOT_DIR/src-tauri/target/release/cpa-gui"
|
||||
BIN_DIR="$ROOT_DIR/bin-work"
|
||||
BIN_OUT="$BIN_DIR/EasyCLIProxyAPI"
|
||||
@@ -16,9 +17,10 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
echo "Cargo build jobs: $BUILD_JOBS"
|
||||
echo "GitCode fallback repository: $GITCODE_REPOSITORY"
|
||||
echo "GitCode GUI fallback repository: $GITCODE_GUI_REPOSITORY"
|
||||
echo "GitCode core fallback repository: $GITCODE_CORE_REPOSITORY"
|
||||
bun install
|
||||
CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_REPOSITORY="$GITCODE_REPOSITORY" bun tauri build --no-bundle
|
||||
CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_GUI_REPOSITORY="$GITCODE_GUI_REPOSITORY" GITCODE_CORE_REPOSITORY="$GITCODE_CORE_REPOSITORY" bun tauri build --no-bundle
|
||||
|
||||
if [ ! -x "$APP_BIN" ]; then
|
||||
echo "Build finished, but executable not found: $APP_BIN"
|
||||
@@ -26,7 +28,7 @@ if [ ! -x "$APP_BIN" ]; then
|
||||
fi
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
bun "$ROOT_DIR/scripts/prepare-portable.mjs" \
|
||||
bun "$ROOT_DIR/scripts/portable.mjs" \
|
||||
--binary "$APP_BIN" \
|
||||
--output "$BIN_DIR" \
|
||||
--download true
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export type PortableUpdateManifestAsset = {
|
||||
url: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type PortableUpdateManifest = {
|
||||
schemaVersion: number;
|
||||
version: string;
|
||||
publishedAt: string;
|
||||
releaseUrl: string;
|
||||
assets: Record<string, PortableUpdateManifestAsset>;
|
||||
fullAssets: Record<string, PortableUpdateManifestAsset>;
|
||||
};
|
||||
|
||||
export function generatePortableUpdateManifest(options: {
|
||||
directory?: string;
|
||||
output?: string;
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
publishedAt?: string;
|
||||
}): Promise<PortableUpdateManifest>;
|
||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import { readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { validateAppVersion } from './version.mjs';
|
||||
|
||||
export async function generatePortableUpdateManifest({
|
||||
directory,
|
||||
@@ -18,11 +19,8 @@ export async function generatePortableUpdateManifest({
|
||||
const normalizedRawTag = String(rawTag ?? '').trim();
|
||||
const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`;
|
||||
const version = tag.slice(1);
|
||||
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
if (!semverPattern.test(version)) {
|
||||
throw new Error(`Invalid release tag: ${normalizedRawTag}`);
|
||||
}
|
||||
validateAppVersion(version, 'release tag');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) {
|
||||
throw new Error(`Invalid GitHub repository: ${resolvedRepository}`);
|
||||
}
|
||||
@@ -75,6 +73,12 @@ export async function generatePortableUpdateManifest({
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function gitcodeReleaseAttachmentUrl(repository, tag, filename) {
|
||||
const [owner, repo] = repository.split('/');
|
||||
return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}`
|
||||
+ `/releases/${tag}/attach_files/${filename}/download`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
@@ -92,12 +96,6 @@ async function main() {
|
||||
console.log(`Generated ${basename(output)} for v${manifest.version}`);
|
||||
}
|
||||
|
||||
export function gitcodeReleaseAttachmentUrl(repository, tag, filename) {
|
||||
const [owner, repo] = repository.split('/');
|
||||
return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}`
|
||||
+ `/releases/${tag}/attach_files/${filename}/download`;
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === entryPoint) {
|
||||
await main();
|
||||
@@ -1,15 +1,16 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { readAppVersion } from './app-version.mjs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readAppVersion } from './version.mjs';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
args.set(process.argv[index], process.argv[index + 1]);
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const output = resolve(args.get('--output') ?? join(root, 'bin-work'));
|
||||
const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui'));
|
||||
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
|
||||
@@ -1,188 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const API_BASE = 'https://api.gitcode.com/api/v5';
|
||||
const API_TIMEOUT_MS = 30_000;
|
||||
const UPLOAD_TIMEOUT_SECONDS = 180;
|
||||
|
||||
function parseRepository(value) {
|
||||
const repository = String(value ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
||||
throw new Error(`Invalid GitCode repository: ${repository}`);
|
||||
}
|
||||
return repository.split('/');
|
||||
}
|
||||
|
||||
function authenticatedUrl(path, token, query = {}) {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
url.searchParams.set('access_token', token);
|
||||
for (const [name, value] of Object.entries(query)) {
|
||||
url.searchParams.set(name, value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function apiRequest(path, {
|
||||
token,
|
||||
method = 'GET',
|
||||
body,
|
||||
query,
|
||||
expected = [200],
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(authenticatedUrl(path, token, query), {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`GitCode API request failed for ${path}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
if (!expected.includes(response.status)) {
|
||||
const details = (await response.text()).slice(0, 500);
|
||||
throw new Error(`GitCode API ${method} ${path} failed: HTTP ${response.status}: ${details}`);
|
||||
}
|
||||
return response.status === 204 || response.status === 404 ? null : response.json();
|
||||
}
|
||||
|
||||
export function uploadFileWithCurl({ url, headers, path }) {
|
||||
const args = [
|
||||
'--fail-with-body',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--connect-timeout', '30',
|
||||
'--speed-limit', '1',
|
||||
'--speed-time', '30',
|
||||
'--max-time', String(UPLOAD_TIMEOUT_SECONDS),
|
||||
'--request', 'PUT',
|
||||
'--upload-file', path,
|
||||
'--output', process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
];
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
args.push('--header', `${name}: ${value}`);
|
||||
}
|
||||
args.push(url);
|
||||
|
||||
return new Promise((resolveUpload, rejectUpload) => {
|
||||
const child = spawn('curl', args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
child.once('error', rejectUpload);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolveUpload();
|
||||
} else {
|
||||
rejectUpload(new Error(`curl upload failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishGitcodeRelease({
|
||||
directory,
|
||||
repository,
|
||||
token,
|
||||
tag,
|
||||
targetCommitish = 'main',
|
||||
prerelease = false,
|
||||
fetchImpl = fetch,
|
||||
uploadImpl = uploadFileWithCurl,
|
||||
}) {
|
||||
const [owner, repo] = parseRepository(repository);
|
||||
if (!token) throw new Error('GITCODE_ACCESS_TOKEN is required');
|
||||
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(tag)) {
|
||||
throw new Error(`Invalid release tag: ${tag}`);
|
||||
}
|
||||
|
||||
const encodedOwner = encodeURIComponent(owner);
|
||||
const encodedRepo = encodeURIComponent(repo);
|
||||
const encodedTag = encodeURIComponent(tag);
|
||||
const releaseByTagPath = `/repos/${encodedOwner}/${encodedRepo}/releases/tags/${encodedTag}`;
|
||||
let release;
|
||||
const existing = await apiRequest(releaseByTagPath, {
|
||||
token,
|
||||
expected: [200, 404],
|
||||
fetchImpl,
|
||||
});
|
||||
if (existing?.tag_name === tag) {
|
||||
release = existing;
|
||||
} else {
|
||||
release = await apiRequest(`/repos/${encodedOwner}/${encodedRepo}/releases`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
tag_name: tag,
|
||||
name: `EasyCLIProxyAPI ${tag}`,
|
||||
body: 'Mirror of the GitHub release for users who cannot access GitHub.',
|
||||
target_commitish: targetCommitish,
|
||||
release_status: prerelease ? 'pre' : 'latest',
|
||||
},
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
const existingAssets = new Set((release.assets ?? []).map((asset) => asset.name));
|
||||
const entries = (await readdir(directory, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
if (entries.length === 0) throw new Error(`No release artifacts found in ${directory}`);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (existingAssets.has(entry.name)) {
|
||||
console.log(`GitCode asset already exists, skipping: ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
const upload = await apiRequest(
|
||||
`/repos/${encodedOwner}/${encodedRepo}/releases/${encodedTag}/upload_url`,
|
||||
{ token, query: { file_name: entry.name }, fetchImpl },
|
||||
);
|
||||
const descriptor = upload?.data ?? upload;
|
||||
const uploadUrl = descriptor?.upload_url ?? descriptor?.url;
|
||||
if (typeof uploadUrl !== 'string' || !uploadUrl.startsWith('https://')) {
|
||||
throw new Error(`GitCode returned an invalid upload URL for ${entry.name}`);
|
||||
}
|
||||
const rawHeaders = descriptor?.headers ?? descriptor?.upload_headers ?? descriptor?.header;
|
||||
const headers = Array.isArray(rawHeaders)
|
||||
? Object.fromEntries(rawHeaders.map(({ key, name, value }) => [key ?? name, value]))
|
||||
: rawHeaders && typeof rawHeaders === 'object' ? rawHeaders : {};
|
||||
console.log(`Uploading GitCode asset: ${entry.name}`);
|
||||
try {
|
||||
await uploadImpl({
|
||||
url: uploadUrl,
|
||||
headers,
|
||||
path: join(directory, entry.name),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`GitCode upload command did not finish cleanly; verifying asset: ${entry.name}`);
|
||||
const refreshedRelease = await apiRequest(releaseByTagPath, { token, fetchImpl });
|
||||
const uploadedDespiteError = (refreshedRelease.assets ?? [])
|
||||
.some((asset) => asset.name === entry.name);
|
||||
if (!uploadedDespiteError) throw error;
|
||||
console.log(`GitCode asset exists despite upload error: ${entry.name}`);
|
||||
}
|
||||
existingAssets.add(entry.name);
|
||||
console.log(`Uploaded GitCode asset: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const directory = resolve(process.env.ARTIFACT_DIRECTORY ?? 'artifacts');
|
||||
await publishGitcodeRelease({
|
||||
directory,
|
||||
repository: process.env.GITCODE_REPOSITORY,
|
||||
token: process.env.GITCODE_ACCESS_TOKEN,
|
||||
tag: process.env.RELEASE_TAG,
|
||||
targetCommitish: process.env.GITCODE_TARGET_COMMITISH || 'main',
|
||||
prerelease: process.env.PRERELEASE === 'true' || process.env.RELEASE_TAG.includes('-'),
|
||||
});
|
||||
console.log(`Published ${basename(directory)} to GitCode ${process.env.GITCODE_REPOSITORY}`);
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === entryPoint) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
parseCargoPackageVersion,
|
||||
setCargoLockPackageVersion,
|
||||
setCargoPackageVersion,
|
||||
validateAppVersion,
|
||||
} from './app-version.mjs';
|
||||
|
||||
const requestedVersion = process.argv[2];
|
||||
if (!requestedVersion) {
|
||||
throw new Error('Usage: node scripts/set-app-version.mjs <semver>');
|
||||
}
|
||||
|
||||
const version = validateAppVersion(requestedVersion, 'requested app version');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const manifest = join(root, 'src-tauri', 'Cargo.toml');
|
||||
const lockfile = join(root, 'src-tauri', 'Cargo.lock');
|
||||
const contents = await readFile(manifest, 'utf8');
|
||||
const lockContents = await readFile(lockfile, 'utf8');
|
||||
const previousVersion = parseCargoPackageVersion(contents, manifest);
|
||||
|
||||
if (previousVersion !== version) {
|
||||
await Promise.all([
|
||||
writeFile(manifest, setCargoPackageVersion(contents, version, manifest)),
|
||||
writeFile(lockfile, setCargoLockPackageVersion(lockContents, 'cpa-gui', version, lockfile)),
|
||||
]);
|
||||
}
|
||||
|
||||
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
||||
const metadata = spawnSync(cargo, [
|
||||
'metadata',
|
||||
'--manifest-path', manifest,
|
||||
'--no-deps',
|
||||
'--format-version', '1',
|
||||
], { stdio: 'ignore' });
|
||||
|
||||
if (metadata.error) throw metadata.error;
|
||||
if (metadata.status !== 0) {
|
||||
throw new Error(`cargo metadata failed with exit code ${metadata.status}`);
|
||||
}
|
||||
|
||||
console.log(`Applied app version ${version}${previousVersion === version ? ' (unchanged)' : ''}`);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
parseCargoPackageVersion,
|
||||
setCargoLockPackageVersion,
|
||||
setCargoPackageVersion,
|
||||
validateAppVersion,
|
||||
} from './version.mjs';
|
||||
|
||||
const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
export function runCargoMetadata(manifest, spawnImpl = spawnSync) {
|
||||
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
||||
const metadata = spawnImpl(cargo, [
|
||||
'metadata',
|
||||
'--manifest-path', manifest,
|
||||
'--no-deps',
|
||||
'--format-version', '1',
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (metadata.error) throw metadata.error;
|
||||
if (metadata.status !== 0) {
|
||||
const detail = String(metadata.stderr || metadata.stdout || '').trim();
|
||||
throw new Error(
|
||||
`cargo metadata failed with exit code ${metadata.status}${detail ? `: ${detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAppVersion(requestedVersion, {
|
||||
rootDir = defaultRoot,
|
||||
readText = readFile,
|
||||
writeText = writeFile,
|
||||
validateProject = runCargoMetadata,
|
||||
} = {}) {
|
||||
const version = validateAppVersion(requestedVersion, 'requested app version');
|
||||
const manifest = join(rootDir, 'src-tauri', 'Cargo.toml');
|
||||
const lockfile = join(rootDir, 'src-tauri', 'Cargo.lock');
|
||||
const [contents, lockContents] = await Promise.all([
|
||||
readText(manifest, 'utf8'),
|
||||
readText(lockfile, 'utf8'),
|
||||
]);
|
||||
const previousVersion = parseCargoPackageVersion(contents, manifest);
|
||||
|
||||
const nextContents = setCargoPackageVersion(contents, version, manifest);
|
||||
const nextLockContents = setCargoLockPackageVersion(
|
||||
lockContents,
|
||||
'cpa-gui',
|
||||
version,
|
||||
lockfile,
|
||||
);
|
||||
const manifestChanged = nextContents !== contents;
|
||||
const lockfileChanged = nextLockContents !== lockContents;
|
||||
let manifestWriteAttempted = false;
|
||||
let lockfileWriteAttempted = false;
|
||||
|
||||
try {
|
||||
if (manifestChanged) {
|
||||
manifestWriteAttempted = true;
|
||||
await writeText(manifest, nextContents);
|
||||
}
|
||||
if (lockfileChanged) {
|
||||
lockfileWriteAttempted = true;
|
||||
await writeText(lockfile, nextLockContents);
|
||||
}
|
||||
await validateProject(manifest);
|
||||
} catch (error) {
|
||||
const rollbackTasks = [];
|
||||
if (manifestWriteAttempted) rollbackTasks.push(writeText(manifest, contents));
|
||||
if (lockfileWriteAttempted) rollbackTasks.push(writeText(lockfile, lockContents));
|
||||
const rollbackResults = await Promise.allSettled(rollbackTasks);
|
||||
const rollbackFailures = rollbackResults
|
||||
.filter((result) => result.status === 'rejected')
|
||||
.map((result) => errorMessage(result.reason));
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw new Error(
|
||||
`${errorMessage(error)}; rollback failed: ${rollbackFailures.join('; ')}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
previousVersion,
|
||||
changed: manifestChanged || lockfileChanged,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requestedVersion = process.argv[2];
|
||||
if (!requestedVersion || process.argv.length > 3) {
|
||||
throw new Error('Usage: node scripts/set-version.mjs <semver>');
|
||||
}
|
||||
const result = await applyAppVersion(requestedVersion);
|
||||
console.log(`Applied app version ${result.version}${result.changed ? '' : ' (unchanged)'}`);
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
|
||||
if (import.meta.url === entryPoint) {
|
||||
await main();
|
||||
}
|
||||
File renamed without changes.
Generated
+1
-1
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.14"
|
||||
version = "0.2.16"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "cpa-gui"
|
||||
# Single source of truth for the desktop app version.
|
||||
version = "0.2.14"
|
||||
version = "0.2.16"
|
||||
description = "CPA desktop management shell"
|
||||
authors = ["lzt404"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=GITCODE_GUI_REPOSITORY");
|
||||
println!("cargo:rerun-if-env-changed=GITCODE_CORE_REPOSITORY");
|
||||
tauri_build::build()
|
||||
}
|
||||
+165
-35
@@ -1563,6 +1563,8 @@ struct GithubRelease {
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
fallback_download_urls: Vec<String>,
|
||||
size: Option<u64>,
|
||||
digest: Option<String>,
|
||||
}
|
||||
@@ -10784,7 +10786,7 @@ async fn fetch_portable_update_manifest(
|
||||
match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await {
|
||||
Ok(manifest) => Ok(manifest),
|
||||
Err(github_error) => {
|
||||
let Some(repository) = configured_gitcode_repository() else {
|
||||
let Some(repository) = configured_gitcode_gui_repository() else {
|
||||
return Err(github_error);
|
||||
};
|
||||
let fallback = async {
|
||||
@@ -10840,8 +10842,16 @@ async fn fetch_portable_update_manifest_url(
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn configured_gitcode_repository() -> Option<&'static str> {
|
||||
let repository = option_env!("GITCODE_REPOSITORY")?.trim();
|
||||
fn configured_gitcode_gui_repository() -> Option<&'static str> {
|
||||
configured_gitcode_repository(option_env!("GITCODE_GUI_REPOSITORY"))
|
||||
}
|
||||
|
||||
fn configured_gitcode_core_repository() -> Option<&'static str> {
|
||||
configured_gitcode_repository(option_env!("GITCODE_CORE_REPOSITORY"))
|
||||
}
|
||||
|
||||
fn configured_gitcode_repository(repository: Option<&'static str>) -> Option<&'static str> {
|
||||
let repository = repository?.trim();
|
||||
let mut parts = repository.split('/');
|
||||
let valid_part = |part: &str| {
|
||||
!part.is_empty()
|
||||
@@ -11048,7 +11058,7 @@ fn validate_portable_update_asset_fallbacks(
|
||||
asset,
|
||||
tag,
|
||||
expected_filenames,
|
||||
configured_gitcode_repository(),
|
||||
configured_gitcode_gui_repository(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11807,17 +11817,7 @@ async fn install_core_version_inner(
|
||||
.ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?;
|
||||
let archive_path = download_dir.join(archive_file_name);
|
||||
|
||||
let downloaded = download_asset(
|
||||
&client,
|
||||
&asset.browser_download_url,
|
||||
&archive_path,
|
||||
asset.size,
|
||||
asset.digest.as_deref(),
|
||||
window,
|
||||
state,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
let downloaded = download_asset(&client, asset, &archive_path, window, state, &token).await?;
|
||||
validate_downloaded_asset(asset, &downloaded)?;
|
||||
|
||||
ensure_not_cancelled(&token, Some(&archive_path))?;
|
||||
@@ -11933,14 +11933,50 @@ async fn fetch_release(
|
||||
return Ok(release_from_tag(version));
|
||||
}
|
||||
let atom_result = fetch_release_from_atom(client).await;
|
||||
match atom_result {
|
||||
let github_result = match atom_result {
|
||||
Ok(release) => Ok(release),
|
||||
Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| {
|
||||
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
|
||||
}),
|
||||
};
|
||||
match github_result {
|
||||
Ok(release) => Ok(release),
|
||||
Err(github_error) => {
|
||||
let Some(repository) = configured_gitcode_core_repository() else {
|
||||
return Err(github_error);
|
||||
};
|
||||
fetch_release_from_gitcode(client, repository)
|
||||
.await
|
||||
.map_err(|gitcode_error| {
|
||||
format!(
|
||||
"GitHub 内核发布源失败: {github_error};GitCode 回退源失败: {gitcode_error}"
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_release_from_gitcode(
|
||||
client: &reqwest::Client,
|
||||
repository: &str,
|
||||
) -> Result<GithubRelease, String> {
|
||||
let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
|
||||
let release = client
|
||||
.get(release_url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("查询 GitCode 最新内核发行版失败: {error}"))?
|
||||
.error_for_status()
|
||||
.map_err(|error| format!("读取 GitCode 最新内核发行版失败: {error}"))?
|
||||
.json::<GitcodeRelease>()
|
||||
.await
|
||||
.map_err(|error| format!("解析 GitCode 最新内核发行版失败: {error}"))?;
|
||||
validate_release_tag(&release.tag_name)?;
|
||||
Ok(release_from_gitcode_tag(&release.tag_name, repository))
|
||||
}
|
||||
|
||||
async fn fetch_release_from_page(client: &reqwest::Client) -> Result<GithubRelease, String> {
|
||||
let response = client
|
||||
.get(RELEASE_PAGE_URL)
|
||||
@@ -12010,6 +12046,18 @@ fn release_tag_from_atom(xml: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn release_from_tag(tag: &str) -> GithubRelease {
|
||||
release_from_tag_for_repositories(tag, configured_gitcode_core_repository(), false)
|
||||
}
|
||||
|
||||
fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease {
|
||||
release_from_tag_for_repositories(tag, Some(repository), true)
|
||||
}
|
||||
|
||||
fn release_from_tag_for_repositories(
|
||||
tag: &str,
|
||||
gitcode_repository: Option<&str>,
|
||||
prefer_gitcode: bool,
|
||||
) -> GithubRelease {
|
||||
let tag = normalize_version(tag);
|
||||
let version = tag.trim_start_matches('v');
|
||||
let assets = [
|
||||
@@ -12023,8 +12071,20 @@ fn release_from_tag(tag: &str) -> GithubRelease {
|
||||
.into_iter()
|
||||
.map(|(os, arch, extension)| {
|
||||
let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}");
|
||||
let github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}");
|
||||
let gitcode_url = gitcode_repository
|
||||
.map(|repository| gitcode_release_attachment_url(repository, &tag, &name));
|
||||
let (browser_download_url, fallback_download_urls) = if prefer_gitcode {
|
||||
(
|
||||
gitcode_url.unwrap_or_else(|| github_url.clone()),
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
(github_url, gitcode_url.into_iter().collect())
|
||||
};
|
||||
GithubAsset {
|
||||
browser_download_url: format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"),
|
||||
browser_download_url,
|
||||
fallback_download_urls,
|
||||
name,
|
||||
size: None,
|
||||
digest: None,
|
||||
@@ -12099,6 +12159,7 @@ fn parse_release_assets(html: &str) -> Vec<GithubAsset> {
|
||||
assets.push(GithubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url,
|
||||
fallback_download_urls: Vec::new(),
|
||||
size: None,
|
||||
digest,
|
||||
});
|
||||
@@ -12136,6 +12197,7 @@ async fn fetch_release_cancelable(
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.redirect(release_https_redirect_policy())
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.read_timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(600))
|
||||
@@ -12176,30 +12238,53 @@ fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_asset(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
asset: &GithubAsset,
|
||||
archive_path: &Path,
|
||||
expected_total: Option<u64>,
|
||||
expected_digest: Option<&str>,
|
||||
window: &tauri::Window,
|
||||
state: &CoreDownloadState,
|
||||
token: &CancellationToken,
|
||||
) -> Result<DownloadedArchive, String> {
|
||||
let result = download_asset_inner(
|
||||
client,
|
||||
url,
|
||||
archive_path,
|
||||
expected_total,
|
||||
expected_digest,
|
||||
window,
|
||||
state,
|
||||
token,
|
||||
)
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(archive_path);
|
||||
let urls =
|
||||
std::iter::once(&asset.browser_download_url).chain(asset.fallback_download_urls.iter());
|
||||
let mut failures = Vec::new();
|
||||
for (index, url) in urls.enumerate() {
|
||||
if index > 0 {
|
||||
state.progress(
|
||||
window,
|
||||
"GitHub 下载失败,正在切换到 GitCode",
|
||||
0,
|
||||
asset.size,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let result = download_asset_inner(
|
||||
client,
|
||||
url,
|
||||
archive_path,
|
||||
asset.size,
|
||||
asset.digest.as_deref(),
|
||||
window,
|
||||
state,
|
||||
token,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(downloaded) => return Ok(downloaded),
|
||||
Err(error) if token.is_cancelled() => {
|
||||
let _ = fs::remove_file(archive_path);
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(archive_path);
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
if failures.is_empty() {
|
||||
let _ = fs::remove_file(archive_path);
|
||||
return Err("内核发行版没有可用的下载地址".to_string());
|
||||
}
|
||||
Err(format!("所有内核下载源均失败: {}", failures.join(";")))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -24027,6 +24112,51 @@ custom_option = "keep-original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_core_release_uses_gitcode_as_download_fallback() {
|
||||
let release = release_from_tag_for_repositories(
|
||||
"7.2.80",
|
||||
Some("lzt404/CLIProxyAPI"),
|
||||
false,
|
||||
);
|
||||
let platform = CorePlatform {
|
||||
os: "windows".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
asset_os: "windows".to_string(),
|
||||
asset_arch: "amd64".to_string(),
|
||||
archive_kind: "zip".to_string(),
|
||||
};
|
||||
let asset = select_release_asset(&release, &platform).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_windows_amd64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
asset.fallback_download_urls,
|
||||
["https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_windows_amd64.zip/download"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitcode_discovered_core_release_downloads_from_gitcode_first() {
|
||||
let release = release_from_gitcode_tag("v7.2.80", "lzt404/CLIProxyAPI");
|
||||
let platform = CorePlatform {
|
||||
os: "linux".to_string(),
|
||||
arch: "aarch64".to_string(),
|
||||
asset_os: "linux".to_string(),
|
||||
asset_arch: "aarch64".to_string(),
|
||||
archive_kind: "tar.gz".to_string(),
|
||||
};
|
||||
let asset = select_release_asset(&release, &platform).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_linux_aarch64.tar.gz/download"
|
||||
);
|
||||
assert!(asset.fallback_download_urls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_asset_names_cover_the_six_supported_gui_targets() {
|
||||
let targets = [
|
||||
|
||||
@@ -40,21 +40,23 @@ const stripKnownSuffix = (baseUrl: string) =>
|
||||
.replace(/\/(?:v1beta|v1)\/models$/i, '')
|
||||
.replace(/\/models$/i, '');
|
||||
|
||||
const endpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => {
|
||||
export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => {
|
||||
const resolvedBaseUrl = baseUrl.trim()
|
||||
|| (provider === 'gemini'
|
||||
? DEFAULT_GEMINI_BASE_URL
|
||||
: provider === 'claude'
|
||||
? DEFAULT_CLAUDE_BASE_URL
|
||||
: '');
|
||||
const base = stripKnownSuffix(resolvedBaseUrl);
|
||||
if (!base) return [];
|
||||
const normalized = normalizeBaseUrl(resolvedBaseUrl);
|
||||
if (!normalized) return [];
|
||||
if (provider === 'openai') {
|
||||
return [/\/models$/i.test(normalized) ? normalized : `${normalized}/models`];
|
||||
}
|
||||
const base = stripKnownSuffix(normalized);
|
||||
const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, '');
|
||||
if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`];
|
||||
if (provider === 'claude') return [`${withoutVersion}/v1/models`];
|
||||
const first = /\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`;
|
||||
const fallback = `${base}/models`;
|
||||
return first === fallback ? [first] : [first, fallback];
|
||||
return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`];
|
||||
};
|
||||
|
||||
const normalizeModelList = (payload: unknown): ModelOption[] => {
|
||||
@@ -95,7 +97,7 @@ export async function fetchModels(
|
||||
timeoutMs?: number,
|
||||
): Promise<ModelOption[]> {
|
||||
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
|
||||
const candidates = endpointCandidates(provider, normalized);
|
||||
const candidates = modelEndpointCandidates(provider, normalized);
|
||||
if (candidates.length === 0) throw new Error(modelText('model.error.baseUrlRequired'));
|
||||
const headers: Record<string, string> = { ...customHeaders };
|
||||
const hasHeader = (name: string) =>
|
||||
|
||||
@@ -44,6 +44,12 @@ const endpointRoot = (provider: ModelProvider, baseUrl: string) => {
|
||||
return normalized.replace(/\/(?:v1beta|v1)$/i, '');
|
||||
};
|
||||
|
||||
const openAIChatCompletionsEndpoint = (baseUrl: string) => {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return '';
|
||||
return `${normalized}/chat/completions`;
|
||||
};
|
||||
|
||||
const hasHeader = (headers: Record<string, string>, name: string) =>
|
||||
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
||||
|
||||
@@ -148,7 +154,7 @@ export function buildProviderHealthProbe(
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${root}/v1/chat/completions`,
|
||||
url: openAIChatCompletionsEndpoint(baseUrl),
|
||||
header: headers,
|
||||
protocol: 'openai-chat',
|
||||
data: JSON.stringify({
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
setCargoLockPackageVersion,
|
||||
setCargoPackageVersion,
|
||||
validateAppVersion,
|
||||
} from '../scripts/app-version.mjs';
|
||||
} from '../scripts/version.mjs';
|
||||
import { applyAppVersion, runCargoMetadata } from '../scripts/set-version.mjs';
|
||||
|
||||
describe('app version', () => {
|
||||
it('reads the version from the package section', () => {
|
||||
@@ -60,4 +61,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
expect(updated).toContain('name = "cpa-gui"\nversion = "2.0.0"');
|
||||
expect(updated).toContain('name = "dependency"\nversion = "1.2.3"');
|
||||
});
|
||||
|
||||
it('repairs a stale lockfile even when Cargo.toml already has the requested version', async () => {
|
||||
const files = new Map<string, string>([
|
||||
['Cargo.toml', '[package]\nname = "cpa-gui"\nversion = "2.0.0"\n'],
|
||||
['Cargo.lock', '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n'],
|
||||
]);
|
||||
const writes: string[] = [];
|
||||
|
||||
const result = await applyAppVersion('2.0.0', {
|
||||
rootDir: 'test-root',
|
||||
readText: async (path: string) => files.get(
|
||||
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
|
||||
)!,
|
||||
writeText: async (path: string, contents: string) => {
|
||||
const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock';
|
||||
writes.push(name);
|
||||
files.set(name, contents);
|
||||
},
|
||||
validateProject: async () => {},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ version: '2.0.0', previousVersion: '2.0.0', changed: true });
|
||||
expect(writes).toEqual(['Cargo.lock']);
|
||||
expect(files.get('Cargo.lock')).toContain('version = "2.0.0"');
|
||||
});
|
||||
|
||||
it('restores both files when cargo metadata validation fails', async () => {
|
||||
const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n';
|
||||
const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n';
|
||||
const files = new Map<string, string>([
|
||||
['Cargo.toml', originalManifest],
|
||||
['Cargo.lock', originalLockfile],
|
||||
]);
|
||||
|
||||
await expect(applyAppVersion('2.0.0', {
|
||||
rootDir: 'test-root',
|
||||
readText: async (path: string) => files.get(
|
||||
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
|
||||
)!,
|
||||
writeText: async (path: string, contents: string) => {
|
||||
files.set(path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', contents);
|
||||
},
|
||||
validateProject: async () => { throw new Error('metadata validation failed'); },
|
||||
})).rejects.toThrow('metadata validation failed');
|
||||
|
||||
expect(files.get('Cargo.toml')).toBe(originalManifest);
|
||||
expect(files.get('Cargo.lock')).toBe(originalLockfile);
|
||||
});
|
||||
|
||||
it('restores the manifest when writing the lockfile fails', async () => {
|
||||
const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n';
|
||||
const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n';
|
||||
const files = new Map<string, string>([
|
||||
['Cargo.toml', originalManifest],
|
||||
['Cargo.lock', originalLockfile],
|
||||
]);
|
||||
let lockfileWrites = 0;
|
||||
|
||||
await expect(applyAppVersion('2.0.0', {
|
||||
rootDir: 'test-root',
|
||||
readText: async (path: string) => files.get(
|
||||
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
|
||||
)!,
|
||||
writeText: async (path: string, contents: string) => {
|
||||
const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock';
|
||||
if (name === 'Cargo.lock' && lockfileWrites++ === 0) {
|
||||
throw new Error('lockfile write failed');
|
||||
}
|
||||
files.set(name, contents);
|
||||
},
|
||||
validateProject: async () => {},
|
||||
})).rejects.toThrow('lockfile write failed');
|
||||
|
||||
expect(files.get('Cargo.toml')).toBe(originalManifest);
|
||||
expect(files.get('Cargo.lock')).toBe(originalLockfile);
|
||||
});
|
||||
|
||||
it('includes cargo stderr when metadata validation fails', () => {
|
||||
expect(() => runCargoMetadata('Cargo.toml', () => ({
|
||||
status: 101,
|
||||
stderr: 'manifest parse error',
|
||||
stdout: '',
|
||||
}))).toThrow('cargo metadata failed with exit code 101: manifest parse error');
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { publishGitcodeRelease } from '../scripts/publish-gitcode-release.mjs';
|
||||
|
||||
describe('GitCode release mirror', () => {
|
||||
test('creates a release and uploads compiled artifacts', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-'));
|
||||
const calls: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const uploads: Array<{ url: string; path: string }> = [];
|
||||
try {
|
||||
await writeFile(join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'artifact');
|
||||
const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
const method = init.method ?? 'GET';
|
||||
calls.push({ url, method, body: init.body });
|
||||
if (method === 'GET' && url.includes('/releases/tags/')) {
|
||||
return new Response('', { status: 404 });
|
||||
}
|
||||
if (method === 'POST' && url.includes('/releases?')) {
|
||||
return Response.json({ tag_name: 'v1.2.3', assets: [] });
|
||||
}
|
||||
if (method === 'GET' && url.includes('/upload_url?')) {
|
||||
return Response.json({
|
||||
upload_url: 'https://file-cdn.gitcode.com/presigned-upload',
|
||||
headers: { 'x-upload-token': 'signed' },
|
||||
});
|
||||
}
|
||||
return new Response('', { status: 500 });
|
||||
};
|
||||
|
||||
await publishGitcodeRelease({
|
||||
directory,
|
||||
repository: 'mirror-owner/EasyCLIProxyAPI',
|
||||
token: 'secret-token',
|
||||
tag: 'v1.2.3',
|
||||
fetchImpl,
|
||||
uploadImpl: async ({ url, path }) => {
|
||||
uploads.push({ url, path });
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls.map(({ method }) => method)).toEqual(['GET', 'POST', 'GET']);
|
||||
expect(calls[0].url).toContain('/repos/mirror-owner/EasyCLIProxyAPI/releases/tags/v1.2.3');
|
||||
expect(calls[0].url).toContain('access_token=secret-token');
|
||||
expect(calls[2].url).toContain('file_name=EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip');
|
||||
expect(uploads).toEqual([{
|
||||
url: 'https://file-cdn.gitcode.com/presigned-upload',
|
||||
path: join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'),
|
||||
}]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('continues when GitCode stores an asset but the upload response times out', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-'));
|
||||
const artifactName = 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip';
|
||||
let releaseRequests = 0;
|
||||
try {
|
||||
await writeFile(join(directory, artifactName), 'artifact');
|
||||
const fetchImpl = async (input: URL | RequestInfo) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/releases/tags/')) {
|
||||
releaseRequests += 1;
|
||||
return Response.json({
|
||||
tag_name: 'v1.2.3',
|
||||
assets: releaseRequests === 1 ? [] : [{ name: artifactName }],
|
||||
});
|
||||
}
|
||||
if (url.includes('/upload_url?')) {
|
||||
return Response.json({ upload_url: 'https://file-cdn.gitcode.com/presigned-upload' });
|
||||
}
|
||||
return new Response('', { status: 500 });
|
||||
};
|
||||
|
||||
await expect(publishGitcodeRelease({
|
||||
directory,
|
||||
repository: 'mirror-owner/EasyCLIProxyAPI',
|
||||
token: 'secret-token',
|
||||
tag: 'v1.2.3',
|
||||
fetchImpl,
|
||||
uploadImpl: async () => {
|
||||
throw new Error('upload response timed out');
|
||||
},
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(releaseRequests).toBe(2);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
generatePortableUpdateManifest,
|
||||
} from '../scripts/generate-portable-update-manifest.mjs';
|
||||
} from '../scripts/manifest.mjs';
|
||||
|
||||
describe('Windows 便携更新清单', () => {
|
||||
test('URL、大小和哈希与实际上传资产一致', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
primaryProviderHealthCredential,
|
||||
runProviderModelHealthChecks,
|
||||
} from '../src/services/providerHealthCheck';
|
||||
import { modelEndpointCandidates } from '../src/services/modelService';
|
||||
|
||||
describe('API 接入健康检测', () => {
|
||||
it('健康检测只保留已勾选模型,并使用发现结果补充模型信息', () => {
|
||||
@@ -59,6 +60,51 @@ describe('API 接入健康检测', () => {
|
||||
expect(probe.protocol).toBe('openai-chat');
|
||||
});
|
||||
|
||||
it('为非 v1 的 OpenAI 兼容版本前缀保留原始版本号', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'openai',
|
||||
'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
'glm-test',
|
||||
'secret-key',
|
||||
);
|
||||
|
||||
expect(probe.url).toBe(
|
||||
'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions',
|
||||
);
|
||||
expect(modelEndpointCandidates(
|
||||
'openai',
|
||||
'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
)).toEqual([
|
||||
'https://open.bigmodel.cn/api/coding/paas/v4/models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('把不含版本号的中转地址视为完整 API 路径前缀', () => {
|
||||
const baseUrl = 'https://relay.example/custom/openai';
|
||||
const probe = buildProviderHealthProbe(
|
||||
'openai',
|
||||
baseUrl,
|
||||
'mapped-model',
|
||||
'secret-key',
|
||||
);
|
||||
|
||||
expect(probe.url).toBe(
|
||||
'https://relay.example/custom/openai/chat/completions',
|
||||
);
|
||||
expect(modelEndpointCandidates('openai', baseUrl)).toEqual([
|
||||
'https://relay.example/custom/openai/models',
|
||||
]);
|
||||
expect(modelEndpointCandidates(
|
||||
'openai',
|
||||
'https://relay.example/custom/openai/v1/models',
|
||||
)).toEqual([
|
||||
'https://relay.example/custom/openai/v1/models',
|
||||
]);
|
||||
expect(modelEndpointCandidates('codex', 'https://codex.example')).toEqual([
|
||||
'https://codex.example/v1/models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('不会覆盖供应商已有的自定义认证头', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'claude',
|
||||
|
||||
Reference in new issue
Block a user