Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ jobs:
with:
go-version-file: go.mod
- name: Build host updater assets
env:
APP_VERSION: ${{ needs.prepare.outputs.tag }}
APP_COMMIT: ${{ needs.prepare.outputs.sha }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
run: ./scripts/build-updater-assets.sh release-assets
- uses: actions/upload-artifact@v4
with:
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

## 0.2.48 - 2026-09-06

### English

- Replace the running host-updater binary in place after a managed update and exit so systemd or LaunchAgent starts the new process
- Roll back a failed host-binary swap with the container, stamp updater version into release assets, let an old updater complete one jump, and offer the three previous stable releases on the System page

### 中文

- 托管更新成功后就地替换正在运行的宿主机更新器二进制并退出,由 systemd / LaunchAgent 拉起新进程
- 宿主机二进制替换失败时连容器一起回滚;Release 附件打上 updater 版本号;旧更新器可完成一次升级;系统页可回滚到最近三个稳定版

## 0.2.47 - 2026-09-06

### English
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
- **账号级常驻运行时**:Qoder 账号使用独立 Node 进程、HOME 与 WASM 上下文;WorkBuddy / Trae 使用进程内 HTTP/SSE 适配器。登录态、云端连接和账号隔离由各 provider 的运行时负责
- **按 provider 支持多种登录方式**:浏览器 Device Flow OAuth、PAT,以及适用 provider 的凭证导入/导出
- **Web 控制台**:账号、模型、接入、请求历史与运行时日志,明暗主题
- **部署与运维**:Docker Compose 单容器、安全托管更新(升级前快照、失败自动回滚、逐版本升级)、默认只监听 `127.0.0.1`
- **部署与运维**:Docker Compose 单容器、安全托管更新(升级前快照、失败自动回滚、直接最新稳定版、可回滚最近三个稳定版)、默认只监听 `127.0.0.1`
- **跨平台**:`linux/amd64` / `linux/arm64` 镜像;macOS、Windows 通过 Docker Desktop 运行

## 快速开始

**强烈建议用 Docker 部署。** 发布镜像、控制台托管更新(升级前快照、失败回滚、逐版本升级)都按单容器 Compose 安装来设计;从源码直接跑 Go / Node 不在这条更新路径上。
**强烈建议用 Docker 部署。** 发布镜像、控制台托管更新(升级前快照、失败回滚、直接最新稳定版)都按单容器 Compose 安装来设计;从源码直接跑 Go / Node 不在这条更新路径上。

依赖:Docker(macOS / Windows 用 Docker Desktop,Linux 用 Docker Engine + Compose),以及一个你自己控制的 Qoder、WorkBuddy 或 Trae 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。

Expand Down
4 changes: 2 additions & 2 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ Long-lived account runtimes, multi-account scheduling. Deploy with Docker; that
- **Account-level runtimes**: Qoder accounts use an isolated Node process, HOME, and WASM context; WorkBuddy / Trae use in-process HTTP/SSE adapters. Each provider owns its login and upstream runtime boundary
- **Provider-specific login methods**: browser Device Flow OAuth, PAT, and credential import/export where supported
- **Web console**: accounts, models, access, request history, and runtime logs, with light and dark themes
- **Deployment and ops**: single Docker Compose container, safe managed updates (pre-update snapshot, automatic rollback on failure, next-version-only upgrades), binds `127.0.0.1` by default
- **Deployment and ops**: single Docker Compose container, safe managed updates (pre-update snapshot, automatic rollback on failure, jump to the latest stable release, roll back to one of the three previous stables), binds `127.0.0.1` by default
- **Cross-platform**: `linux/amd64` / `linux/arm64` images; macOS and Windows run them through Docker Desktop

## Quick start

**Deploy with Docker.** Published images and console managed updates (pre-update snapshot, automatic rollback, next-version upgrades) are built around the single Compose container. Running the Go / Node sources directly is not on that update path.
**Deploy with Docker.** Published images and console managed updates (pre-update snapshot, automatic rollback, jump to the latest stable release) are built around the single Compose container. Running the Go / Node sources directly is not on that update path.

Requirements: Docker (Docker Desktop on macOS/Windows, Docker Engine + Compose on Linux) and a Qoder, WorkBuddy, or Trae account you control. On Windows, Docker Desktop must use Linux containers.

Expand Down
7 changes: 6 additions & 1 deletion cmd/updater/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,15 @@ func main() {
authToken = strings.TrimSpace(string(data))
}

hostBinary, err := os.Executable()
if err != nil {
log.Fatal(err)
}
executor := updater.NewExecutor(updater.ExecutorConfig{
ComposeFile: composeFile, EnvFile: envFile, ServiceName: serviceName,
ContainerName: containerName, ImageRepository: imageRepository, HealthURL: healthURL,
HealthTimeout: healthTimeout,
HealthTimeout: healthTimeout, HostBinaryPath: hostBinary,
GitHubToken: strings.TrimSpace(os.Getenv("UPDATE_GITHUB_TOKEN")),
})
service := updater.NewService(updater.Config{
SocketPath: socketPath, ListenAddress: listenAddress, AuthToken: authToken, StatusFile: statusFile,
Expand Down
4 changes: 2 additions & 2 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /web/dist/ ./internal/webui/static/
RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -ldflags "-X github.com/caigee-cmd/cli2api/internal/buildinfo.Version=${APP_VERSION} -X github.com/caigee-cmd/cli2api/internal/buildinfo.Commit=${APP_COMMIT}" -o /out/qoder-api-proxy ./cmd/server \
&& CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -o /out/cli2api-updater ./cmd/updater
RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -ldflags "-s -w -X github.com/caigee-cmd/cli2api/internal/buildinfo.Version=${APP_VERSION} -X github.com/caigee-cmd/cli2api/internal/buildinfo.Commit=${APP_COMMIT}" -o /out/qoder-api-proxy ./cmd/server \
&& CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -ldflags "-s -w -X github.com/caigee-cmd/cli2api/internal/buildinfo.Version=${APP_VERSION} -X github.com/caigee-cmd/cli2api/internal/buildinfo.Commit=${APP_COMMIT}" -o /out/cli2api-updater ./cmd/updater

# ---- single runtime ----
FROM node:20-bookworm-slim
Expand Down
29 changes: 20 additions & 9 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,13 @@ Start `qoder-api-proxy` once before installing the optional host updater.
| Windows ARM64 | Docker Desktop Linux containers | `cli2api-updater_windows_arm64.exe` |

Release assets include a SHA256 manifest. Installers use a verified prebuilt
updater whenever possible. Linux first copies the matching binary from the
running container; older releases without assets can use the latest compatible
asset or fall back to a local Go `1.25.6+` build.
updater whenever possible. They download the latest GitHub updater asset
first, then the asset matching the running container, then copy
`/app/cli2api-updater` from the container, and finally fall back to a local
Go `1.25.6+` build. A managed console update stages the host binary from the
target release, copies `/app/cli2api-updater` from the new Linux container when
possible, then atomically replaces the running updater and exits so systemd or
LaunchAgent starts the new process.

macOS + Docker Desktop:

Expand Down Expand Up @@ -169,17 +173,24 @@ Before replacement, the Go process pauses new API requests, waits for active
requests to drain, and creates a verified SQLite snapshot in `/data/backups`.
The updater recreates only `qoder-api-proxy`; it never runs
`docker compose down -v`, and it verifies that the same `/data` mount remains
attached. If the new version fails its versioned health check, the updater
restores both the previous image and the pre-update SQLite snapshot, then pins
attached. If the new version fails its versioned health check, or the host-updater binary
cannot be replaced, the updater restores the previous image and the pre-update
SQLite snapshot, discards any staged `.new` host binary, then pins
`CLI2API_IMAGE` to the previous version for future restarts. The five most recent
snapshots are retained.

The updater remains unavailable for development builds without a semantic
version. The System page updates directly to the latest stable release and
lists the intermediate versions it passes over.
version. The System page updates directly to the latest stable release, lists
the intermediate versions it passes over, and can roll back to one of the three
previous stable releases. An older host updater that does not yet speak staged
updates still completes one jump, then replaces itself.

The updater API currently reports protocol version `1`. Protocol `0` remains
temporarily accepted for bootstrap compatibility; unknown versions fail closed.
Release updater assets and the `/app/cli2api-updater` binary inside the image
are stamped with the same `Version` and `Commit` as the application. Status
reports that version next to protocol `2`.

The updater API currently reports protocol version `2`. Protocol `1` remains
accepted for staged-update hosts; unknown versions fail closed.

## 8. Platform notes

Expand Down
11 changes: 6 additions & 5 deletions deploy/install-updater.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,19 @@ function Get-UpdaterAssetName {
function Install-ReleasedUpdater {
param([string]$Destination)

$Version = Get-RunningReleaseVersion
if ([string]::IsNullOrWhiteSpace($Version)) {
return $false
}
$AssetName = Get-UpdaterAssetName
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cli2api-updater-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
$AssetPath = Join-Path $TempDir $AssetName
$ChecksumPath = Join-Path $TempDir "cli2api-updater_checksums.txt"
$Sources = @("latest")
$Version = Get-RunningReleaseVersion
if (-not [string]::IsNullOrWhiteSpace($Version)) {
$Sources += $Version
}

try {
foreach ($SourceLabel in @($Version, "latest")) {
foreach ($SourceLabel in $Sources) {
if ($SourceLabel -eq "latest") {
$BaseUrl = "https://github.com/$GitHubRepository/releases/latest/download"
} else {
Expand Down
31 changes: 17 additions & 14 deletions deploy/install-updater.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ install_released_updater() {
local destination="$1"
local os_name="$2"
local version asset temp_dir checksum_file expected actual base_url source_label
version="$(running_release_version)" || return 1
asset="$(updater_asset_name "${os_name}")" || return 2
temp_dir="$(mktemp -d)"
checksum_file="${temp_dir}/cli2api-updater_checksums.txt"

for source_label in "${version}" latest; do
asset="$(updater_asset_name "${os_name}")" || return 2
temp_dir="$(mktemp -d)"
checksum_file="${temp_dir}/cli2api-updater_checksums.txt"
local sources=(latest)
if version="$(running_release_version)"; then
sources+=("${version}")
fi

for source_label in "${sources[@]}"; do
if [[ "${source_label}" == "latest" ]]; then
base_url="https://github.com/${GITHUB_REPOSITORY}/releases/latest/download"
else
Expand Down Expand Up @@ -167,14 +170,14 @@ install_linux() {
exit 1
fi

local temp_dir status
temp_dir="$(mktemp -d)"
if docker cp "${CONTAINER_NAME}:/app/cli2api-updater" "${temp_dir}/cli2api-updater" >/dev/null 2>&1; then
install -m 0755 "${temp_dir}/cli2api-updater" /usr/local/bin/cli2api-updater
echo "Installed updater from the running container."
elif install_released_updater /usr/local/bin/cli2api-updater linux; then
:
else
local temp_dir status
temp_dir="$(mktemp -d)"
if install_released_updater /usr/local/bin/cli2api-updater linux; then
:
elif docker cp "${CONTAINER_NAME}:/app/cli2api-updater" "${temp_dir}/cli2api-updater" >/dev/null 2>&1; then
install -m 0755 "${temp_dir}/cli2api-updater" /usr/local/bin/cli2api-updater
echo "Installed updater from the running container."
else
status=$?
if [[ "${status}" -ne 1 ]]; then
rm -rf "${temp_dir}"
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export type SystemUpdateInfo = {
current_version: string
next_version?: string
skipped_versions?: string[]
rollback_versions?: Array<{
tag_name: string
name?: string
published_at?: string
html_url?: string
}>
has_update: boolean
managed: boolean
cached: boolean
Expand Down Expand Up @@ -98,3 +104,7 @@ export function applyPreparedSystemUpdate() {
export function cancelSystemUpdate() {
return api<{ ok: boolean }>('/api/system/update/cancel', { method: 'POST', body: '{}' })
}

export function rollbackSystemUpdate(version: string) {
return api<StartUpdateResult>('/api/system/update/rollback', { method: 'POST', body: JSON.stringify({ version }) })
}
16 changes: 12 additions & 4 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ export const messages: Record<Lang, Dict> = {
updateReadyTargetHint: 'Restart will switch this host to {version}. New requests pause only during that restart.',
updateNewerReleaseHint: 'A newer release is available ({latest}). Restart this downloaded image first, or discard it and prepare again.',
updateStagedUnavailableHint: 'Update the host updater first to use the staged update flow.',
updateLegacyOneShotHint: 'This host updater will recreate the container in one step, then replace itself. Later updates can stage the image first.',
rollbackToVersion: 'Roll back',
rollbackToVersionHint: 'Recreate the container with one of the three most recent stable releases older than the running version.',
updateReloadingIn: 'Updating… refresh in {seconds}s',
updateInProgress: 'Updating…',
updatePreparingImage: 'Downloading image…',
updatePreparingImageHint: 'The host updater is pulling the new image. The running service stays on the current version until you confirm the restart. Cancel if the download hangs.',
updateApplyingHint: 'The host updater is recreating the container. The console will refresh after the health check.',
updatePreparingImageHint: 'The host updater is pulling the new image and the matching host-updater binary. The running service stays on the current version until you confirm the restart. Cancel if the download hangs.',
updateApplyingHint: 'The host updater is recreating the container, then replacing its own binary and restarting. The console will refresh after the health check.',
updateInProgressHint: 'The service is updating and will refresh automatically when it is ready.',
updateUnavailableHint: 'Updates are temporarily unavailable. Please try again later.',
updateFailedHint: 'The update could not be completed. Please try again.',
Expand Down Expand Up @@ -109,6 +112,7 @@ export const messages: Record<Lang, Dict> = {
updateState_submitting: 'Submitting update',
updateState_running: 'Updating service',
updateState_pulling: 'Pulling image',
updateState_host_binary: 'Downloading host updater',
updateState_image_ready: 'Image ready',
updateState_ready_to_apply: 'Ready to restart',
updateState_recreating: 'Recreating service',
Expand Down Expand Up @@ -649,11 +653,14 @@ export const messages: Record<Lang, Dict> = {
updateReadyTargetHint: '确认后会把本机切换到 {version}。只有重启时才会暂停新请求。',
updateNewerReleaseHint: '又有更新的版本({latest})。可以先重启这份已下载镜像,或放弃后重新准备。',
updateStagedUnavailableHint: '请先更新宿主机更新器,才能使用分阶段更新。',
updateLegacyOneShotHint: '当前宿主机更新器会一次重建容器并替换自己。之后的更新可以先准备镜像再确认重启。',
rollbackToVersion: '回滚版本',
rollbackToVersionHint: '可回滚到当前版本之前最近三个稳定版,会重建容器。',
updateReloadingIn: '更新中… {seconds}s 后刷新',
updateInProgress: '更新中…',
updatePreparingImage: '正在下载镜像…',
updatePreparingImageHint: '宿主机更新器正在拉取新镜像。当前服务仍运行旧版本,确认重启后才会切换。下载卡住时可以取消。',
updateApplyingHint: '宿主机更新器正在重建容器。健康检查通过后控制台会自动刷新。',
updatePreparingImageHint: '宿主机更新器正在拉取新镜像,并下载对应的宿主机更新器二进制。当前服务仍运行旧版本,确认重启后才会切换。下载卡住时可以取消。',
updateApplyingHint: '宿主机更新器正在重建容器,然后就地替换自己的二进制并退出重启。健康检查通过后控制台会自动刷新。',
updateInProgressHint: '系统正在更新,准备就绪后会自动刷新页面。',
updateUnavailableHint: '暂时无法更新,请稍后重试。',
updateFailedHint: '更新未完成,请重试。',
Expand Down Expand Up @@ -681,6 +688,7 @@ export const messages: Record<Lang, Dict> = {
updateState_submitting: '提交更新中',
updateState_running: '更新服务中',
updateState_pulling: '拉取镜像中',
updateState_host_binary: '正在下载宿主机更新器',
updateState_image_ready: '镜像已就绪',
updateState_ready_to_apply: '待确认重启',
updateState_recreating: '重建服务中',
Expand Down
Loading
Loading