@simonyea/holysheep-cli 2.1.9 → 2.1.11

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.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
4
4
  "description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
5
5
  "scripts": {
6
- "test": "node tests/droid.test.js && node tests/workspace-store.test.js",
6
+ "test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js",
7
7
  "prepublishOnly": "node scripts/check-tarball-size.js"
8
8
  },
9
9
  "keywords": [
@@ -113,34 +113,45 @@ function autoInstallBun(logger) {
113
113
  }
114
114
 
115
115
  // ── Runtime resolution ───────────────────────────────────────────────────────
116
- // Priority:
117
- // 1. Local dev checkout at ../aionui-fork/dist-server (developer building locally)
118
- // 2. Installed runtime at ~/.holysheep/aionui-runtime/dist-server
119
- // 3. If --setup-runtime + HOLYSHEEP_AIONUI_RUNTIME_URL, download into (2)
120
- // 4. null fall back to legacy workspace
121
- function resolveAionUiRuntimeDir() {
122
- // (1) Dev checkout — if holysheep-cli was cloned and `bun run build` ran in aionui-fork
123
- const repoRoot = path.resolve(__dirname, '..', '..')
124
- const devRuntime = path.join(repoRoot, 'aionui-fork')
125
- if (fs.existsSync(path.join(devRuntime, 'dist-server', 'server.mjs'))) {
126
- return { dir: devRuntime, source: 'dev-checkout' }
127
- }
128
- // (2) User-installed runtime
129
- const home = process.env.HOME || process.env.USERPROFILE
130
- if (home) {
131
- const installed = path.join(home, '.holysheep', 'aionui-runtime')
132
- if (fs.existsSync(path.join(installed, 'dist-server', 'server.mjs'))) {
133
- return { dir: installed, source: 'installed' }
116
+ // This file used to have its own "does `server.mjs` exist?" resolver and
117
+ // short-circuit before the fetcher was ever consulted. That's why earlier
118
+ // releases' version-drift detection (added in `aionui-runtime-fetcher.js`)
119
+ // never kicked in — we never called it. Now the single source of truth is
120
+ // `resolveRuntime()` from the fetcher module, with stale detection + atomic
121
+ // upgrade baked in. We just call it twice:
122
+ // * Once synchronously-shaped (allowDownload:false) for the "[mode=…]"
123
+ // status line so we don't incur a network hit just to print a label.
124
+ // * Once with allowDownload:true inside startAionUiMode, where we're
125
+ // willing to pay the ~20MB download to get a fresh runtime.
126
+ async function resolveAionUiRuntime({ allowDownload, logger = () => {} } = {}) {
127
+ const { resolveRuntime } = require('../webui/aionui-runtime-fetcher')
128
+ return resolveRuntime({ allowDownload: !!allowDownload, logger })
129
+ }
130
+
131
+ // Cheap probe for the status line. No download, never blocks.
132
+ function probeAionUiRuntimeLabel() {
133
+ try {
134
+ const {
135
+ USER_CACHE_DIR,
136
+ VENDOR_DIR,
137
+ isValidRuntimeDir,
138
+ isVersionCurrent,
139
+ } = require('../webui/aionui-runtime-fetcher')
140
+
141
+ const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
142
+ if (isValidRuntimeDir(forkDir)) return 'aionui-fork'
143
+ if (isValidRuntimeDir(VENDOR_DIR)) return 'vendor'
144
+ if (isValidRuntimeDir(USER_CACHE_DIR)) {
145
+ return isVersionCurrent(USER_CACHE_DIR) ? 'installed' : 'installed-stale'
134
146
  }
135
- }
136
- return null
147
+ } catch {}
148
+ return 'none'
137
149
  }
138
150
 
139
151
  async function downloadAionUiRuntime(logger) {
140
152
  // Fetcher has a baked-in default URL + SHA256 — no env var required.
141
153
  // HOLYSHEEP_AIONUI_RUNTIME_URL still works as an override.
142
- const { resolveRuntime } = require('../webui/aionui-runtime-fetcher')
143
- return resolveRuntime({ allowDownload: true, logger })
154
+ return resolveAionUiRuntime({ allowDownload: true, logger })
144
155
  }
145
156
 
146
157
  // ── HolySheep config → API key ───────────────────────────────────────────────
@@ -345,20 +356,47 @@ function spawnAionUiServer({ bunPath, runtimeDir, port }) {
345
356
  async function startAionUiMode(opts) {
346
357
  const port = Number(opts.port) || 9876
347
358
 
348
- // 1. Resolve runtime. First launch after `npm i -g` won't have one locally
349
- // — we auto-download from the baked-in URL (verified by SHA256) unless the
350
- // user opts out via HOLYSHEEP_WEBUI_NO_AUTOFETCH=1.
351
- let runtime = resolveAionUiRuntimeDir()
359
+ // 1. Resolve runtime. Fetcher returns one of:
360
+ // source='user-cache' → version matches baked-in DEFAULT_RUNTIME_VERSION
361
+ // source='user-cache-stale' → version mismatch AND upgrade was skipped/failed
362
+ // (we can still boot with the old runtime,
363
+ // but the user sees an explicit warning)
364
+ // source='download-upgrade' → cache was stale, we auto-upgraded in place
365
+ // source='download' → first-ever install, freshly downloaded
366
+ // source='aionui-fork'/'vendor' → dev checkout
367
+ //
368
+ // We always call with allowDownload:true here (unless the user disabled
369
+ // auto-fetch) so stale caches get upgraded the same way first-run downloads
370
+ // happen. Before 2.1.10 the local resolver short-circuited on "file exists"
371
+ // and stale caches stayed stale forever.
352
372
  const autoFetchDisabled = process.env.HOLYSHEEP_WEBUI_NO_AUTOFETCH === '1'
353
- if (!runtime && !autoFetchDisabled) {
354
- console.log(chalk.cyan('▶ AionUi runtime not installed downloading automatically (~21 MB, one-time)'))
355
- console.log(chalk.gray(' (disable with HOLYSHEEP_WEBUI_NO_AUTOFETCH=1; override URL with HOLYSHEEP_AIONUI_RUNTIME_URL)'))
356
- const downloaded = await downloadAionUiRuntime((m) => console.log(chalk.gray(` ${m}`)))
357
- if (downloaded) {
358
- runtime = { dir: downloaded.dir, source: downloaded.source }
359
- console.log(chalk.gray(' AionUi runtime installed. Next: ensure bun is available to launch it.'))
373
+ if (!autoFetchDisabled) {
374
+ // Pre-announce only when we know we're going to hit the network. For
375
+ // first-ever install we always do. For stale cache we _may_ do, depending
376
+ // on HOLYSHEEP_AIONUI_SKIP_UPGRADE.
377
+ const probe = probeAionUiRuntimeLabel()
378
+ if (probe === 'none') {
379
+ console.log(chalk.cyan('AionUi runtime not installed downloading automatically (~21 MB, one-time)'))
380
+ console.log(chalk.gray(' (disable with HOLYSHEEP_WEBUI_NO_AUTOFETCH=1; override URL with HOLYSHEEP_AIONUI_RUNTIME_URL)'))
381
+ } else if (probe === 'installed-stale' && process.env.HOLYSHEEP_AIONUI_SKIP_UPGRADE !== '1') {
382
+ console.log(chalk.cyan('▶ AionUi runtime is out of date — upgrading in place (atomic)'))
383
+ console.log(chalk.gray(' (set HOLYSHEEP_AIONUI_SKIP_UPGRADE=1 to pin current cache; HOLYSHEEP_AIONUI_PIN_VERSION=<v> to lock a version)'))
360
384
  }
361
385
  }
386
+ let runtime = await resolveAionUiRuntime({
387
+ allowDownload: !autoFetchDisabled,
388
+ logger: (m) => console.log(chalk.gray(` ${m}`)),
389
+ })
390
+ if (runtime && runtime.source === 'download-upgrade') {
391
+ console.log(chalk.green(` ✓ AionUi runtime upgraded to ${runtime.version}`))
392
+ } else if (runtime && runtime.source === 'user-cache-stale') {
393
+ console.log(chalk.yellow(
394
+ ` ⚠ Running stale AionUi runtime (${runtime.version}, expected newer). ` +
395
+ `Run: rm -rf ~/.holysheep/aionui-runtime && hs web to force rebuild.`
396
+ ))
397
+ } else if (runtime && runtime.source === 'download') {
398
+ console.log(chalk.gray(' AionUi runtime installed. Next: ensure bun is available to launch it.'))
399
+ }
362
400
  if (!runtime) {
363
401
  const home = process.env.HOME || process.env.USERPROFILE || '~'
364
402
  console.log(chalk.red('✗ AionUi runtime not found and auto-download did not succeed'))
@@ -538,10 +576,12 @@ async function webui(opts) {
538
576
  console.log()
539
577
  return await startLegacyMode(opts)
540
578
  }
541
- // Tri-state mode line: helps user-reports when things go sideways
579
+ // Tri-state mode line: helps user-reports when things go sideways.
580
+ // Runtime label now distinguishes `installed` (version matches baked-in
581
+ // DEFAULT_RUNTIME_VERSION) from `installed-stale` (will auto-upgrade on
582
+ // next call to resolveAionUiRuntime). `none` means first-ever launch.
542
583
  const bunFound = resolveBunPath() ? 'found' : 'missing'
543
- const rt = resolveAionUiRuntimeDir()
544
- const rtLabel = rt ? rt.source : 'none'
584
+ const rtLabel = probeAionUiRuntimeLabel()
545
585
  console.log(chalk.gray(`[mode=aionui platform=${process.platform} bun=${bunFound} runtime=${rtLabel}]`))
546
586
  console.log()
547
587
  return await startAionUiMode(opts)
@@ -2,17 +2,39 @@
2
2
  * AionUi runtime resolver
3
3
  *
4
4
  * Resolution order:
5
- * 1. ~/.holysheep/aionui-runtime/ (installed / cached)
6
- * 2. <cli>/src/webui/vendor/aionui/ (dev checkout not shipped to npm)
7
- * 3. <cli>/../aionui-fork/ (dev repo checkout not shipped to npm)
5
+ * 1. ~/.holysheep/aionui-runtime/ (installed / cached) — ONLY if its
6
+ * version string matches DEFAULT_RUNTIME_VERSION (or
7
+ * HOLYSHEEP_AIONUI_PIN_VERSION). A mismatch is treated as *stale*: the
8
+ * cache is atomically replaced with the current version on next launch.
9
+ * 2. <cli>/src/webui/vendor/aionui/ (dev checkout — not shipped to npm,
10
+ * no version gate)
11
+ * 3. <cli>/../aionui-fork/ (dev repo checkout — not shipped to npm,
12
+ * no version gate; lets local `bun run build` take precedence over
13
+ * any cached runtime when you're iterating on the fork)
8
14
  * 4. DEFAULT_RUNTIME_URL + DEFAULT_RUNTIME_SHA256 (baked in; auto-download
9
- * when --setup-runtime is passed). Users can override via env.
15
+ * when --setup-runtime is passed OR when cache is stale). Users can
16
+ * override via env.
10
17
  * 5. null → caller must show a clear "runtime not installed" error
11
18
  *
12
- * The baked-in default URL is a public mirror of the AionUi v1.9.18 fork
13
- * with HolySheep API-key login patched in. It's the prebuilt artifact that
14
- * ships alongside `@simonyea/holysheep-cli` and is used by `hs web` on
15
- * first launch when no local runtime is present.
19
+ * ── Why stale detection exists ──────────────────────────────────────────────
20
+ * Before 2.1.10 the resolver returned the cache as soon as it saw
21
+ * `dist-server/server.mjs` on disk, regardless of which version it was. That
22
+ * meant users who installed hs web back when hs0/hs1 was the baked-in default
23
+ * kept running that exact build forever — every subsequent release (hs2
24
+ * rebrand, hs3 link fixes, hs5 OpenClaw model selector + HolySheep CLI card)
25
+ * silently did nothing on their machines, because their ~/.holysheep/…
26
+ * directory already "looked valid." This file now compares the cached
27
+ * package.json version against DEFAULT_RUNTIME_VERSION and upgrades in place
28
+ * via an atomic rename flow (stage → backup old → swap → drop backup).
29
+ *
30
+ * ── Atomic upgrade safety ───────────────────────────────────────────────────
31
+ * 1. Download tarball to `${USER_CACHE_DIR}.staging-${ts}` and verify SHA256.
32
+ * 2. Rename existing `USER_CACHE_DIR` → `${USER_CACHE_DIR}.bak-${ts}` (fast).
33
+ * 3. Rename staging dir → `USER_CACHE_DIR` (fast).
34
+ * 4. rm -rf the backup.
35
+ * Any failure before step 3 leaves the old cache untouched. A failure between
36
+ * step 2 and 3 is auto-restored from the backup. The user never ends up with
37
+ * no runtime at all.
16
38
  */
17
39
 
18
40
  'use strict'
@@ -27,16 +49,16 @@ const http = require('http')
27
49
  const USER_CACHE_DIR = path.join(os.homedir(), '.holysheep', 'aionui-runtime')
28
50
  const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
29
51
 
30
- // Baked-in defaults — updated with every 2.x.0 release that bumps the bundle.
31
- // Override via HOLYSHEEP_AIONUI_RUNTIME_URL / HOLYSHEEP_AIONUI_RUNTIME_SHA256.
32
- // hs3 = fix user-visible iOfficeAI/AionUi links → holysheep.ai (2.1.6, 2026-04-22).
33
- // Previous revisions still live on the CDN for forensics, but all new installs of
34
- // @simonyea/holysheep-cli >= 2.1.6 pull this tarball.
52
+ // Baked-in defaults — updated with every release that bumps the runtime bundle.
53
+ // Override at runtime via HOLYSHEEP_AIONUI_RUNTIME_URL / HOLYSHEEP_AIONUI_RUNTIME_SHA256
54
+ // / HOLYSHEEP_AIONUI_PIN_VERSION. When DEFAULT_RUNTIME_VERSION changes in a
55
+ // new CLI release, the next `hs web` invocation on user machines will detect
56
+ // the version drift and upgrade the cache in place.
35
57
  const DEFAULT_RUNTIME_URL =
36
- 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs5.tar.gz'
58
+ 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs7.tar.gz'
37
59
  const DEFAULT_RUNTIME_SHA256 =
38
- 'e398a13bf4b2256b63cc977322ab8c817c09e7d4000235218c3d2838e9dfb85f'
39
- const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs5'
60
+ 'abe85be2fa5e682bbfdcfd719f69f0eda296be5ee369173191c1c38e020120ef'
61
+ const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs7'
40
62
 
41
63
  function isValidRuntimeDir(dir) {
42
64
  if (!dir) return false
@@ -50,7 +72,35 @@ function isValidRuntimeDir(dir) {
50
72
  }
51
73
  }
52
74
 
75
+ /**
76
+ * Read the version of a runtime directory.
77
+ *
78
+ * Order:
79
+ * 1. `.holysheep-runtime-version` (flat text file we stamp after a
80
+ * successful download/upgrade — guaranteed to match what the fetcher
81
+ * pulled, regardless of whether the tarball itself shipped a
82
+ * package.json). This is the file future `isVersionCurrent` comparisons
83
+ * rely on; without it, every subsequent `hs web` would mis-detect the
84
+ * fresh cache as stale and re-download forever.
85
+ * 2. `package.json` `version` field (legacy — the hs0/hs1 tarballs did
86
+ * include one with `1.9.18-holysheep`). Kept for backwards compat.
87
+ * 3. 'unknown' — treated as NOT current, so the fetcher will upgrade.
88
+ */
53
89
  function readVersion(dir) {
90
+ try {
91
+ const stamp = path.join(dir, '.holysheep-runtime-version')
92
+ if (fs.existsSync(stamp)) {
93
+ // .trim() is REQUIRED. writeVersionStamp() appends '\n', and a human
94
+ // doing manual recovery might have used `echo foo > file` (writes
95
+ // 'foo\n') or `echo -n foo > file` (writes 'foo' but on bash `echo`
96
+ // builtins that don't recognize -n you get '-n foo\n'). Trimming
97
+ // whitespace makes all these shapes compare equal — without it,
98
+ // isVersionCurrent() returned false on a freshly-stamped cache and
99
+ // looped into auto-upgrade. DO NOT remove this .trim().
100
+ const v = fs.readFileSync(stamp, 'utf8').trim()
101
+ if (v) return v
102
+ }
103
+ } catch {}
54
104
  try {
55
105
  const pkgPath = path.join(dir, 'package.json')
56
106
  if (fs.existsSync(pkgPath)) {
@@ -61,34 +111,109 @@ function readVersion(dir) {
61
111
  return 'unknown'
62
112
  }
63
113
 
114
+ /**
115
+ * Stamp the runtime directory with the version we just installed so that
116
+ * readVersion() returns the right answer on subsequent launches. Fails
117
+ * silently — a missing stamp only means the _next_ launch will re-download,
118
+ * which is annoying but not dangerous.
119
+ */
120
+ function writeVersionStamp(dir, version) {
121
+ try {
122
+ fs.writeFileSync(path.join(dir, '.holysheep-runtime-version'), version + '\n', 'utf8')
123
+ } catch {}
124
+ }
125
+
126
+ /**
127
+ * Strict string equality against the expected version.
128
+ *
129
+ * We intentionally do not parse semver ranges — runtime releases are tagged
130
+ * explicitly (hs5, hs6, …) and every CLI release pins exactly one expected
131
+ * string. This keeps the upgrade decision a boolean, not a version-math
132
+ * guessing game.
133
+ *
134
+ * Priority:
135
+ * 1. HOLYSHEEP_AIONUI_PIN_VERSION (user opted-in lock)
136
+ * 2. DEFAULT_RUNTIME_VERSION (baked into this CLI release)
137
+ */
138
+ function expectedVersion() {
139
+ return process.env.HOLYSHEEP_AIONUI_PIN_VERSION || DEFAULT_RUNTIME_VERSION
140
+ }
141
+
142
+ function isVersionCurrent(dir) {
143
+ return readVersion(dir) === expectedVersion()
144
+ }
145
+
64
146
  /**
65
147
  * Resolve an AionUi runtime directory.
66
- * @returns {{ dir: string, version: string, source: 'user-cache'|'vendor'|'env-download' } | null}
148
+ *
149
+ * @param {object} opts
150
+ * @param {boolean} opts.allowDownload - if true, will attempt to download
151
+ * the runtime when no local cache exists, OR when the local cache is
152
+ * stale (version mismatch). Stale-upgrade is suppressed when the env var
153
+ * `HOLYSHEEP_AIONUI_SKIP_UPGRADE=1` is set, in which case the stale cache
154
+ * is returned as-is with `source: 'user-cache-stale'`.
155
+ * @param {(msg:string)=>void} opts.logger
156
+ *
157
+ * @returns {{ dir: string, version: string,
158
+ * source: 'user-cache'|'user-cache-stale'|'vendor'|'aionui-fork'|'download'|'download-upgrade'
159
+ * } | null}
67
160
  */
68
161
  async function resolveRuntime({ allowDownload = false, logger = () => {} } = {}) {
69
162
  // 1. User cache (installed runtime)
70
163
  if (isValidRuntimeDir(USER_CACHE_DIR)) {
71
- return { dir: USER_CACHE_DIR, version: readVersion(USER_CACHE_DIR), source: 'user-cache' }
164
+ const ver = readVersion(USER_CACHE_DIR)
165
+ if (isVersionCurrent(USER_CACHE_DIR)) {
166
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache' }
167
+ }
168
+
169
+ // Stale. Decide whether to upgrade in place.
170
+ const skipUpgrade = process.env.HOLYSHEEP_AIONUI_SKIP_UPGRADE === '1'
171
+ if (allowDownload && !skipUpgrade) {
172
+ logger(`runtime cache is out of date (found ${ver}, expected ${expectedVersion()}) — upgrading...`)
173
+ try {
174
+ await performAtomicUpgrade(USER_CACHE_DIR, logger)
175
+ return {
176
+ dir: USER_CACHE_DIR,
177
+ version: readVersion(USER_CACHE_DIR) || expectedVersion(),
178
+ source: 'download-upgrade',
179
+ }
180
+ } catch (e) {
181
+ logger(`runtime upgrade failed: ${e.message}`)
182
+ // Fall through: return stale cache as a degraded-but-usable state
183
+ // so the user still gets _some_ WebUI rather than a hard error.
184
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache-stale' }
185
+ }
186
+ }
187
+ // No upgrade allowed/desired → return stale
188
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache-stale' }
72
189
  }
73
190
 
74
- // 2. Dev vendor checkout (bundled for darwin-arm64 dev rigs — not in npm)
191
+ // 2. Dev vendor checkout (bundled for darwin-arm64 dev rigs — not in npm).
192
+ // No version gate: a dev checkout is assumed current by construction.
75
193
  if (isValidRuntimeDir(VENDOR_DIR)) {
76
194
  return { dir: VENDOR_DIR, version: readVersion(VENDOR_DIR), source: 'vendor' }
77
195
  }
78
196
 
79
- // 3. Dev repo aionui-fork/ (when running from the holysheep-cli source repo)
197
+ // 3. Dev repo aionui-fork/ (when running from the holysheep-cli source repo).
198
+ // No version gate: whoever is building the fork locally is on the bleeding
199
+ // edge by definition; comparing against DEFAULT_RUNTIME_VERSION would just
200
+ // break local dev every time you bump the version bakeline.
80
201
  const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
81
202
  if (isValidRuntimeDir(forkDir)) {
82
203
  return { dir: forkDir, version: readVersion(forkDir), source: 'aionui-fork' }
83
204
  }
84
205
 
85
- // 4. Download from env override OR baked-in default
206
+ // 4. Fresh install — download from env override OR baked-in default
86
207
  if (allowDownload) {
87
208
  const url = process.env.HOLYSHEEP_AIONUI_RUNTIME_URL || DEFAULT_RUNTIME_URL
88
209
  const expectedSha = process.env.HOLYSHEEP_AIONUI_RUNTIME_SHA256 || DEFAULT_RUNTIME_SHA256
89
210
  try {
211
+ fs.mkdirSync(USER_CACHE_DIR, { recursive: true })
90
212
  await downloadAndExtract(url, USER_CACHE_DIR, expectedSha, logger)
91
213
  if (isValidRuntimeDir(USER_CACHE_DIR)) {
214
+ // Stamp the version so subsequent launches don't re-detect as stale
215
+ // (hs5+ tarballs ship no root package.json).
216
+ writeVersionStamp(USER_CACHE_DIR, expectedVersion())
92
217
  return {
93
218
  dir: USER_CACHE_DIR,
94
219
  version: readVersion(USER_CACHE_DIR) || DEFAULT_RUNTIME_VERSION,
@@ -104,6 +229,82 @@ async function resolveRuntime({ allowDownload = false, logger = () => {} } = {})
104
229
  return null
105
230
  }
106
231
 
232
+ /**
233
+ * Atomically replace the runtime at `targetDir` with a freshly downloaded
234
+ * copy. Safe against mid-operation failure: the old cache is preserved
235
+ * either in place (if download/verify fails) or as a `.bak-*` directory
236
+ * (if we crash between backup and swap).
237
+ *
238
+ * Implementation:
239
+ * a) Download + extract to `${targetDir}.staging-${ts}`, verify SHA256.
240
+ * b) `mv targetDir → ${targetDir}.bak-${ts}` (atomic, same filesystem)
241
+ * c) `mv stagingDir → targetDir` (atomic, same filesystem)
242
+ * d) `rm -rf ${targetDir}.bak-${ts}` on success.
243
+ * e) On any failure in (b)→(c), try to restore from the `.bak-*` path.
244
+ */
245
+ async function performAtomicUpgrade(targetDir, logger) {
246
+ const url = process.env.HOLYSHEEP_AIONUI_RUNTIME_URL || DEFAULT_RUNTIME_URL
247
+ const expectedSha = process.env.HOLYSHEEP_AIONUI_RUNTIME_SHA256 || DEFAULT_RUNTIME_SHA256
248
+ const ts = Date.now()
249
+ const stagingDir = `${targetDir}.staging-${ts}`
250
+ const backupDir = `${targetDir}.bak-${ts}`
251
+
252
+ // (a) Download + extract + verify into an isolated staging dir.
253
+ fs.mkdirSync(stagingDir, { recursive: true })
254
+ try {
255
+ await downloadAndExtract(url, stagingDir, expectedSha, logger)
256
+ } catch (e) {
257
+ try { rmrf(stagingDir) } catch {}
258
+ throw e
259
+ }
260
+ if (!isValidRuntimeDir(stagingDir)) {
261
+ try { rmrf(stagingDir) } catch {}
262
+ throw new Error('staged runtime failed structure validation')
263
+ }
264
+
265
+ // (b) Back up the old cache, if any.
266
+ let backedUp = false
267
+ if (fs.existsSync(targetDir)) {
268
+ try {
269
+ fs.renameSync(targetDir, backupDir)
270
+ backedUp = true
271
+ } catch (e) {
272
+ try { rmrf(stagingDir) } catch {}
273
+ throw new Error(`failed to back up old runtime: ${e.message}`)
274
+ }
275
+ }
276
+
277
+ // (c) Move staging into place.
278
+ try {
279
+ fs.renameSync(stagingDir, targetDir)
280
+ } catch (e) {
281
+ // Try to restore the backup so the user isn't left runtime-less.
282
+ if (backedUp) {
283
+ try { fs.renameSync(backupDir, targetDir) } catch {}
284
+ }
285
+ try { rmrf(stagingDir) } catch {}
286
+ throw new Error(`failed to swap in new runtime: ${e.message}`)
287
+ }
288
+
289
+ // (d) Stamp the version. hs5+ tarballs do NOT include a root package.json,
290
+ // so without this stamp readVersion() would fall back to 'unknown' and the
291
+ // very next `hs web` would re-enter the stale-upgrade branch and download
292
+ // again. See readVersion() docstring above.
293
+ writeVersionStamp(targetDir, expectedVersion())
294
+
295
+ // (e) Success — drop the backup.
296
+ if (backedUp) {
297
+ try { rmrf(backupDir) } catch (e) {
298
+ logger(`warning: could not remove old runtime backup at ${backupDir}: ${e.message}`)
299
+ }
300
+ }
301
+ }
302
+
303
+ function rmrf(p) {
304
+ // fs.rmSync landed in Node 14.14, which is well below our floor.
305
+ fs.rmSync(p, { recursive: true, force: true })
306
+ }
307
+
107
308
  /**
108
309
  * Download a tar.gz from url, verify optional SHA256, extract into destDir.
109
310
  * Uses only Node built-ins (https + tar via exec) — zero new deps.
@@ -122,7 +323,7 @@ function downloadAndExtract(url, destDir, expectedSha, logger) {
122
323
  // Follow redirects (GitHub Releases → S3 CDN)
123
324
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
124
325
  file.close()
125
- fs.unlinkSync(tmpFile)
326
+ try { fs.unlinkSync(tmpFile) } catch {}
126
327
  return resolve(downloadAndExtract(res.headers.location, destDir, expectedSha, logger))
127
328
  }
128
329
  if (res.statusCode !== 200) {
@@ -147,7 +348,7 @@ function downloadAndExtract(url, destDir, expectedSha, logger) {
147
348
  fs.mkdirSync(destDir, { recursive: true })
148
349
  const { execSync } = require('child_process')
149
350
  execSync(`tar -xzf "${tmpFile}" -C "${destDir}" --strip-components=0`, { stdio: 'ignore' })
150
- fs.unlinkSync(tmpFile)
351
+ try { fs.unlinkSync(tmpFile) } catch {}
151
352
  // Post-extract sanity: hs4 once shipped a tarball that only
152
353
  // contained dist-server/ — the WebUI launched but every
153
354
  // renderer-side feature silently did nothing. Fail loudly here so
@@ -196,11 +397,8 @@ function describeInstallGuidance() {
196
397
  ` export HOLYSHEEP_AIONUI_RUNTIME_SHA256=${DEFAULT_RUNTIME_SHA256}`,
197
398
  ' hs web --setup-runtime',
198
399
  '',
199
- ' Build from source (requires bun):',
200
- ' git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi',
201
- ' cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server',
202
- ` mkdir -p ${USER_CACHE_DIR}`,
203
- ` cp -R ~/AionUi/{dist-server,out} ${USER_CACHE_DIR}/`,
400
+ ' Force a fresh runtime download even if cache exists:',
401
+ ` rm -rf ${USER_CACHE_DIR} && hs web`,
204
402
  '',
205
403
  ' Or fall back to the legacy HolySheep Workspace:',
206
404
  ' HOLYSHEEP_WEBUI_LEGACY=1 hs web',
@@ -213,8 +411,12 @@ module.exports = {
213
411
  DEFAULT_RUNTIME_URL,
214
412
  DEFAULT_RUNTIME_SHA256,
215
413
  DEFAULT_RUNTIME_VERSION,
414
+ expectedVersion,
415
+ isVersionCurrent,
216
416
  isValidRuntimeDir,
217
417
  readVersion,
418
+ writeVersionStamp,
218
419
  resolveRuntime,
420
+ performAtomicUpgrade,
219
421
  describeInstallGuidance,
220
422
  }