@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 +2 -2
- package/src/commands/webui.js +76 -36
- package/src/webui/aionui-runtime-fetcher.js +239 -28
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "2.1.
|
|
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": [
|
package/src/commands/webui.js
CHANGED
|
@@ -113,34 +113,45 @@ function autoInstallBun(logger) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// ── Runtime resolution ───────────────────────────────────────────────────────
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
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 (!
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
console.log(chalk.
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
31
|
-
// Override via HOLYSHEEP_AIONUI_RUNTIME_URL / HOLYSHEEP_AIONUI_RUNTIME_SHA256
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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-
|
|
58
|
+
'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs6.tar.gz'
|
|
37
59
|
const DEFAULT_RUNTIME_SHA256 =
|
|
38
|
-
'
|
|
39
|
-
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
'
|
|
184
|
-
|
|
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
|
}
|