From 94a18263b90cf54c0cb9385e2338da121b7c7d00 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:46:14 +0800 Subject: [PATCH] feat: make the update source one repository address, the official repo by default The update source used to be three things - a remote name or an address, plus an optional branch - and unset it followed whatever this directory's remotes and upstream happened to say. That asked people to understand git topology before they could update, and the person it was built for could not tell what "follow the remote this tree tracks" meant. It is now one field: config.yaml update.repo, an address, empty meaning the official repository (https://github.com/AIPentest/CyberStrikeAI.git). Point it at a mirror (GitHub is hard to reach in some places) or at your own fork and that is the whole configuration. The branch nothing is any more: an update follows the source repository's own default branch, resolved from the source itself (ls-remote --symref HEAD), so a fork whose default is not "main" still works and nobody has to name a branch. Remote names, the mine/origin/upstream ranking, @{upstream} tracking and the adopt flow's branch argument are all gone with it. The console page loses two input fields: the source section is one address, a hint that empty means the official repository, and a save button; clearing the address is the way back to the default. The install facts drop the remote name, the check section shows the source's default branch once the network answer arrives, and the adopt entry no longer requires saving an address first - the official default makes connecting a tarball install a zero-configuration action. The CLI now reads the same update.repo field the page saves (LoadUpdateRepo reads just that field, without validating the rest of the file - a broken unrelated section must not block the path that fixes the tree), so the keyboard and the button cannot update from different repositories. -check-update prints the source it is about to use. Tests: the source suite now pins the default (official), the single-address save (including the empty-string reset), and that the followed branch comes from the source's own default branch rather than a hardcoded "main"; the console suite posts the one field and renders the default hint; docs, config.example.yaml and upgrade.sh describe the one-field story. --- README.md | 19 +-- README_CN.md | 12 +- cmd/server/update_cli.go | 26 ++- config.example.yaml | 11 +- docs/en-US/deployment.md | 25 +-- docs/en-US/developer-guide.md | 34 ++-- docs/en-US/rbac.md | 4 +- docs/zh-CN/deployment.md | 21 +-- docs/zh-CN/developer-guide.md | 23 ++- docs/zh-CN/rbac.md | 4 +- internal/config/config.go | 36 +++-- internal/config/config_test.go | 37 +++++ internal/handler/config.go | 43 ++--- internal/handler/openapi.go | 20 ++- internal/handler/update.go | 73 ++++----- internal/handler/update_http_test.go | 58 ++++--- internal/update/adopt.go | 77 +++------ internal/update/adopt_test.go | 22 +-- internal/update/apply.go | 4 +- internal/update/source_test.go | 86 ++++++---- internal/update/update.go | 229 ++++++++++++--------------- internal/update/update_test.go | 36 +++-- upgrade.sh | 39 ++--- web/static/i18n/en-US.json | 38 ++--- web/static/i18n/ru-RU.json | 38 ++--- web/static/i18n/zh-CN.json | 38 ++--- web/static/js/update-ui.test.cjs | 65 +++++--- web/static/js/update.js | 68 +++----- 28 files changed, 602 insertions(+), 584 deletions(-) diff --git a/README.md b/README.md index ac70abac..03e6abe3 100644 --- a/README.md +++ b/README.md @@ -270,21 +270,22 @@ If server logs show `client sent an HTTP request to an HTTPS server`, a client i ### Upgrade and Compatibility **Prefer the platform's own one-click update** (console System settings -> One-click update, or -`./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls **the remote this -installation directory already tracks**, fast-forwards, rebuilds and swaps the binary atomically, keeps -your `roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it -kept), and refuses - rather than overwrites - when source is modified locally or the branch has -diverged. See the [deployment guide](docs/en-US/deployment.md). +`./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls the newest commit of the +**update source repository**'s default branch (the official repository by default; one setting points +it at your own fork or a mirror), fast-forwards, rebuilds and swaps the binary atomically, keeps your +`roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it kept), and +refuses - rather than overwrites - when source is modified locally or the local history has diverged. +See the [deployment guide](docs/en-US/deployment.md). `upgrade.sh` still works: when this directory is a git work tree it is a thin shell over the command above; only a non-git (tarball) installation falls back to the old "download a GitHub Release and rsync it in" path, where the source repository comes from `--repo owner/name` or `GITHUB_REPO` and the built-in default applies only when neither is given (with a warning naming the repository). -The update source is one setting, in the `update` section of `config.yaml` or right on the console -page - the official repository, your own fork, or somebody else's fork. An installation unpacked from -a Release archive (no git) can be previewed and connected from the same page and updates in one click -from then on. +The update source is one setting, a repository address in the `update` section of `config.yaml` or +right on the console page - the official repository, your own fork, or somebody else's fork. An +installation unpacked from a Release archive (no git) can be previewed and connected from the same +page and updates in one click from then on. **CyberStrikeAI upgrade script:** 1. (First time) enable the script: `chmod +x upgrade.sh` diff --git a/README_CN.md b/README_CN.md index 796bacb4..c63ee8d2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -269,16 +269,18 @@ go build -o cyberstrike-ai ./cmd/server ### 版本升级与兼容性 **优先用平台自带的「一键更新」**(控制台「系统设置 → 一键更新」,或 `./cyberstrike-ai -update` / -`-check-update` / `-update-rollback`):它拉的是**本安装目录自己跟踪的远端**,快进后重编译并原子换二进制, -你的 `roles/skills/tools/agents/data/config.yaml` 会被原样保留(结果里逐个点名保留了什么), -本地源码有改动或分支已分叉时它会拒绝而不是覆盖。详见 [部署指南](docs/zh-CN/deployment.md)。 +`-check-update` / `-update-rollback`):它从**更新源仓库**(默认官方仓库,可一处配置改成自己的二开或镜像) +拉取默认分支的最新代码,快进后重编译并原子换二进制,你的 `roles/skills/tools/agents/data/config.yaml` +会被原样保留(结果里逐个点名保留了什么),本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。 +详见 [部署指南](docs/zh-CN/deployment.md)。 `upgrade.sh` 仍然可用:本目录是 git 工作树时它就是上面那条命令的薄壳;不是 git 工作树(tarball 安装)时 才回落到"下载 GitHub Release 包 + rsync"的老路径,此时源码仓库由 `--repo owner/name` 或 `GITHUB_REPO` 决定(都不给才用内置默认值,并会警告说明代码来自哪个仓库)。 -更新源可在 `config.yaml` 的 `update` 段或控制台「一键更新」页指定(跟随官方仓库、自己的二开或别人的二开 -都只是一处配置);解压安装(目录里没有 git)也能在同一页「预览并接入」后进入一键更新。 +更新源是 `config.yaml` `update` 段里的一个仓库地址(`repo`),也可以在控制台「一键更新」页填写保存 +(跟随官方仓库、自己的二开、别人的二开或镜像都只是这一处;不写就是官方仓库);解压安装(目录里没有 git) +也能在同一页「预览并接入」后进入一键更新。 1. (首次使用)启用脚本:`chmod +x upgrade.sh` 2. 升级:`./upgrade.sh`(可选参数:`--check`、`--tag vX.Y.Z`、`--repo owner/name`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/`、`agents/` 会始终保留不被覆盖。 diff --git a/cmd/server/update_cli.go b/cmd/server/update_cli.go index fde9f0dc..a9afa81a 100644 --- a/cmd/server/update_cli.go +++ b/cmd/server/update_cli.go @@ -2,27 +2,41 @@ package main import ( "context" + "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/update" "fmt" "os" "path/filepath" + "strings" "time" ) // The same one-click update the console page drives, for the two cases a page cannot // serve: the process is not running at all, and somebody is ssh'd in with a keyboard. // Keeping it in the binary rather than only in a shell script means one implementation of -// "fetch, fast-forward, rebuild, swap" instead of two that can disagree. +// "fetch, fast-forward, rebuild, swap" instead of two that can disagree. The update +// source is read from the same config.yaml field the page saves to, so the keyboard and +// the button can never update from different repositories. func updateCommandIfNeeded(configPath string, check, apply, rollback bool) (bool, int) { if !check && !apply && !rollback { return false, 0 } root := filepath.Dir(configPath) - opts := update.Options{Root: root} + repo, err := config.LoadUpdateRepo(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "读取更新源失败: %v\n", err) + return true, 1 + } + opts := update.Options{Root: root, Repo: repo} ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute) defer cancel() + source := strings.TrimSpace(repo) + if source == "" { + source = update.DefaultRepoURL + "(官方仓库默认)" + } fmt.Printf("安装目录:%s\n", root) + fmt.Printf("更新源:%s\n", source) switch { case check: snap, err := update.Check(ctx, opts) @@ -62,12 +76,16 @@ func printSnapshot(s *update.Snapshot) { fmt.Printf("这个目录不是 git 工作树(%s),无法自动更新\n", s.Root) return } - fmt.Printf("分支 %s / 远端 %s / 提交 %s (%s)\n", s.Branch, s.Remote, s.Commit, s.Subject) + target := s.TargetBranch + if target == "" { + target = "?" + } + fmt.Printf("本机分支 %s / 提交 %s (%s),更新源默认分支 %s\n", s.Branch, s.Commit, s.Subject, target) if s.CheckError != "" { fmt.Printf("检查失败:%s\n", s.CheckError) return } - fmt.Printf("远端最新 %s (%s),落后 %d 个提交,本地领先 %d 个提交\n", s.RemoteCommit, s.RemoteSubject, s.Behind, s.Ahead) + fmt.Printf("更新源最新 %s (%s),落后 %d 个提交,本地领先 %d 个提交\n", s.RemoteCommit, s.RemoteSubject, s.Behind, s.Ahead) for _, c := range s.Incoming { fmt.Printf(" ○ %s %s\n", c.Commit, c.Subject) } diff --git a/config.example.yaml b/config.example.yaml index 71b4ff4a..bffd9e62 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -593,11 +593,10 @@ project: # ============================================ # 一键更新(可选) # ============================================ -# 更新源:不配置时,更新跟随本目录自己跟踪的 git 远端;配置后按这里指定的来源更新。 -# remote 与 remote_url 二选一(页面保存时两者互斥;都手写时以 remote_url 为准)。 -# 非 git 目录(解压/打包安装)配置地址后,可在控制台「一键更新」页执行「接入更新源」, +# 更新源:一个仓库地址,更新跟随该仓库自己的默认分支;不配置时就是官方仓库 +# (https://github.com/AIPentest/CyberStrikeAI.git)。GitHub 访问不畅时可在这里填镜像地址, +# 想跟随自己的二开也只改这一处;控制台「系统设置 → 一键更新」页可以直接填写保存。 +# 非 git 目录(解压/打包安装)不需要配置,也能在控制台「一键更新」页执行「接入更新源」, # 把目录变成跟踪该仓库的 git 工作树,此后按正常安装一键更新。 # update: -# remote: origin # 用已有远端名指定(mine/origin/upstream/任意名) -# remote_url: https://github.com/AIPentest/CyberStrikeAI.git # 或直接给仓库地址 -# branch: main # 要跟踪的分支;留空按 upstream/当前分支推断 +# repo: https://github.com/AIPentest/CyberStrikeAI.git # 一个仓库地址;留空/不写 = 官方仓库 diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index 26e9c2de..f926033d 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -78,13 +78,14 @@ proxy_set_header Connection "upgrade"; ## Upgrading **Prefer the platform's own one-click update** (console System settings -> One-click update, -`POST /api/system/update/apply`, or `./cyberstrike-ai -update` at a keyboard). It pulls **the remote -this install directory already tracks**: fetch, fast-forward, `go build`, atomic binary swap (the old -binary is kept as `cyberstrike-ai.prev`), while operator content - `roles/ skills/ tools/ agents/ -knowledge_base/ data/ config.yaml` - is put aside and restored, and the result names every file it -kept. For a fork this is the only path that does not overwrite you with somebody else's code; when -source is modified locally or the branch has diverged it refuses and says why instead of forcing. -See [the developer guide](developer-guide.md) for the full semantics. +`POST /api/system/update/apply`, or `./cyberstrike-ai -update` at a keyboard). It pulls the newest +commit of the **update source repository**'s default branch (the official repository +`https://github.com/AIPentest/CyberStrikeAI.git` by default; one setting points it at your own fork +or a mirror): fetch, fast-forward, `go build`, atomic binary swap (the old binary is kept as +`cyberstrike-ai.prev`), while operator content - `roles/ skills/ tools/ agents/ knowledge_base/ +data/ config.yaml` - is put aside and restored, and the result names every file it kept. When source +is modified locally or the local history has diverged from the source it refuses and says why +instead of forcing. See [the developer guide](developer-guide.md) for the full semantics. When a supervisor is detected (systemd/launchd startup markers in the environment), the "exit after updating" tick is pre-ticked: the process stands down after a successful update, the supervisor brings @@ -92,11 +93,11 @@ the new binary back, and the page reloads itself once the service answers (sessi one fresh login follows). Forgot the tick, or swapped the binary from the CLI? The console carries a persistent banner with **Restart now** - no shell login needed. -The source is chosen either in the `update` section of `config.yaml` (`remote` or `remote_url`, plus -an optional `branch`) or right on the console page: the official repository, your own fork, or -somebody else's second-development repository are all just this one setting; unset, it follows the -remote this directory already tracks. An installation unpacked from a Release archive (no `.git`) -can be connected from the same page: the preview lists the files the target would replace and the +The update source is a single repository address in the `update` section of `config.yaml` (`repo`), +or right on the console page: the official repository, your own fork, somebody else's repository or a +mirror are all just this one setting; empty means the official repository. An installation unpacked +from a Release archive (no `.git`) can be connected from the same page (no configuration needed - the +default official repository applies): the preview lists the files the target would replace and the operator content that is kept, replaced files are backed up under `.update-backup//overwritten/`, and confirming turns the directory into a normal installation with one-click updates. diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index f607e37f..f3a2be90 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -58,34 +58,32 @@ additionally requires a global (`all` scope) session: one machine has one source ### Where the code comes from -From **the remote this directory already tracks**; no third-party repository is hardcoded. The remote -is picked from the ones this tree has, in the order `mine`, `origin`, `upstream` (falling back to the -first remote when none of those names exists), and the branch defaults to what the current branch's -`@{upstream}` names (the current branch itself when there is no upstream). `upgrade.sh` is now a thin -shell over the same implementation: when this directory is a git work tree it just calls -`./cyberstrike-ai -update`. +From **the update source repository**: one address, the official repository +`https://github.com/AIPentest/CyberStrikeAI.git` by default, and the update follows that repository's +**own default branch** (nothing about branches is configured, and "main" is not hardcoded), so an +installation with no configuration at all still has exactly one answer to "where does an update come +from". `upgrade.sh` is now a thin shell over the same implementation: when this directory is a git +work tree it just calls `./cyberstrike-ai -update`. -### Choosing the source (the update section of config.yaml) +### Changing the source (the update section of config.yaml) -Unset, the rules above apply; set, the explicit choice wins: +The update source is a single field: a repository address. Empty means the official repository; to +follow your own fork, a mirror (when GitHub is hard to reach), or somebody else's repository, this is +the one place to change: ```yaml update: - remote: origin # an existing remote name (mine/origin/upstream/anything), or remote_url instead - # remote_url: https://github.com/AIPentest/CyberStrikeAI.git - branch: main # optional; defaults to @{upstream} / the current branch + repo: https://github.com/AIPentest/CyberStrikeAI.git # one repository address; empty/absent = the official repository ``` -The console page edits and saves all three. Saving validates: remote and branch names go through the -same allowlist an update uses, and the address allowlist is https/http/ssh/git/file plus local -absolute paths - git's `ext::` transport executes commands and is rejected outright. Following the -official repository, your own fork, or somebody else's second-development repository is exactly the -choice this section expresses. +The console's "Update source" block edits and saves this address (saving it empty goes back to the +official repository). Saving validates the address against an allowlist: https/http/ssh/git/file plus +local absolute paths - git's `ext::` transport executes commands and is rejected outright. ### Connecting a non-git installation (the tarball kind) -An installation that started as an unpacked Release archive has no `.git`; once a source is saved it -can be connected from the same page: the preview fetches in a throwaway repository and first lists +An installation that started as an unpacked Release archive has no `.git`; it can be connected from +the same page (the default official repository works without any configuration): the preview fetches in a throwaway repository and first lists **the local files the target would replace** and **the operator content that will be kept**; on confirm the directory becomes a git work tree (`git init`, origin added, the target branch checked out), every replaced file is kept under `.update-backup//overwritten/`, operator content diff --git a/docs/en-US/rbac.md b/docs/en-US/rbac.md index 170c64d7..67d1e717 100644 --- a/docs/en-US/rbac.md +++ b/docs/en-US/rbac.md @@ -101,8 +101,8 @@ Important distinctions: - `fofa:execute` is kept for backward compatibility, but it now protects the Reconnaissance page for FOFA, ZoomEye, Quake, and Shodan searches. - `mcp:write` manages external MCP configuration; it is separate from external tool execution. - One-click update is two permissions: `update:read` shows the installed version and the gap against - the remote, while `update:apply` pulls this installation's own repository, rebuilds the platform - binary and swaps it. The mutating side additionally requires the session to hold that permission + the update source, while `update:apply` pulls the configured source repository's default branch + (the official repository by default), rebuilds the platform binary and swaps it. The mutating side additionally requires the session to hold that permission with `all` scope - one machine has one source tree, so an `assigned`/`own` session must not be able to move the code everybody else is running. - `robot:write` manages robot configuration and the test endpoint. Chatbot conversations use the bound user or configured service account's business permissions. diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index e27f9896..978309c3 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -180,21 +180,22 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal` ## 升级 **优先用平台自带的「一键更新」**(控制台「系统设置 → 一键更新」、`POST /api/system/update/apply`、 -或键盘上的 `./cyberstrike-ai -update`)。它拉的是**这个安装目录自己跟踪的远端**:fetch → 快进合并 → -`go build` → 原子换二进制(旧的留作 `cyberstrike-ai.prev`),并把 `roles/ skills/ tools/ agents/ -knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果里点名保留了哪些文件。 -跑 fork 时这是唯一不会"用别人的代码覆盖自己"的路径;本地源码有改动或分支已分叉时它会拒绝并说明原因, -而不是硬来。细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。 +或键盘上的 `./cyberstrike-ai -update`)。它从**更新源仓库**(默认官方仓库 +`https://github.com/AIPentest/CyberStrikeAI.git`,可一处配置改成自己的二开或镜像)拉取该仓库默认分支的 +最新代码:fetch → 快进合并 → `go build` → 原子换二进制(旧的留作 `cyberstrike-ai.prev`),并把 +`roles/ skills/ tools/ agents/ knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果里点名 +保留了哪些文件。本地源码有改动、或本地历史与更新源分叉时它会拒绝并说明原因,而不是硬来。 +细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。 检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、 由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行 换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。 -更新源可以在 `config.yaml` 的 `update` 段指定(`remote` 或 `remote_url`,可带 `branch`),也可以直接在 -控制台「一键更新」页填写保存:想跟随官方仓库、自己的二开或别人的二开,改的都是这一处;不配置时跟随 -本目录自己跟踪的远端。**用 Release 包解压安装、目录里没有 git 的**,配好地址后在同一页点「预览并接入」: -预览先列出会被目标版本替换的文件与会保留的运维者内容,被替换的都会备份在 -`.update-backup/<时间戳>/overwritten/`,确认后目录成为可一键更新的正常安装。 +更新源是 `config.yaml` `update` 段里的**一个仓库地址**(`repo`),也可以直接在控制台「一键更新」页填写保存: +想跟随官方仓库、自己的二开、别人的二开或镜像,改的都是这一处;不写就是官方仓库。**用 Release 包解压安装、 +目录里没有 git 的**,在同一页点「预览并接入」(不配置也能用默认的官方仓库):预览先列出会被目标版本替换的 +文件与会保留的运维者内容,被替换的都会备份在 `.update-backup/<时间戳>/overwritten/`,确认后目录成为可一键 +更新的正常安装。 `upgrade.sh` 仍然可用,它现在只是这条实现的薄壳,并按安装形态分两条路: diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index 0418caed..5269e479 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -63,28 +63,27 @@ go run ./cmd/server --config config.yaml ### 它从哪里取代码 -从**这个目录自己的远端**,没有任何写死的第三方仓库地址。远端按 `mine → origin → upstream` 的次序在这个目录已有的 -remote 里取(都不在时取第一个 remote),分支默认取当前分支 `@{upstream}` 指向的名字(没有 upstream 时用当前分支名)。 -`upgrade.sh` 现在只是这条实现的薄壳:本目录是 git 工作树时它直接调 `./cyberstrike-ai -update`。 +从**更新源仓库**:一个仓库地址,默认就是官方仓库 `https://github.com/AIPentest/CyberStrikeAI.git`; +更新跟随该仓库**自己的默认分支**(不用配置分支,也不写死 main),所以对一套没做过任何配置的安装, +「从哪更新」永远有一个明确答案。`upgrade.sh` 现在只是这条实现的薄壳:本目录是 git 工作树时它直接调 +`./cyberstrike-ai -update`。 -### 选择更新源(config.yaml 的 update 段) +### 更换更新源(config.yaml 的 update 段) -不配置时按上面的规则跟随本树;配置后显式来源优先: +更新源只有一个字段:仓库地址。留空 = 官方仓库;想跟随自己的二开、镜像(GitHub 访问不畅时)或别人的仓库, +只改这一处: ```yaml update: - remote: origin # 已有远端名(mine/origin/upstream/任意名),与 remote_url 二选一 - # remote_url: https://github.com/AIPentest/CyberStrikeAI.git - branch: main # 可选;留空按 upstream/当前分支推断 + repo: https://github.com/AIPentest/CyberStrikeAI.git # 一个仓库地址;留空/不写 = 官方仓库 ``` -控制台的「一键更新」页可以直接编辑并保存这三项。服务端保存时校验:远端名与分支名走与更新同一套白名单, -地址只允许 https/http/ssh/git/file:// 与本机绝对路径——git 的 `ext::` 传输会执行命令,一律拒绝。 -想让这套安装跟随官方仓库、自己的二开或别人的二开,改的都是这一处。 +控制台的「一键更新 → 更新源」区块可以直接编辑并保存这个地址(留空保存 = 回到官方仓库)。 +服务端保存时校验地址:只允许 https/http/ssh/git/file:// 与本机绝对路径——git 的 `ext::` 传输会执行命令,一律拒绝。 ### 接入非 git 安装(解压/打包装的那类) -一开始用 Release 包解压安装、目录里没有 `.git` 的,配好上面的地址后可在同一页执行「预览并接入」: +一开始用 Release 包解压安装、目录里没有 `.git` 的,可在同一页执行「预览并接入」(不配置也能用默认的官方仓库): 预览在临时仓库里 fetch,先列出**会被目标版本替换的本机文件**与**会保留的运维者内容**;确认后目录接入 成为 git 工作树(`git init` + 添加 origin + 落地目标分支),被替换的文件全部留底在 `.update-backup/<时间戳>/overwritten/`,运维者内容照旧先暂存再放回,随后重编译二进制。 diff --git a/docs/zh-CN/rbac.md b/docs/zh-CN/rbac.md index fb52e411..685485e7 100644 --- a/docs/zh-CN/rbac.md +++ b/docs/zh-CN/rbac.md @@ -108,8 +108,8 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角 - `mcp:execute` 用于访问认证后的 MCP HTTP 入口。 - `mcp:external:execute` 用于 Agent 调用外部 MCP 工具,当前还要求该权限的 Scope 为 `all`。 - 管理外部 MCP 配置使用 `mcp:write`,与执行外部工具是两项权限。 -- 一键更新是**两项**:`update:read` 看本机安装的版本与远端差集;`update:apply` 拉自己的远端、重编译并换掉 - 平台二进制。写侧还要求会话是 `all` scope——一台机器一份源码,`assigned`/`own` 不该能移动别人正在跑的代码。 +- 一键更新是**两项**:`update:read` 看本机安装的版本与更新源差集;`update:apply` 拉取更新源仓库(默认官方仓库) + 的默认分支、重编译并换掉平台二进制。写侧还要求会话是 `all` scope——一台机器一份源码,`assigned`/`own` 不该能移动别人正在跑的代码。 - `robot:write` 管理机器人配置和测试入口;机器人聊天本身使用绑定用户或服务账号的业务权限。 --- diff --git a/internal/config/config.go b/internal/config/config.go index 807f36e4..4f375ec3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,17 +52,11 @@ type Config struct { Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"` } -// UpdateConfig 一键更新的更新源(可选):显式指定从哪个仓库/分支更新本安装。 -// 不配置时更新跟随安装目录自己跟踪的远端;两种来源都在本地解析,没有写死的仓库地址。 +// UpdateConfig 一键更新的更新源(可选):一个仓库地址,更新跟随该仓库的默认分支。 +// 留空即官方仓库(internal/update.DefaultRepoURL),跟随镜像或自己的二开只需改这一处。 type UpdateConfig struct { - // Remote 用安装树里已有的远端名指定更新源(mine/origin/upstream 或任意自定义名)。 - Remote string `yaml:"remote,omitempty" json:"remote,omitempty"` - // RemoteURL 直接给出仓库地址(https/http/ssh/git/file:// 或本机绝对路径),不必先 git remote add; - // 与 Remote 同时配置时以地址为准(页面保存时两者互斥)。 - RemoteURL string `yaml:"remote_url,omitempty" json:"remote_url,omitempty"` - // Branch 要跟踪的分支;留空时取当前分支的 upstream(没有 upstream 时取当前分支名; - // 非 git 目录接入时取仓库的默认分支)。 - Branch string `yaml:"branch,omitempty" json:"branch,omitempty"` + // Repo 更新源仓库地址(https/http/ssh/git/file:// 或本机绝对路径);留空 = 官方仓库。 + Repo string `yaml:"repo,omitempty" json:"repo,omitempty"` } type EnsureLocalConfigResult struct { @@ -1733,6 +1727,28 @@ func validateOpenAIOutputLimits(openAI OpenAIConfig) error { return nil } +// LoadUpdateRepo 只读 update 段的 repo 字段,不加载也不校验文件其余部分:命令行一键更新 +// 必须与页面用同一个更新源,而配置里其它段落坏掉不该拦住"把源码更新好"这条修复路径。 +// 文件不存在不是错误(未配置 = 官方仓库);YAML 解析失败则报错,不去猜更新源。 +func LoadUpdateRepo(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("读取配置文件失败: %w", err) + } + var thin struct { + Update struct { + Repo string `yaml:"repo"` + } `yaml:"update"` + } + if err := yaml.Unmarshal(data, &thin); err != nil { + return "", fmt.Errorf("解析配置文件失败: %w", err) + } + return strings.TrimSpace(thin.Update.Repo), nil +} + func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) { path = strings.TrimSpace(path) if path == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 007ae1d3..3e767148 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -334,3 +334,40 @@ func TestLatestUserMessageRunesEffective(t *testing.T) { t.Fatalf("custom latest user tail runes = %d", got) } } + +func TestLoadUpdateRepoReadsOnlyTheSourceField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + // The rest of the file may be broken or unvalidatable; the CLI only asks for the + // update source, and a full config load must not be a precondition for updating. + if err := os.WriteFile(path, []byte("update:\n repo: https://github.com/Sycun/CyberStrikeAI.git\nserver:\n port: 8088\n"), 0o644); err != nil { + t.Fatal(err) + } + repo, err := LoadUpdateRepo(path) + if err != nil { + t.Fatal(err) + } + if repo != "https://github.com/Sycun/CyberStrikeAI.git" { + t.Fatalf("repo = %q, want the configured address", repo) + } + + // Unset and missing files both mean "the official repository is the default". + if err := os.WriteFile(path, []byte("server:\n port: 8088\n"), 0o644); err != nil { + t.Fatal(err) + } + if repo, err := LoadUpdateRepo(path); err != nil || repo != "" { + t.Fatalf("repo = %q err = %v, want empty with no error", repo, err) + } + if repo, err := LoadUpdateRepo(filepath.Join(dir, "missing.yaml")); err != nil || repo != "" { + t.Fatalf("a missing config is not an error: repo = %q err = %v", repo, err) + } + + // A file that is not YAML at all must fail loudly rather than silently fall back to + // the official repository (that would be an update from the wrong place). + if err := os.WriteFile(path, []byte("update: [not: a: map\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadUpdateRepo(path); err == nil { + t.Fatal("a malformed config must be an error, not a silent default") + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index d57b8b82..8159a4cc 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1912,24 +1912,20 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { }) } -// SetUpdateSource 记录一键更新的更新源(remote 与 remote_url 二选一,另有可选 branch)。 +// SetUpdateSource 记录一键更新的更新源(单个仓库地址;空 = 回到官方仓库)。 // 走与其他设置相同的写入路径:备份旧文件、按 YAML 文档只改 update 段、原子落盘。 -func (h *ConfigHandler) SetUpdateSource(remote, remoteURL, branch string) error { +func (h *ConfigHandler) SetUpdateSource(repo string) error { h.mu.Lock() - h.config.Update = config.UpdateConfig{ - Remote: strings.TrimSpace(remote), - RemoteURL: strings.TrimSpace(remoteURL), - Branch: strings.TrimSpace(branch), - } + h.config.Update = config.UpdateConfig{Repo: strings.TrimSpace(repo)} h.mu.Unlock() return h.saveConfig() } -// UpdateSource 读当前生效的更新源(加读锁;更新处理器每次请求现读,保存后立即生效)。 -func (h *ConfigHandler) UpdateSource() (string, string, string) { +// UpdateSource 读当前配置的更新源地址(加读锁;更新处理器每次请求现读,保存后立即生效)。 +func (h *ConfigHandler) UpdateSource() string { h.mu.RLock() defer h.mu.RUnlock() - return h.config.Update.Remote, h.config.Update.RemoteURL, h.config.Update.Branch + return h.config.Update.Repo } // saveConfig 保存配置到文件 @@ -2086,33 +2082,18 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) { setIntInMap(mcpNode, "port", cfg.Port) } -// updateUpdateSourceConfig 写入一键更新的更新源(update 段)。三项全空时把整段删掉, -// 而不是留下一个空的 update: 块——"没有配置"应当就是文件里没有这一段。 +// updateUpdateSourceConfig 写入一键更新的更新源(update 段的单个 repo 字段)。 +// 留空时把整段删掉,而不是留下一个空的 update: 块——"没有配置"应当就是文件里没有这一段, +// 运行时按官方仓库处理。 func updateUpdateSourceConfig(doc *yaml.Node, cfg config.UpdateConfig) { root := doc.Content[0] - remote := strings.TrimSpace(cfg.Remote) - remoteURL := strings.TrimSpace(cfg.RemoteURL) - branch := strings.TrimSpace(cfg.Branch) - if remote == "" && remoteURL == "" && branch == "" { + repo := strings.TrimSpace(cfg.Repo) + if repo == "" { removeKeyFromMap(root, "update") return } node := ensureMap(root, "update") - if remote != "" { - setStringInMap(node, "remote", remote) - } else { - removeKeyFromMap(node, "remote") - } - if remoteURL != "" { - setStringInMap(node, "remote_url", remoteURL) - } else { - removeKeyFromMap(node, "remote_url") - } - if branch != "" { - setStringInMap(node, "branch", branch) - } else { - removeKeyFromMap(node, "branch") - } + setStringInMap(node, "repo", repo) } func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) { diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 3f9505ec..9034285c 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -3422,7 +3422,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "post": map[string]interface{}{ "tags": []string{"系统更新"}, "summary": "配置更新源(本安装从哪个仓库更新)", - "description": "把更新源写进 config.yaml 的 update 段:remote(已有远端名)与 remote_url(仓库地址)二选一,branch 可选。不配置时更新跟随本目录自己跟踪的远端;地址白名单为 https/http/ssh/git/file:// 与本机绝对路径(git 的 ext:: 传输会执行命令,一律拒绝)。", + "description": "把更新源写进 config.yaml 的 update 段:单个 repo 字段(仓库地址),更新跟随该仓库的默认分支。留空(或写空串)= 回到官方仓库 https://github.com/AIPentest/CyberStrikeAI.git。地址白名单为 https/http/ssh/git/file:// 与本机绝对路径(git 的 ext:: 传输会执行命令,一律拒绝)。", "operationId": "saveUpdateSource", "requestBody": map[string]interface{}{ "content": map[string]interface{}{ @@ -3430,17 +3430,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "schema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "remote": map[string]interface{}{"type": "string", "description": "安装树里已有的远端名(mine/origin/upstream/任意名)"}, - "remoteUrl": map[string]interface{}{"type": "string", "description": "仓库地址(与 remote 二选一)"}, - "branch": map[string]interface{}{"type": "string", "description": "要跟踪的分支;留空按 upstream/当前分支推断"}, + "repo": map[string]interface{}{"type": "string", "description": "更新源仓库地址;留空 = 官方仓库"}, }, }, }, }, }, "responses": map[string]interface{}{ - "200": map[string]interface{}{"description": "已保存(返回生效的更新源)"}, - "400": map[string]interface{}{"description": "远端名/地址/分支不合法,或两者同时给出"}, + "200": map[string]interface{}{"description": "已保存(返回配置值与官方默认地址)"}, + "400": map[string]interface{}{"description": "仓库地址不合法"}, "401": map[string]interface{}{"description": "未授权"}, }, }, @@ -3449,7 +3447,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "post": map[string]interface{}{ "tags": []string{"系统更新"}, "summary": "把非 git 目录接入更新源", - "description": "解压/打包安装所在的目录接入配置好的更新源:不带 confirm 做无副作用预览(在临时仓库里 fetch,列出会被目标版本替换的文件与会保留的运维者内容);confirm=true 时异步执行(202 + job_id,用 /api/system/update/job 轮询):git init、添加 origin、落地目标分支内容,运维者内容先暂存再放回,被替换的文件全部留底到 .update-backup/<时间戳>/overwritten/。已是 git 工作树时返回 409(用一键更新而不是接入)。", + "description": "解压/打包安装所在的目录接入配置好的更新源(未配置 = 官方仓库):不带 confirm 做无副作用预览(在临时仓库里 fetch,列出会被目标版本替换的文件与会保留的运维者内容);confirm=true 时异步执行(202 + job_id,用 /api/system/update/job 轮询):git init、添加 origin、落地更新源默认分支的内容,运维者内容先暂存再放回,被替换的文件全部留底到 .update-backup/<时间戳>/overwritten/。已是 git 工作树时返回 409(用一键更新而不是接入)。", "operationId": "adoptUpdateSource", "requestBody": map[string]interface{}{ "content": map[string]interface{}{ @@ -3467,7 +3465,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "responses": map[string]interface{}{ "200": map[string]interface{}{"description": "预览结果(plan:将被替换/保留的文件清单与总数)"}, "202": map[string]interface{}{"description": "接入任务已受理,返回 job_id"}, - "400": map[string]interface{}{"description": "没有配置更新源、地址不合法、分支不合法,或要求重启但没有重启钩子"}, + "400": map[string]interface{}{"description": "更新源地址不合法,或要求重启但没有重启钩子"}, "409": map[string]interface{}{"description": "目录已是 git 工作树;或有任务在进行中"}, "401": map[string]interface{}{"description": "未授权"}, }, @@ -6523,8 +6521,8 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "/api/system/update/check": map[string]interface{}{ "post": map[string]interface{}{ "tags": []string{"系统更新"}, - "summary": "检查本安装自己的远端有无新提交", - "description": "对安装目录已经跟踪的远端分支执行 fetch,报告落后/领先提交数与新提交列表;取代码的来源永远是本目录自己的远端,不是任何写死的第三方仓库。检查失败返回 200 且带 checkError,不把「查不了」说成「已是最新」", + "summary": "检查本安装的更新源有无新提交", + "description": "对配置的更新源仓库(未配置 = 官方仓库 https://github.com/AIPentest/CyberStrikeAI.git)的默认分支执行 fetch,报告落后/领先提交数与新提交列表。检查失败返回 200 且带 checkError,不把「查不了」说成「已是最新」", "operationId": "checkUpdate", "responses": map[string]interface{}{ "200": map[string]interface{}{ @@ -6540,7 +6538,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "post": map[string]interface{}{ "tags": []string{"系统更新"}, "summary": "一键更新源码并重编译", - "description": "快进到远端分支、重新编译二进制并原子换入(旧二进制留作 .prev 以便回滚)。本机改过源码文件或分支已分叉时拒绝执行并点名;roles/skills/tools/agents/bundles/knowledge_base/data/config.yaml 等运维者内容在合并前暂存、合并后放回,被保留的文件列在 keptContent。请求立即返回 job_id,进度用 GET /api/system/update/job 轮询;已有任务在跑时返回 409", + "description": "快进到更新源仓库默认分支的最新提交、重新编译二进制并原子换入(旧二进制留作 .prev 以便回滚)。本机改过源码文件或分支已分叉时拒绝执行并点名;roles/skills/tools/agents/bundles/knowledge_base/data/config.yaml 等运维者内容在合并前暂存、合并后放回,被保留的文件列在 keptContent。请求立即返回 job_id,进度用 GET /api/system/update/job 轮询;已有任务在跑时返回 409", "operationId": "applyUpdate", "requestBody": map[string]interface{}{ "content": map[string]interface{}{ diff --git a/internal/handler/update.go b/internal/handler/update.go index d54e8e55..3e33a661 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -36,13 +36,14 @@ type UpdateHandler struct { // request asked for a restart, because an unsupervised process that exits stays // exited - "I restarted you" would be a lie on most deployments. restart func() - // source reports the configured update source (config.yaml update section) as the - // operator last saved it; empty strings mean "not configured". A function rather - // than a value because the config can change while the process runs. - source func() (remote, remoteURL, branch string) + // source reports the configured update source (config.yaml update.repo) as the + // operator last saved it; an empty string means "not configured" and the official + // repository is used. A function rather than a value because the config can change + // while the process runs. + source func() string // saveSource persists a new source through the config layer; nil means this start // has no config to write to. - saveSource func(remote, remoteURL, branch string) error + saveSource func(repo string) error // baseline is the binary this process is actually running: the file on disk at // construction time. An update, a rollback or a CLI run that replaces it leaves a // different file behind, and that difference is what "restart to activate" means. @@ -81,7 +82,7 @@ func (j *updateJob) view() *updateJob { // a SetAudit the assembly has to remember: forgetting here is a compile error instead of // a privileged endpoint that writes no audit records at all. func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func(), - source func() (remote, remoteURL, branch string), saveSource func(remote, remoteURL, branch string) error) *UpdateHandler { + source func() string, saveSource func(repo string) error) *UpdateHandler { return &UpdateHandler{ root: root, logger: logger, @@ -121,20 +122,23 @@ func restartSupervised() bool { func (h *UpdateHandler) options() update.Options { opts := update.Options{Root: h.root} if h.source != nil { - opts.Remote, opts.RemoteURL, opts.Branch = h.source() + opts.Repo = h.source() } return opts } -// sourceView is the configured source as the page edits it (empty strings = unset). +// sourceView is the configured source as the page edits it: the address (empty = the +// official repository) plus the default, so the page never hardcodes a URL the server +// would disagree with. func (h *UpdateHandler) sourceView() gin.H { - var remote, remoteURL, branch string + repo := "" if h.source != nil { - remote, remoteURL, branch = h.source() + repo = strings.TrimSpace(h.source()) } return gin.H{ - "remote": remote, "remoteUrl": remoteURL, "branch": branch, - "configured": remote != "" || remoteURL != "" || branch != "", + "repo": repo, + "configured": repo != "", + "defaultRepo": update.DefaultRepoURL, } } @@ -167,58 +171,43 @@ func (h *UpdateHandler) Check(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - h.record(c, "update.check", resultOf(snap.CheckError), fmt.Sprintf("检查更新:%s/%s", snap.Remote, snap.Branch), map[string]interface{}{ + h.record(c, "update.check", resultOf(snap.CheckError), fmt.Sprintf("检查更新:%s(默认分支 %s)", snap.Repo, snap.TargetBranch), map[string]interface{}{ "commit": snap.Commit, "remote_commit": snap.RemoteCommit, "behind": snap.Behind, "error": snap.CheckError, }) c.JSON(http.StatusOK, gin.H{"status": snap}) } -// SaveSource answers POST /api/system/update/source: the operator picks what this -// installation updates from - the official repository, their own fork, or a second -// development line - and the choice lands in the update section of config.yaml. A remote -// name and an address are two ways to say the same thing and are refused together rather -// than silently ordered. +// SaveSource answers POST /api/system/update/source: the operator picks which repository +// this installation updates from - the official one, a mirror, or their own fork - and +// the choice lands in the update section of config.yaml. An empty address clears the +// setting, which means the official repository again. func (h *UpdateHandler) SaveSource(c *gin.Context) { var body struct { - Remote string `json:"remote"` - RemoteURL string `json:"remoteUrl"` - Branch string `json:"branch"` + Repo string `json:"repo"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"}) return } - body.Remote = strings.TrimSpace(body.Remote) - body.RemoteURL = strings.TrimSpace(body.RemoteURL) - body.Branch = strings.TrimSpace(body.Branch) - if body.Remote != "" && body.RemoteURL != "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "远端名与远端地址二选一:要么指名已有远端,要么直接给地址"}) - return - } - if body.Remote != "" && !update.ValidName(body.Remote) { - c.JSON(http.StatusBadRequest, gin.H{"error": "远端名不合法:" + body.Remote}) - return - } - if body.RemoteURL != "" && !update.ValidRemoteURL(body.RemoteURL) { - c.JSON(http.StatusBadRequest, gin.H{"error": "地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"}) - return - } - if body.Branch != "" && !update.ValidName(body.Branch) { - c.JSON(http.StatusBadRequest, gin.H{"error": "分支名不合法:" + body.Branch}) + body.Repo = strings.TrimSpace(body.Repo) + if body.Repo != "" && !update.ValidRemoteURL(body.Repo) { + c.JSON(http.StatusBadRequest, gin.H{"error": "仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"}) return } if h.saveSource == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"}) return } - if err := h.saveSource(body.Remote, body.RemoteURL, body.Branch); err != nil { + if err := h.saveSource(body.Repo); err != nil { h.record(c, "update.source", "failure", "保存更新源失败: "+err.Error(), map[string]interface{}{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()}) return } - h.record(c, "update.source", "success", "更新源已保存", map[string]interface{}{ - "remote": body.Remote, "remote_url": body.RemoteURL, "branch": body.Branch, - }) + message := "更新源已保存:" + body.Repo + if body.Repo == "" { + message = "已恢复默认更新源(官方仓库)" + } + h.record(c, "update.source", "success", message, map[string]interface{}{"repo": body.Repo}) c.JSON(http.StatusOK, gin.H{"source": h.sourceView()}) } diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index fa157d64..1d10ad2c 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -33,7 +33,7 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) } func newUpdateRouterWithSource(root string, restart func(), - source func() (string, string, string), save func(string, string, string) error) (*gin.Engine, *UpdateHandler) { + source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) { gin.SetMode(gin.TestMode) h := NewUpdateHandler(root, nil, nil, restart, source, save) router := gin.New() @@ -191,17 +191,11 @@ func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) { func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) { var saved []string router, _ := newUpdateRouterWithSource(t.TempDir(), nil, - func() (string, string, string) { return "", "", "" }, - func(r, u, b string) error { saved = append(saved, r+"|"+u+"|"+b); return nil }) + func() string { return "" }, + func(repo string) error { saved = append(saved, repo); return nil }) - // A name and an address are two ways to say the same thing; guessing which one the - // operator meant is worse than asking again. - w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remote":"origin","remoteUrl":"https://github.com/x/y.git"}`) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body = %s, want 400 for remote+remoteUrl together", w.Code, w.Body) - } // git's ext:: transport runs commands; it must never reach the config. - w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":"ext::sh -c true"}`) + w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":"ext::sh -c true"}`) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d body = %s, want 400 for an ext:: address", w.Code, w.Body) } @@ -209,19 +203,28 @@ func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) { t.Fatalf("a refused save must not reach the config layer: %v", saved) } - // A valid save is trimmed and lands in the config layer as three values. - w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":" https://github.com/Sycun/CyberStrikeAI.git ","branch":"main"}`) + // A valid save is trimmed and lands in the config layer as one address. + w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":" https://github.com/Sycun/CyberStrikeAI.git "}`) if w.Code != http.StatusOK { t.Fatalf("status = %d body = %s, want 200", w.Code, w.Body) } - if len(saved) != 1 || saved[0] != "|https://github.com/Sycun/CyberStrikeAI.git|main" { - t.Fatalf("saved = %v, want the trimmed address and branch", saved) + if len(saved) != 1 || saved[0] != "https://github.com/Sycun/CyberStrikeAI.git" { + t.Fatalf("saved = %v, want the trimmed address", saved) + } + + // An empty address is the way back to the official repository, not an error. + w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":""}`) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s, want 200 for the reset-to-default save", w.Code, w.Body) + } + if len(saved) != 2 || saved[1] != "" { + t.Fatalf("saved = %v, want the reset to reach the config layer", saved) } } func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) { router, _ := newUpdateRouterWithSource(t.TempDir(), nil, - func() (string, string, string) { return "", "https://github.com/Sycun/CyberStrikeAI.git", "main" }, nil) + func() string { return "https://github.com/Sycun/CyberStrikeAI.git" }, nil) w := doUpdate(router, http.MethodGet, "/api/system/update", "") if w.Code != http.StatusOK { @@ -229,22 +232,27 @@ func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) { } var body struct { Source struct { - Remote string `json:"remote"` - RemoteURL string `json:"remoteUrl"` - Branch string `json:"branch"` - Configured bool `json:"configured"` + Repo string `json:"repo"` + Configured bool `json:"configured"` + DefaultRepo string `json:"defaultRepo"` } `json:"source"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } - if !body.Source.Configured || body.Source.RemoteURL != "https://github.com/Sycun/CyberStrikeAI.git" || body.Source.Branch != "main" { - t.Fatalf("source = %+v, want the configured address and branch", body.Source) + if !body.Source.Configured || body.Source.Repo != "https://github.com/Sycun/CyberStrikeAI.git" { + t.Fatalf("source = %+v, want the configured address", body.Source) + } + if body.Source.DefaultRepo != update.DefaultRepoURL { + t.Fatalf("defaultRepo = %q, want the official repository the page offers as the default", body.Source.DefaultRepo) } } -func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) { - router, _ := newUpdateRouter(t.TempDir(), nil) +func TestUpdateAdoptRefusesAnAddressGitMustNeverSee(t *testing.T) { + // A plain directory with an unusable source: the refusal must come back before any + // fetch, so this test never touches the network. + router, _ := newUpdateRouterWithSource(t.TempDir(), nil, + func() string { return "ext::sh -c true" }, nil) w := doUpdate(router, http.MethodPost, "/api/system/update/adopt", `{}`) if w.Code != http.StatusBadRequest { @@ -254,8 +262,8 @@ func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } - if body["reason"] != "no_source" { - t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"]) + if body["reason"] != "bad_source" { + t.Fatalf("reason = %v, want bad_source so the page can say what is wrong", body["reason"]) } } diff --git a/internal/update/adopt.go b/internal/update/adopt.go index 352f012e..74f4d6f9 100644 --- a/internal/update/adopt.go +++ b/internal/update/adopt.go @@ -56,7 +56,7 @@ type adoptionScan struct { // Preview builds an adoption plan without touching the directory: the fetch happens in a // throwaway bare repository outside it, so a failure cannot leave a half-adopted tree. func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) { - root, source, branch, err := adoptPreflight(ctx, opts) + root, source, err := adoptPreflight(ctx, opts) if err != nil { return nil, err } @@ -69,7 +69,7 @@ func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) { if _, err := gitCmd(ctx, tmp, "init", "--quiet", "--bare"); err != nil { return nil, err } - branch, commit, subject, err := fetchSource(ctx, tmp, source, branch) + branch, commit, subject, err := fetchSource(ctx, tmp, source) if err != nil { return nil, err } @@ -90,11 +90,11 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) } start := time.Now() - root, source, branch, err := adoptPreflight(ctx, opts) + root, source, err := adoptPreflight(ctx, opts) if err != nil { return nil, err } - step("preflight", fmt.Sprintf("把目录 %s 接入更新源 %s(分支 %s)", root, source, branch)) + step("preflight", fmt.Sprintf("把目录 %s 接入更新源 %s", root, source)) if _, err := gitCmd(ctx, root, "init", "--quiet"); err != nil { return nil, &Error{Reason: "init_failed", Message: "git init 失败:" + err.Error()} @@ -110,7 +110,7 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error return nil, &Error{Reason: "init_failed", Message: "添加远端失败:" + err.Error()} } - branch, commit, subject, err := fetchSource(ctx, root, source, branch) + branch, commit, subject, err := fetchSource(ctx, root, source) if err != nil { return nil, err } @@ -204,49 +204,40 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error return res, nil } -// adoptPreflight validates the operation's preconditions: a plain directory, a usable -// source address, and (when given) a sane branch name. It does not touch the directory. -func adoptPreflight(ctx context.Context, opts Options) (root, source, branch string, err error) { +// adoptPreflight validates the operation's preconditions: a plain directory and a usable +// source address. It does not touch the directory. +func adoptPreflight(ctx context.Context, opts Options) (root, source string, err error) { root, err = opts.installRoot() if err != nil { - return "", "", "", err + return "", "", err } if _, err := exec.LookPath("git"); err != nil { - return "", "", "", &Error{Reason: "no_git", Message: "本机没有 git,无法把目录接入更新源"} + return "", "", &Error{Reason: "no_git", Message: "本机没有 git,无法把目录接入更新源"} } if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err == nil { - return "", "", "", &Error{Reason: "already_a_repo", Message: "这个目录已经是 git 工作树:用「一键更新」,不需要接入"} + return "", "", &Error{Reason: "already_a_repo", Message: "这个目录已经是 git 工作树:用「一键更新」,不需要接入"} } if fi, statErr := os.Stat(root); statErr != nil || !fi.IsDir() { - return "", "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root} - } - source = strings.TrimSpace(opts.RemoteURL) - if source == "" { - return "", "", "", &Error{Reason: "no_source", Message: "还没有配置更新源地址:先填入仓库地址(配置文件的 update.remote_url)再接入"} + return "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root} } + source = opts.repoURL() if !ValidRemoteURL(source) { - return "", "", "", &Error{Reason: "bad_source", Message: "更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"} + return "", "", &Error{Reason: "bad_source", Message: "更新源仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"} } - branch = strings.TrimSpace(opts.Branch) - if branch != "" && !ValidName(branch) { - return "", "", "", &Error{Reason: "bad_branch", Message: fmt.Sprintf("分支名不合法:%q", branch)} - } - return root, source, branch, nil + return root, source, nil } -// fetchSource fetches one branch from source into repo and reports which branch that -// ended up being (an empty request resolves the repository's default branch), its short -// commit and its subject. Both callers fetch into a repository of their own. -func fetchSource(ctx context.Context, repo, source, branch string) (string, string, string, error) { +// fetchSource fetches the source's default branch into repo and reports which branch +// that ended up being, its short commit and its subject. Both callers fetch into a +// repository of their own. +func fetchSource(ctx context.Context, repo, source string) (string, string, string, error) { + out, err := gitCmd(ctx, repo, "ls-remote", "--symref", source, "HEAD") + if err != nil { + return "", "", "", &Error{Reason: "fetch_failed", Message: "读取远端默认分支失败:" + err.Error()} + } + branch := parseDefaultBranch(out) if branch == "" { - out, err := gitCmd(ctx, repo, "ls-remote", "--symref", source, "HEAD") - if err != nil { - return "", "", "", &Error{Reason: "fetch_failed", Message: "读取远端默认分支失败:" + err.Error()} - } - branch = parseDefaultBranch(out) - if branch == "" { - return "", "", "", &Error{Reason: "no_branch", Message: "无法确定远端默认分支,请在配置里写明分支(update.branch)"} - } + return "", "", "", &Error{Reason: "no_branch", Message: "无法确定远端默认分支:" + source} } if _, err := gitCmd(ctx, repo, "fetch", "--quiet", "--no-tags", "--recurse-submodules=no", source, "+refs/heads/"+branch+":refs/remotes/origin/"+branch); err != nil { @@ -261,24 +252,6 @@ func fetchSource(ctx context.Context, repo, source, branch string) (string, stri return branch, commit, subject, nil } -// parseDefaultBranch reads "ref: refs/heads/\tHEAD" out of ls-remote --symref output. -func parseDefaultBranch(out string) string { - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "ref: refs/heads/") { - continue - } - rest := strings.TrimPrefix(line, "ref: refs/heads/") - if i := strings.IndexAny(rest, "\t "); i >= 0 { - rest = rest[:i] - } - if ValidName(rest) { - return rest - } - } - return "" -} - // scanAdoption compares the directory against the tracked files of ref in repo. func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, error) { out, err := gitRaw(ctx, repo, "ls-tree", "-r", "-z", "--full-tree", ref) diff --git a/internal/update/adopt_test.go b/internal/update/adopt_test.go index 42909581..d033e9df 100644 --- a/internal/update/adopt_test.go +++ b/internal/update/adopt_test.go @@ -33,7 +33,7 @@ func TestPreviewNamesWhatAdoptionWouldReplaceWithoutTouchingTheDirectory(t *test requireGit(t) tr, dir := newAdoptFixture(t) - plan, err := Preview(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main"}) + plan, err := Preview(context.Background(), Options{Root: dir, Repo: tr.upstream}) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestAdoptConnectsTheDirectoryAndKeepsOperatorContent(t *testing.T) { requireGit(t) tr, dir := newAdoptFixture(t) - res, err := Adopt(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main", BinaryName: "none"}, nil) + res, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil) if err != nil { t.Fatalf("adopt failed: %v\nresult: %+v", err, res) } @@ -105,14 +105,14 @@ func TestAdoptConnectsTheDirectoryAndKeepsOperatorContent(t *testing.T) { tr.upstreamCommit(t, "second: bump service", map[string]string{ "internal_service.go": "package service\n\nconst Version = \"2\"\n", }) - snap, err := Check(context.Background(), Options{Root: dir, BinaryName: "none"}) + snap, err := Check(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}) if err != nil { t.Fatal(err) } if snap.CheckError != "" || snap.Behind != 1 { t.Fatalf("the adopted tree must update like any other: checkError=%q behind=%d", snap.CheckError, snap.Behind) } - res2, err := Apply(context.Background(), Options{Root: dir, BinaryName: "none"}, nil) + res2, err := Apply(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil) if err != nil { t.Fatalf("apply after adopt failed: %v", err) } @@ -131,16 +131,18 @@ func TestAdoptRefusesWhatItCannotHonestlyDo(t *testing.T) { requireGit(t) tr := newTree(t) // Already a work tree: that is an update, not an adoption. - if _, err := Adopt(context.Background(), Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"}, nil); asReason(err) != "already_a_repo" { + if _, err := Adopt(context.Background(), Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}, nil); asReason(err) != "already_a_repo" { t.Errorf("err = %v, want already_a_repo", err) } - // No source configured. + // Nothing to adopt onto: the directory has to exist. plain := t.TempDir() - if _, err := Adopt(context.Background(), Options{Root: plain, BinaryName: "none"}, nil); asReason(err) != "no_source" { - t.Errorf("err = %v, want no_source", err) + missing := filepath.Join(plain, "not-a-directory") + if _, err := Adopt(context.Background(), Options{Root: missing}, nil); asReason(err) != "bad_root" { + t.Errorf("err = %v, want bad_root", err) } - // An address git must never be handed. - if _, err := Adopt(context.Background(), Options{Root: plain, RemoteURL: "ext::sh -c true", BinaryName: "none"}, nil); asReason(err) != "bad_source" { + // An address git must never be handed. (An unset address is no longer an error: the + // official repository is the default update source.) + if _, err := Adopt(context.Background(), Options{Root: plain, Repo: "ext::sh -c true", BinaryName: "none"}, nil); asReason(err) != "bad_source" { t.Errorf("err = %v, want bad_source", err) } } diff --git a/internal/update/apply.go b/internal/update/apply.go index c00db2ac..6218c4e8 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -114,7 +114,7 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error // A refusal is not the end of the timeline: the page renders these lines, so the first // one says what was looked at even when the answer is "nothing to do". - step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit)) + step("preflight", fmt.Sprintf("检查安装目录 %s(本机分支 %s / 更新源 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit)) if !snap.Installed { return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"} @@ -147,7 +147,7 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error }, nil } - step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit)) + step("fetch", fmt.Sprintf("%s(默认分支 %s)有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.TargetBranch, snap.Behind, snap.Commit, snap.RemoteCommit)) // Put operator-owned content aside, and clear the paths the incoming change would // write to, so the fast-forward cannot be refused by "your local changes would be diff --git a/internal/update/source_test.go b/internal/update/source_test.go index c261e1f2..9c202eb7 100644 --- a/internal/update/source_test.go +++ b/internal/update/source_test.go @@ -7,11 +7,13 @@ import ( "testing" ) -// The explicit update source (config.yaml update.remote_url) is what lets an installation -// follow a repository that is not one of its own remotes - the official one, a fork, or a -// second-development repository somebody was told about. These tests keep that path honest: -// the address must win over remote names, must not invent a remote, and must not let git's -// ext:: transport (which executes commands) through. +// The update source is one address (config.yaml update.repo): the official repository by +// default, changeable in one field to a mirror, a fork or a second-development +// repository. The branch that is followed is the source's own default branch, so nothing +// about branches needs configuring. These tests keep that path honest: the configured +// address must be what git is pointed at, the default must be the official repository, +// the followed branch must come from the source itself, and git's ext:: transport (which +// executes commands) must never get through. func TestValidRemoteURL(t *testing.T) { yes := []string{ @@ -45,25 +47,41 @@ func TestValidRemoteURL(t *testing.T) { } } -func TestCheckAndApplyFromAnExplicitURL(t *testing.T) { +func TestDefaultRepoIsTheOfficialRepository(t *testing.T) { + requireGit(t) + tr := newTree(t) + + snap, err := Status(context.Background(), Options{Root: tr.install}) + if err != nil { + t.Fatal(err) + } + if snap.Repo != DefaultRepoURL || snap.RepoConfigured { + t.Fatalf("repo = %q configured=%v, want the official default %q without configuration", + snap.Repo, snap.RepoConfigured, DefaultRepoURL) + } + if DefaultRepoURL != "https://github.com/AIPentest/CyberStrikeAI.git" { + t.Errorf("DefaultRepoURL = %q, want the official repository", DefaultRepoURL) + } +} + +func TestCheckAndApplyFromTheConfiguredRepo(t *testing.T) { requireGit(t) tr := newTree(t) // No remote at all: the source is purely the configured address. mustGit(t, tr.install, "remote", "remove", "origin") - urlOpts := Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"} + urlOpts := Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"} snap, err := Status(context.Background(), urlOpts) if err != nil { t.Fatal(err) } - if !snap.Installed || snap.Remote != "" || snap.RemoteURL != tr.upstream { - t.Fatalf("source must resolve to the configured address: installed=%v remote=%q url=%q", - snap.Installed, snap.Remote, snap.RemoteURL) + if !snap.Installed || snap.Repo != tr.upstream || !snap.RepoConfigured { + t.Fatalf("source must resolve to the configured address: installed=%v repo=%q configured=%v", + snap.Installed, snap.Repo, snap.RepoConfigured) } - // With no upstream to name a branch, the current branch is the default. if snap.Branch != "main" { - t.Fatalf("branch = %q, want main", snap.Branch) + t.Fatalf("local branch = %q, want main", snap.Branch) } tr.upstreamCommit(t, "second: bump service", map[string]string{ @@ -79,6 +97,9 @@ func TestCheckAndApplyFromAnExplicitURL(t *testing.T) { if !snap.UpdateAvailable || snap.Behind != 1 { t.Fatalf("behind=%d available=%v, want 1/true", snap.Behind, snap.UpdateAvailable) } + if snap.TargetBranch != "main" { + t.Fatalf("targetBranch = %q, want the source's own default branch", snap.TargetBranch) + } res, err := Apply(context.Background(), urlOpts, nil) if err != nil { @@ -90,41 +111,50 @@ func TestCheckAndApplyFromAnExplicitURL(t *testing.T) { if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") { t.Fatalf("code was not updated: %q", got) } - // A URL source parks its ref in a namespace of its own; it must not fabricate a remote - // the operator never configured. + // The configured source parks its ref in a namespace of its own; it must not fabricate a + // remote the operator never configured. if remotes := strings.TrimSpace(mustGit(t, tr.install, "remote")); remotes != "" { - t.Fatalf("a URL source must not create a remote, got %q", remotes) + t.Fatalf("a configured repo must not create a remote, got %q", remotes) } } -func TestRemoteURLWinsOverARemoteName(t *testing.T) { +func TestSourceFollowsTheRepositoriesDefaultBranch(t *testing.T) { requireGit(t) tr := newTree(t) - // A second repository with its own newer commit: the address must beat the origin remote. - other := filepath.Join(filepath.Dir(tr.upstream), "other") - mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, other) - writeFile(t, filepath.Join(other, "internal_service.go"), "package service\n\nconst Version = \"9\"\n") - mustGit(t, other, "add", ".") - mustGit(t, other, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "other moves") - - snap, err := Check(context.Background(), Options{ - Root: tr.install, Remote: "origin", RemoteURL: other, BinaryName: "none", + // The source publishes a default branch that is not "main": the update follows what + // the source itself names, so nothing about branches needs configuring. + mustGit(t, tr.upstream, "checkout", "-q", "-b", "stable") + tr.upstreamCommit(t, "stable: bump service", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", }) + + snap, err := Check(context.Background(), Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}) if err != nil { t.Fatal(err) } if snap.CheckError != "" { t.Fatalf("check failed: %s", snap.CheckError) } - if snap.RemoteURL != other || snap.Behind != 1 || len(snap.Incoming) != 1 || snap.Incoming[0].Subject != "other moves" { - t.Fatalf("the address must win over the remote name: %+v", snap) + if snap.TargetBranch != "stable" { + t.Fatalf("targetBranch = %q, want the source's default branch stable", snap.TargetBranch) + } + if !snap.UpdateAvailable || snap.Behind != 1 { + t.Fatalf("behind=%d available=%v, want 1/true against the source's default branch", snap.Behind, snap.UpdateAvailable) + } + + res, err := Apply(context.Background(), Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}, nil) + if err != nil { + t.Fatalf("apply failed: %v\nresult: %+v", err, res) + } + if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") { + t.Fatalf("code was not updated from the source's default branch: %q", got) } } func TestInvalidRemoteURLIsAReadableStateNotAnError(t *testing.T) { requireGit(t) tr := newTree(t) - snap, err := Status(context.Background(), Options{Root: tr.install, RemoteURL: "ext::sh -c true"}) + snap, err := Status(context.Background(), Options{Root: tr.install, Repo: "ext::sh -c true"}) if err != nil { t.Fatalf("a bad configured source is a state the page can show, not a fault: %v", err) } diff --git a/internal/update/update.go b/internal/update/update.go index b090f5ad..77d136b9 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -1,19 +1,19 @@ // Package update makes "bring this installation up to the newest commit of the -// repository it was cloned from" one action: fetch, move the working tree, rebuild the +// repository it updates from" one action: fetch, move the working tree, rebuild the // binary, swap it in. // -// Why this exists rather than more shell: the shipped upgrade.sh downloads a Release -// tarball of one fixed upstream repository. For anyone running a fork - which is how -// this project is actually deployed here - "upgrade" therefore means "overwrite yourself -// with somebody else's code", and the honest alternative is to pull by hand and remember -// the build command. The platform has to answer "is there anything new" and "take it" -// against the remote *this directory already tracks*, with nobody typing git or go. +// The update source is one thing: a repository address. It defaults to the official +// repository and can be pointed at a mirror, a fork or a second-development repository +// with one config field (config.yaml update.repo); the branch that is followed is that +// repository's own default branch, so there is nothing else to configure and nothing to +// guess. The platform has to answer "is there anything new" and "take it" with nobody +// typing git or go. // // Two properties hold the design down: // -// - Nothing destructive happens by default. Local source edits, or a branch that has -// diverged from its remote, stop with the file names and commit counts in the error -// instead of being reset to match. +// - Nothing destructive happens by default. Local source edits, or a tree that has +// diverged from the incoming history, stop with the file names and commit counts in +// the error instead of being reset to match. // - Content the operator owns is not ours to overwrite. roles/, skills/, tools/, // agents/, bundles/, knowledge_base/, data/, config.yaml and friends are put aside // and restored on top of the merge, so an update moves code while the roles and @@ -34,9 +34,13 @@ import ( // Binary is the default executable name an update replaces. const Binary = "cyberstrike-ai" -// urlRefNamespace is where a fetch from an explicit address (config.yaml update.remote_url) -// parks its remote-tracking ref. A fixed name no remote can shadow keeps the update reading -// the same shape whether the source is a remote name or a URL. +// DefaultRepoURL is where an installation updates from unless config.yaml says +// otherwise. Keeping the official repository as the default is what makes the feature +// work with no configuration at all; forks and mirrors change this one value. +const DefaultRepoURL = "https://github.com/AIPentest/CyberStrikeAI.git" + +// urlRefNamespace is where a fetch parks its remote-tracking ref. A fixed name no +// remote can shadow keeps every installation reading the same shape. const urlRefNamespace = "update-source" // maxIncoming caps how many commits a status page lists; the count is reported @@ -109,21 +113,27 @@ func Protected(rel string) bool { return false } -// Options describes which installation to act on. Root is the only required field; -// Remote, RemoteURL and Branch carry an explicit update source (config.yaml update -// section) for a tree that tracks something other than its own upstream - either a remote -// it already has, by name, or an address to fetch directly. When both are set, the -// address wins. +// Options describes which installation to act on. Root is the only required field; Repo +// is the update source (config.yaml update.repo) - a repository address to fetch from. +// Empty means the official repository, so an installation with no configuration at all +// still has exactly one answer to "where does an update come from". type Options struct { - Root string - Remote string - RemoteURL string - Branch string + Root string + Repo string // BinaryName overrides the executable swapped after a successful update. Empty // means "cyberstrike-ai" in Root; "none" means leave the binary alone. BinaryName string } +// repoURL is the effective update source. Trimmed here once so every caller sees the +// same decision: configured address if there is one, the official repository otherwise. +func (o Options) repoURL() string { + if repo := strings.TrimSpace(o.Repo); repo != "" { + return repo + } + return DefaultRepoURL +} + func (o Options) binaryName() string { if o.BinaryName == "" { return Binary @@ -163,20 +173,23 @@ type Commit struct { type Snapshot struct { Root string `json:"root"` Installed bool `json:"installed"` - Branch string `json:"branch"` - Remote string `json:"remote"` - // RemoteURL is set when the update source is an explicit address (config.yaml - // update.remote_url) rather than a remote this tree already has. - RemoteURL string `json:"remoteUrl,omitempty"` - Remotes []string - Commit string `json:"commit"` - Subject string `json:"subject"` - CommittedAt string `json:"committedAt"` + // Branch is the local branch the install tree is on, for display only. + Branch string `json:"branch"` + // Repo is the effective update source address, and RepoConfigured says whether it + // came from config.yaml or is the built-in official repository. + Repo string `json:"repo"` + RepoConfigured bool `json:"repoConfigured"` + // TargetBranch is the update source's own default branch, learned from the source + // itself when the network answer arrives (empty until then). + TargetBranch string `json:"targetBranch,omitempty"` + Commit string `json:"commit"` + Subject string `json:"subject"` + CommittedAt string `json:"committedAt"` Behind int `json:"behind"` Ahead int `json:"ahead"` - // Diverged means this tree has commits the remote does not. Updating then is a - // merge decision, not a download, so Apply refuses it. + // Diverged means this tree has commits the incoming history does not. Updating then + // is a merge decision, not a download, so Apply refuses it. Diverged bool `json:"diverged"` UpdateAvailable bool `json:"updateAvailable"` @@ -239,7 +252,7 @@ func nulList(blob string) []string { return out } -// ValidName reports whether s can safely reach git's argv as a remote or branch name. +// ValidName reports whether s can safely reach git's argv as a branch name. func ValidName(s string) bool { return namePattern.MatchString(s) } // Status reads the install tree without touching the network. @@ -250,24 +263,22 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { } snap := &Snapshot{ Root: root, + Repo: opts.repoURL(), + RepoConfigured: strings.TrimSpace(opts.Repo) != "", Incoming: []Commit{}, LocalChanges: []Change{}, BlockingChanges: []Change{}, } - - if url := strings.TrimSpace(opts.RemoteURL); url != "" { - if !ValidRemoteURL(url) { - snap.CheckError = "配置的更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)" - return snap, nil - } - snap.RemoteURL = url + if !ValidRemoteURL(snap.Repo) { + snap.CheckError = "配置的更新源仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)" + return snap, nil } if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err != nil { // Not a git tree: the page still has to say something useful, so this is a // state rather than an error. snap.Installed = false - snap.CheckError = "这个目录不是 git 工作树:可以先配置更新源再接入(配置文件 update 段),或按发布包方式更新" + snap.CheckError = "这个目录不是 git 工作树:可以先配置更新源仓库再接入,或按发布包方式更新" snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) snap.HasBinary = fileExists(filepath.Join(root, opts.binaryName())) return snap, nil @@ -287,30 +298,6 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { *q.dest = v } } - if opts.Branch != "" { - snap.Branch = opts.Branch - } - - remotes, err := gitCmd(ctx, root, "remote") - if err == nil { - for _, r := range strings.Fields(remotes) { - if ValidName(r) { - snap.Remotes = append(snap.Remotes, r) - } - } - } - snap.Remote = pickRemote(snap.Remotes, snap.Branch, opts.Remote) - - if upstream, err := gitCmd(ctx, root, "rev-parse", "--abbrev-ref", "@{upstream}"); err == nil && ValidName(upstream) { - if i := strings.Index(upstream, "/"); i > 0 { - if snap.Remote == "" { - snap.Remote = upstream[:i] - } - if opts.Branch == "" && upstream[i+1:] != "HEAD" { - snap.Branch = upstream[i+1:] - } - } - } snap.LocalChanges, snap.BlockingChanges = localChanges(ctx, root) snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) @@ -323,56 +310,11 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { return snap, nil } -// pickRemote chooses which remote to pull from: an explicit option, then the usual -// suspects in the order that matches how this project is actually cloned (a fork named -// for its owner tracking the owner's work, with the upstream kept for reference). -func pickRemote(remotes []string, branch, optsRemote string) string { - if optsRemote != "" && ValidName(optsRemote) { - return optsRemote - } - ranked := []string{"mine", "origin", "upstream"} - known := map[string]bool{} - for _, r := range remotes { - known[r] = true - } - for _, r := range ranked { - if known[r] { - return r - } - } - if len(remotes) > 0 { - return remotes[0] - } - _ = branch - return "" -} - -// fetchTarget is what git is pointed at: the configured address, or the chosen remote. -func (s *Snapshot) fetchTarget() string { - if s.RemoteURL != "" { - return s.RemoteURL - } - return s.Remote -} - -// fetchNamespace is where the fetched branch is parked under refs/remotes/. -func (s *Snapshot) fetchNamespace() string { - if s.RemoteURL != "" { - return urlRefNamespace - } - return s.Remote -} - // sourceRef is the remote-tracking ref a status or an update compares against. -func (s *Snapshot) sourceRef() string { return s.fetchNamespace() + "/" + s.Branch } +func (s *Snapshot) sourceRef() string { return urlRefNamespace + "/" + s.TargetBranch } // sourceLabel is the human-facing name of where an update would come from. -func (s *Snapshot) sourceLabel() string { - if s.RemoteURL != "" { - return s.RemoteURL - } - return s.Remote -} +func (s *Snapshot) sourceLabel() string { return s.Repo } // localChanges lists modified and deleted tracked files, and splits them by who owns // the path. @@ -411,31 +353,33 @@ func changeVerb(status string) string { } } -// Check fetches the chosen remote and reports what would come in. The fetch is the only -// network operation in this package, and a failure here is a readable answer rather than -// a fault: the page has to distinguish "up to date" from "could not look". +// Check asks the update source what it has and reports the gap. The two source calls are +// the only network operations in this package, and a failure is a readable answer rather +// than a fault: the page has to distinguish "up to date" from "could not look". func Check(ctx context.Context, opts Options) (*Snapshot, error) { snap, err := Status(ctx, opts) if err != nil { return nil, err } - if !snap.Installed { + if !snap.Installed || snap.CheckError != "" { return snap, nil } root := snap.Root - if snap.Remote == "" && snap.RemoteURL == "" { - snap.CheckError = "该安装没有配置任何 git remote,也没有配置更新源地址,无法检查更新" - return snap, nil - } - if snap.Branch == "" || snap.Branch == "HEAD" || !ValidName(snap.Branch) { - snap.CheckError = "当前不在可自动更新的分支上(游离 HEAD 或分支名不合规范),无法确定要跟踪哪个分支" + + // The branch to follow is the source repository's own default branch, so the + // operator never names one and a fork whose default differs from main still works. + target, err := resolveDefaultBranch(ctx, root, snap.Repo) + if err != nil { + snap.CheckError = err.Error() return snap, nil } + snap.TargetBranch = target + // --recurse-submodules=no and --prune keep the fetch to this repository's own - // history. A detached fetch of one branch means a remote with many refs cannot - // turn the update check into a full clone. + // history. A branch-scoped fetch means a source with many refs cannot turn the + // update check into a full clone. if _, err := gitCmd(ctx, root, "fetch", "--quiet", "--no-tags", "--prune", "--recurse-submodules=no", - snap.fetchTarget(), "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.fetchNamespace()+"/"+snap.Branch); err != nil { + snap.Repo, "+refs/heads/"+target+":refs/remotes/"+urlRefNamespace+"/"+target); err != nil { snap.CheckError = err.Error() return snap, nil } @@ -475,3 +419,36 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) { } return snap, nil } + +// resolveDefaultBranch asks the source repository which branch it publishes as its +// default. It is the one naming decision in the update path, and the source makes it, +// so nothing needs configuring and a source whose default is not "main" still works. +func resolveDefaultBranch(ctx context.Context, root, source string) (string, error) { + out, err := gitCmd(ctx, root, "ls-remote", "--symref", source, "HEAD") + if err != nil { + return "", fmt.Errorf("无法访问更新源仓库 %s:%s", source, strings.TrimSpace(err.Error())) + } + branch := parseDefaultBranch(out) + if branch == "" { + return "", fmt.Errorf("无法确定更新源仓库的默认分支:%s", source) + } + return branch, nil +} + +// parseDefaultBranch reads "ref: refs/heads/\tHEAD" out of ls-remote --symref output. +func parseDefaultBranch(out string) string { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "ref: refs/heads/") { + continue + } + rest := strings.TrimPrefix(line, "ref: refs/heads/") + if i := strings.IndexAny(rest, "\t "); i >= 0 { + rest = rest[:i] + } + if ValidName(rest) { + return rest + } + } + return "" +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 1ee7a8f2..2a38e910 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -61,21 +61,28 @@ func (t *tree) upstreamCommit(t2 *testing.T, message string, files map[string]st mustGit(t2, t.upstream, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", message) } -func opts(root string) Options { return Options{Root: root, BinaryName: "none"} } +// opts points an update at the tree's own source repository: the explicit address is +// now the whole configuration, so tests name it the way an operator would. +func opts(tr *tree) Options { + return Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"} +} func TestStatusWithoutNetworkDescribesTheTree(t *testing.T) { requireGit(t) tr := newTree(t) - snap, err := Status(context.Background(), opts(tr.install)) + snap, err := Status(context.Background(), opts(tr)) if err != nil { t.Fatal(err) } if !snap.Installed { t.Fatal("a clone must be reported as an installable tree") } - if snap.Branch != "main" || snap.Remote != "origin" { - t.Errorf("branch/remote = %q/%q, want main/origin", snap.Branch, snap.Remote) + if snap.Branch != "main" { + t.Errorf("local branch = %q, want main", snap.Branch) + } + if snap.Repo != tr.upstream || !snap.RepoConfigured { + t.Errorf("repo = %q configured=%v, want the configured source %q", snap.Repo, snap.RepoConfigured, tr.upstream) } if snap.Commit == "" { t.Error("commit is empty") @@ -93,7 +100,7 @@ func TestStatusReportsTheInstallRootInAbsoluteForm(t *testing.T) { // A relative config path gives filepath.Dir as "."; the page must still be able to say // which directory is being updated. t.Chdir(tr.install) - snap, err := Status(context.Background(), opts(".")) + snap, err := Status(context.Background(), Options{Root: ".", Repo: tr.upstream, BinaryName: "none"}) if err != nil { t.Fatal(err) } @@ -112,13 +119,16 @@ func TestStatusReportsTheInstallRootInAbsoluteForm(t *testing.T) { func TestStatusOnNonGitTreeIsAStateNotAnError(t *testing.T) { requireGit(t) dir := t.TempDir() - snap, err := Status(context.Background(), opts(dir)) + snap, err := Status(context.Background(), Options{Root: dir}) if err != nil { t.Fatalf("a tarball install is a readable state, not a fault: %v", err) } if snap.Installed || snap.CheckError == "" { t.Errorf("installed=%v checkError=%q, want false with a reason", snap.Installed, snap.CheckError) } + if snap.Repo != DefaultRepoURL || snap.RepoConfigured { + t.Errorf("repo = %q configured=%v, want the official repository by default", snap.Repo, snap.RepoConfigured) + } } func TestCheckFetchesAndListsIncoming(t *testing.T) { @@ -127,7 +137,7 @@ func TestCheckFetchesAndListsIncoming(t *testing.T) { tr.upstreamCommit(t, "second: bump service", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) tr.upstreamCommit(t, "third: new role", map[string]string{"roles/extra.yaml": "name: extra\n"}) - snap, err := Check(context.Background(), opts(tr.install)) + snap, err := Check(context.Background(), opts(tr)) if err != nil { t.Fatal(err) } @@ -161,7 +171,7 @@ func TestApplyKeepsOperatorContentAndMovesCode(t *testing.T) { "internal_service.go": "package service\n\nconst Version = \"3\"\n", }) - res, err := Apply(context.Background(), opts(tr.install), nil) + res, err := Apply(context.Background(), opts(tr), nil) if err != nil { t.Fatalf("apply failed: %v\nresult: %+v", err, res) } @@ -210,7 +220,7 @@ func TestApplyRefusesLocalSourceEdits(t *testing.T) { tr.upstreamCommit(t, "new upstream", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"}) writeFile(t, filepath.Join(tr.install, "internal_service.go"), "package service\n\nconst Version = \"local hand edit\"\n") - _, err := Apply(context.Background(), opts(tr.install), nil) + _, err := Apply(context.Background(), opts(tr), nil) ue, ok := err.(*Error) if !ok { t.Fatalf("want *Error, got %v", err) @@ -234,7 +244,7 @@ func TestApplyRefusesDivergedBranch(t *testing.T) { mustGit(t, tr.install, "add", "my_patch.go") mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "my own commit") - _, err := Apply(context.Background(), opts(tr.install), nil) + _, err := Apply(context.Background(), opts(tr), nil) ue, ok := err.(*Error) if !ok || ue.Reason != "diverged" { t.Fatalf("a branch with its own commits is a merge decision, not a download; got %v", err) @@ -244,7 +254,7 @@ func TestApplyRefusesDivergedBranch(t *testing.T) { func TestApplyWhenAlreadyUpToDateIsANoOp(t *testing.T) { requireGit(t) tr := newTree(t) - res, err := Apply(context.Background(), opts(tr.install), nil) + res, err := Apply(context.Background(), opts(tr), nil) if err != nil { t.Fatalf("up to date must not be an error: %v", err) } @@ -261,7 +271,7 @@ func TestRollbackMovesBackToTheRecordedCommit(t *testing.T) { tr := newTree(t) before := mustGit(t, tr.install, "rev-parse", "HEAD") tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"}) - if _, err := Apply(context.Background(), opts(tr.install), nil); err != nil { + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { t.Fatal(err) } // Rollback restores the binary kept before a swap; stand one up the way installBinary @@ -292,7 +302,7 @@ func TestRollbackRefusesWhenHeadMoved(t *testing.T) { requireGit(t) tr := newTree(t) tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"}) - if _, err := Apply(context.Background(), opts(tr.install), nil); err != nil { + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { t.Fatal(err) } bin := filepath.Join(tr.install, Binary) diff --git a/upgrade.sh b/upgrade.sh index e44a9c8f..ae0e267f 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -5,12 +5,14 @@ set -euo pipefail # # Two installation kinds, decided at runtime from this directory itself: # -# 1. git work tree (the normal case, and the only honest one for a fork): +# 1. git work tree (the normal case): # the update is delegated to the platform binary, `./cyberstrike-ai -update`, which -# fetches the remote *this directory already tracks*, fast-forwards, rebuilds with -# `go build` and swaps the binary in atomically. No repository name is hardcoded on -# this path, so "upgrade" can never overwrite your work with somebody else's code. -# Refusals (local source edits, a diverged branch) come from that same implementation +# fetches the configured update source (config.yaml update.repo; empty = the official +# repository, and one field points it at a fork or a mirror) and follows that +# repository's own default branch, fast-forwards, rebuilds with `go build` and swaps +# the binary in atomically. The CLI reads the same config field the console page +# saves, so the keyboard and the button update from the same place. +# Refusals (local source edits, diverged history) come from that same implementation # and name the files/commits involved. # # 2. Plain directory (Release tarball install, no git): falls back to downloading a @@ -64,11 +66,12 @@ Usage: What it does depends on the installation kind (detected automatically): - git work tree Updates from the remote this directory already tracks, by calling + git work tree Updates from the configured source repository (config.yaml + update.repo; empty = the official repository) by calling ./cyberstrike-ai -update (fetch, fast-forward, go build, swap binary). --tag/--repo are ignored on this path. Your own roles/skills/tools/ agents/data/config are put aside and restored by the platform; - local source edits or a diverged branch stop the update instead of + local source edits or diverged history stop the update instead of being overwritten. plain directory (Release tarball install, no git) Downloads the Release tarball and syncs it in with rsync --delete. --repo / GITHUB_REPO apply here. @@ -164,22 +167,14 @@ resolve_install_kind() { show_git_source() { # Say out loud where the code is coming from: that is the whole point of this path. + # The source itself is one field (config.yaml update.repo, empty = the official + # repository) and the binary is the one that reads it - `-check-update` prints the + # exact repository and branch that would land, without changing anything. local branch branch="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" info "Install directory: ${ROOT_DIR}" - info "Branch: ${branch:-unknown}" - - local remotes name url - remotes="$(git -C "$ROOT_DIR" remote 2>/dev/null || true)" - if [[ -z "$remotes" ]]; then - warn "This repository has no git remote, so there is nothing to fetch. Add one (git remote add ...) before updating." - return 0 - fi - info "Remotes this directory tracks (the update pulls from its own remote, never from a hardcoded third-party one):" - for name in $remotes; do - url="$(git -C "$ROOT_DIR" config --get "remote.${name}.url" 2>/dev/null || true)" - info " - ${name}: ${url:-unknown}" - done + info "Local branch: ${branch:-unknown}" + info "Update source: config.yaml update.repo (empty = the official repository); ./${BINARY_NAME} -check-update prints it exactly." } explain_no_go() { @@ -269,10 +264,10 @@ confirm_or_exit() { fi if [[ "$INSTALL_KIND" == "git" ]]; then - warn "About to update this installation from the remote it already tracks:" + warn "About to update this installation from its configured update source:" info " - git fetch + fast-forward of ${ROOT_DIR}" info " (refused with file names if source files are modified locally, or if the" - info " branch has diverged - that is a merge decision, not a download)" + info " local history has diverged from the source - that is a merge decision, not a download)" info " - go build, then the binary is swapped; the previous one is kept as ${BINARY_NAME}.prev" info " - your content (roles/skills/tools/agents/knowledge_base/data/config.yaml)" info " is put aside before the merge and restored after it; the result lists what was kept" diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 9dda6287..f2699f96 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -81,17 +81,16 @@ }, "update": { "title": "One-Click Update", - "intro": "This page changes the very installation that is answering your requests: it pulls the remote this tree already tracks, rebuilds the binary and swaps it in. The roles/skills/tools and configuration you maintain here are not overwritten.", + "intro": "This page changes the very installation that is answering your requests: it pulls the newest commit of the update source's default branch (the official repository unless configured), rebuilds the binary and swaps it in. The roles/skills/tools and configuration you maintain here are not overwritten.", "loading": "Reading this installation's state", "loadFailed": "Failed to read this installation's state: {{reason}}", "installTitle": "This installation", - "remoteTitle": "Against the remote", + "remoteTitle": "Against the update source", "applyTitle": "One-click update", "jobTitle": "Update progress", "rollbackTitle": "Rollback", "root": "Install directory", - "branch": "Branch", - "remote": "Remote", + "branch": "Local branch", "commit": "Current commit", "committedAt": "Committed at", "subject": "Commit subject", @@ -104,17 +103,17 @@ "binaryMissing": "Binary missing", "rollbackPresent": "Rollback point exists", "rollbackMissing": "No rollback point", - "notChecked": "The remote has not been consulted yet. Opening this page queues one automatic check (once per session); press Check for updates to ask again any time.", + "notChecked": "The update source has not been consulted yet. Opening this page queues one automatic check (once per session); press Check for updates to ask again any time.", "checkBtn": "Check for updates", - "checking": "Checking the remote...", + "checking": "Checking the update source...", "checkFailed": "Check failed: {{reason}}", "applyFailed": "Failed to start the update: {{reason}}", "rollbackFailed": "Rollback failed: {{reason}}", "behindCount": "{{n}} commits behind", "aheadCount": "{{n}} commits ahead locally", - "upToDate": "Up to date: local {{commit}} matches the remote", - "remoteHead": "Remote head", - "remoteSubject": "Remote commit subject", + "upToDate": "Up to date: local {{commit}} matches the update source", + "remoteHead": "Source head", + "remoteSubject": "Source commit subject", "incomingTitle": "Incoming commits", "incomingTruncated": "{{total}} new commits in total; only the first {{shown}} are listed", "applyBtn": "Update now", @@ -124,12 +123,12 @@ "blockedJobRunning": "An update is already running; wait for it to finish.", "blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.", "blockedDiverged": "Local history is {{ahead}} commits ahead and {{behind}} behind: that is a merge, not a download, so handle it by hand.", - "blockedNoUpdate": "The remote has nothing newer than this machine (press Check for updates if you have not looked yet).", + "blockedNoUpdate": "The update source has nothing newer than this machine (press Check for updates if you have not looked yet).", "restartChoiceLabel": "Exit the process after the update (only an external supervisor such as systemd or run.sh will bring it back; restart it yourself if nothing supervises it)", "restartNotWired": "This start did not provide a restart hook: restart the service yourself after the update.", "confirmRestartYes": "Selected: the process exits once the update succeeds, and only an external supervisor (systemd, run.sh, ...) will bring it back.", "confirmRestartNo": "Not selected: the process keeps running the old binary until you restart it yourself.", - "confirmApply": "About to run: fast-forward {{remote}}/{{branch}} to {{target}} ({{n}} commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.", + "confirmApply": "About to run: update from {{repo}} to {{target}} ({{n}} new commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.", "applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.", "localChangesTitle": "Local changes", "localChangesHint": "Files marked local content are kept exactly as they are; files marked product source block the automatic update.", @@ -183,19 +182,16 @@ "confirmRollback": "Roll back to {{commit}}? The source returns to before that update and the previous binary is restored.", "rollbackDone": "Rolled back to {{commit}}", "sourceTitle": "Update source", - "sourceFromConfig": "Updates follow the source configured in config.yaml.", - "sourceFromTracked": "No source configured: updates follow the remote this directory already tracks.", - "sourceUrl": "Remote address", - "sourceRemote": "Remote name", - "sourceBranch": "Branch", - "sourceUrlLabel": "Repository address (or an existing remote name instead)", - "sourceRemoteLabel": "Existing remote name", - "sourceBranchLabel": "Branch (optional)", + "sourceFromConfig": "The update source is configured in config.yaml.", + "sourceIsDefault": "No update source configured: the official repository {{repo}} is used.", + "sourceUrl": "Update source", + "sourceBranch": "Source default branch", + "sourceUrlLabel": "Update source repository address", + "sourceRepoHint": "Leave empty to use the official repository; point it at your own fork or a mirror to follow that instead.", "sourceSaveBtn": "Save source", "sourceSaveOk": "Update source saved", "sourceSaveFailed": "Failed to save the update source: {{reason}}", - "adoptHint": "This directory is not a git work tree yet. Connecting it to the source above turns it into a normal installation that updates in one click.", - "adoptNeedsSource": "Fill in a repository address above and save it first; then this directory can be connected.", + "adoptHint": "This directory is not a git work tree yet. Connecting it to the update source turns it into a normal installation that updates in one click.", "adoptPreviewBtn": "Preview and connect", "adoptIncoming": "Files in the target branch", "adoptOverwrittenTotal": "Will be replaced by the target", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 4f8609bd..c00d3ad1 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -81,17 +81,16 @@ }, "update": { "title": "Обновление в один клик", - "intro": "Эта страница изменяет саму установку, которая отвечает на ваши запросы: она подтягивает удалённый репозиторий, который уже отслеживает это дерево, пересобирает бинарный файл и подменяет его. Ваши roles/skills/tools и конфигурация не перезаписываются.", + "intro": "Эта страница изменяет саму установку, которая отвечает на ваши запросы: она подтягивает свежий коммит ветки по умолчанию репозитория-источника (по умолчанию — официальный репозиторий), пересобирает бинарный файл и подменяет его. Ваши roles/skills/tools и конфигурация не перезаписываются.", "loading": "Чтение состояния этой установки", "loadFailed": "Не удалось прочитать состояние установки: {{reason}}", "installTitle": "Эта установка", - "remoteTitle": "Сравнение с удалённым репозиторием", + "remoteTitle": "Сравнение с источником обновлений", "applyTitle": "Обновление в один клик", "jobTitle": "Ход обновления", "rollbackTitle": "Откат", "root": "Каталог установки", - "branch": "Ветка", - "remote": "Удалённый репозиторий", + "branch": "Локальная ветка", "commit": "Текущий коммит", "committedAt": "Дата коммита", "subject": "Заголовок коммита", @@ -104,17 +103,17 @@ "binaryMissing": "Бинарный файл отсутствует", "rollbackPresent": "Точка отката есть", "rollbackMissing": "Точки отката нет", - "notChecked": "Удалённый репозиторий ещё не проверялся. При открытии страницы запускается одна автоматическая проверка (по одной за сессию); нажмите «Проверить обновления», чтобы спросить ещё раз.", + "notChecked": "Источник обновлений ещё не проверялся. При открытии страницы запускается одна автоматическая проверка (по одной за сессию); нажмите «Проверить обновления», чтобы спросить ещё раз.", "checkBtn": "Проверить обновления", - "checking": "Проверка удалённого репозитория...", + "checking": "Проверка источника обновлений...", "checkFailed": "Проверка не удалась: {{reason}}", "applyFailed": "Не удалось запустить обновление: {{reason}}", "rollbackFailed": "Откат не удался: {{reason}}", "behindCount": "Отставание на {{n}} коммитов", "aheadCount": "Локально на {{n}} коммитов впереди", - "upToDate": "Всё актуально: локальный {{commit}} совпадает с удалённым", - "remoteHead": "Вершина удалённого репозитория", - "remoteSubject": "Заголовок удалённого коммита", + "upToDate": "Всё актуально: локальный {{commit}} совпадает с источником обновлений", + "remoteHead": "Вершина источника", + "remoteSubject": "Заголовок коммита источника", "incomingTitle": "Входящие коммиты", "incomingTruncated": "Всего новых коммитов: {{total}}; показаны только первые {{shown}}", "applyBtn": "Обновить сейчас", @@ -124,12 +123,12 @@ "blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.", "blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.", "blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.", - "blockedNoUpdate": "В удалённом репозитории нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).", + "blockedNoUpdate": "В источнике обновлений нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).", "restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)", "restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.", "confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).", "confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.", - "confirmApply": "Сейчас будет выполнено: fast-forward {{remote}}/{{branch}} до {{target}} ({{n}} коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.", + "confirmApply": "Сейчас будет выполнено: обновление из {{repo}} до {{target}} ({{n}} новых коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.", "applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.", "localChangesTitle": "Локальные изменения", "localChangesHint": "Файлы, помеченные как локальное содержимое, сохраняются как есть; файлы, помеченные как исходный код продукта, блокируют автоматическое обновление.", @@ -183,19 +182,16 @@ "confirmRollback": "Откатиться до {{commit}}? Исходники вернутся к состоянию до того обновления, а прежний бинарный файл будет восстановлен.", "rollbackDone": "Откат выполнен до {{commit}}", "sourceTitle": "Источник обновления", - "sourceFromConfig": "Обновления следуют источнику, настроенному в config.yaml.", - "sourceFromTracked": "Источник не настроен: обновления следуют удалённому репозиторию, который отслеживает этот каталог.", - "sourceUrl": "Адрес репозитория", - "sourceRemote": "Имя удалённого репозитория", - "sourceBranch": "Ветка", - "sourceUrlLabel": "Адрес репозитория (или имя существующего удалённого репозитория)", - "sourceRemoteLabel": "Имя существующего удалённого репозитория", - "sourceBranchLabel": "Ветка (необязательно)", + "sourceFromConfig": "Источник обновлений настроен в config.yaml.", + "sourceIsDefault": "Источник обновлений не настроен: используется официальный репозиторий {{repo}}.", + "sourceUrl": "Репозиторий-источник", + "sourceBranch": "Ветка по умолчанию источника", + "sourceUrlLabel": "Адрес репозитория-источника", + "sourceRepoHint": "Оставьте пустым, чтобы использовать официальный репозиторий; укажите свой форк или зеркало, чтобы следовать за ним.", "sourceSaveBtn": "Сохранить источник", "sourceSaveOk": "Источник обновления сохранён", "sourceSaveFailed": "Не удалось сохранить источник обновления: {{reason}}", - "adoptHint": "Этот каталог ещё не git-рабочее дерево. После подключения к источнику выше он станет обычной установкой с обновлением в один клик.", - "adoptNeedsSource": "Сначала укажите адрес репозитория выше и сохраните его — затем каталог можно подключить.", + "adoptHint": "Этот каталог ещё не git-рабочее дерево. После подключения к источнику обновлений он станет обычной установкой с обновлением в один клик.", "adoptPreviewBtn": "Предпросмотр и подключение", "adoptIncoming": "Файлов в целевой ветке", "adoptOverwrittenTotal": "Будут заменены целевой версией", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 2ab633fc..93fce6f3 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -81,17 +81,16 @@ }, "update": { "title": "一键更新", - "intro": "这一页改的就是当前正在应答请求的这套安装:拉取它自己跟踪的远端、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。", + "intro": "这一页改的就是当前正在应答请求的这套安装:从更新源仓库(默认官方仓库)拉取默认分支的最新代码、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。", "loading": "正在读取本机安装状态", "loadFailed": "读取本机安装状态失败:{{reason}}", "installTitle": "本机安装", - "remoteTitle": "与远端比较", + "remoteTitle": "与更新源比较", "applyTitle": "一键更新", "jobTitle": "更新进度", "rollbackTitle": "回滚", "root": "安装目录", - "branch": "分支", - "remote": "远端", + "branch": "本机分支", "commit": "当前提交", "committedAt": "提交时间", "subject": "提交说明", @@ -104,17 +103,17 @@ "binaryMissing": "本机没有二进制", "rollbackPresent": "有可回滚点", "rollbackMissing": "无可回滚点", - "notChecked": "还没查过远端。进入本页后会自动查一次(每个会话只查一次);也可以随时点「检查更新」。", + "notChecked": "还没查过更新源。进入本页后会自动查一次(每个会话只查一次);也可以随时点「检查更新」。", "checkBtn": "检查更新", - "checking": "正在检查远端…", + "checking": "正在检查更新源…", "checkFailed": "检查失败:{{reason}}", "applyFailed": "启动更新失败:{{reason}}", "rollbackFailed": "回滚失败:{{reason}}", "behindCount": "落后 {{n}} 个提交", "aheadCount": "本地领先 {{n}} 个提交", - "upToDate": "已是最新:本机 {{commit}} 已与远端一致", - "remoteHead": "远端最新提交", - "remoteSubject": "远端提交说明", + "upToDate": "已是最新:本机 {{commit}} 已与更新源一致", + "remoteHead": "更新源最新提交", + "remoteSubject": "更新源提交说明", "incomingTitle": "待并入的提交", "incomingTruncated": "共 {{total}} 个新提交,这里只列出前 {{shown}} 个", "applyBtn": "一键更新", @@ -124,12 +123,12 @@ "blockedJobRunning": "已有更新在进行中,请等它结束。", "blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。", "blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。", - "blockedNoUpdate": "远端没有比本机更新的提交(还没查过就先点「检查更新」)。", + "blockedNoUpdate": "更新源没有比本机更新的提交(还没查过就先点「检查更新」)。", "restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)", "restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。", "confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。", "confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。", - "confirmApply": "将要执行:把 {{remote}}/{{branch}} 快进到 {{target}}({{n}} 个提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。", + "confirmApply": "将要执行:从更新源 {{repo}} 更新到 {{target}}({{n}} 个新提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。", "applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。", "localChangesTitle": "本机改动", "localChangesHint": "标为「本机内容」的文件更新后原样保留;标为「产品源码」的文件会阻止自动更新。", @@ -183,19 +182,16 @@ "confirmRollback": "确定回滚到 {{commit}} 吗?源码回到那次更新之前,二进制用回备份的那一份。", "rollbackDone": "已回滚到 {{commit}}", "sourceTitle": "更新源", - "sourceFromConfig": "更新源来自配置(config.yaml 的 update 段)。", - "sourceFromTracked": "未配置更新源:跟随本目录自己跟踪的远端。", - "sourceUrl": "远端地址", - "sourceRemote": "远端名", - "sourceBranch": "分支", - "sourceUrlLabel": "仓库地址(与下方的已有远端名二选一)", - "sourceRemoteLabel": "已有远端名", - "sourceBranchLabel": "分支(可留空)", + "sourceFromConfig": "更新源已配置(config.yaml 的 update 段)。", + "sourceIsDefault": "未配置更新源:使用官方仓库 {{repo}}。", + "sourceUrl": "更新源仓库", + "sourceBranch": "源默认分支", + "sourceUrlLabel": "更新源仓库地址", + "sourceRepoHint": "留空即使用官方仓库;可改为自己的二开或镜像地址。", "sourceSaveBtn": "保存更新源", "sourceSaveOk": "更新源已保存", "sourceSaveFailed": "保存更新源失败:{{reason}}", - "adoptHint": "这个目录还不是 git 工作树。接入上面的更新源后,它就成为可一键更新的正常安装。", - "adoptNeedsSource": "先在上面填入仓库地址并保存,然后才能接入。", + "adoptHint": "这个目录还不是 git 工作树。接入更新源后,它就成为可一键更新的正常安装。", "adoptPreviewBtn": "预览并接入", "adoptIncoming": "目标分支文件数", "adoptOverwrittenTotal": "将被目标版本替换", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 908a34be..d0dbb556 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -19,8 +19,9 @@ const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'startRestartNow', 'refreshUpdatePage']; // The envelope shape GET /api/system/update answers with, plus what the source endpoints return. -const emptySource = { remote: '', remoteUrl: '', branch: '', configured: false }; -const configuredSource = { remote: '', remoteUrl: 'https://github.com/Sycun/CyberStrikeAI.git', branch: 'main', configured: true }; +const officialRepo = 'https://github.com/AIPentest/CyberStrikeAI.git'; +const emptySource = { repo: '', configured: false, defaultRepo: officialRepo }; +const configuredSource = { repo: 'https://github.com/Sycun/CyberStrikeAI.git', configured: true, defaultRepo: officialRepo }; const adoptPlan = { root: '/srv/csai', source: 'https://github.com/Sycun/CyberStrikeAI.git', @@ -57,7 +58,9 @@ const baseStatus = { root: '/srv/csai', installed: true, branch: 'main', - remote: 'mine', + repo: officialRepo, + repoConfigured: false, + targetBranch: 'main', commit: 'a1b2c3d', subject: 'fix: 修好一个东西', committedAt: '2026-09-30 18:04:11 +0800', @@ -101,7 +104,7 @@ const runningJob = { id: 'upd-1', state: 'running', started: '2026-10-01T04:00:00+08:00', - steps: [{ phase: 'fetch', message: '开始拉取 mine/main', at: '2026-10-01T04:00:01+08:00' }], + steps: [{ phase: 'fetch', message: '开始拉取更新源(默认分支 main)', at: '2026-10-01T04:00:01+08:00' }], restartRequested: false, }; @@ -111,7 +114,7 @@ const succeededJob = { started: '2026-10-01T04:00:00+08:00', finished: '2026-10-01T04:03:12+08:00', steps: [ - { phase: 'fetch', message: 'mine/main 有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' }, + { phase: 'fetch', message: officialRepo + '(默认分支 main)有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' }, { phase: 'protect', message: '已暂存 2 个本地内容文件', at: '04:00:05' }, { phase: 'build', message: '开始编译二进制', at: '04:00:09' }, { phase: 'done', message: '已更新 3 个提交并换好二进制', at: '04:03:10' }, @@ -558,7 +561,7 @@ test('opening the page reads the install tree and claims nothing about the remot assert.match(html, /有可回滚点/); assert.match(html, /待并入|本机安装/, 'the console must be laid out in labelled sections'); // behind is 0 only because nobody looked yet: that is "not checked", never "up to date". - assert.match(html, /还没查过远端/); + assert.match(html, /还没查过更新源/); assert.doesNotMatch(html, /已是最新/, 'a page that did not check must not say 已是最新'); assert.doesNotMatch(html, /update\.[a-zA-Z]/, 'a raw i18n key leaked into the markup'); assert.doesNotMatch(html, /\{\{/, 'an un-interpolated placeholder leaked into the markup'); @@ -619,10 +622,10 @@ test('a check whose request dies mid-flight releases the button and says so', as const html = h.html(); assert.match(html, /检查失败:/, 'a dead request is a failure, and it has to be called one'); assert.match(html, /network unreachable/); - assert.doesNotMatch(html, /正在检查远端/, 'the button must not stay frozen on "checking"'); + assert.doesNotMatch(html, /正在检查更新源/, 'the button must not stay frozen on "checking"'); assert.doesNotMatch(html, /update-check-btn" disabled/, 'and it must be clickable again'); assert.doesNotMatch(html, /已是最新/, 'a request that went nowhere is not an answer about the remote'); - assert.match(html, /还没查过远端/, 'the page falls back to the honest "nobody looked yet"'); + assert.match(html, /还没查过更新源/, 'the page falls back to the honest "nobody looked yet"'); assert.equal(h.toasts.length, 1); assert.equal(h.toasts[0].type, 'error'); assert.equal(h.liveTimers().length, 0); @@ -663,7 +666,7 @@ test('local edits, a diverged tree and a clean-but-unchecked tree each block the }, { patch: { updateAvailable: false, behind: 0, blockingChanges: [] }, - copy: /远端没有比本机更新的提交/, + copy: /更新源没有比本机更新的提交/, path: null, }, ]; @@ -737,7 +740,7 @@ test('applying posts {"restart":false} and then reads the job every 1.5 seconds' await h.tick(); await h.flush(); assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update/job']); - assert.match(h.html(), /开始拉取 mine\/main/, 'each step line must be rendered as it arrives'); + assert.match(h.html(), /开始拉取更新源/, 'each step line must be rendered as it arrives'); assert.match(h.html(), /进行中/); assert.equal(h.liveTimers().length, 1); @@ -852,7 +855,7 @@ test('the confirm dialog says what the update will do, and cancelling posts noth assert.equal(h.confirms.length, 1, 'the operator must be asked before the tree moves'); assert.match(h.confirms[0], /重新编译/, 'the confirm must say the binary gets rebuilt: ' + h.confirms[0]); assert.match(h.confirms[0], /二进制/); - assert.match(h.confirms[0], /mine\/main/, 'the confirm must name the remote and branch being merged'); + assert.match(h.confirms[0], /github\.com\/AIPentest\/CyberStrikeAI/, 'the confirm must name the repository being fetched'); assert.match(h.confirms[0], /a1b2c3d/, 'the confirm must name where this machine is now'); assert.doesNotMatch(h.confirms[0], /\{\{/, 'the confirm went out un-interpolated: ' + h.confirms[0]); assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), [], 'a cancelled update must not POST'); @@ -1117,25 +1120,35 @@ test('the hint describes the automatic check instead of denying any network use' } }); -test('the source section shows what is in effect, and saving posts all three fields', async () => { +test('the source section shows what is in effect, and saving posts the one address', async () => { const h = await harness({ status: statusOf({ installed: true }), source: configuredSource }); assert.match(h.html(), /https:\/\/github\.com\/Sycun\/CyberStrikeAI\.git/, - 'the configured address must be visible'); + 'the effective address must be visible'); + assert.match(h.html(), /update-source-url/, 'the editable address field must be there'); assert.match(h.html(), /update-source-save-btn/); - h.runHandler("updateSourceFieldChanged('branch', 'release')"); + h.runHandler("updateSourceRepoChanged('https://gitlab.com/mirror/csai.git')"); await h.fire('saveUpdateSource'); await h.flush(); const save = h.calls.find(c => c.url === '/api/system/update/source'); assert.ok(save, 'saving must hit the source endpoint'); - assert.deepEqual(JSON.parse(save.body), { - remote: '', remoteUrl: 'https://github.com/Sycun/CyberStrikeAI.git', branch: 'release', - }); + assert.deepEqual(JSON.parse(save.body), { repo: 'https://gitlab.com/mirror/csai.git' }, + 'the save carries exactly the one address, nothing else'); assert.ok(h.toasts.some(t => t.type === 'success'), 'a saved source must be confirmed'); }); +test('clearing the address is the way back to the official repository', async () => { + const h = await harness({ status: statusOf({ installed: true }), source: configuredSource }); + h.runHandler('updateSourceRepoChanged("")'); + await h.fire('saveUpdateSource'); + await h.flush(); + const save = h.calls.find(c => c.url === '/api/system/update/source'); + assert.deepEqual(JSON.parse(save.body), { repo: '' }, + 'an empty address means \"use the official repository again\", not an error'); +}); + test('a refused save shows the server reason instead of a generic failure', async () => { - const refusal = '远端名与远端地址二选一:要么指名已有远端,要么直接给地址'; + const refusal = '仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)'; const h = await harness({ status: statusOf({ installed: true }), source: configuredSource, @@ -1143,19 +1156,21 @@ test('a refused save shows the server reason instead of a generic failure', asyn 'POST /api/system/update/source': [{ status: 400, body: { error: refusal } }], }, }); - h.runHandler("updateSourceFieldChanged('remote', 'origin')"); + h.runHandler("updateSourceRepoChanged('ext::sh -c true')"); await h.fire('saveUpdateSource'); await h.flush(); - assert.match(h.html(), /二选一/, 'the refusal must be on screen'); - assert.ok(h.toasts.some(t => t.type === 'error' && t.msg.includes('二选一')), 'and in a toast'); - assert.match(h.html(), /origin/, 'the typed value must survive the failed save'); + assert.match(h.html(), /不合法/, 'the refusal must be on screen'); + assert.ok(h.toasts.some(t => t.type === 'error' && t.msg.includes('不合法')), 'and in a toast'); + assert.match(h.html(), /ext::sh -c true/, 'the typed value must survive the failed save'); }); test('the adopt entry appears only for a directory that is not a git installation', async () => { - const notGit = await harness({ status: statusOf({ installed: false }), source: configuredSource }); - assert.match(notGit.html(), /previewAdoptSource\(\)/, 'a configured non-git tree must offer connecting'); + // No source configured either: the official repository is the default, so connecting is + // always available for a non-git directory. + const notGit = await harness({ status: statusOf({ installed: false }), source: emptySource }); + assert.match(notGit.html(), /previewAdoptSource\(\)/, 'a non-git tree must offer connecting even with the default source'); - const gitTree = await harness({ status: statusOf({ installed: true }), source: configuredSource }); + const gitTree = await harness({ status: statusOf({ installed: true }), source: emptySource }); assert.doesNotMatch(gitTree.html(), /previewAdoptSource\(\)/, 'a git installation updates, it does not adopt'); }); diff --git a/web/static/js/update.js b/web/static/js/update.js index eef1a56b..f94687e0 100644 --- a/web/static/js/update.js +++ b/web/static/js/update.js @@ -32,7 +32,7 @@ let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任 let updateWatchdogDeadline = 0; let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来 let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰 -let updateSourceDraft = null; // 编辑中的更新源;null=还贴着服务端保存的值 +let updateSourceDraft = null; // 编辑中的更新源仓库地址;null=还贴着服务端保存的值 let updateSourceError = ''; // 最近一次保存/接入失败的原因,画在区块里 let updateAdoptPlan = null; // 预览回来的接入计划;确认后清掉 @@ -47,21 +47,17 @@ function updateT(key, opts) { } function updateSourceOf() { - return (updateConsoleState && updateConsoleState.source) || { remote: '', remoteUrl: '', branch: '', configured: false }; + return (updateConsoleState && updateConsoleState.source) || { repo: '', configured: false, defaultRepo: '' }; } // 草稿优先:轮询重绘不许把正在输入的地址冲掉;保存成功后草稿丢弃、回到服务端的值。 -function updateSourceField(field) { - if (updateSourceDraft && typeof updateSourceDraft[field] === 'string') return updateSourceDraft[field]; - return updateSourceOf()[field] || ''; +function updateSourceRepo() { + if (typeof updateSourceDraft === 'string') return updateSourceDraft; + return updateSourceOf().repo || ''; } -function updateSourceFieldChanged(field, value) { - if (!updateSourceDraft) { - const s = updateSourceOf(); - updateSourceDraft = { remote: s.remote || '', remoteUrl: s.remoteUrl || '', branch: s.branch || '' }; - } - updateSourceDraft[field] = String(value == null ? '' : value); +function updateSourceRepoChanged(value) { + updateSourceDraft = String(value == null ? '' : value); } // scheduleUpdateAutoCheck answers the question the page exists to answer, without making @@ -151,7 +147,7 @@ async function fetchUpdateStatus() { supervised: !!r.data.supervised, needsRestart: !!r.data.needsRestart, binaryBuiltAt: r.data.binaryBuiltAt || '', - source: r.data.source || { remote: '', remoteUrl: '', branch: '', configured: false }, + source: r.data.source || { repo: '', configured: false, defaultRepo: '' }, }; noteRestartDefault(); return updateConsoleState; @@ -434,10 +430,9 @@ function updateRestartRequested() { function updateConfirmText(status, restart) { const base = updateT('confirmApply', { - remote: status.remote || '-', - branch: status.branch || '-', - commit: status.commit || '-', + repo: status.repo || '-', target: status.remoteCommit || '-', + commit: status.commit || '-', n: updateBehindCount(status), }); return base + ' ' + (restart ? updateT('confirmRestartYes') : updateT('confirmRestartNo')); @@ -516,19 +511,18 @@ async function rollbackUpdate() { }, 'rollback'); } -// 更新源:服务端负责校验与写入(config.yaml 的 update 段),这里只做搬运与说明。 +// 更新源:服务端负责校验与写入(config.yaml update 段的单个 repo 字段),这里只做搬运与说明。 +// 留空提交 = 回到官方仓库,和"从没配过"是同一种状态。 async function saveUpdateSource() { if (updateBusy) return; const body = { - remote: updateSourceField('remote').trim(), - remoteUrl: updateSourceField('remoteUrl').trim(), - branch: updateSourceField('branch').trim(), + repo: updateSourceRepo().trim(), }; updateSourceError = ''; await withUpdateBusy(async () => { const r = await runUpdateRequest('POST', '/api/system/update/source', body); if (!r.ok) { - // 服务端的拒绝文案(二选一、地址不合法……)就是要展示的那句,不在这里另编。 + // 服务端的拒绝文案(地址不合法……)就是要展示的那句,不在这里另编。 updateSourceError = updateT('sourceSaveFailed', { reason: r.error }); renderUpdateConsole(); notify(updateSourceError, 'error'); @@ -609,29 +603,19 @@ function updateMark(ok, yesKey, noKey, reason) { function renderUpdateSourceBody(status) { const src = updateSourceOf(); + const defaultRepo = src.defaultRepo || ''; const parts = []; - parts.push('
' + escapeHtml(src.configured ? updateT('sourceFromConfig') : updateT('sourceFromTracked')) + '
'); - if (status.remoteUrl) { - parts.push('
' + updateFact(updateT('sourceUrl'), status.remoteUrl) + '
'); - } - if (status.remote) { - parts.push('
' + updateFact(updateT('sourceRemote'), status.remote) + '
'); - } - if (status.branch) { - parts.push('
' + updateFact(updateT('sourceBranch'), status.branch) + '
'); + parts.push('
' + escapeHtml(src.configured ? updateT('sourceFromConfig') : updateT('sourceIsDefault', { repo: defaultRepo })) + '
'); + if (status.repo) { + parts.push('
' + updateFact(updateT('sourceUrl'), status.repo) + '
'); } // 手输错误属于页面自己的状态,重绘(轮询)不能把它冲掉。 const disabled = updateBusy ? ' disabled data-state-disabled="true"' : ''; parts.push('
' + '' + - '' + - '' + + '' + + '
' + escapeHtml(updateT('sourceRepoHint', { repo: defaultRepo })) + '
' + '
'); @@ -645,10 +629,6 @@ function renderUpdateSourceBody(status) { } function renderAdoptBody() { - const typedUrl = updateSourceField('remoteUrl') || updateSourceField('remote'); - if (!updateSourceOf().configured && !typedUrl) { - return '
' + escapeHtml(updateT('adoptNeedsSource')) + '
'; - } const parts = []; parts.push('
' + escapeHtml(updateT('adoptHint')) + '
'); const disabled = updateBusy ? ' disabled data-state-disabled="true"' : ''; @@ -683,7 +663,6 @@ function renderUpdateInstallBody(status) { const facts = [ updateFact(updateT('root'), status.root), updateFact(updateT('branch'), status.branch), - updateFact(updateT('remote'), status.remote), updateFact(updateT('commit'), status.commit), updateFact(updateT('committedAt'), status.committedAt), updateFact(updateT('subject'), status.subject), @@ -721,8 +700,9 @@ function renderUpdateRemoteBody(status) { if (status.ahead > 0) { parts.push('
' + escapeHtml(updateT('aheadCount', { n: status.ahead })) + '
'); } - if (status.remoteCommit) { + if (status.targetBranch || status.remoteCommit) { parts.push('
' + + updateFact(updateT('sourceBranch'), status.targetBranch) + updateFact(updateT('remoteHead'), status.remoteCommit) + updateFact(updateT('remoteSubject'), status.remoteSubject) + '
'); } @@ -1003,4 +983,4 @@ window.renderUpdateConsole = renderUpdateConsole; window.saveUpdateSource = saveUpdateSource; window.previewAdoptSource = previewAdoptSource; window.confirmAdoptSource = confirmAdoptSource; -window.updateSourceFieldChanged = updateSourceFieldChanged; +window.updateSourceRepoChanged = updateSourceRepoChanged;