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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,15 @@ services:
| `ZCODE_PANEL_ENABLED` | 关 | 设为 `1`/`true` 后,无界面的 `serve` 模式(含 Docker)额外启动一个本机 Web 面板 |
| `ZCODE_PANEL_TOKEN` | 无 | 面板的访问令牌,**开启面板时必填**(不填则面板不启动,避免裸奔的控制接口) |
| `ZCODE_PANEL_PORT` | `8090` | 面板端口(只监听 `127.0.0.1`) |
| `ZCODE_UPDATE_CHECK` | 开 | 设为 `off`/`0` 关闭启动时的「有新版」检查(只提示,不自动更新) |
| `ZCODE_UPDATE_SKIP` | 无 | 逗号分隔要忽略的版本,如 `v4.7.6,v4.7.7` |

套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 <kbd>t</kbd> 切换,会写回 config.yaml。

服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。面板上的「Stop proxy」只停代理,进程本身仍能正常退出(SIGTERM/SIGINT 和面板的 shutdown 都会先清掉后台定时器——自动领取、验证码池——再退出);在面板里登出会同时清掉运行中的凭据并停掉代理,避免登出后新请求还继续花旧账号的额度。

**有新版提示**:`serve` 和 TUI 启动时会异步向 GitHub 查一次 latest release,最多多打一行日志(TUI 里按 <kbd>u</kbd> 可手动重查),不阻塞启动、不影响代理;离线、被挡、限流或返回格式变了都一律静默忽略。手动检查总会给你明确答复(「已是最新」或「检查不可用」)。容器里镜像是不可变的,所以提示给的是**当前运行时的拉取命令**(Docker 为 `docker compose pull && docker compose up -d`,Podman 为 `podman compose pull && podman compose up -d`;认不出运行时则只说「拉取新镜像后重建容器」),而不是自己去替换文件(release 目前也没有校验和,所以不做自动下载替换)。不想让它查就设 `ZCODE_UPDATE_CHECK=off`,某个版本太吵可以 `ZCODE_UPDATE_SKIP=v4.7.6` 忽略。

**Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环:

```yaml
Expand Down
4 changes: 4 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,15 @@ The config file is `config.yaml` in the project root (auto-generated on first st
| `ZCODE_PANEL_ENABLED` | off | Set to `1`/`true` to start a local web panel in headless `serve` mode (including Docker) |
| `ZCODE_PANEL_TOKEN` | none | Access token for the panel, **required when the panel is enabled** (without it the panel does not start, so the control endpoints are never left open) |
| `ZCODE_PANEL_PORT` | `8090` | Panel port (bound to `127.0.0.1` only) |
| `ZCODE_UPDATE_CHECK` | on | Set to `off`/`0` to disable the startup "new version" check (it only notifies, it never updates in place) |
| `ZCODE_UPDATE_SKIP` | none | Comma-separated tags to mute, e.g. `v4.7.6,v4.7.7` |

The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with <kbd>t</kbd>, which writes the change back to config.yaml.

Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=<your own random string>`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. Stopping the proxy from the page does not keep the process alive: SIGTERM/SIGINT and the panel's own shutdown all clear the background timers (auto-claim, captcha pool) before exiting. Logging out from the page also clears the live credential and stops the proxy, so a logged-out account is not spent any further.

**Update notice**: on startup `serve` and the TUI ask GitHub once for the latest release, printing at most one extra log line (press <kbd>u</kbd> in the TUI to re-check manually). It never blocks startup and never affects the proxy: offline, blocked, rate-limited or unexpected answers are ignored silently. A manual check always answers — "already on the latest version" or "check unavailable". Container images are immutable, so the hint names the pull command of the **detected runtime** (Docker: `docker compose pull && docker compose up -d`, Podman: `podman compose pull && podman compose up -d`; when the runtime cannot be told apart it just says "pull the new image and recreate the container") rather than replacing files in place (release artifacts carry no checksums yet, so automatic download-and-replace is not offered). Set `ZCODE_UPDATE_CHECK=off` to disable the check, or `ZCODE_UPDATE_SKIP=v4.7.6` to mute a single tag.

**Reaching the panel from Docker**: the panel listens on the *container's own* `127.0.0.1`, so with the default bridge network `-p 8080:8080` does not expose it, and adding `-p 8090:8090` does not help either (that maps a non-loopback container address). On a Linux server, use host networking so the container shares the host's loopback:

```yaml
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type PanelServer,
type PanelSettings,
} from "./server/panel.js";
import { checkForUpdate } from "./update/check.js";
import { readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
Expand Down Expand Up @@ -412,6 +413,13 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo
}
if (debug) console.log(` debug: ON`);

// Update notice (issue #60): fire-and-forget — a slow or blocked GitHub must
// never delay startup, and every failure mode is silent (see update/check.ts).
// In a container the hint is the compose command: the image cannot self-update.
void checkForUpdate(VERSION).then((result) => {
if (result.kind === "update") console.log(` update: ${result.notice.text}`);
});

let panelRuntime: PanelServer | null = null;

const closePanel = (): void => {
Expand Down
24 changes: 24 additions & 0 deletions src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { appendFileSync } from "node:fs";
import type { ProxyConfig } from "../config/types.js";
import type { ProviderId } from "../provider/types.js";
import { LogPane, type LogLevel } from "./log-pane.js";
import { checkForUpdate, createUpdateCheckQueue } from "../update/check.js";
import { KeyParser, type KeyAction } from "./keys.js";
import { buildFrame, findRegion, type ClickAction, type ClickRegion, type Frame, type QuotaState } from "./frame.js";

Expand Down Expand Up @@ -235,6 +236,27 @@ export async function runTui(args: ServeArgs): Promise<void> {
scheduleRender();
}

// --- update notice (issue #60) -------------------------------------------
// Same check the Android app runs at startup: silent on every failure, and
// the result lands in the Logs card (the panel surfaces it through the log
// tee for free). `u` re-runs it manually — an explicit request overrides
// ZCODE_UPDATE_CHECK=off and the muted-tag list, and always answers visibly.
// The queue keeps a press made while the startup check is still in flight
// instead of dropping it: that check's result may be up-to-date, skipped or
// unavailable, none of which is an answer to an explicit request.
const runUpdateCheck = createUpdateCheckQueue(async (manual) => {
const result = await checkForUpdate(VERSION, { force: manual });
if (result.kind === "update") {
emit(`update: ${result.notice.text}`, "info");
if (manual) setToast(`update available: ${result.notice.latest}`, "info");
return;
}
if (!manual) return;
if (result.kind === "up-to-date") setToast(`already on the latest version (v${VERSION})`, "ok");
else if (result.kind === "skipped") setToast(`v${VERSION} is muted via ZCODE_UPDATE_SKIP`, "info");
else setToast("update check unavailable (offline, or GitHub blocked)", "err");
});

// --- auth ----------------------------------------------------------------
async function refreshAuth(): Promise<void> {
const cred = await loadCredential().catch(() => null);
Expand Down Expand Up @@ -593,6 +615,7 @@ export async function runTui(args: ServeArgs): Promise<void> {
case "l": void startLogin(); return;
case "o": void logout(); return;
case "r": void refreshQuota(); return;
case "u": void runUpdateCheck(true); return;
case "p": switchProvider(); return;
case "t": switchPlan(); return;
case "c": pane.clear(); scheduleRender(); return;
Expand Down Expand Up @@ -677,6 +700,7 @@ export async function runTui(args: ServeArgs): Promise<void> {
// --- boot ---------------------------------------------------------------------
console.log(`zcode-proxy TUI — config: ${path}`);
console.log(`provider: ${state.provider} · plan: ${state.plan}`);
void runUpdateCheck();
await refreshAuth();
renderNow();
if (!state.loggedIn) {
Expand Down
2 changes: 1 addition & 1 deletion src/tui/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ export function buildFrame(s: FrameState): Frame {
// narrow terminals, so the primary actions must come first.
const footerItems: Array<[string, string]> = [
["s", "start/stop"], ["l", "login"], ["o", "logout"], ["g", "follow"], ["q", "quit"],
["p", "provider"], ["t", "plan"], ["r", "quota refresh"], ["c", "clear"],
["p", "provider"], ["t", "plan"], ["r", "quota refresh"], ["u", "update"], ["c", "clear"],
];
footerParts.push({ t: "↑↓ scroll", c: DIM });
for (const [key, label] of footerItems) {
Expand Down
Loading
Loading