onbuzz 5.4.7 → 5.4.9
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 +15 -10
- package/scripts/bump-version.js +142 -20
- package/scripts/swap-brand.js +66 -0
- package/scripts/verify-update-feed.js +121 -0
- package/src/core/__tests__/stateManager.atomicWrite.test.js +25 -5
- package/src/core/stateManager.js +34 -5
- package/src/index.js +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onbuzz",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.9",
|
|
4
4
|
"description": "Loxia OnBuzz - Your AI Fleet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"electron:build:win": "npm run build:web-ui && electron-builder --win",
|
|
34
34
|
"electron:build:mac": "npm run build:web-ui && electron-builder --mac",
|
|
35
35
|
"electron:build:linux": "npm run build:web-ui && electron-builder --linux",
|
|
36
|
-
"electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.win.icon=electron/icon-onbuzz.png",
|
|
37
|
-
"electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.mac.icon=electron/icon-onbuzz.png",
|
|
38
|
-
"electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.linux.icon=electron/icon-onbuzz.png"
|
|
36
|
+
"electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.win.icon=electron/icon-onbuzz.png -c.nsis.artifactName=Loxia-OnBuzz-Setup-${version}.${ext}",
|
|
37
|
+
"electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.mac.icon=electron/icon-onbuzz.png -c.mac.artifactName=Loxia-OnBuzz-${version}-${arch}.${ext}",
|
|
38
|
+
"electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.linux.icon=electron/icon-onbuzz.png -c.linux.artifactName=Loxia-OnBuzz-${version}.${ext}"
|
|
39
39
|
},
|
|
40
40
|
"keywords": [
|
|
41
41
|
"ai",
|
|
@@ -128,9 +128,9 @@
|
|
|
128
128
|
"uuid": "$uuid"
|
|
129
129
|
},
|
|
130
130
|
"build": {
|
|
131
|
-
"appId": "com.loxia.
|
|
132
|
-
"productName": "Loxia
|
|
133
|
-
"executableName": "loxia-
|
|
131
|
+
"appId": "com.loxia.onbuzz",
|
|
132
|
+
"productName": "Loxia OnBuzz",
|
|
133
|
+
"executableName": "loxia-onbuzz",
|
|
134
134
|
"npmRebuild": false,
|
|
135
135
|
"publish": [
|
|
136
136
|
{
|
|
@@ -154,15 +154,20 @@
|
|
|
154
154
|
},
|
|
155
155
|
"win": {
|
|
156
156
|
"target": "nsis",
|
|
157
|
-
"icon": "electron/icon.png"
|
|
157
|
+
"icon": "electron/icon-onbuzz.png"
|
|
158
|
+
},
|
|
159
|
+
"nsis": {
|
|
160
|
+
"artifactName": "Loxia-OnBuzz-Setup-${version}.${ext}"
|
|
158
161
|
},
|
|
159
162
|
"mac": {
|
|
160
163
|
"target": "dmg",
|
|
161
|
-
"icon": "electron/icon.png"
|
|
164
|
+
"icon": "electron/icon-onbuzz.png",
|
|
165
|
+
"artifactName": "Loxia-OnBuzz-${version}-${arch}.${ext}"
|
|
162
166
|
},
|
|
163
167
|
"linux": {
|
|
164
168
|
"target": "AppImage",
|
|
165
|
-
"icon": "electron/icon.png"
|
|
169
|
+
"icon": "electron/icon-onbuzz.png",
|
|
170
|
+
"artifactName": "Loxia-OnBuzz-${version}.${ext}"
|
|
166
171
|
}
|
|
167
172
|
},
|
|
168
173
|
"pkg": {
|
package/scripts/bump-version.js
CHANGED
|
@@ -5,10 +5,25 @@
|
|
|
5
5
|
* Bumps version across ALL files that track it, commits, tags, and pushes.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
|
-
* node scripts/bump-version.js patch
|
|
9
|
-
* node scripts/bump-version.js minor
|
|
10
|
-
* node scripts/bump-version.js major
|
|
11
|
-
* node scripts/bump-version.js 3.8.0
|
|
8
|
+
* node scripts/bump-version.js patch → 3.6.0 → 3.6.1
|
|
9
|
+
* node scripts/bump-version.js minor → 3.6.0 → 3.7.0
|
|
10
|
+
* node scripts/bump-version.js major → 3.6.0 → 4.0.0
|
|
11
|
+
* node scripts/bump-version.js 3.8.0 → explicit version
|
|
12
|
+
* node scripts/bump-version.js --status → print the version truth table
|
|
13
|
+
* (package files, latest git tag,
|
|
14
|
+
* npm latest) and exit
|
|
15
|
+
* node scripts/bump-version.js <v> --dry-run
|
|
16
|
+
* → run every guard + print the plan,
|
|
17
|
+
* write/commit/push NOTHING
|
|
18
|
+
*
|
|
19
|
+
* Guards (all run BEFORE any file is written):
|
|
20
|
+
* - explicit versions must be exact semver (x.y.z or x.y.z-pre)
|
|
21
|
+
* - the target git tag must NOT already exist → hard fail
|
|
22
|
+
* - the target version must NOT be on npm → hard fail
|
|
23
|
+
* These exist because a release run that half-succeeds (tag pushed, npm
|
|
24
|
+
* publish failed) leaves the ecosystem desynced — package.json said 5.4.7
|
|
25
|
+
* while the operator believed 5.4.5, and npm was missing 5.4.5/5.4.6
|
|
26
|
+
* entirely. Fail EARLY, before anything is mutated.
|
|
12
27
|
*/
|
|
13
28
|
|
|
14
29
|
import fs from 'fs';
|
|
@@ -18,6 +33,7 @@ import { fileURLToPath } from 'url';
|
|
|
18
33
|
|
|
19
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
35
|
const ROOT = path.resolve(__dirname, '..');
|
|
36
|
+
const NPM_PKG = '@loxia-labs/loxia-autopilot-one';
|
|
21
37
|
|
|
22
38
|
// ─── All files that contain a version to sync ──────────────────────────────
|
|
23
39
|
|
|
@@ -46,6 +62,8 @@ function writeVersion(relPath, newVersion) {
|
|
|
46
62
|
|
|
47
63
|
// ─── Bump logic ────────────────────────────────────────────────────────────
|
|
48
64
|
|
|
65
|
+
const EXPLICIT_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
66
|
+
|
|
49
67
|
function bumpVersion(current, type) {
|
|
50
68
|
const [major, minor, patch] = current.split('.').map(Number);
|
|
51
69
|
switch (type) {
|
|
@@ -53,24 +71,131 @@ function bumpVersion(current, type) {
|
|
|
53
71
|
case 'minor': return `${major}.${minor + 1}.0`;
|
|
54
72
|
case 'patch': return `${major}.${minor}.${patch + 1}`;
|
|
55
73
|
default:
|
|
56
|
-
// Explicit version string —
|
|
57
|
-
if (
|
|
74
|
+
// Explicit version string — must be EXACT semver, no trailing junk.
|
|
75
|
+
if (EXPLICIT_RE.test(type)) return type;
|
|
58
76
|
throw new Error(`Invalid bump type: "${type}". Use: patch, minor, major, or explicit version (e.g. 3.7.0)`);
|
|
59
77
|
}
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
// ─── Ecosystem truth (git tags + npm registry) ─────────────────────────────
|
|
81
|
+
|
|
82
|
+
function gitTagExists(version) {
|
|
83
|
+
try {
|
|
84
|
+
const out = execSync(`git tag -l v${version}`, { cwd: ROOT, stdio: 'pipe' }).toString().trim();
|
|
85
|
+
return out === `v${version}`;
|
|
86
|
+
} catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function latestGitTag() {
|
|
90
|
+
try {
|
|
91
|
+
return execSync('git tag -l "v*" --sort=-v:refname', { cwd: ROOT, stdio: 'pipe' })
|
|
92
|
+
.toString().split('\n')[0].trim() || '(none)';
|
|
93
|
+
} catch { return '(unavailable)'; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @returns {'exists'|'missing'|'unknown'} */
|
|
97
|
+
function npmVersionState(version) {
|
|
98
|
+
try {
|
|
99
|
+
const out = execSync(`npm view ${NPM_PKG}@${version} version`, { cwd: ROOT, stdio: 'pipe' })
|
|
100
|
+
.toString().trim();
|
|
101
|
+
return out === version ? 'exists' : 'missing';
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// npm view exits 1 with E404 when the version isn't published — that's
|
|
104
|
+
// the GOOD case. Anything else (network down) is genuinely unknown.
|
|
105
|
+
const msg = String(err?.stderr || err?.message || '');
|
|
106
|
+
return msg.includes('E404') ? 'missing' : 'unknown';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function npmLatest() {
|
|
111
|
+
try {
|
|
112
|
+
return execSync(`npm view ${NPM_PKG} version`, { cwd: ROOT, stdio: 'pipe' }).toString().trim();
|
|
113
|
+
} catch { return '(unavailable)'; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Status panel ──────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function printStatus() {
|
|
119
|
+
const pkgV = readVersion('package.json');
|
|
120
|
+
let tplV = '(missing)';
|
|
121
|
+
try { tplV = readVersion('package.autopilot.json'); } catch { /* absent */ }
|
|
122
|
+
const tag = latestGitTag();
|
|
123
|
+
const npmV = npmLatest();
|
|
124
|
+
|
|
125
|
+
const sync = pkgV === tplV ? 'in sync' : '⚠ OUT OF SYNC';
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log(' ── Version truth table ─────────────────────────');
|
|
128
|
+
console.log(` package.json : ${pkgV}`);
|
|
129
|
+
console.log(` package.autopilot.json : ${tplV} (${sync})`);
|
|
130
|
+
console.log(` latest git tag : ${tag}`);
|
|
131
|
+
console.log(` npm latest (${NPM_PKG.split('/')[1]}) : ${npmV}`);
|
|
132
|
+
console.log(` next patch would be : ${bumpVersion(pkgV, 'patch')}`);
|
|
133
|
+
if (npmV !== '(unavailable)' && npmV !== pkgV) {
|
|
134
|
+
console.log(` ⚠ package.json (${pkgV}) != npm latest (${npmV}) — pick your target deliberately.`);
|
|
135
|
+
}
|
|
136
|
+
console.log(' ────────────────────────────────────────────────');
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
139
|
+
|
|
62
140
|
// ─── Main ──────────────────────────────────────────────────────────────────
|
|
63
141
|
|
|
64
|
-
const
|
|
142
|
+
const args = process.argv.slice(2);
|
|
143
|
+
const dryRun = args.includes('--dry-run');
|
|
144
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
145
|
+
const arg = positional[0];
|
|
146
|
+
|
|
147
|
+
if (args.includes('--status')) {
|
|
148
|
+
printStatus();
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
65
152
|
if (!arg) {
|
|
66
|
-
console.log('Usage: node scripts/bump-version.js <patch|minor|major|x.y.z>');
|
|
153
|
+
console.log('Usage: node scripts/bump-version.js <patch|minor|major|x.y.z> [--dry-run] | --status');
|
|
67
154
|
process.exit(1);
|
|
68
155
|
}
|
|
69
156
|
|
|
70
157
|
const currentVersion = readVersion('package.json');
|
|
71
|
-
|
|
158
|
+
let newVersion;
|
|
159
|
+
try {
|
|
160
|
+
newVersion = bumpVersion(currentVersion, arg);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`\n ❌ ${err.message}\n`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
72
165
|
|
|
73
|
-
|
|
166
|
+
// ── Guards: fail BEFORE mutating anything ──────────────────────────────────
|
|
167
|
+
|
|
168
|
+
if (gitTagExists(newVersion)) {
|
|
169
|
+
console.error(`\n ❌ Tag v${newVersion} already exists — this version was already cut.`);
|
|
170
|
+
console.error(` Pick a different version (check with: node scripts/bump-version.js --status)\n`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const npmState = npmVersionState(newVersion);
|
|
175
|
+
if (npmState === 'exists') {
|
|
176
|
+
console.error(`\n ❌ ${NPM_PKG}@${newVersion} is already published on npm — npm will reject a republish.`);
|
|
177
|
+
console.error(` Pick a different version (check with: node scripts/bump-version.js --status)\n`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
if (npmState === 'unknown') {
|
|
181
|
+
console.log(` ⚠ Could not verify npm registry (offline?) — continuing, but publish may fail if ${newVersion} exists.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(`\n Version bump: ${currentVersion} → ${newVersion}${dryRun ? ' [DRY RUN — nothing will be written]' : ''}\n`);
|
|
185
|
+
|
|
186
|
+
if (dryRun) {
|
|
187
|
+
for (const { file, independent } of VERSION_FILES) {
|
|
188
|
+
const fullPath = path.join(ROOT, file);
|
|
189
|
+
if (!fs.existsSync(fullPath)) { console.log(` ⚠ Would skip (not found): ${file}`); continue; }
|
|
190
|
+
const target = independent && EXPLICIT_RE.test(arg)
|
|
191
|
+
? bumpVersion(readVersion(file), 'patch')
|
|
192
|
+
: independent ? bumpVersion(readVersion(file), arg) : newVersion;
|
|
193
|
+
console.log(` → would set ${file}: ${readVersion(file)} → ${target}`);
|
|
194
|
+
}
|
|
195
|
+
console.log(` → would commit "v${newVersion}", tag v${newVersion}, push origin main --tags`);
|
|
196
|
+
console.log('\n ✅ All guards passed (dry run).\n');
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
74
199
|
|
|
75
200
|
// Update all files
|
|
76
201
|
for (const { file, independent } of VERSION_FILES) {
|
|
@@ -82,11 +207,11 @@ for (const { file, independent } of VERSION_FILES) {
|
|
|
82
207
|
|
|
83
208
|
let targetVersion = newVersion;
|
|
84
209
|
|
|
85
|
-
// web-ui has its own version scheme
|
|
210
|
+
// web-ui has its own version scheme. For patch/minor/major it takes the
|
|
211
|
+
// same bump TYPE; for an EXPLICIT main version it takes a patch bump —
|
|
212
|
+
// an explicit "5.4.8" must not stomp web-ui's independent numbering.
|
|
86
213
|
if (independent) {
|
|
87
|
-
|
|
88
|
-
const webNew = bumpVersion(webVersion, arg.match(/^\d/) ? arg : arg); // same bump type
|
|
89
|
-
targetVersion = webNew;
|
|
214
|
+
targetVersion = bumpVersion(readVersion(file), EXPLICIT_RE.test(arg) ? 'patch' : arg);
|
|
90
215
|
}
|
|
91
216
|
|
|
92
217
|
const old = writeVersion(file, targetVersion);
|
|
@@ -103,12 +228,9 @@ console.log(` ✓ Staged ${filesToStage.length} files`);
|
|
|
103
228
|
execSync(`git commit -m "v${newVersion}"`, { cwd: ROOT, stdio: 'pipe' });
|
|
104
229
|
console.log(` ✓ Committed: v${newVersion}`);
|
|
105
230
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} catch {
|
|
110
|
-
console.log(` ⚠ Tag v${newVersion} already exists, skipping`);
|
|
111
|
-
}
|
|
231
|
+
// Guard ran pre-flight, so a collision here means a race — surface it loudly.
|
|
232
|
+
execSync(`git tag v${newVersion}`, { cwd: ROOT, stdio: 'pipe' });
|
|
233
|
+
console.log(` ✓ Tagged: v${newVersion}`);
|
|
112
234
|
|
|
113
235
|
execSync(`git push origin main --tags`, { cwd: ROOT, stdio: 'pipe' });
|
|
114
236
|
console.log(` ✓ Pushed to origin/main with tags`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Brand swap for publishing: rewrite package.json as the OnBuzz identity
|
|
4
|
+
* (after snapshotting the current file to package.autopilot.json for restore).
|
|
5
|
+
*
|
|
6
|
+
* CRITICAL: the swap must cover the COMPLETE brand surface, including the
|
|
7
|
+
* electron-builder `build` section. The v5.4.8 release was built with a swap
|
|
8
|
+
* that changed only name/bin/description — the base config's hardcoded
|
|
9
|
+
* "Loxia-Autopilot-Setup-…" artifactName leaked into the OnBuzz build, both
|
|
10
|
+
* brands produced identically-named installers, the OnBuzz upload CLOBBERED
|
|
11
|
+
* the Autopilot one, and Autopilot updaters sha-failed (installed apps kept
|
|
12
|
+
* crash-looping). Any build that reads a swapped package.json must inherit
|
|
13
|
+
* brand-correct artifact names.
|
|
14
|
+
*
|
|
15
|
+
* Usage: node scripts/swap-brand.js onbuzz
|
|
16
|
+
* (Restore is a plain copy: package.autopilot.json → package.json.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const pkgPath = path.join(ROOT, 'package.json');
|
|
25
|
+
const backupPath = path.join(ROOT, 'package.autopilot.json');
|
|
26
|
+
|
|
27
|
+
const target = process.argv[2];
|
|
28
|
+
if (target !== 'onbuzz') {
|
|
29
|
+
console.error('Usage: node scripts/swap-brand.js onbuzz');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Snapshot the Autopilot identity for the post-publish restore — byte-exact
|
|
34
|
+
// copy (NOT a re-serialization, which shuffles key order and leaves phantom
|
|
35
|
+
// diffs after the restore).
|
|
36
|
+
fs.copyFileSync(pkgPath, backupPath);
|
|
37
|
+
|
|
38
|
+
const p = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
39
|
+
|
|
40
|
+
// npm identity
|
|
41
|
+
p.name = 'onbuzz';
|
|
42
|
+
p.bin = { onbuzz: './bin/cli.js' };
|
|
43
|
+
p.description = 'Loxia OnBuzz - Your AI Fleet';
|
|
44
|
+
delete p.scripts.prepublishOnly;
|
|
45
|
+
|
|
46
|
+
// electron-builder identity — keep EVERY brand-bearing field consistent so
|
|
47
|
+
// any electron build from this file produces OnBuzz-named, OnBuzz-branded
|
|
48
|
+
// artifacts that cannot collide with Autopilot's on the shared release.
|
|
49
|
+
if (p.build) {
|
|
50
|
+
p.build.appId = 'com.loxia.onbuzz';
|
|
51
|
+
p.build.productName = 'Loxia OnBuzz';
|
|
52
|
+
p.build.executableName = 'loxia-onbuzz';
|
|
53
|
+
p.build.nsis = { ...(p.build.nsis || {}), artifactName: 'Loxia-OnBuzz-Setup-${version}.${ext}' };
|
|
54
|
+
if (p.build.win) p.build.win.icon = 'electron/icon-onbuzz.png';
|
|
55
|
+
if (p.build.mac) {
|
|
56
|
+
p.build.mac.artifactName = 'Loxia-OnBuzz-${version}-${arch}.${ext}';
|
|
57
|
+
p.build.mac.icon = 'electron/icon-onbuzz.png';
|
|
58
|
+
}
|
|
59
|
+
if (p.build.linux) {
|
|
60
|
+
p.build.linux.artifactName = 'Loxia-OnBuzz-${version}.${ext}';
|
|
61
|
+
p.build.linux.icon = 'electron/icon-onbuzz.png';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fs.writeFileSync(pkgPath, JSON.stringify(p, null, 2) + '\n');
|
|
66
|
+
console.log(' name: ' + p.name + ' | nsis artifact: ' + p.build?.nsis?.artifactName);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Update-feed verifier — the guarantee that INSTALLED apps can always find
|
|
4
|
+
* and download the next version.
|
|
5
|
+
*
|
|
6
|
+
* How updates actually work: an installed app (any vintage, either naming
|
|
7
|
+
* era) fetches `<brand>.yml` from the LATEST GitHub release and downloads
|
|
8
|
+
* exactly the filename that yml names, from that same release. It never
|
|
9
|
+
* guesses names — so artifact renames between releases are safe IF AND ONLY
|
|
10
|
+
* IF each release's yml matches its real assets. Every updater incident so
|
|
11
|
+
* far was a violation of that contract:
|
|
12
|
+
* - ≤5.4.7: yml said hyphens, GitHub dot-sanitized the space-named
|
|
13
|
+
* uploads → 404 → installed apps crash-looped.
|
|
14
|
+
* - 5.4.8: onbuzz.yml pointed at the AUTOPILOT installer with a mismatched
|
|
15
|
+
* sha512 (brand artifactName collision) → OnBuzz updates dead.
|
|
16
|
+
*
|
|
17
|
+
* Checks, per brand (autopilot + onbuzz), against the live feed:
|
|
18
|
+
* 1. <brand>.yml exists on the latest release.
|
|
19
|
+
* 2. yml version matches the release tag.
|
|
20
|
+
* 3. Every files[].url names an asset that EXISTS on that release
|
|
21
|
+
* (byte-for-byte name match).
|
|
22
|
+
* 4. yml size matches the asset's actual size (catches wrong-file uploads
|
|
23
|
+
* even when the name matches — sha512 isn't exposed by the GitHub API,
|
|
24
|
+
* size is the practical proxy).
|
|
25
|
+
* 5. The two brands' installers are DISTINCT files (collision guard).
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node scripts/verify-update-feed.js → verify latest release
|
|
29
|
+
* node scripts/verify-update-feed.js v5.4.8 → verify a specific tag
|
|
30
|
+
*
|
|
31
|
+
* Exit 0 = feed healthy. Exit 1 = installed apps cannot update — fix the
|
|
32
|
+
* release BEFORE walking away.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const REPO = 'Loxia-ai/autopilot-releases';
|
|
36
|
+
const BRANDS = ['autopilot', 'onbuzz'];
|
|
37
|
+
|
|
38
|
+
async function getJson(url) {
|
|
39
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'loxia-feed-verify' } });
|
|
40
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getText(url) {
|
|
45
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'loxia-feed-verify' } });
|
|
46
|
+
if (!res.ok) return null;
|
|
47
|
+
return res.text();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Minimal yml field extraction — the electron-builder yml is flat enough. */
|
|
51
|
+
function parseYml(text) {
|
|
52
|
+
const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim();
|
|
53
|
+
const files = [];
|
|
54
|
+
// files: - url: NAME \n sha512: … \n size: N (block list)
|
|
55
|
+
const fileBlocks = text.matchAll(/-\s+url:\s*(.+)\n(?:\s+sha512:.*\n)?\s+size:\s*(\d+)/g);
|
|
56
|
+
for (const m of fileBlocks) files.push({ url: m[1].trim(), size: Number(m[2]) });
|
|
57
|
+
// fallback: top-level path (older shape) — no size guaranteed
|
|
58
|
+
const path = (text.match(/^path:\s*(.+)$/m) || [])[1]?.trim();
|
|
59
|
+
if (path && !files.some((f) => f.url === path)) files.push({ url: path, size: null });
|
|
60
|
+
return { version, files };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const argTag = process.argv[2];
|
|
64
|
+
const release = argTag
|
|
65
|
+
? await getJson(`https://api.github.com/repos/${REPO}/releases/tags/${argTag}`)
|
|
66
|
+
: await getJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
67
|
+
|
|
68
|
+
const tag = release.tag_name;
|
|
69
|
+
const tagVersion = tag.replace(/^v/, '');
|
|
70
|
+
const assets = new Map(release.assets.map((a) => [a.name, a]));
|
|
71
|
+
|
|
72
|
+
console.log(`\n Verifying update feed on ${tag} (${assets.size} assets)\n`);
|
|
73
|
+
|
|
74
|
+
const problems = [];
|
|
75
|
+
const brandInstallers = {};
|
|
76
|
+
|
|
77
|
+
for (const brand of BRANDS) {
|
|
78
|
+
const ymlName = `${brand}.yml`;
|
|
79
|
+
if (!assets.has(ymlName)) {
|
|
80
|
+
problems.push(`[${brand}] ${ymlName} MISSING from ${tag} — installed ${brand} apps see no update feed.`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const text = await getText(`https://github.com/${REPO}/releases/download/${tag}/${ymlName}`);
|
|
84
|
+
if (!text) { problems.push(`[${brand}] ${ymlName} exists but could not be fetched.`); continue; }
|
|
85
|
+
|
|
86
|
+
const { version, files } = parseYml(text);
|
|
87
|
+
if (version !== tagVersion) {
|
|
88
|
+
problems.push(`[${brand}] ${ymlName} says version ${version} but the release tag is ${tagVersion}.`);
|
|
89
|
+
}
|
|
90
|
+
if (files.length === 0) {
|
|
91
|
+
problems.push(`[${brand}] ${ymlName} lists no files — malformed yml.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
const asset = assets.get(f.url);
|
|
96
|
+
if (!asset) {
|
|
97
|
+
problems.push(`[${brand}] yml names "${f.url}" but NO asset with that exact name exists → updater 404s.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (f.size != null && asset.size !== f.size) {
|
|
101
|
+
problems.push(`[${brand}] "${f.url}": yml size ${f.size} != asset size ${asset.size} → WRONG FILE uploaded under this name (sha will fail).`);
|
|
102
|
+
}
|
|
103
|
+
if (f.url.endsWith('.exe')) brandInstallers[brand] = f.url;
|
|
104
|
+
console.log(` [${brand}] ${f.url} → asset ${asset ? 'found' : 'MISSING'}${f.size != null && asset ? (asset.size === f.size ? ', size OK' : ', SIZE MISMATCH') : ''}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Cross-brand collision: both ymls must not claim the same installer file.
|
|
109
|
+
if (brandInstallers.autopilot && brandInstallers.onbuzz
|
|
110
|
+
&& brandInstallers.autopilot === brandInstallers.onbuzz) {
|
|
111
|
+
problems.push(`BRAND COLLISION: autopilot.yml and onbuzz.yml both point at "${brandInstallers.autopilot}" — one brand's users get the other brand's app (or a sha failure).`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log('');
|
|
115
|
+
if (problems.length) {
|
|
116
|
+
console.error(` ❌ Update feed BROKEN on ${tag} — installed apps cannot update cleanly:\n`);
|
|
117
|
+
for (const p of problems) console.error(` • ${p}`);
|
|
118
|
+
console.error('');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log(` ✅ Update feed healthy on ${tag} — installed apps (all vintages, both brands) can find and download this version.\n`);
|
|
@@ -87,19 +87,39 @@ describe('saveJSON — atomic write', () => {
|
|
|
87
87
|
expect(mockUnlink).not.toHaveBeenCalled(); // ultimately succeeded — no cleanup
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// THE CRASH FIX. On Windows, rename over a file with an open read handle
|
|
91
|
+
// fails EPERM — and readers are constant (agent-list scans, UI polling).
|
|
92
|
+
// When retries exhaust, saveJSON must NOT reject: most persist calls are
|
|
93
|
+
// fire-and-forget, so the rejection would reach the global
|
|
94
|
+
// unhandledRejection handler, whose default branch process.exit(1)'s —
|
|
95
|
+
// that chain crashed the app at launch / during agent load. Instead it
|
|
96
|
+
// falls back to the pre-atomic DIRECT write (allowed alongside open read
|
|
97
|
+
// handles) and cleans the temp.
|
|
98
|
+
test('rename exhaustion under contention falls back to a direct write — NO rejection', async () => {
|
|
91
99
|
const sm = makeSM();
|
|
92
100
|
mockRename.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }));
|
|
93
|
-
|
|
94
|
-
expect(
|
|
101
|
+
|
|
102
|
+
await expect(sm.saveJSON('/mock/userdata/state/r2.json', { ok: 1 })).resolves.toBeUndefined();
|
|
103
|
+
|
|
104
|
+
expect(mockRename).toHaveBeenCalledTimes(8); // patient retry first
|
|
105
|
+
// Final write went DIRECTLY to the live path (the fallback)...
|
|
106
|
+
const lastWrite = mockWriteFile.mock.calls.at(-1);
|
|
107
|
+
expect(lastWrite[0]).toBe('/mock/userdata/state/r2.json');
|
|
108
|
+
expect(JSON.parse(lastWrite[1])).toEqual({ ok: 1 });
|
|
109
|
+
// ...and the orphaned temp was cleaned up.
|
|
95
110
|
expect(mockUnlink).toHaveBeenCalledTimes(1);
|
|
96
|
-
});
|
|
111
|
+
}, 20000); // real backoff timers (~5.5s) run in this test
|
|
97
112
|
|
|
98
|
-
test('a non-transient rename error is NOT retried', async () => {
|
|
113
|
+
test('a non-transient rename error is NOT retried, NOT fallback-written, and still throws', async () => {
|
|
99
114
|
const sm = makeSM();
|
|
100
115
|
mockRename.mockRejectedValue(Object.assign(new Error('nospace'), { code: 'ENOSPC' }));
|
|
101
116
|
await expect(sm.saveJSON('/mock/userdata/state/r3.json', {})).rejects.toThrow('nospace');
|
|
102
117
|
expect(mockRename).toHaveBeenCalledTimes(1);
|
|
118
|
+
// No direct write to the live path — a real error must stay visible.
|
|
119
|
+
for (const [p] of mockWriteFile.mock.calls) {
|
|
120
|
+
expect(p).not.toBe('/mock/userdata/state/r3.json');
|
|
121
|
+
}
|
|
122
|
+
expect(mockUnlink).toHaveBeenCalledTimes(1); // temp cleaned
|
|
103
123
|
});
|
|
104
124
|
});
|
|
105
125
|
|
package/src/core/stateManager.js
CHANGED
|
@@ -1278,18 +1278,46 @@ class StateManager {
|
|
|
1278
1278
|
await fs.writeFile(tmpPath, contents, 'utf8');
|
|
1279
1279
|
await this._renameWithRetry(tmpPath, filePath);
|
|
1280
1280
|
} catch (err) {
|
|
1281
|
-
|
|
1281
|
+
const transient = err && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY');
|
|
1282
|
+
if (transient) {
|
|
1283
|
+
// SHARING-VIOLATION FALLBACK — this branch is load-bearing. On
|
|
1284
|
+
// Windows, rename over a file that ANY reader holds open fails with
|
|
1285
|
+
// EPERM, and readers are constant here (agent-list scans, snippet
|
|
1286
|
+
// enrichment, UI polling). When retries exhaust under sustained
|
|
1287
|
+
// contention, DO NOT throw: most persist calls are fire-and-forget,
|
|
1288
|
+
// so the rejection reaches process.on('unhandledRejection') — whose
|
|
1289
|
+
// default branch calls process.exit(1). That exact chain crashed the
|
|
1290
|
+
// app "sometimes at launch, sometimes while loading an agent".
|
|
1291
|
+
// Fall back to the pre-atomic direct write instead: Windows allows
|
|
1292
|
+
// in-place writes alongside open read handles, and our per-path
|
|
1293
|
+
// write chain already guarantees no same-file write interleaving.
|
|
1294
|
+
// Trade-off: this one write loses crash-atomicity — strictly no
|
|
1295
|
+
// worse than the original implementation, and only under contention.
|
|
1296
|
+
this.logger?.warn?.(
|
|
1297
|
+
`Atomic rename blocked by open handles — falling back to direct write: ${path.basename(filePath)}`,
|
|
1298
|
+
{ filePath, code: err.code }
|
|
1299
|
+
);
|
|
1300
|
+
try {
|
|
1301
|
+
await fs.writeFile(filePath, contents, 'utf8');
|
|
1302
|
+
return;
|
|
1303
|
+
} finally {
|
|
1304
|
+
try { await fs.unlink(tmpPath); } catch { /* already gone */ }
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// Non-transient (ENOSPC, EROFS, …): clean up and surface the real error.
|
|
1282
1308
|
try { await fs.unlink(tmpPath); } catch { /* already gone */ }
|
|
1283
1309
|
throw err;
|
|
1284
1310
|
}
|
|
1285
1311
|
}
|
|
1286
1312
|
|
|
1287
1313
|
/**
|
|
1288
|
-
* fs.rename with
|
|
1289
|
-
* errors that occur when a replace races an open read handle.
|
|
1314
|
+
* fs.rename with retries for the transient Windows sharing-violation
|
|
1315
|
+
* errors that occur when a replace races an open read handle. Backoff is
|
|
1316
|
+
* patient (~9s worst case) because big conversation files (tens of MB)
|
|
1317
|
+
* can legitimately hold read handles for seconds during agent load.
|
|
1290
1318
|
* @private
|
|
1291
1319
|
*/
|
|
1292
|
-
async _renameWithRetry(from, to, attempts =
|
|
1320
|
+
async _renameWithRetry(from, to, attempts = 8) {
|
|
1293
1321
|
for (let i = 0; i < attempts; i++) {
|
|
1294
1322
|
try {
|
|
1295
1323
|
await fs.rename(from, to);
|
|
@@ -1297,7 +1325,8 @@ class StateManager {
|
|
|
1297
1325
|
} catch (err) {
|
|
1298
1326
|
const transient = err && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY');
|
|
1299
1327
|
if (!transient || i === attempts - 1) throw err;
|
|
1300
|
-
|
|
1328
|
+
// 40ms, 120ms, 270ms, 520ms, 900ms, 1.4s, 2.2s → ~5.5s cumulative.
|
|
1329
|
+
await new Promise((r) => setTimeout(r, 40 * (i + 1) * (i + 1) * 0.75 + 10));
|
|
1301
1330
|
}
|
|
1302
1331
|
}
|
|
1303
1332
|
}
|
package/src/index.js
CHANGED
|
@@ -1012,7 +1012,16 @@ class LoxiaApplication {
|
|
|
1012
1012
|
'ECONNRESET',
|
|
1013
1013
|
'fetch failed',
|
|
1014
1014
|
'AbortError',
|
|
1015
|
-
'The operation was aborted'
|
|
1015
|
+
'The operation was aborted',
|
|
1016
|
+
// electron-updater failures (packaged app; backend shares the
|
|
1017
|
+
// process). A broken/missing release asset must NEVER kill the app:
|
|
1018
|
+
// v5.4.7's yml was published without its .exe and every installed
|
|
1019
|
+
// 5.4.5 crash-looped ~40s after launch on exactly this rejection.
|
|
1020
|
+
// The updater retries on its 6h cycle; crashing helps nobody.
|
|
1021
|
+
'Cannot download',
|
|
1022
|
+
'ERR_UPDATER',
|
|
1023
|
+
'HttpError: 404',
|
|
1024
|
+
'builder-util-runtime'
|
|
1016
1025
|
];
|
|
1017
1026
|
|
|
1018
1027
|
const isNonCritical = nonCriticalPatterns.some(pattern =>
|