Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
0c2aed2
1
Parent(s): bc98e69
feat: auto-release pipeline + multi-mode update system + commit hash display
Browse files- sync-electron.yml: auto bump patch version + create tag after merge, concurrency group
- release.yml: replace CHANGELOG extraction with GitHub auto-generated release notes
- self-update.ts: support git/docker/electron deploy modes, GitHub Releases API, periodic checker
- Header: show commit hash in status badge, expandable update panel with changelog
- use-update-status: add applyUpdate, mode/commits/release fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .github/workflows/release.yml +93 -0
- .github/workflows/sync-electron.yml +62 -2
- shared/hooks/use-update-status.ts +30 -5
- shared/i18n/translations.ts +16 -0
- src/index.ts +3 -0
- src/routes/web.ts +49 -27
- src/self-update.ts +190 -43
- vitest.config.ts +14 -1
- web/src/App.tsx +18 -6
- web/src/components/Header.tsx +92 -6
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Release Electron App
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- "v*"
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: write
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
build:
|
| 14 |
+
strategy:
|
| 15 |
+
fail-fast: false
|
| 16 |
+
matrix:
|
| 17 |
+
include:
|
| 18 |
+
- os: windows-latest
|
| 19 |
+
platform: win
|
| 20 |
+
artifact: windows
|
| 21 |
+
- os: macos-latest
|
| 22 |
+
platform: mac
|
| 23 |
+
artifact: macos
|
| 24 |
+
- os: ubuntu-latest
|
| 25 |
+
platform: linux
|
| 26 |
+
artifact: linux
|
| 27 |
+
|
| 28 |
+
runs-on: ${{ matrix.os }}
|
| 29 |
+
|
| 30 |
+
steps:
|
| 31 |
+
- name: Checkout
|
| 32 |
+
uses: actions/checkout@v4
|
| 33 |
+
|
| 34 |
+
- name: Setup Node.js
|
| 35 |
+
uses: actions/setup-node@v4
|
| 36 |
+
with:
|
| 37 |
+
node-version: 20
|
| 38 |
+
cache: npm
|
| 39 |
+
|
| 40 |
+
- name: Install root dependencies
|
| 41 |
+
run: npm ci --ignore-scripts
|
| 42 |
+
|
| 43 |
+
- name: Setup curl-impersonate
|
| 44 |
+
run: npx tsx scripts/setup-curl.ts
|
| 45 |
+
env:
|
| 46 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 47 |
+
|
| 48 |
+
- name: Install web dependencies
|
| 49 |
+
run: cd web && npm ci
|
| 50 |
+
|
| 51 |
+
- name: Install desktop dependencies
|
| 52 |
+
run: cd desktop && npm ci
|
| 53 |
+
|
| 54 |
+
- name: Build app
|
| 55 |
+
run: npm run app:build
|
| 56 |
+
|
| 57 |
+
- name: Pack (${{ matrix.platform }})
|
| 58 |
+
run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }} --publish never
|
| 59 |
+
|
| 60 |
+
- name: Upload artifacts
|
| 61 |
+
uses: actions/upload-artifact@v4
|
| 62 |
+
with:
|
| 63 |
+
name: release-${{ matrix.artifact }}
|
| 64 |
+
path: |
|
| 65 |
+
release/*.exe
|
| 66 |
+
release/*.dmg
|
| 67 |
+
release/*.AppImage
|
| 68 |
+
release/*.yml
|
| 69 |
+
release/*.yaml
|
| 70 |
+
if-no-files-found: ignore
|
| 71 |
+
|
| 72 |
+
release:
|
| 73 |
+
needs: build
|
| 74 |
+
runs-on: ubuntu-latest
|
| 75 |
+
if: startsWith(github.ref, 'refs/tags/v')
|
| 76 |
+
|
| 77 |
+
steps:
|
| 78 |
+
- name: Checkout
|
| 79 |
+
uses: actions/checkout@v4
|
| 80 |
+
|
| 81 |
+
- name: Download all artifacts
|
| 82 |
+
uses: actions/download-artifact@v4
|
| 83 |
+
with:
|
| 84 |
+
path: artifacts
|
| 85 |
+
merge-multiple: true
|
| 86 |
+
|
| 87 |
+
- name: Create GitHub Release
|
| 88 |
+
uses: softprops/action-gh-release@v2
|
| 89 |
+
with:
|
| 90 |
+
generate_release_notes: true
|
| 91 |
+
draft: false
|
| 92 |
+
prerelease: false
|
| 93 |
+
files: artifacts/*
|
.github/workflows/sync-electron.yml
CHANGED
|
@@ -5,6 +5,10 @@ on:
|
|
| 5 |
branches: [master]
|
| 6 |
workflow_dispatch:
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
permissions:
|
| 9 |
contents: write
|
| 10 |
issues: write
|
|
@@ -29,9 +33,65 @@ jobs:
|
|
| 29 |
echo "CONFLICT=true" >> "$GITHUB_ENV"
|
| 30 |
}
|
| 31 |
|
| 32 |
-
- name:
|
| 33 |
if: env.CONFLICT != 'true'
|
| 34 |
-
run:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
- name: Create issue on conflict
|
| 37 |
if: env.CONFLICT == 'true'
|
|
|
|
| 5 |
branches: [master]
|
| 6 |
workflow_dispatch:
|
| 7 |
|
| 8 |
+
concurrency:
|
| 9 |
+
group: sync-electron
|
| 10 |
+
cancel-in-progress: true
|
| 11 |
+
|
| 12 |
permissions:
|
| 13 |
contents: write
|
| 14 |
issues: write
|
|
|
|
| 33 |
echo "CONFLICT=true" >> "$GITHUB_ENV"
|
| 34 |
}
|
| 35 |
|
| 36 |
+
- name: Auto bump + tag if new commits
|
| 37 |
if: env.CONFLICT != 'true'
|
| 38 |
+
run: |
|
| 39 |
+
# Find last version tag
|
| 40 |
+
LAST_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || echo "")
|
| 41 |
+
if [ -z "$LAST_TAG" ]; then
|
| 42 |
+
echo "No previous tag found, skipping auto-bump"
|
| 43 |
+
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 44 |
+
exit 0
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
# Check for new non-merge commits since last tag
|
| 48 |
+
NEW_COMMITS=$(git log "${LAST_TAG}..HEAD" --no-merges --oneline | wc -l)
|
| 49 |
+
if [ "$NEW_COMMITS" -eq 0 ]; then
|
| 50 |
+
echo "No new non-merge commits since $LAST_TAG, skipping bump"
|
| 51 |
+
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 52 |
+
exit 0
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
echo "Found $NEW_COMMITS new commit(s) since $LAST_TAG"
|
| 56 |
+
|
| 57 |
+
# Parse current version and bump patch
|
| 58 |
+
CURRENT="${LAST_TAG#v}"
|
| 59 |
+
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
| 60 |
+
NEW_PATCH=$((PATCH + 1))
|
| 61 |
+
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
| 62 |
+
NEW_TAG="v${NEW_VERSION}"
|
| 63 |
+
|
| 64 |
+
# Check tag doesn't already exist (concurrent protection)
|
| 65 |
+
if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
|
| 66 |
+
echo "Tag $NEW_TAG already exists, skipping"
|
| 67 |
+
echo "BUMP=false" >> "$GITHUB_ENV"
|
| 68 |
+
exit 0
|
| 69 |
+
fi
|
| 70 |
+
|
| 71 |
+
# Bump version in package.json
|
| 72 |
+
node -e "
|
| 73 |
+
const fs = require('fs');
|
| 74 |
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
| 75 |
+
pkg.version = '${NEW_VERSION}';
|
| 76 |
+
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
| 77 |
+
"
|
| 78 |
+
|
| 79 |
+
git add package.json
|
| 80 |
+
git commit -m "chore: bump version to ${NEW_VERSION}"
|
| 81 |
+
git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}"
|
| 82 |
+
|
| 83 |
+
echo "BUMP=true" >> "$GITHUB_ENV"
|
| 84 |
+
echo "NEW_TAG=$NEW_TAG" >> "$GITHUB_ENV"
|
| 85 |
+
echo "Bumped to $NEW_VERSION, tagged $NEW_TAG"
|
| 86 |
+
|
| 87 |
+
- name: Push electron branch (with tags if bumped)
|
| 88 |
+
if: env.CONFLICT != 'true'
|
| 89 |
+
run: |
|
| 90 |
+
if [ "$BUMP" = "true" ]; then
|
| 91 |
+
git push origin electron --follow-tags
|
| 92 |
+
else
|
| 93 |
+
git push origin electron
|
| 94 |
+
fi
|
| 95 |
|
| 96 |
- name: Create issue on conflict
|
| 97 |
if: env.CONFLICT == 'true'
|
shared/hooks/use-update-status.ts
CHANGED
|
@@ -5,7 +5,11 @@ export interface UpdateStatus {
|
|
| 5 |
version: string;
|
| 6 |
commit: string | null;
|
| 7 |
can_self_update: boolean;
|
|
|
|
| 8 |
commits_behind: number | null;
|
|
|
|
|
|
|
|
|
|
| 9 |
update_in_progress: boolean;
|
| 10 |
};
|
| 11 |
codex: {
|
|
@@ -24,7 +28,10 @@ export interface CheckResult {
|
|
| 24 |
commits_behind: number;
|
| 25 |
current_commit: string | null;
|
| 26 |
latest_commit: string | null;
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
error?: string;
|
| 29 |
};
|
| 30 |
codex?: {
|
|
@@ -43,12 +50,13 @@ export function useUpdateStatus() {
|
|
| 43 |
const [checking, setChecking] = useState(false);
|
| 44 |
const [result, setResult] = useState<CheckResult | null>(null);
|
| 45 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 46 |
|
| 47 |
const load = useCallback(async () => {
|
| 48 |
try {
|
| 49 |
const resp = await fetch("/admin/update-status");
|
| 50 |
if (resp.ok) {
|
| 51 |
-
setStatus(await resp.json());
|
| 52 |
}
|
| 53 |
} catch { /* ignore */ }
|
| 54 |
}, []);
|
|
@@ -61,12 +69,11 @@ export function useUpdateStatus() {
|
|
| 61 |
setResult(null);
|
| 62 |
try {
|
| 63 |
const resp = await fetch("/admin/check-update", { method: "POST" });
|
| 64 |
-
const data
|
| 65 |
if (!resp.ok) {
|
| 66 |
setError("Check failed");
|
| 67 |
} else {
|
| 68 |
setResult(data);
|
| 69 |
-
// Refresh status after check
|
| 70 |
await load();
|
| 71 |
}
|
| 72 |
} catch (err) {
|
|
@@ -76,5 +83,23 @@ export function useUpdateStatus() {
|
|
| 76 |
}
|
| 77 |
}, [load]);
|
| 78 |
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
|
|
|
| 5 |
version: string;
|
| 6 |
commit: string | null;
|
| 7 |
can_self_update: boolean;
|
| 8 |
+
mode: "git" | "docker" | "electron";
|
| 9 |
commits_behind: number | null;
|
| 10 |
+
commits: { hash: string; message: string }[];
|
| 11 |
+
release: { version: string; body: string; url: string } | null;
|
| 12 |
+
update_available: boolean;
|
| 13 |
update_in_progress: boolean;
|
| 14 |
};
|
| 15 |
codex: {
|
|
|
|
| 28 |
commits_behind: number;
|
| 29 |
current_commit: string | null;
|
| 30 |
latest_commit: string | null;
|
| 31 |
+
commits: { hash: string; message: string }[];
|
| 32 |
+
release: { version: string; body: string; url: string } | null;
|
| 33 |
+
update_available: boolean;
|
| 34 |
+
mode: "git" | "docker" | "electron";
|
| 35 |
error?: string;
|
| 36 |
};
|
| 37 |
codex?: {
|
|
|
|
| 50 |
const [checking, setChecking] = useState(false);
|
| 51 |
const [result, setResult] = useState<CheckResult | null>(null);
|
| 52 |
const [error, setError] = useState<string | null>(null);
|
| 53 |
+
const [applying, setApplying] = useState(false);
|
| 54 |
|
| 55 |
const load = useCallback(async () => {
|
| 56 |
try {
|
| 57 |
const resp = await fetch("/admin/update-status");
|
| 58 |
if (resp.ok) {
|
| 59 |
+
setStatus(await resp.json() as UpdateStatus);
|
| 60 |
}
|
| 61 |
} catch { /* ignore */ }
|
| 62 |
}, []);
|
|
|
|
| 69 |
setResult(null);
|
| 70 |
try {
|
| 71 |
const resp = await fetch("/admin/check-update", { method: "POST" });
|
| 72 |
+
const data = await resp.json() as CheckResult;
|
| 73 |
if (!resp.ok) {
|
| 74 |
setError("Check failed");
|
| 75 |
} else {
|
| 76 |
setResult(data);
|
|
|
|
| 77 |
await load();
|
| 78 |
}
|
| 79 |
} catch (err) {
|
|
|
|
| 83 |
}
|
| 84 |
}, [load]);
|
| 85 |
|
| 86 |
+
const applyUpdate = useCallback(async () => {
|
| 87 |
+
setApplying(true);
|
| 88 |
+
setError(null);
|
| 89 |
+
try {
|
| 90 |
+
const resp = await fetch("/admin/apply-update", { method: "POST" });
|
| 91 |
+
const data = await resp.json() as { started: boolean; error?: string };
|
| 92 |
+
if (!resp.ok || !data.started) {
|
| 93 |
+
setError(data.error ?? "Apply failed");
|
| 94 |
+
} else {
|
| 95 |
+
await load();
|
| 96 |
+
}
|
| 97 |
+
} catch (err) {
|
| 98 |
+
setError(err instanceof Error ? err.message : "Network error");
|
| 99 |
+
} finally {
|
| 100 |
+
setApplying(false);
|
| 101 |
+
}
|
| 102 |
+
}, [load]);
|
| 103 |
+
|
| 104 |
+
return { status, checking, result, error, checkForUpdate, applyUpdate, applying };
|
| 105 |
}
|
shared/i18n/translations.ts
CHANGED
|
@@ -141,6 +141,14 @@ export const translations = {
|
|
| 141 |
speed: "Speed",
|
| 142 |
speedStandard: "Standard",
|
| 143 |
speedFast: "Fast",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
},
|
| 145 |
zh: {
|
| 146 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -287,6 +295,14 @@ export const translations = {
|
|
| 287 |
speed: "\u901f\u5ea6",
|
| 288 |
speedStandard: "\u6807\u51c6",
|
| 289 |
speedFast: "\u5feb\u901f",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
},
|
| 291 |
} as const;
|
| 292 |
|
|
|
|
| 141 |
speed: "Speed",
|
| 142 |
speedStandard: "Standard",
|
| 143 |
speedFast: "Fast",
|
| 144 |
+
updateAvailable: "Update Available",
|
| 145 |
+
updateNow: "Update Now",
|
| 146 |
+
applyingUpdate: "Updating...",
|
| 147 |
+
viewChanges: "View Changes",
|
| 148 |
+
hideChanges: "Hide",
|
| 149 |
+
newVersion: "New version",
|
| 150 |
+
dockerUpdateCmd: "Run to update:",
|
| 151 |
+
downloadUpdate: "Download",
|
| 152 |
},
|
| 153 |
zh: {
|
| 154 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 295 |
speed: "\u901f\u5ea6",
|
| 296 |
speedStandard: "\u6807\u51c6",
|
| 297 |
speedFast: "\u5feb\u901f",
|
| 298 |
+
updateAvailable: "\u6709\u53ef\u7528\u66f4\u65b0",
|
| 299 |
+
updateNow: "\u7acb\u5373\u66f4\u65b0",
|
| 300 |
+
applyingUpdate: "\u66f4\u65b0\u4e2d...",
|
| 301 |
+
viewChanges: "\u67e5\u770b\u53d8\u66f4",
|
| 302 |
+
hideChanges: "\u6536\u8d77",
|
| 303 |
+
newVersion: "\u65b0\u7248\u672c",
|
| 304 |
+
dockerUpdateCmd: "\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a",
|
| 305 |
+
downloadUpdate: "\u4e0b\u8f7d",
|
| 306 |
},
|
| 307 |
} as const;
|
| 308 |
|
src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { ProxyPool } from "./proxy/proxy-pool.js";
|
|
| 19 |
import { createProxyRoutes } from "./routes/proxies.js";
|
| 20 |
import { createResponsesRoutes } from "./routes/responses.js";
|
| 21 |
import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
|
|
|
|
| 22 |
import { initProxy } from "./tls/curl-binary.js";
|
| 23 |
import { initTransport } from "./tls/transport.js";
|
| 24 |
import { loadStaticModels } from "./models/model-store.js";
|
|
@@ -117,6 +118,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 117 |
|
| 118 |
// Start background update checker
|
| 119 |
startUpdateChecker();
|
|
|
|
| 120 |
|
| 121 |
// Start background model refresh (requires auth to be ready)
|
| 122 |
startModelRefresh(accountPool, cookieJar, proxyPool);
|
|
@@ -134,6 +136,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 134 |
return new Promise((resolve) => {
|
| 135 |
server.close(() => {
|
| 136 |
stopUpdateChecker();
|
|
|
|
| 137 |
stopModelRefresh();
|
| 138 |
refreshScheduler.destroy();
|
| 139 |
proxyPool.destroy();
|
|
|
|
| 19 |
import { createProxyRoutes } from "./routes/proxies.js";
|
| 20 |
import { createResponsesRoutes } from "./routes/responses.js";
|
| 21 |
import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
|
| 22 |
+
import { startProxyUpdateChecker, stopProxyUpdateChecker } from "./self-update.js";
|
| 23 |
import { initProxy } from "./tls/curl-binary.js";
|
| 24 |
import { initTransport } from "./tls/transport.js";
|
| 25 |
import { loadStaticModels } from "./models/model-store.js";
|
|
|
|
| 118 |
|
| 119 |
// Start background update checker
|
| 120 |
startUpdateChecker();
|
| 121 |
+
startProxyUpdateChecker();
|
| 122 |
|
| 123 |
// Start background model refresh (requires auth to be ready)
|
| 124 |
startModelRefresh(accountPool, cookieJar, proxyPool);
|
|
|
|
| 136 |
return new Promise((resolve) => {
|
| 137 |
server.close(() => {
|
| 138 |
stopUpdateChecker();
|
| 139 |
+
stopProxyUpdateChecker();
|
| 140 |
stopModelRefresh();
|
| 141 |
refreshScheduler.destroy();
|
| 142 |
proxyPool.destroy();
|
src/routes/web.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { AccountPool } from "../auth/account-pool.js";
|
|
| 6 |
import { getConfig, getFingerprint } from "../config.js";
|
| 7 |
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
|
| 8 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 9 |
-
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress } from "../self-update.js";
|
| 10 |
|
| 11 |
export function createWebRoutes(accountPool: AccountPool): Hono {
|
| 12 |
const app = new Hono();
|
|
@@ -127,13 +127,18 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 127 |
app.get("/admin/update-status", (c) => {
|
| 128 |
const proxyInfo = getProxyInfo();
|
| 129 |
const codexState = getUpdateState();
|
|
|
|
| 130 |
|
| 131 |
return c.json({
|
| 132 |
proxy: {
|
| 133 |
version: proxyInfo.version,
|
| 134 |
commit: proxyInfo.commit,
|
| 135 |
can_self_update: canSelfUpdate(),
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
update_in_progress: isProxyUpdateInProgress(),
|
| 138 |
},
|
| 139 |
codex: {
|
|
@@ -150,34 +155,42 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 150 |
|
| 151 |
app.post("/admin/check-update", async (c) => {
|
| 152 |
const results: {
|
| 153 |
-
proxy?: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
codex?: { update_available: boolean; current_version: string; latest_version: string | null; version_changed?: boolean; error?: string };
|
| 155 |
} = {};
|
| 156 |
|
| 157 |
-
// 1. Proxy
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
}
|
| 181 |
}
|
| 182 |
|
| 183 |
// 2. Codex fingerprint check
|
|
@@ -208,5 +221,14 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 208 |
});
|
| 209 |
});
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
return app;
|
| 212 |
}
|
|
|
|
| 6 |
import { getConfig, getFingerprint } from "../config.js";
|
| 7 |
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
|
| 8 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 9 |
+
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
|
| 10 |
|
| 11 |
export function createWebRoutes(accountPool: AccountPool): Hono {
|
| 12 |
const app = new Hono();
|
|
|
|
| 127 |
app.get("/admin/update-status", (c) => {
|
| 128 |
const proxyInfo = getProxyInfo();
|
| 129 |
const codexState = getUpdateState();
|
| 130 |
+
const cached = getCachedProxyUpdateResult();
|
| 131 |
|
| 132 |
return c.json({
|
| 133 |
proxy: {
|
| 134 |
version: proxyInfo.version,
|
| 135 |
commit: proxyInfo.commit,
|
| 136 |
can_self_update: canSelfUpdate(),
|
| 137 |
+
mode: getDeployMode(),
|
| 138 |
+
commits_behind: cached?.commitsBehind ?? null,
|
| 139 |
+
commits: cached?.commits ?? [],
|
| 140 |
+
release: cached?.release ? { version: cached.release.version, body: cached.release.body, url: cached.release.url } : null,
|
| 141 |
+
update_available: cached?.updateAvailable ?? false,
|
| 142 |
update_in_progress: isProxyUpdateInProgress(),
|
| 143 |
},
|
| 144 |
codex: {
|
|
|
|
| 155 |
|
| 156 |
app.post("/admin/check-update", async (c) => {
|
| 157 |
const results: {
|
| 158 |
+
proxy?: {
|
| 159 |
+
commits_behind: number;
|
| 160 |
+
current_commit: string | null;
|
| 161 |
+
latest_commit: string | null;
|
| 162 |
+
commits: Array<{ hash: string; message: string }>;
|
| 163 |
+
release: { version: string; body: string; url: string } | null;
|
| 164 |
+
update_available: boolean;
|
| 165 |
+
mode: string;
|
| 166 |
+
error?: string;
|
| 167 |
+
};
|
| 168 |
codex?: { update_available: boolean; current_version: string; latest_version: string | null; version_changed?: boolean; error?: string };
|
| 169 |
} = {};
|
| 170 |
|
| 171 |
+
// 1. Proxy update check (all modes)
|
| 172 |
+
try {
|
| 173 |
+
const proxyResult = await checkProxySelfUpdate();
|
| 174 |
+
results.proxy = {
|
| 175 |
+
commits_behind: proxyResult.commitsBehind,
|
| 176 |
+
current_commit: proxyResult.currentCommit,
|
| 177 |
+
latest_commit: proxyResult.latestCommit,
|
| 178 |
+
commits: proxyResult.commits,
|
| 179 |
+
release: proxyResult.release ? { version: proxyResult.release.version, body: proxyResult.release.body, url: proxyResult.release.url } : null,
|
| 180 |
+
update_available: proxyResult.updateAvailable,
|
| 181 |
+
mode: proxyResult.mode,
|
| 182 |
+
};
|
| 183 |
+
} catch (err) {
|
| 184 |
+
results.proxy = {
|
| 185 |
+
commits_behind: 0,
|
| 186 |
+
current_commit: null,
|
| 187 |
+
latest_commit: null,
|
| 188 |
+
commits: [],
|
| 189 |
+
release: null,
|
| 190 |
+
update_available: false,
|
| 191 |
+
mode: getDeployMode(),
|
| 192 |
+
error: err instanceof Error ? err.message : String(err),
|
| 193 |
+
};
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
// 2. Codex fingerprint check
|
|
|
|
| 221 |
});
|
| 222 |
});
|
| 223 |
|
| 224 |
+
app.post("/admin/apply-update", async (c) => {
|
| 225 |
+
if (!canSelfUpdate()) {
|
| 226 |
+
c.status(400);
|
| 227 |
+
return c.json({ started: false, error: "Self-update not available in this deploy mode" });
|
| 228 |
+
}
|
| 229 |
+
const result = await applyProxySelfUpdate();
|
| 230 |
+
return c.json(result);
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
return app;
|
| 234 |
}
|
src/self-update.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
/**
|
| 2 |
-
* Proxy self-update —
|
| 3 |
-
*
|
|
|
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { execFile, execFileSync } from "child_process";
|
|
@@ -11,39 +13,64 @@ import { isEmbedded } from "./paths.js";
|
|
| 11 |
|
| 12 |
const execFileAsync = promisify(execFile);
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
export interface ProxyInfo {
|
| 15 |
version: string;
|
| 16 |
commit: string | null;
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export interface ProxySelfUpdateResult {
|
| 20 |
commitsBehind: number;
|
| 21 |
currentCommit: string | null;
|
| 22 |
latestCommit: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
let _proxyUpdateInProgress = false;
|
| 26 |
let _gitAvailable: boolean | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
/** Read proxy version from package.json + current git commit hash. */
|
| 29 |
export function getProxyInfo(): ProxyInfo {
|
| 30 |
let version = "unknown";
|
| 31 |
try {
|
| 32 |
-
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
|
| 33 |
version = pkg.version ?? "unknown";
|
| 34 |
} catch { /* ignore */ }
|
| 35 |
|
| 36 |
let commit: string | null = null;
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
} catch { /* ignore */ }
|
| 46 |
-
}
|
| 47 |
|
| 48 |
return { version, commit };
|
| 49 |
}
|
|
@@ -53,13 +80,11 @@ export function canSelfUpdate(): boolean {
|
|
| 53 |
if (isEmbedded()) return false;
|
| 54 |
if (_gitAvailable !== null) return _gitAvailable;
|
| 55 |
|
| 56 |
-
// Check .git directory exists
|
| 57 |
if (!existsSync(resolve(process.cwd(), ".git"))) {
|
| 58 |
_gitAvailable = false;
|
| 59 |
return false;
|
| 60 |
}
|
| 61 |
|
| 62 |
-
// Check git command is available
|
| 63 |
try {
|
| 64 |
execFileSync("git", ["--version"], {
|
| 65 |
cwd: process.cwd(),
|
|
@@ -74,51 +99,137 @@ export function canSelfUpdate(): boolean {
|
|
| 74 |
return _gitAvailable;
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
/** Whether a proxy self-update is currently in progress. */
|
| 78 |
export function isProxyUpdateInProgress(): boolean {
|
| 79 |
return _proxyUpdateInProgress;
|
| 80 |
}
|
| 81 |
|
| 82 |
-
/**
|
| 83 |
-
export
|
| 84 |
-
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
try {
|
| 89 |
-
const { stdout } = await execFileAsync(
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
|
|
|
| 94 |
try {
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
try {
|
| 105 |
-
const { stdout: countOut } = await execFileAsync(
|
| 106 |
-
"git", ["rev-list", "HEAD..origin/master", "--count"], { cwd, timeout: 5000 },
|
| 107 |
-
);
|
| 108 |
-
commitsBehind = parseInt(countOut.trim(), 10) || 0;
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
/**
|
| 120 |
* Apply proxy self-update: git pull + npm install + npm run build.
|
| 121 |
-
*
|
| 122 |
*/
|
| 123 |
export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?: string }> {
|
| 124 |
if (_proxyUpdateInProgress) {
|
|
@@ -148,3 +259,39 @@ export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?
|
|
| 148 |
return { started: false, error: msg };
|
| 149 |
}
|
| 150 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Proxy self-update — detects available updates in three deployment modes:
|
| 3 |
+
* - CLI (git): git fetch + commit log
|
| 4 |
+
* - Docker (no .git): GitHub Releases API
|
| 5 |
+
* - Electron (embedded): GitHub Releases API
|
| 6 |
*/
|
| 7 |
|
| 8 |
import { execFile, execFileSync } from "child_process";
|
|
|
|
| 13 |
|
| 14 |
const execFileAsync = promisify(execFile);
|
| 15 |
|
| 16 |
+
const GITHUB_REPO = "icebear0828/codex-proxy";
|
| 17 |
+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
| 18 |
+
const INITIAL_DELAY_MS = 10_000; // 10 seconds after startup
|
| 19 |
+
|
| 20 |
export interface ProxyInfo {
|
| 21 |
version: string;
|
| 22 |
commit: string | null;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export interface CommitInfo {
|
| 26 |
+
hash: string;
|
| 27 |
+
message: string;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export interface GitHubReleaseInfo {
|
| 31 |
+
version: string;
|
| 32 |
+
tag: string;
|
| 33 |
+
body: string;
|
| 34 |
+
url: string;
|
| 35 |
+
publishedAt: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export type DeployMode = "git" | "docker" | "electron";
|
| 39 |
+
|
| 40 |
export interface ProxySelfUpdateResult {
|
| 41 |
commitsBehind: number;
|
| 42 |
currentCommit: string | null;
|
| 43 |
latestCommit: string | null;
|
| 44 |
+
commits: CommitInfo[];
|
| 45 |
+
release: GitHubReleaseInfo | null;
|
| 46 |
+
updateAvailable: boolean;
|
| 47 |
+
mode: DeployMode;
|
| 48 |
}
|
| 49 |
|
| 50 |
let _proxyUpdateInProgress = false;
|
| 51 |
let _gitAvailable: boolean | null = null;
|
| 52 |
+
let _cachedResult: ProxySelfUpdateResult | null = null;
|
| 53 |
+
let _checkTimer: ReturnType<typeof setInterval> | null = null;
|
| 54 |
+
let _initialTimer: ReturnType<typeof setTimeout> | null = null;
|
| 55 |
+
let _checking = false;
|
| 56 |
|
| 57 |
/** Read proxy version from package.json + current git commit hash. */
|
| 58 |
export function getProxyInfo(): ProxyInfo {
|
| 59 |
let version = "unknown";
|
| 60 |
try {
|
| 61 |
+
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8")) as { version?: string };
|
| 62 |
version = pkg.version ?? "unknown";
|
| 63 |
} catch { /* ignore */ }
|
| 64 |
|
| 65 |
let commit: string | null = null;
|
| 66 |
+
try {
|
| 67 |
+
const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
| 68 |
+
cwd: process.cwd(),
|
| 69 |
+
encoding: "utf-8",
|
| 70 |
+
timeout: 5000,
|
| 71 |
+
});
|
| 72 |
+
commit = out.trim() || null;
|
| 73 |
+
} catch { /* ignore */ }
|
|
|
|
|
|
|
| 74 |
|
| 75 |
return { version, commit };
|
| 76 |
}
|
|
|
|
| 80 |
if (isEmbedded()) return false;
|
| 81 |
if (_gitAvailable !== null) return _gitAvailable;
|
| 82 |
|
|
|
|
| 83 |
if (!existsSync(resolve(process.cwd(), ".git"))) {
|
| 84 |
_gitAvailable = false;
|
| 85 |
return false;
|
| 86 |
}
|
| 87 |
|
|
|
|
| 88 |
try {
|
| 89 |
execFileSync("git", ["--version"], {
|
| 90 |
cwd: process.cwd(),
|
|
|
|
| 99 |
return _gitAvailable;
|
| 100 |
}
|
| 101 |
|
| 102 |
+
/** Determine deployment mode. */
|
| 103 |
+
export function getDeployMode(): DeployMode {
|
| 104 |
+
if (isEmbedded()) return "electron";
|
| 105 |
+
if (canSelfUpdate()) return "git";
|
| 106 |
+
return "docker";
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
/** Whether a proxy self-update is currently in progress. */
|
| 110 |
export function isProxyUpdateInProgress(): boolean {
|
| 111 |
return _proxyUpdateInProgress;
|
| 112 |
}
|
| 113 |
|
| 114 |
+
/** Return cached proxy update result (set by periodic checker or manual check). */
|
| 115 |
+
export function getCachedProxyUpdateResult(): ProxySelfUpdateResult | null {
|
| 116 |
+
return _cachedResult;
|
| 117 |
+
}
|
| 118 |
|
| 119 |
+
/** Get commit log between HEAD and origin/master. */
|
| 120 |
+
async function getCommitLog(cwd: string): Promise<CommitInfo[]> {
|
| 121 |
try {
|
| 122 |
+
const { stdout } = await execFileAsync(
|
| 123 |
+
"git", ["log", "HEAD..origin/master", "--oneline", "--format=%h %s"],
|
| 124 |
+
{ cwd, timeout: 10000 },
|
| 125 |
+
);
|
| 126 |
+
return stdout.trim().split("\n")
|
| 127 |
+
.filter((line) => line.length > 0)
|
| 128 |
+
.map((line) => {
|
| 129 |
+
const spaceIdx = line.indexOf(" ");
|
| 130 |
+
return {
|
| 131 |
+
hash: line.substring(0, spaceIdx),
|
| 132 |
+
message: line.substring(spaceIdx + 1),
|
| 133 |
+
};
|
| 134 |
+
});
|
| 135 |
+
} catch {
|
| 136 |
+
return [];
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
|
| 140 |
+
/** Check GitHub Releases API for the latest version. */
|
| 141 |
+
async function checkGitHubRelease(): Promise<GitHubReleaseInfo | null> {
|
| 142 |
try {
|
| 143 |
+
const resp = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
| 144 |
+
headers: { Accept: "application/vnd.github.v3+json" },
|
| 145 |
+
signal: AbortSignal.timeout(15000),
|
| 146 |
+
});
|
| 147 |
+
if (!resp.ok) return null;
|
| 148 |
+
const data = await resp.json() as {
|
| 149 |
+
tag_name?: string;
|
| 150 |
+
body?: string | null;
|
| 151 |
+
html_url?: string;
|
| 152 |
+
published_at?: string;
|
| 153 |
+
};
|
| 154 |
+
return {
|
| 155 |
+
version: String(data.tag_name ?? "").replace(/^v/, ""),
|
| 156 |
+
tag: String(data.tag_name ?? ""),
|
| 157 |
+
body: String(data.body ?? ""),
|
| 158 |
+
url: String(data.html_url ?? ""),
|
| 159 |
+
publishedAt: String(data.published_at ?? ""),
|
| 160 |
+
};
|
| 161 |
+
} catch {
|
| 162 |
+
return null;
|
| 163 |
}
|
| 164 |
+
}
|
| 165 |
|
| 166 |
+
/** Fetch latest from origin and check how many commits behind. */
|
| 167 |
+
export async function checkProxySelfUpdate(): Promise<ProxySelfUpdateResult> {
|
| 168 |
+
const mode = getDeployMode();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
+
if (mode === "git") {
|
| 171 |
+
const cwd = process.cwd();
|
| 172 |
+
|
| 173 |
+
let currentCommit: string | null = null;
|
| 174 |
+
try {
|
| 175 |
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd, timeout: 5000 });
|
| 176 |
+
currentCommit = stdout.trim() || null;
|
| 177 |
+
} catch { /* ignore */ }
|
| 178 |
+
|
| 179 |
+
try {
|
| 180 |
+
await execFileAsync("git", ["fetch", "origin", "master", "--quiet"], { cwd, timeout: 30000 });
|
| 181 |
+
} catch (err) {
|
| 182 |
+
console.warn("[SelfUpdate] git fetch failed:", err instanceof Error ? err.message : err);
|
| 183 |
+
const result: ProxySelfUpdateResult = {
|
| 184 |
+
commitsBehind: 0, currentCommit, latestCommit: currentCommit,
|
| 185 |
+
commits: [], release: null, updateAvailable: false, mode,
|
| 186 |
+
};
|
| 187 |
+
_cachedResult = result;
|
| 188 |
+
return result;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
let commitsBehind = 0;
|
| 192 |
+
let latestCommit: string | null = null;
|
| 193 |
+
try {
|
| 194 |
+
const { stdout: countOut } = await execFileAsync(
|
| 195 |
+
"git", ["rev-list", "HEAD..origin/master", "--count"], { cwd, timeout: 5000 },
|
| 196 |
+
);
|
| 197 |
+
commitsBehind = parseInt(countOut.trim(), 10) || 0;
|
| 198 |
+
|
| 199 |
+
const { stdout: latestOut } = await execFileAsync(
|
| 200 |
+
"git", ["rev-parse", "--short", "origin/master"], { cwd, timeout: 5000 },
|
| 201 |
+
);
|
| 202 |
+
latestCommit = latestOut.trim() || null;
|
| 203 |
+
} catch { /* ignore */ }
|
| 204 |
+
|
| 205 |
+
const commits = commitsBehind > 0 ? await getCommitLog(cwd) : [];
|
| 206 |
|
| 207 |
+
const result: ProxySelfUpdateResult = {
|
| 208 |
+
commitsBehind, currentCommit, latestCommit,
|
| 209 |
+
commits, release: null,
|
| 210 |
+
updateAvailable: commitsBehind > 0, mode,
|
| 211 |
+
};
|
| 212 |
+
_cachedResult = result;
|
| 213 |
+
return result;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Docker or Electron — GitHub Releases API
|
| 217 |
+
const release = await checkGitHubRelease();
|
| 218 |
+
const currentVersion = getProxyInfo().version;
|
| 219 |
+
const updateAvailable = release !== null && release.version !== currentVersion;
|
| 220 |
+
|
| 221 |
+
const result: ProxySelfUpdateResult = {
|
| 222 |
+
commitsBehind: 0, currentCommit: null, latestCommit: null,
|
| 223 |
+
commits: [], release: updateAvailable ? release : null,
|
| 224 |
+
updateAvailable, mode,
|
| 225 |
+
};
|
| 226 |
+
_cachedResult = result;
|
| 227 |
+
return result;
|
| 228 |
}
|
| 229 |
|
| 230 |
/**
|
| 231 |
* Apply proxy self-update: git pull + npm install + npm run build.
|
| 232 |
+
* Only works in git (CLI) mode.
|
| 233 |
*/
|
| 234 |
export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?: string }> {
|
| 235 |
if (_proxyUpdateInProgress) {
|
|
|
|
| 259 |
return { started: false, error: msg };
|
| 260 |
}
|
| 261 |
}
|
| 262 |
+
|
| 263 |
+
/** Run a background check (guards against concurrent execution). */
|
| 264 |
+
async function runCheck(): Promise<void> {
|
| 265 |
+
if (_checking) return;
|
| 266 |
+
_checking = true;
|
| 267 |
+
try {
|
| 268 |
+
await checkProxySelfUpdate();
|
| 269 |
+
} catch (err) {
|
| 270 |
+
console.warn("[SelfUpdate] Periodic check failed:", err instanceof Error ? err.message : err);
|
| 271 |
+
} finally {
|
| 272 |
+
_checking = false;
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/** Start periodic proxy update checking (initial check after 10s, then every 6h). */
|
| 277 |
+
export function startProxyUpdateChecker(): void {
|
| 278 |
+
_initialTimer = setTimeout(() => {
|
| 279 |
+
runCheck();
|
| 280 |
+
}, INITIAL_DELAY_MS);
|
| 281 |
+
|
| 282 |
+
_checkTimer = setInterval(() => {
|
| 283 |
+
runCheck();
|
| 284 |
+
}, CHECK_INTERVAL_MS);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/** Stop periodic proxy update checking. */
|
| 288 |
+
export function stopProxyUpdateChecker(): void {
|
| 289 |
+
if (_initialTimer) {
|
| 290 |
+
clearTimeout(_initialTimer);
|
| 291 |
+
_initialTimer = null;
|
| 292 |
+
}
|
| 293 |
+
if (_checkTimer) {
|
| 294 |
+
clearInterval(_checkTimer);
|
| 295 |
+
_checkTimer = null;
|
| 296 |
+
}
|
| 297 |
+
}
|
vitest.config.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
| 1 |
import { defineConfig } from "vitest/config";
|
|
|
|
| 2 |
|
| 3 |
export default defineConfig({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
test: {
|
| 5 |
-
root: "src",
|
| 6 |
environment: "node",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
},
|
| 8 |
});
|
|
|
|
| 1 |
import { defineConfig } from "vitest/config";
|
| 2 |
+
import { resolve } from "path";
|
| 3 |
|
| 4 |
export default defineConfig({
|
| 5 |
+
resolve: {
|
| 6 |
+
alias: {
|
| 7 |
+
"@src": resolve(__dirname, "src"),
|
| 8 |
+
"@helpers": resolve(__dirname, "tests/_helpers"),
|
| 9 |
+
"@fixtures": resolve(__dirname, "tests/_fixtures"),
|
| 10 |
+
},
|
| 11 |
+
},
|
| 12 |
test: {
|
|
|
|
| 13 |
environment: "node",
|
| 14 |
+
include: [
|
| 15 |
+
"src/**/*.{test,spec}.ts",
|
| 16 |
+
"tests/unit/**/*.{test,spec}.ts",
|
| 17 |
+
"tests/integration/**/*.{test,spec}.ts",
|
| 18 |
+
"tests/e2e/**/*.{test,spec}.ts",
|
| 19 |
+
],
|
| 20 |
},
|
| 21 |
});
|
web/src/App.tsx
CHANGED
|
@@ -30,11 +30,8 @@ function useUpdateMessage() {
|
|
| 30 |
if (r.proxy?.error) {
|
| 31 |
parts.push(`Proxy: ${r.proxy.error}`);
|
| 32 |
color = "text-red-500";
|
| 33 |
-
} else if (r.proxy?.
|
| 34 |
-
parts.push(t("
|
| 35 |
-
color = "text-blue-500";
|
| 36 |
-
} else if (r.proxy && r.proxy.commits_behind > 0) {
|
| 37 |
-
parts.push(`Proxy: ${r.proxy.commits_behind} ${t("commits")} ${t("proxyBehind")}`);
|
| 38 |
color = "text-amber-500";
|
| 39 |
}
|
| 40 |
|
|
@@ -54,7 +51,18 @@ function useUpdateMessage() {
|
|
| 54 |
color = "text-red-500";
|
| 55 |
}
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
function Dashboard() {
|
|
@@ -77,6 +85,8 @@ function Dashboard() {
|
|
| 77 |
updateStatusMsg={update.msg}
|
| 78 |
updateStatusColor={update.color}
|
| 79 |
version={update.status?.proxy.version ?? null}
|
|
|
|
|
|
|
| 80 |
/>
|
| 81 |
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 82 |
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
|
@@ -164,7 +174,9 @@ function ProxySettingsPage() {
|
|
| 164 |
updateStatusMsg={update.msg}
|
| 165 |
updateStatusColor={update.color}
|
| 166 |
version={update.status?.proxy.version ?? null}
|
|
|
|
| 167 |
isProxySettings
|
|
|
|
| 168 |
/>
|
| 169 |
<ProxySettings />
|
| 170 |
</>
|
|
|
|
| 30 |
if (r.proxy?.error) {
|
| 31 |
parts.push(`Proxy: ${r.proxy.error}`);
|
| 32 |
color = "text-red-500";
|
| 33 |
+
} else if (r.proxy?.update_available) {
|
| 34 |
+
parts.push(t("updateAvailable"));
|
|
|
|
|
|
|
|
|
|
| 35 |
color = "text-amber-500";
|
| 36 |
}
|
| 37 |
|
|
|
|
| 51 |
color = "text-red-500";
|
| 52 |
}
|
| 53 |
|
| 54 |
+
const proxyUpdate = update.status?.proxy.update_available
|
| 55 |
+
? {
|
| 56 |
+
mode: update.status.proxy.mode,
|
| 57 |
+
updateAvailable: true as const,
|
| 58 |
+
commits: update.status.proxy.commits,
|
| 59 |
+
release: update.status.proxy.release,
|
| 60 |
+
onApply: update.applyUpdate,
|
| 61 |
+
applying: update.applying,
|
| 62 |
+
}
|
| 63 |
+
: null;
|
| 64 |
+
|
| 65 |
+
return { ...update, msg, color, proxyUpdate };
|
| 66 |
}
|
| 67 |
|
| 68 |
function Dashboard() {
|
|
|
|
| 85 |
updateStatusMsg={update.msg}
|
| 86 |
updateStatusColor={update.color}
|
| 87 |
version={update.status?.proxy.version ?? null}
|
| 88 |
+
commit={update.status?.proxy.commit ?? null}
|
| 89 |
+
proxyUpdate={update.proxyUpdate}
|
| 90 |
/>
|
| 91 |
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 92 |
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
|
|
|
| 174 |
updateStatusMsg={update.msg}
|
| 175 |
updateStatusColor={update.color}
|
| 176 |
version={update.status?.proxy.version ?? null}
|
| 177 |
+
commit={update.status?.proxy.commit ?? null}
|
| 178 |
isProxySettings
|
| 179 |
+
proxyUpdate={update.proxyUpdate}
|
| 180 |
/>
|
| 181 |
<ProxySettings />
|
| 182 |
</>
|
web/src/components/Header.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import { useI18n } from "../../../shared/i18n/context";
|
| 2 |
import { useTheme } from "../../../shared/theme/context";
|
| 3 |
|
|
@@ -13,6 +14,15 @@ const SVG_SUN = (
|
|
| 13 |
</svg>
|
| 14 |
);
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
interface HeaderProps {
|
| 17 |
onAddAccount: () => void;
|
| 18 |
onCheckUpdate: () => void;
|
|
@@ -20,12 +30,15 @@ interface HeaderProps {
|
|
| 20 |
updateStatusMsg: string | null;
|
| 21 |
updateStatusColor: string;
|
| 22 |
version: string | null;
|
|
|
|
| 23 |
isProxySettings?: boolean;
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, isProxySettings }: HeaderProps) {
|
| 27 |
const { lang, toggleLang, t } = useI18n();
|
| 28 |
const { isDark, toggle: toggleTheme } = useTheme();
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
<header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
|
|
@@ -55,7 +68,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 55 |
)}
|
| 56 |
</div>
|
| 57 |
{/* Actions */}
|
| 58 |
-
<div class="flex items-center gap-
|
| 59 |
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
| 60 |
<span class="relative flex h-2.5 w-2.5">
|
| 61 |
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
|
@@ -68,13 +81,16 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 68 |
{version && (
|
| 69 |
<span class="text-[0.65rem] font-mono text-primary/70">v{version}</span>
|
| 70 |
)}
|
|
|
|
|
|
|
|
|
|
| 71 |
</div>
|
| 72 |
{/* Star on GitHub */}
|
| 73 |
<a
|
| 74 |
href="https://github.com/icebear0828/codex-proxy"
|
| 75 |
target="_blank"
|
| 76 |
rel="noopener noreferrer"
|
| 77 |
-
class="hidden
|
| 78 |
>
|
| 79 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
|
| 80 |
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
@@ -85,7 +101,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 85 |
<button
|
| 86 |
onClick={onCheckUpdate}
|
| 87 |
disabled={checking}
|
| 88 |
-
class="hidden
|
| 89 |
>
|
| 90 |
<svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 91 |
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
|
|
@@ -98,7 +114,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 98 |
{updateStatusMsg && !checking && (
|
| 99 |
<button
|
| 100 |
onClick={onCheckUpdate}
|
| 101 |
-
class={`hidden
|
| 102 |
>
|
| 103 |
{updateStatusMsg}
|
| 104 |
</button>
|
|
@@ -124,7 +140,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 124 |
<>
|
| 125 |
<a
|
| 126 |
href="#/proxy-settings"
|
| 127 |
-
class="hidden
|
| 128 |
>
|
| 129 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 130 |
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
|
@@ -148,6 +164,76 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
|
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</header>
|
| 152 |
);
|
| 153 |
}
|
|
|
|
| 1 |
+
import { useState } from "preact/hooks";
|
| 2 |
import { useI18n } from "../../../shared/i18n/context";
|
| 3 |
import { useTheme } from "../../../shared/theme/context";
|
| 4 |
|
|
|
|
| 14 |
</svg>
|
| 15 |
);
|
| 16 |
|
| 17 |
+
export interface ProxyUpdateInfo {
|
| 18 |
+
mode: "git" | "docker" | "electron";
|
| 19 |
+
updateAvailable: boolean;
|
| 20 |
+
commits: { hash: string; message: string }[];
|
| 21 |
+
release: { version: string; body: string; url: string } | null;
|
| 22 |
+
onApply: () => void;
|
| 23 |
+
applying: boolean;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
interface HeaderProps {
|
| 27 |
onAddAccount: () => void;
|
| 28 |
onCheckUpdate: () => void;
|
|
|
|
| 30 |
updateStatusMsg: string | null;
|
| 31 |
updateStatusColor: string;
|
| 32 |
version: string | null;
|
| 33 |
+
commit?: string | null;
|
| 34 |
isProxySettings?: boolean;
|
| 35 |
+
proxyUpdate?: ProxyUpdateInfo | null;
|
| 36 |
}
|
| 37 |
|
| 38 |
+
export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, proxyUpdate }: HeaderProps) {
|
| 39 |
const { lang, toggleLang, t } = useI18n();
|
| 40 |
const { isDark, toggle: toggleTheme } = useTheme();
|
| 41 |
+
const [showChangelog, setShowChangelog] = useState(false);
|
| 42 |
|
| 43 |
return (
|
| 44 |
<header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
|
|
|
|
| 68 |
)}
|
| 69 |
</div>
|
| 70 |
{/* Actions */}
|
| 71 |
+
<div class="flex items-center gap-2">
|
| 72 |
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
| 73 |
<span class="relative flex h-2.5 w-2.5">
|
| 74 |
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
|
|
|
| 81 |
{version && (
|
| 82 |
<span class="text-[0.65rem] font-mono text-primary/70">v{version}</span>
|
| 83 |
)}
|
| 84 |
+
{commit && (
|
| 85 |
+
<span class="text-[0.6rem] font-mono text-primary/40">{commit.slice(0, 7)}</span>
|
| 86 |
+
)}
|
| 87 |
</div>
|
| 88 |
{/* Star on GitHub */}
|
| 89 |
<a
|
| 90 |
href="https://github.com/icebear0828/codex-proxy"
|
| 91 |
target="_blank"
|
| 92 |
rel="noopener noreferrer"
|
| 93 |
+
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
|
| 94 |
>
|
| 95 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
|
| 96 |
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
|
|
| 101 |
<button
|
| 102 |
onClick={onCheckUpdate}
|
| 103 |
disabled={checking}
|
| 104 |
+
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 105 |
>
|
| 106 |
<svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 107 |
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
|
|
|
|
| 114 |
{updateStatusMsg && !checking && (
|
| 115 |
<button
|
| 116 |
onClick={onCheckUpdate}
|
| 117 |
+
class={`hidden md:inline text-xs font-medium ${updateStatusColor} hover:underline`}
|
| 118 |
>
|
| 119 |
{updateStatusMsg}
|
| 120 |
</button>
|
|
|
|
| 140 |
<>
|
| 141 |
<a
|
| 142 |
href="#/proxy-settings"
|
| 143 |
+
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 144 |
>
|
| 145 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 146 |
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
|
|
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
</div>
|
| 167 |
+
{/* Expandable update panel */}
|
| 168 |
+
{proxyUpdate && proxyUpdate.updateAvailable && (
|
| 169 |
+
<div class="border-t border-amber-200 dark:border-amber-700/30 bg-amber-50/80 dark:bg-amber-900/10">
|
| 170 |
+
<div class="px-4 md:px-8 lg:px-40 flex justify-center">
|
| 171 |
+
<div class="w-full max-w-[960px] py-2">
|
| 172 |
+
<div class="flex items-center justify-between">
|
| 173 |
+
<span class="text-xs font-medium text-amber-700 dark:text-amber-400">
|
| 174 |
+
{proxyUpdate.mode === "git"
|
| 175 |
+
? `${proxyUpdate.commits.length} ${t("commits")} ${t("proxyBehind")}`
|
| 176 |
+
: `${t("newVersion")} v${proxyUpdate.release?.version}`}
|
| 177 |
+
</span>
|
| 178 |
+
<button
|
| 179 |
+
onClick={() => setShowChangelog(!showChangelog)}
|
| 180 |
+
class="text-xs font-medium text-amber-600 dark:text-amber-400 hover:underline"
|
| 181 |
+
>
|
| 182 |
+
{showChangelog ? t("hideChanges") : t("viewChanges")}
|
| 183 |
+
</button>
|
| 184 |
+
</div>
|
| 185 |
+
{showChangelog && (
|
| 186 |
+
<div class="mt-2">
|
| 187 |
+
{proxyUpdate.mode === "git" ? (
|
| 188 |
+
<>
|
| 189 |
+
<ul class="space-y-0.5 text-xs text-slate-600 dark:text-text-dim max-h-48 overflow-y-auto">
|
| 190 |
+
{proxyUpdate.commits.map((c) => (
|
| 191 |
+
<li key={c.hash} class="flex gap-2">
|
| 192 |
+
<code class="text-primary/70 shrink-0">{c.hash}</code>
|
| 193 |
+
<span>{c.message}</span>
|
| 194 |
+
</li>
|
| 195 |
+
))}
|
| 196 |
+
</ul>
|
| 197 |
+
<button
|
| 198 |
+
onClick={proxyUpdate.onApply}
|
| 199 |
+
disabled={proxyUpdate.applying}
|
| 200 |
+
class="mt-2 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover disabled:opacity-50 transition-colors"
|
| 201 |
+
>
|
| 202 |
+
{proxyUpdate.applying ? t("applyingUpdate") : t("updateNow")}
|
| 203 |
+
</button>
|
| 204 |
+
</>
|
| 205 |
+
) : (
|
| 206 |
+
<>
|
| 207 |
+
{proxyUpdate.release && (
|
| 208 |
+
<pre class="text-xs text-slate-600 dark:text-text-dim whitespace-pre-wrap max-h-48 overflow-y-auto mb-2">
|
| 209 |
+
{proxyUpdate.release.body}
|
| 210 |
+
</pre>
|
| 211 |
+
)}
|
| 212 |
+
{proxyUpdate.mode === "docker" ? (
|
| 213 |
+
<div class="flex items-center gap-2">
|
| 214 |
+
<span class="text-xs text-slate-500 dark:text-text-dim">{t("dockerUpdateCmd")}</span>
|
| 215 |
+
<code class="text-xs bg-slate-100 dark:bg-bg-dark px-2 py-0.5 rounded font-mono select-all">
|
| 216 |
+
docker compose up -d --build
|
| 217 |
+
</code>
|
| 218 |
+
</div>
|
| 219 |
+
) : (
|
| 220 |
+
<a
|
| 221 |
+
href={proxyUpdate.release?.url}
|
| 222 |
+
target="_blank"
|
| 223 |
+
rel="noopener noreferrer"
|
| 224 |
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover transition-colors"
|
| 225 |
+
>
|
| 226 |
+
{t("downloadUpdate")}
|
| 227 |
+
</a>
|
| 228 |
+
)}
|
| 229 |
+
</>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
)}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
</header>
|
| 238 |
);
|
| 239 |
}
|