@simonyea/holysheep-cli 2.1.8 → 2.1.10

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.8",
3
+ "version": "2.1.10",
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-hs6.tar.gz'
37
59
  const DEFAULT_RUNTIME_SHA256 =
38
- 'e398a13bf4b2256b63cc977322ab8c817c09e7d4000235218c3d2838e9dfb85f'
39
- const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs5'
60
+ 'e29e956219d49f14007860fdc27eef63ea938e88c7eedf309babd9318343375f'
61
+ const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs6'
40
62
 
41
63
  function isValidRuntimeDir(dir) {
42
64
  if (!dir) return false
@@ -50,7 +72,28 @@ 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
+ const v = fs.readFileSync(stamp, 'utf8').trim()
94
+ if (v) return v
95
+ }
96
+ } catch {}
54
97
  try {
55
98
  const pkgPath = path.join(dir, 'package.json')
56
99
  if (fs.existsSync(pkgPath)) {
@@ -61,34 +104,109 @@ function readVersion(dir) {
61
104
  return 'unknown'
62
105
  }
63
106
 
107
+ /**
108
+ * Stamp the runtime directory with the version we just installed so that
109
+ * readVersion() returns the right answer on subsequent launches. Fails
110
+ * silently — a missing stamp only means the _next_ launch will re-download,
111
+ * which is annoying but not dangerous.
112
+ */
113
+ function writeVersionStamp(dir, version) {
114
+ try {
115
+ fs.writeFileSync(path.join(dir, '.holysheep-runtime-version'), version + '\n', 'utf8')
116
+ } catch {}
117
+ }
118
+
119
+ /**
120
+ * Strict string equality against the expected version.
121
+ *
122
+ * We intentionally do not parse semver ranges — runtime releases are tagged
123
+ * explicitly (hs5, hs6, …) and every CLI release pins exactly one expected
124
+ * string. This keeps the upgrade decision a boolean, not a version-math
125
+ * guessing game.
126
+ *
127
+ * Priority:
128
+ * 1. HOLYSHEEP_AIONUI_PIN_VERSION (user opted-in lock)
129
+ * 2. DEFAULT_RUNTIME_VERSION (baked into this CLI release)
130
+ */
131
+ function expectedVersion() {
132
+ return process.env.HOLYSHEEP_AIONUI_PIN_VERSION || DEFAULT_RUNTIME_VERSION
133
+ }
134
+
135
+ function isVersionCurrent(dir) {
136
+ return readVersion(dir) === expectedVersion()
137
+ }
138
+
64
139
  /**
65
140
  * Resolve an AionUi runtime directory.
66
- * @returns {{ dir: string, version: string, source: 'user-cache'|'vendor'|'env-download' } | null}
141
+ *
142
+ * @param {object} opts
143
+ * @param {boolean} opts.allowDownload - if true, will attempt to download
144
+ * the runtime when no local cache exists, OR when the local cache is
145
+ * stale (version mismatch). Stale-upgrade is suppressed when the env var
146
+ * `HOLYSHEEP_AIONUI_SKIP_UPGRADE=1` is set, in which case the stale cache
147
+ * is returned as-is with `source: 'user-cache-stale'`.
148
+ * @param {(msg:string)=>void} opts.logger
149
+ *
150
+ * @returns {{ dir: string, version: string,
151
+ * source: 'user-cache'|'user-cache-stale'|'vendor'|'aionui-fork'|'download'|'download-upgrade'
152
+ * } | null}
67
153
  */
68
154
  async function resolveRuntime({ allowDownload = false, logger = () => {} } = {}) {
69
155
  // 1. User cache (installed runtime)
70
156
  if (isValidRuntimeDir(USER_CACHE_DIR)) {
71
- return { dir: USER_CACHE_DIR, version: readVersion(USER_CACHE_DIR), source: 'user-cache' }
157
+ const ver = readVersion(USER_CACHE_DIR)
158
+ if (isVersionCurrent(USER_CACHE_DIR)) {
159
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache' }
160
+ }
161
+
162
+ // Stale. Decide whether to upgrade in place.
163
+ const skipUpgrade = process.env.HOLYSHEEP_AIONUI_SKIP_UPGRADE === '1'
164
+ if (allowDownload && !skipUpgrade) {
165
+ logger(`runtime cache is out of date (found ${ver}, expected ${expectedVersion()}) — upgrading...`)
166
+ try {
167
+ await performAtomicUpgrade(USER_CACHE_DIR, logger)
168
+ return {
169
+ dir: USER_CACHE_DIR,
170
+ version: readVersion(USER_CACHE_DIR) || expectedVersion(),
171
+ source: 'download-upgrade',
172
+ }
173
+ } catch (e) {
174
+ logger(`runtime upgrade failed: ${e.message}`)
175
+ // Fall through: return stale cache as a degraded-but-usable state
176
+ // so the user still gets _some_ WebUI rather than a hard error.
177
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache-stale' }
178
+ }
179
+ }
180
+ // No upgrade allowed/desired → return stale
181
+ return { dir: USER_CACHE_DIR, version: ver, source: 'user-cache-stale' }
72
182
  }
73
183
 
74
- // 2. Dev vendor checkout (bundled for darwin-arm64 dev rigs — not in npm)
184
+ // 2. Dev vendor checkout (bundled for darwin-arm64 dev rigs — not in npm).
185
+ // No version gate: a dev checkout is assumed current by construction.
75
186
  if (isValidRuntimeDir(VENDOR_DIR)) {
76
187
  return { dir: VENDOR_DIR, version: readVersion(VENDOR_DIR), source: 'vendor' }
77
188
  }
78
189
 
79
- // 3. Dev repo aionui-fork/ (when running from the holysheep-cli source repo)
190
+ // 3. Dev repo aionui-fork/ (when running from the holysheep-cli source repo).
191
+ // No version gate: whoever is building the fork locally is on the bleeding
192
+ // edge by definition; comparing against DEFAULT_RUNTIME_VERSION would just
193
+ // break local dev every time you bump the version bakeline.
80
194
  const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
81
195
  if (isValidRuntimeDir(forkDir)) {
82
196
  return { dir: forkDir, version: readVersion(forkDir), source: 'aionui-fork' }
83
197
  }
84
198
 
85
- // 4. Download from env override OR baked-in default
199
+ // 4. Fresh install — download from env override OR baked-in default
86
200
  if (allowDownload) {
87
201
  const url = process.env.HOLYSHEEP_AIONUI_RUNTIME_URL || DEFAULT_RUNTIME_URL
88
202
  const expectedSha = process.env.HOLYSHEEP_AIONUI_RUNTIME_SHA256 || DEFAULT_RUNTIME_SHA256
89
203
  try {
204
+ fs.mkdirSync(USER_CACHE_DIR, { recursive: true })
90
205
  await downloadAndExtract(url, USER_CACHE_DIR, expectedSha, logger)
91
206
  if (isValidRuntimeDir(USER_CACHE_DIR)) {
207
+ // Stamp the version so subsequent launches don't re-detect as stale
208
+ // (hs5+ tarballs ship no root package.json).
209
+ writeVersionStamp(USER_CACHE_DIR, expectedVersion())
92
210
  return {
93
211
  dir: USER_CACHE_DIR,
94
212
  version: readVersion(USER_CACHE_DIR) || DEFAULT_RUNTIME_VERSION,
@@ -104,6 +222,82 @@ async function resolveRuntime({ allowDownload = false, logger = () => {} } = {})
104
222
  return null
105
223
  }
106
224
 
225
+ /**
226
+ * Atomically replace the runtime at `targetDir` with a freshly downloaded
227
+ * copy. Safe against mid-operation failure: the old cache is preserved
228
+ * either in place (if download/verify fails) or as a `.bak-*` directory
229
+ * (if we crash between backup and swap).
230
+ *
231
+ * Implementation:
232
+ * a) Download + extract to `${targetDir}.staging-${ts}`, verify SHA256.
233
+ * b) `mv targetDir → ${targetDir}.bak-${ts}` (atomic, same filesystem)
234
+ * c) `mv stagingDir → targetDir` (atomic, same filesystem)
235
+ * d) `rm -rf ${targetDir}.bak-${ts}` on success.
236
+ * e) On any failure in (b)→(c), try to restore from the `.bak-*` path.
237
+ */
238
+ async function performAtomicUpgrade(targetDir, logger) {
239
+ const url = process.env.HOLYSHEEP_AIONUI_RUNTIME_URL || DEFAULT_RUNTIME_URL
240
+ const expectedSha = process.env.HOLYSHEEP_AIONUI_RUNTIME_SHA256 || DEFAULT_RUNTIME_SHA256
241
+ const ts = Date.now()
242
+ const stagingDir = `${targetDir}.staging-${ts}`
243
+ const backupDir = `${targetDir}.bak-${ts}`
244
+
245
+ // (a) Download + extract + verify into an isolated staging dir.
246
+ fs.mkdirSync(stagingDir, { recursive: true })
247
+ try {
248
+ await downloadAndExtract(url, stagingDir, expectedSha, logger)
249
+ } catch (e) {
250
+ try { rmrf(stagingDir) } catch {}
251
+ throw e
252
+ }
253
+ if (!isValidRuntimeDir(stagingDir)) {
254
+ try { rmrf(stagingDir) } catch {}
255
+ throw new Error('staged runtime failed structure validation')
256
+ }
257
+
258
+ // (b) Back up the old cache, if any.
259
+ let backedUp = false
260
+ if (fs.existsSync(targetDir)) {
261
+ try {
262
+ fs.renameSync(targetDir, backupDir)
263
+ backedUp = true
264
+ } catch (e) {
265
+ try { rmrf(stagingDir) } catch {}
266
+ throw new Error(`failed to back up old runtime: ${e.message}`)
267
+ }
268
+ }
269
+
270
+ // (c) Move staging into place.
271
+ try {
272
+ fs.renameSync(stagingDir, targetDir)
273
+ } catch (e) {
274
+ // Try to restore the backup so the user isn't left runtime-less.
275
+ if (backedUp) {
276
+ try { fs.renameSync(backupDir, targetDir) } catch {}
277
+ }
278
+ try { rmrf(stagingDir) } catch {}
279
+ throw new Error(`failed to swap in new runtime: ${e.message}`)
280
+ }
281
+
282
+ // (d) Stamp the version. hs5+ tarballs do NOT include a root package.json,
283
+ // so without this stamp readVersion() would fall back to 'unknown' and the
284
+ // very next `hs web` would re-enter the stale-upgrade branch and download
285
+ // again. See readVersion() docstring above.
286
+ writeVersionStamp(targetDir, expectedVersion())
287
+
288
+ // (e) Success — drop the backup.
289
+ if (backedUp) {
290
+ try { rmrf(backupDir) } catch (e) {
291
+ logger(`warning: could not remove old runtime backup at ${backupDir}: ${e.message}`)
292
+ }
293
+ }
294
+ }
295
+
296
+ function rmrf(p) {
297
+ // fs.rmSync landed in Node 14.14, which is well below our floor.
298
+ fs.rmSync(p, { recursive: true, force: true })
299
+ }
300
+
107
301
  /**
108
302
  * Download a tar.gz from url, verify optional SHA256, extract into destDir.
109
303
  * Uses only Node built-ins (https + tar via exec) — zero new deps.
@@ -122,7 +316,7 @@ function downloadAndExtract(url, destDir, expectedSha, logger) {
122
316
  // Follow redirects (GitHub Releases → S3 CDN)
123
317
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
124
318
  file.close()
125
- fs.unlinkSync(tmpFile)
319
+ try { fs.unlinkSync(tmpFile) } catch {}
126
320
  return resolve(downloadAndExtract(res.headers.location, destDir, expectedSha, logger))
127
321
  }
128
322
  if (res.statusCode !== 200) {
@@ -147,7 +341,23 @@ function downloadAndExtract(url, destDir, expectedSha, logger) {
147
341
  fs.mkdirSync(destDir, { recursive: true })
148
342
  const { execSync } = require('child_process')
149
343
  execSync(`tar -xzf "${tmpFile}" -C "${destDir}" --strip-components=0`, { stdio: 'ignore' })
150
- fs.unlinkSync(tmpFile)
344
+ try { fs.unlinkSync(tmpFile) } catch {}
345
+ // Post-extract sanity: hs4 once shipped a tarball that only
346
+ // contained dist-server/ — the WebUI launched but every
347
+ // renderer-side feature silently did nothing. Fail loudly here so
348
+ // the user gets a clear error instead of a half-broken UI.
349
+ const serverMjs = path.join(destDir, 'dist-server', 'server.mjs')
350
+ const rendererIndex = path.join(destDir, 'out', 'renderer', 'index.html')
351
+ const missing = []
352
+ if (!fs.existsSync(serverMjs)) missing.push('dist-server/server.mjs')
353
+ if (!fs.existsSync(rendererIndex)) missing.push('out/renderer/index.html')
354
+ if (missing.length) {
355
+ return reject(new Error(
356
+ `Runtime tarball is missing required files: ${missing.join(', ')}.\n` +
357
+ `This runtime cannot launch the WebUI. Check HOLYSHEEP_AIONUI_RUNTIME_URL ` +
358
+ `or report the broken URL (${url}).`
359
+ ))
360
+ }
151
361
  resolve()
152
362
  } catch (e) {
153
363
  try { fs.unlinkSync(tmpFile) } catch {}
@@ -180,11 +390,8 @@ function describeInstallGuidance() {
180
390
  ` export HOLYSHEEP_AIONUI_RUNTIME_SHA256=${DEFAULT_RUNTIME_SHA256}`,
181
391
  ' hs web --setup-runtime',
182
392
  '',
183
- ' Build from source (requires bun):',
184
- ' git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi',
185
- ' cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server',
186
- ` mkdir -p ${USER_CACHE_DIR}`,
187
- ` cp -R ~/AionUi/{dist-server,out} ${USER_CACHE_DIR}/`,
393
+ ' Force a fresh runtime download even if cache exists:',
394
+ ` rm -rf ${USER_CACHE_DIR} && hs web`,
188
395
  '',
189
396
  ' Or fall back to the legacy HolySheep Workspace:',
190
397
  ' HOLYSHEEP_WEBUI_LEGACY=1 hs web',
@@ -197,8 +404,12 @@ module.exports = {
197
404
  DEFAULT_RUNTIME_URL,
198
405
  DEFAULT_RUNTIME_SHA256,
199
406
  DEFAULT_RUNTIME_VERSION,
407
+ expectedVersion,
408
+ isVersionCurrent,
200
409
  isValidRuntimeDir,
201
410
  readVersion,
411
+ writeVersionStamp,
202
412
  resolveRuntime,
413
+ performAtomicUpgrade,
203
414
  describeInstallGuidance,
204
415
  }