@turmind/halo 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/0-ba3da5a638cc90d9.js +1 -0
  3. package/admin-out/_next/static/chunks/989-1e0950ea81450f70.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-aac322e92546015f.js +1 -0
  5. package/admin-out/_next/static/css/{e890aa0d13e94290.css → 2e6b00182ac44ae1.css} +1 -1
  6. package/admin-out/index.html +1 -1
  7. package/admin-out/index.txt +5 -5
  8. package/bundled-docs/design/agentcore.md +385 -34
  9. package/bundled-docs/design/architecture.md +1 -1
  10. package/bundled-docs/design/background-dispatch.md +2 -2
  11. package/bundled-docs/design/canvas-extensions.md +76 -29
  12. package/bundled-docs/design/cron.md +3 -3
  13. package/bundled-docs/design/observability.md +4 -2
  14. package/bundled-docs/design/relay.md +2 -2
  15. package/bundled-docs/design/session.md +6 -4
  16. package/bundled-docs/design/storage.md +27 -5
  17. package/bundled-docs/design/stt-local.md +2 -2
  18. package/bundled-docs/design/ws.md +2 -1
  19. package/bundled-docs/dev/add-model-provider.md +1 -0
  20. package/bundled-docs/dev/api.md +94 -14
  21. package/bundled-docs/dev/deploy.md +8 -0
  22. package/bundled-docs/dev/env.md +8 -4
  23. package/bundled-docs/guide/cli.md +25 -11
  24. package/bundled-docs/guide/skills.md +2 -2
  25. package/bundled-docs/guide/workspace.md +2 -2
  26. package/bundled-docs/requirements/editor.md +4 -4
  27. package/bundled-docs/requirements/settings.md +15 -3
  28. package/dist/index.js +4000 -2122
  29. package/package.json +2 -1
  30. package/templates/skills/extension/SKILL.md +21 -16
  31. package/templates/skills/extension/config.yaml +6 -2
  32. package/templates/skills/extension/templates/ext.sh +137 -57
  33. package/admin-out/_next/static/chunks/87-6a2716c98a819c4e.js +0 -1
  34. package/admin-out/_next/static/chunks/989-cb287787a4aca288.js +0 -1
  35. package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +0 -1
  36. /package/admin-out/_next/static/{snq8MT7CRlizWQcyu-Kt4 → F5kkXJkkJ-4vNC_tb2Hjk}/_buildManifest.js +0 -0
  37. /package/admin-out/_next/static/{snq8MT7CRlizWQcyu-Kt4 → F5kkXJkkJ-4vNC_tb2Hjk}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turmind/halo",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Halo — multi-agent collaborative workspace. CLI + TUI + HTTP/WS server in one package.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@
55
55
  "qrcode": "^1.5.4",
56
56
  "react": "^19.2.5",
57
57
  "react-dom": "^19.2.5",
58
+ "semver": "^7.8.5",
58
59
  "sherpa-onnx-node": "1.13.8",
59
60
  "simple-git": "^3.27.0",
60
61
  "ws": "^8.21.3",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: extension
3
- description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`) — and update the model provider list (new models, changed provider endpoints) from the hub. `/extension install <id|zip|url>` pulls the latest `<id>-v*` release from the configured hub (default turmind/halo-hub); `/extension models` pulls the latest `models-v*` provider configs. Activate when the user wants to preview a file type the editor can't open, asks to install / update / remove an extension, or asks to update the model list / get a newly released model ("更新模型列表").
3
+ description: Install / list / remove admin preview extensions (`~/.halo/global/extensions/<id>/`) — static viewers the admin editor loads for file types it has no built-in preview for (e.g. `.glb`) — and update the model provider list (new models, changed provider endpoints) from the hub. `/extension install <id|zip|url>` installs the version the configured hub's `<id>/update.json` names (default hub turmind/halo-hub); `/extension models` pulls the latest `models-v*` provider configs. Activate when the user wants to preview a file type the editor can't open, asks to install / update / remove an extension, or asks to update the model list / get a newly released model ("更新模型列表").
4
4
  command: /extension
5
5
  requiresAccess: full
6
6
  verbs:
@@ -35,16 +35,17 @@ Pick EXT = the first that exists, then:
35
35
  When the payload names a source, run the install right away — no confirmation,
36
36
  no explanation first. Ask only when no source was given.
37
37
 
38
- `HALO_HUB_REPO='{{params.hub_repo}}' bash EXT install <source>` — keep
39
- `{{params.hub_repo}}` exactly as-is (the system substitutes it at runtime), where
40
- `<source>` is one of:
41
- - an extension id (e.g. `glb`) → the newest `glb-v*` release zip from the hub
42
- (a GitHub / Gitea / Forgejo / GitLab repo), or the newest `glb-v<x.y.z>` tag
43
- when the hub is any other git URL / local repo;
38
+ `HALO_HUB_BASE='{{params.hub_base}}' HALO_HUB_REPO='{{params.hub_repo}}' bash EXT install <source>`
39
+ — keep `{{params.hub_base}}` and `{{params.hub_repo}}` exactly as-is (the system
40
+ substitutes them at runtime), where `<source>` is one of:
41
+ - an extension id (e.g. `glb`) → reads `<hub_base>/glb/update.json` and installs
42
+ the zip it names (https only, checked against its sha256, id and version);
44
43
  - a local `.zip` path;
45
44
  - an `https://…zip` URL.
46
45
 
47
- The hub is set in Settings → Skills → extension → `hub_repo`; empty means
46
+ The hub is set in Settings → Skills → extension: `hub_base` (where the
47
+ `<id>/update.json` files live — a mirror goes here); empty means the GitHub raw
48
+ `extensions/` directory of `hub_repo`, and empty `hub_repo` means
48
49
  https://github.com/turmind/halo-hub.
49
50
 
50
51
  The script unpacks to a temp dir, pre-checks `halo-extension.json` (id matches,
@@ -53,12 +54,11 @@ Reinstalling the same id upgrades (or downgrades) it. Relay the script's last
53
54
  line to the user (`installed glb 1.0.0` / the error). Never edit files inside an
54
55
  installed extension — reinstall instead.
55
56
 
56
- On failure relay the script's hint. "release API failed" → rate-limited
57
- (GitHub: 60 anonymous requests/hour), private, or a wrong `hub_repo`; setting
58
- `GITHUB_TOKEN` / `GITLAB_TOKEN` / `GITEA_TOKEN` in the server environment helps.
59
- "no release for <id>" → the id isn't on that hub. "needs a build step" → that
60
- extension can't be installed from git tags; get its release zip. Either way the
61
- user can download the zip by hand and run `install <path>`.
57
+ On failure relay the script's hint. "no update.json for <id>" → the id isn't on
58
+ that hub, or `hub_base` is wrong. "no hub_base set" → `hub_repo` isn't on GitHub,
59
+ so `hub_base` must be set. "sha256 mismatch" / "does not match update.json" →
60
+ the hub's package doesn't match its update.json; tell the user, don't retry.
61
+ Either way the user can download the zip by hand and run `install <path>`.
62
62
 
63
63
  ## list
64
64
 
@@ -92,8 +92,10 @@ The script prints one line per provider (`install` / `up-to-date` / `skip` /
92
92
  `--yes` on your own.
93
93
  - anything else — relay the error. Model configs come only from an https
94
94
  GitHub / Gitea / Forgejo / GitLab hub with releases; a local / plain-git /
95
- http hub is refused. "release API failed" / "no release for models" → same
96
- hints as for `install`.
95
+ http hub is refused. "release API failed" → rate-limited (GitHub: 60
96
+ anonymous requests/hour), private, or a wrong `hub_repo`; setting
97
+ `GITHUB_TOKEN` / `GITLAB_TOKEN` / `GITEA_TOKEN` in the server environment
98
+ helps. "no release for models" → that hub publishes no model configs.
97
99
 
98
100
  `bash EXT models list` shows each provider, which copy is in effect (`bundled`
99
101
  = shipped with halo, `hub` = installed by this command) and its revision.
@@ -104,6 +106,9 @@ The script prints one line per provider (`install` / `up-to-date` / `skip` /
104
106
  asks "install it for this project".
105
107
  - Mention the configured hub (its `extensions/` directory) only when the user
106
108
  asks what extensions exist or an install fails; do not scrape it.
109
+ - The admin's Settings → Extensions checks for updates itself (same
110
+ update.json files) and offers a one-click update; point the user there for
111
+ "is there a newer version".
107
112
  - The script only pre-checks the required fields; the server's scanner is the
108
113
  authority. A package it rejects shows up in `list` (and the admin) as
109
114
  `ERROR` with the reason.
@@ -4,6 +4,10 @@ description_zh: 安装 / 列出 / 删除管理后台的预览扩展。
4
4
 
5
5
  params:
6
6
  - key: hub_repo
7
- description: "Hub that `install <id>` and `models` pull from: a GitHub / Gitea / Forgejo / GitLab repo URL (newest `<id>-v*` release zip), `owner/repo` for GitHub, or any git URL / local repo path (newest `<id>-v<x.y.z>` tag; not for `models`, which needs an https release hub). Empty = https://github.com/turmind/halo-hub."
8
- description_zh: "`install <id>` 和 `models` 的来源仓库:GitHub / Gitea / Forgejo / GitLab 仓库地址(取最新的 `<id>-v*` release zip),GitHub 可简写为 `owner/repo`;也可以是任意 git 地址或本地仓库路径(取最新的 `<id>-v<x.y.z>` 标签;`models` 不支持,它只认 https 的 release 仓库)。留空 = https://github.com/turmind/halo-hub。"
7
+ description: "Hub that `models` pulls from (newest `models-v*` release zip): an https GitHub / Gitea / Forgejo / GitLab repo URL, or `owner/repo` for GitHub. Also where the default `hub_base` comes from. Empty = https://github.com/turmind/halo-hub."
8
+ description_zh: "`models` 的来源仓库(取最新的 `models-v*` release zip):https 的 GitHub / Gitea / Forgejo / GitLab 仓库地址,GitHub 可简写为 `owner/repo`。`hub_base` 的默认值也由它推出。留空 = https://github.com/turmind/halo-hub。"
9
9
  default: https://github.com/turmind/halo-hub
10
+ - key: hub_base
11
+ description: "https base URL holding `<id>/update.json` for every hub extension — where `install <id>` and the admin's update check (extensions without their own `source`) read from. Point it at a mirror (intranet server, S3, CDN). Empty = the GitHub raw `extensions/` directory on `main` of `hub_repo` (https://raw.githubusercontent.com/<owner>/<repo>/main/extensions); a non-GitHub `hub_repo` needs this set."
12
+ description_zh: "存放各个 hub 扩展 `<id>/update.json` 的 https 基础地址,`install <id>` 和后台的检查更新(没写 `source` 的扩展)都从这里读。用镜像(内网服务器、S3、CDN)时改成镜像地址。留空 = `hub_repo` 在 GitHub 上 `main` 分支的 `extensions/` 目录(https://raw.githubusercontent.com/<owner>/<repo>/main/extensions);`hub_repo` 不是 GitHub 时必须填写。"
13
+ default: ""
@@ -11,12 +11,15 @@
11
11
  # ext.sh models update [--yes] # hub's newest models-v* zip → halo models install
12
12
  # ext.sh models list # = halo models list
13
13
  #
14
- # `install <id>` reads the hub in $HALO_HUB_REPO (the skill's hub_repo param;
15
- # empty → https://github.com/turmind/halo-hub). GitHub / Gitea-Forgejo / GitLab
16
- # hubs go through their public release API (GITHUB_TOKEN / GITEA_TOKEN /
17
- # GITLAB_TOKEN, if set, are sent — lifts GitHub's 60 req/h anonymous limit);
18
- # any other git URL or local repo installs from `<id>-v<semver>` tags.
19
- # Needs curl + unzip + node (+ git for tag-based hubs). No `gh`.
14
+ # `install <id>` reads `<hub base>/<id>/update.json` — $HALO_HUB_BASE (the
15
+ # skill's hub_base param; empty → the GitHub raw `extensions/` dir of
16
+ # $HALO_HUB_REPO) — over https only, then the zip it names, checked against
17
+ # its sha256, id and version (same protocol as the admin's update check,
18
+ # server updates/update-json.ts). `models update` reads $HALO_HUB_REPO (the
19
+ # hub_repo param; empty → https://github.com/turmind/halo-hub) through its
20
+ # public release API (GITHUB_TOKEN / GITEA_TOKEN / GITLAB_TOKEN, if set, are
21
+ # sent — lifts GitHub's 60 req/h anonymous limit).
22
+ # Needs curl + unzip + node. No `gh`.
20
23
  set -euo pipefail
21
24
 
22
25
  ROOT="$HOME/.halo/global/extensions"
@@ -47,8 +50,7 @@ precheck() {
47
50
 
48
51
  # Normalize a hub setting: empty / unsubstituted `{{…}}` → default,
49
52
  # `owner/repo` → GitHub, an existing dir → absolute path, drop trailing `/`.
50
- # A `.git` suffix stays (git mode clones the URL as given); the API helpers
51
- # below drop it.
53
+ # A `.git` suffix stays; the API helpers below drop it.
52
54
  hub_url() {
53
55
  local h=${1:-}
54
56
  if [[ -z $h || $h == '{{'* ]]; then h=$DEFAULT_HUB; fi
@@ -93,7 +95,7 @@ hub_probe() {
93
95
  [[ $ct == application/json* ]]
94
96
  }
95
97
 
96
- # github | gitlab | gitea | git (= tag-based fallback) for a normalized hub URL.
98
+ # github | gitlab | gitea | git (= no release API) for a normalized hub URL.
97
99
  hub_platform() {
98
100
  if [[ ! $1 =~ ^https?://([^/]+)/[^/]+/.+$ ]]; then echo git; return; fi
99
101
  case ${BASH_REMATCH[1]} in
@@ -128,33 +130,6 @@ hub_pick_release() {
128
130
  })' "$1" "$2"
129
131
  }
130
132
 
131
- # stdin = `git ls-remote --tags --refs`; print the highest `<id>-vX.Y.Z` tag
132
- # (prerelease `-…` suffixes skipped), nothing if there is none.
133
- hub_pick_tag() {
134
- local v
135
- v=$(sed -n "s|.*refs/tags/$1-v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$|\1|p" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
136
- if [[ -n $v ]]; then echo "$1-v$v"; fi
137
- }
138
-
139
- # Tag-based hub: shallow-clone the newest `<id>-v<semver>` tag and echo
140
- # `extensions/<id>/` minus the build inputs scripts/pack.mjs leaves out of a
141
- # release zip. A package with a build step is refused — its output isn't in git.
142
- hub_git_package() {
143
- local hub=$1 id=$2 tmp=$3 url=$1 refs tag dir
144
- if [[ $url == /* ]]; then url="file://$url"; fi # a plain path makes git ignore --depth
145
- refs=$(git ls-remote --tags --refs "$url") || { echo "cannot list tags of $hub (git ls-remote failed)" >&2; exit 1; }
146
- tag=$(printf '%s\n' "$refs" | hub_pick_tag "$id")
147
- [[ -n $tag ]] || { echo "no release for $id in $hub (no $id-v<x.y.z> tag)" >&2; exit 1; }
148
- git -c advice.detachedHead=false clone -q --depth 1 --branch "$tag" "$url" "$tmp/repo" || { echo "git clone of $tag from $hub failed" >&2; exit 1; }
149
- dir="$tmp/repo/extensions/$id"
150
- [[ -d $dir ]] || { echo "$hub tag $tag has no extensions/$id/" >&2; exit 1; }
151
- if [[ -e $dir/fetch-deps.sh || -e $dir/build.sh ]]; then
152
- echo "$id needs a build step — install it from a release zip (install <path/to.zip> or <https://…zip>)" >&2; exit 1
153
- fi
154
- rm -rf "$dir/node_modules" "$dir/src" "$dir/package.json" "$dir/package-lock.json"
155
- echo "$dir"
156
- }
157
-
158
133
  # Download the newest `<id>-v*` release zip of <platform> hub $2 into $4 and
159
134
  # echo its path. Extra args ($5…) go to every curl (models pass
160
135
  # `--proto =https --proto-redir =https`).
@@ -174,14 +149,99 @@ hub_fetch_release() {
174
149
  echo "$tmp/pkg.zip"
175
150
  }
176
151
 
177
- # Fetch hub package <id> from hub URL $1 into $3: echo a zip path (release
178
- # API) or the package dir (git tags). API errors never fall back to git — a
179
- # git checkout of a build-step package would install broken.
180
- hub_fetch() {
181
- local hub=$1 id=$2 tmp=$3 platform
182
- platform=$(hub_platform "$hub")
183
- if [[ $platform == git ]]; then hub_git_package "$hub" "$id" "$tmp"; return; fi
184
- hub_fetch_release "$platform" "$hub" "$id" "$tmp"
152
+ # Hub base for `install <id>` from settings $1 = hub_base, $2 = hub_repo:
153
+ # hub_base as given (trailing `/` dropped); empty / unsubstituted `{{…}}` →
154
+ # the GitHub raw `extensions/` dir on `main` of a GitHub hub_repo (the
155
+ # server's updates/update-json.ts defaultHubBase); nothing for any other hub.
156
+ hub_base() {
157
+ local b=${1:-} h
158
+ if [[ -n $b && $b != '{{'* ]]; then echo "${b%/}"; return; fi
159
+ h=$(hub_url "${2:-}")
160
+ if [[ $h =~ ^https://github\.com/([A-Za-z0-9][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)$ ]]; then
161
+ echo "https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]%.git}/main/extensions"
162
+ fi
163
+ }
164
+
165
+ # update.json `minHalo` $3 for <id> $1 <version> $2 vs the installed Halo
166
+ # (`halo --version`, the build sha / `-dirty` dropped — the admin check's rule,
167
+ # server updates/update-json.ts haloBaseVersion). Below it → refuse; no
168
+ # readable version (no halo command, desktop, dev build) → warn and go on.
169
+ hub_check_min_halo() {
170
+ local hv
171
+ hv=$("${HALO_CLI:-halo}" --version 2>/dev/null) || hv=""
172
+ node -e '
173
+ const [id, ver, min, raw] = process.argv.slice(1)
174
+ const base = raw.trim().replace(/^halo\s+/, "").replace(/-dirty$/, "").replace(/-[0-9a-f]{7,40}$/, "")
175
+ const parse = (v) => {
176
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v)
177
+ return m && { core: m.slice(1, 4).map(Number), pre: m[4] ? m[4].split(".") : [] }
178
+ }
179
+ // semver precedence: core numbers, then a prerelease sorts before none.
180
+ const cmp = (a, b) => {
181
+ for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return a.core[i] - b.core[i]
182
+ if (!a.pre.length || !b.pre.length) return b.pre.length - a.pre.length
183
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
184
+ const x = a.pre[i], y = b.pre[i]
185
+ if (x === undefined) return -1
186
+ if (y === undefined) return 1
187
+ if (x === y) continue
188
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
189
+ if (nx && ny) return Number(x) - Number(y)
190
+ if (nx !== ny) return nx ? -1 : 1
191
+ return x < y ? -1 : 1
192
+ }
193
+ return 0
194
+ }
195
+ const cur = parse(base)
196
+ if (!cur) {
197
+ console.error("warning: cannot read the Halo version (" + (raw.trim() || "no halo command") + ") — not checking that " + id + " " + ver + " needs Halo " + min)
198
+ process.exit(0)
199
+ }
200
+ if (cmp(cur, parse(min)) < 0) {
201
+ console.error(id + " " + ver + " needs Halo " + min + " (this is " + base + ") — upgrade Halo first: halo upgrade && halo server restart")
202
+ process.exit(1)
203
+ }
204
+ ' "$1" "$2" "$3" "$hv"
205
+ }
206
+
207
+ # Fetch hub package <id> into $3 by `<hub base $1>/<id>/update.json`: https
208
+ # only (redirects too, ≤ 5), update.json ≤ 64 KB / 10 s, zip ≤ 100 MB (10 min,
209
+ # aborted after 30 s under 1 KB/s); a relative `url` resolves against
210
+ # update.json's final (redirected) URL; `minHalo` is checked against this
211
+ # Halo; the zip must hash to its `sha256`. Echo the zip path; the version it
212
+ # promises goes to $3/want-version for the manifest check after unpacking.
213
+ hub_fetch_update() {
214
+ local base=$1 id=$2 tmp=$3 json final line url sha ver min got
215
+ local https=(--proto =https --proto-redir =https --max-redirs 5)
216
+ [[ -n $base ]] || { echo "no hub_base set and hub_repo is not a GitHub repo to derive one from — set hub_base (Settings → Skills → extension), or install <path/to.zip>" >&2; exit 1; }
217
+ [[ $base == https://* ]] || { echo "hub_base must be an https URL: $base" >&2; exit 1; }
218
+ json="$base/$id/update.json"
219
+ final=$(curl -fsSL "${https[@]}" --max-time 10 --max-filesize 65536 -o "$tmp/update.json" -w '%{url_effective}' "$json") \
220
+ || { echo "no update.json for $id at $json — check the id and hub_base, or install <path/to.zip>" >&2; exit 1; }
221
+ line=$(node -e '
222
+ const fs = require("fs"), [file, base, id] = process.argv.slice(1)
223
+ const fail = (msg) => { console.log(msg); process.exit(1) }
224
+ let u
225
+ try { u = JSON.parse(fs.readFileSync(file, "utf8")) } catch { fail("not valid JSON") }
226
+ if (!u || typeof u !== "object" || Array.isArray(u)) fail("not a JSON object")
227
+ if (u.id !== id) fail("id " + JSON.stringify(u.id) + " does not match \"" + id + "\"")
228
+ if (typeof u.version !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(u.version)) fail("version must be semver (x.y.z[-pre])")
229
+ if (typeof u.url !== "string" || u.url === "") fail("url is required")
230
+ let url
231
+ try { url = new URL(u.url, base) } catch { fail("url is invalid: " + u.url) }
232
+ if (url.protocol !== "https:") fail("url must be https: " + url.href)
233
+ if (typeof u.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(u.sha256)) fail("sha256 must be 64 hex characters")
234
+ if (u.minHalo !== undefined && (typeof u.minHalo !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(u.minHalo))) fail("minHalo must be semver (x.y.z[-pre])")
235
+ console.log(url.href + " " + u.sha256.toLowerCase() + " " + u.version + " " + (u.minHalo || ""))
236
+ ' "$tmp/update.json" "$final" "$id") || { echo "invalid update.json at $final: $line" >&2; exit 1; }
237
+ read -r url sha ver min <<<"$line"
238
+ if [[ -n $min ]]; then hub_check_min_halo "$id" "$ver" "$min" || exit 1; fi
239
+ curl -fsSL "${https[@]}" --max-filesize 104857600 --connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30 "$url" -o "$tmp/pkg.zip" \
240
+ || { echo "download failed: $url" >&2; exit 1; }
241
+ got=$(node -e 'console.log(require("crypto").createHash("sha256").update(require("fs").readFileSync(process.argv[1])).digest("hex"))' "$tmp/pkg.zip")
242
+ [[ $got == "$sha" ]] || { echo "sha256 mismatch for $url: update.json says $sha, the download is $got" >&2; exit 1; }
243
+ echo "$ver" >"$tmp/want-version"
244
+ echo "$tmp/pkg.zip"
185
245
  }
186
246
 
187
247
  # Unpack zip $1 into the new dir $2 with the same refusals as the server's zip
@@ -194,6 +254,23 @@ safe_unzip() {
194
254
  unzip -q "$1" -d "$2"
195
255
  }
196
256
 
257
+ # Drop extension $1's on / off state from `$ROOT/.state.json` (the server's
258
+ # extensions/state.ts owns that file; this is its forgetExtension), so the id
259
+ # starts on. Missing or unreadable file = nothing to drop. A failed write only
260
+ # warns: the directory change it follows already happened.
261
+ state_forget() {
262
+ node -e '
263
+ const fs = require("fs"), file = process.argv[1], id = process.argv[2]
264
+ let s
265
+ try { s = JSON.parse(fs.readFileSync(file, "utf8")) } catch { process.exit(0) }
266
+ if (!s || typeof s !== "object" || Array.isArray(s) || !Object.hasOwn(s, id)) process.exit(0)
267
+ delete s[id]
268
+ const tmp = file + "." + process.pid + ".tmp"
269
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 2) + "\n")
270
+ fs.renameSync(tmp, file)
271
+ ' "$ROOT/.state.json" "$1" || echo "warning: could not clear the on / off state of $1 in $ROOT/.state.json" >&2
272
+ }
273
+
197
274
  # Model provider configs: newest `models-v*` release of hub $1, https +
198
275
  # release API only (a provider yaml carries endpoints the server will call —
199
276
  # no plain-git / local / http source). Validation, planning and the write into
@@ -220,13 +297,13 @@ models_update() {
220
297
  "${HALO_CLI:-halo}" models install "$dir" "$@"
221
298
  }
222
299
 
223
- # Echo a local zip or an unpacked package dir for source $1 (path, https URL, or hub id).
300
+ # Echo a local zip path for source $1 (path, https URL, or hub id).
224
301
  resolve_source() {
225
302
  local src=$1
226
303
  if [[ -f $src ]]; then echo "$src"; return; fi
227
304
  if [[ $src =~ ^https?:// ]]; then curl -fsSL "$src" -o "$TMP/pkg.zip"; echo "$TMP/pkg.zip"; return; fi
228
305
  [[ $src =~ $ID_RE ]] || { echo "invalid id or missing file: $src" >&2; exit 1; }
229
- hub_fetch "$(hub_url "${HALO_HUB_REPO:-}")" "$src" "$TMP"
306
+ hub_fetch_update "$(hub_base "${HALO_HUB_BASE:-}" "${HALO_HUB_REPO:-}")" "$src" "$TMP"
230
307
  }
231
308
 
232
309
  # Sourced (tests, later reuse of the hub_* functions): definitions only.
@@ -240,26 +317,28 @@ case $cmd in
240
317
  src=${1:?usage: ext.sh install <id|zip|url>}
241
318
  TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
242
319
  pkg=$(resolve_source "$src")
243
- if [[ -d $pkg ]]; then
244
- dir=$pkg
245
- else
246
- safe_unzip "$pkg" "$TMP/x"
247
- # `zip -r glb.zip glb/` wraps everything in one directory — descend into it.
248
- dir="$TMP/x"
249
- if [[ ! -f $dir/halo-extension.json ]]; then
250
- only=$(find "$dir" -mindepth 1 -maxdepth 1 | head -2)
251
- if [[ $(printf '%s\n' "$only" | wc -l) -eq 1 && -f $only/halo-extension.json ]]; then dir=$only; fi
252
- fi
320
+ safe_unzip "$pkg" "$TMP/x"
321
+ # `zip -r glb.zip glb/` wraps everything in one directory — descend into it.
322
+ dir="$TMP/x"
323
+ if [[ ! -f $dir/halo-extension.json ]]; then
324
+ only=$(find "$dir" -mindepth 1 -maxdepth 1 | head -2)
325
+ if [[ $(printf '%s\n' "$only" | wc -l) -eq 1 && -f $only/halo-extension.json ]]; then dir=$only; fi
253
326
  fi
254
327
  expect=""
255
328
  if [[ $src =~ $ID_RE && ! -f $src ]]; then expect=$src; fi
256
329
  if ! out=$(precheck "$dir" "$expect"); then echo "invalid extension: $out" >&2; exit 1; fi
257
330
  read -r id ver _ <<<"$out"
331
+ if [[ -f $TMP/want-version ]]; then
332
+ want=$(<"$TMP/want-version")
333
+ [[ $ver == "$want" ]] || { echo "invalid extension: manifest version \"$ver\" does not match update.json \"$want\"" >&2; exit 1; }
334
+ fi
258
335
  mkdir -p "$ROOT"
259
336
  stage="$ROOT/.tmp-$id-$RANDOM$RANDOM"
260
337
  mv "$dir" "$stage"
261
338
  old=""
262
- if [[ -d $ROOT/$id ]]; then old="$ROOT/.old-$id-$RANDOM$RANDOM"; mv "$ROOT/$id" "$old"; fi
339
+ # A fresh install (no `<id>/` yet) starts on, whatever a manual `rm -rf`
340
+ # left in the state file; an update keeps the switch.
341
+ if [[ -d $ROOT/$id ]]; then old="$ROOT/.old-$id-$RANDOM$RANDOM"; mv "$ROOT/$id" "$old"; else state_forget "$id"; fi
263
342
  mv "$stage" "$ROOT/$id" || { [[ -n $old ]] && mv "$old" "$ROOT/$id"; rm -rf "$stage"; exit 1; }
264
343
  if [[ -n $old ]]; then rm -rf "$old"; fi
265
344
  echo "installed $id $ver" ;;
@@ -280,6 +359,7 @@ case $cmd in
280
359
  [[ $id =~ $ID_RE ]] || { echo "invalid id: $id" >&2; exit 1; }
281
360
  [[ -d $ROOT/$id ]] || { echo "not installed: $id" >&2; exit 1; }
282
361
  rm -rf "$ROOT/$id"
362
+ state_forget "$id"
283
363
  echo "removed $id" ;;
284
364
  models)
285
365
  sub=${1:-update}