@nubisco/openbridge 0.30.0 → 0.31.0
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/README.md +17 -2
- package/dist/__tests__/server.test.js +52 -8
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +36 -23
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +77 -167
- package/dist/server.js.map +1 -1
- package/dist/updater.d.ts +47 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +152 -0
- package/dist/updater.js.map +1 -0
- package/package.json +5 -5
- package/ui-dist/assets/index-C5plGA32.css +32 -0
- package/ui-dist/assets/{index-ZZYyAzBo.js → index-DLJby5vo.js} +11 -11
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-Q0sxZHIR.css +0 -32
package/dist/server.js
CHANGED
|
@@ -17,6 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
17
17
|
// Version: resolved in version.ts so the CLI can read it without loading this module.
|
|
18
18
|
import { APP_VOLUME, VERSION_FILE, OPENBRIDGE_VERSION } from './version.js';
|
|
19
19
|
export { OPENBRIDGE_VERSION };
|
|
20
|
+
import { CURRENT_DIR, PACKAGE_NAME, PREVIOUS_DIR, STAGING_DIR, detectInstall, fetchLatestVersion, fetchReleaseNotes, installedVersionAt, npmInstall, releaseUrl, } from './updater.js';
|
|
20
21
|
let updateProgress = { stage: 'idle' };
|
|
21
22
|
const updateListeners = new Set();
|
|
22
23
|
// Resolve UI dist: env override → npm layout (../ui-dist) → monorepo dev layout
|
|
@@ -69,67 +70,36 @@ nativeAccessoryUuid = null) {
|
|
|
69
70
|
};
|
|
70
71
|
});
|
|
71
72
|
// ─── Update check ─────────────────────────────────────────────────────────
|
|
72
|
-
// The
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// Release notes still come from
|
|
76
|
-
//
|
|
77
|
-
//
|
|
73
|
+
// The npm registry is the source of truth: it is the only place that knows
|
|
74
|
+
// what can actually be installed. A git tag can exist while the publish that
|
|
75
|
+
// followed it failed, and offering that version would promise an update that
|
|
76
|
+
// cannot complete. Release notes still come from GitHub, best-effort.
|
|
77
|
+
// The last successful check is cached so a network blip reports the known
|
|
78
|
+
// latest version rather than a confident "up to date".
|
|
78
79
|
let lastKnownLatest = null;
|
|
79
|
-
async function fetchLatestVersion() {
|
|
80
|
-
const res = await fetch('https://github.com/nubisco/openbridge/releases/latest', {
|
|
81
|
-
signal: AbortSignal.timeout(8000),
|
|
82
|
-
redirect: 'manual',
|
|
83
|
-
headers: { 'User-Agent': 'openbridge-daemon' },
|
|
84
|
-
});
|
|
85
|
-
const location = res.headers.get('location') ?? '';
|
|
86
|
-
const match = location.match(/\/releases\/tag\/v?([^/]+)$/);
|
|
87
|
-
if (!match)
|
|
88
|
-
return null;
|
|
89
|
-
return { version: decodeURIComponent(match[1]), url: location };
|
|
90
|
-
}
|
|
91
80
|
app.get('/api/updates/check', async () => {
|
|
92
81
|
try {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const res = await fetch('https://api.github.com/repos/nubisco/openbridge/releases/latest', {
|
|
98
|
-
signal: AbortSignal.timeout(8000),
|
|
99
|
-
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openbridge-daemon' },
|
|
100
|
-
});
|
|
101
|
-
if (res.ok)
|
|
102
|
-
notes = (await res.json()).body;
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
/* notes are cosmetic: never fail the check over them */
|
|
106
|
-
}
|
|
107
|
-
lastKnownLatest = { version: found.version, url: found.url, notes };
|
|
82
|
+
const version = await fetchLatestVersion();
|
|
83
|
+
if (version && (version !== lastKnownLatest?.version || lastKnownLatest.notes === null)) {
|
|
84
|
+
lastKnownLatest = { version, notes: await fetchReleaseNotes(version) };
|
|
108
85
|
}
|
|
109
86
|
}
|
|
110
87
|
catch {
|
|
111
88
|
// Network error, timeout, etc.: fall through to use lastKnownLatest
|
|
112
89
|
}
|
|
113
90
|
const latest = lastKnownLatest?.version ?? null;
|
|
114
|
-
const
|
|
115
|
-
// Detect if self-update is possible (volume is writable)
|
|
116
|
-
let updateMethod = 'manual';
|
|
117
|
-
try {
|
|
118
|
-
const testFile = join(APP_VOLUME, '.write-test');
|
|
119
|
-
writeFileSync(testFile, 'ok');
|
|
120
|
-
const { unlinkSync } = await import('fs');
|
|
121
|
-
unlinkSync(testFile);
|
|
122
|
-
updateMethod = 'self';
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
/* volume not writable: manual update only */
|
|
126
|
-
}
|
|
91
|
+
const install = detectInstall();
|
|
127
92
|
return {
|
|
128
93
|
current: OPENBRIDGE_VERSION,
|
|
129
94
|
latest,
|
|
130
|
-
updateAvailable,
|
|
131
|
-
updateMethod,
|
|
132
|
-
|
|
95
|
+
updateAvailable: latest !== null && latest !== OPENBRIDGE_VERSION,
|
|
96
|
+
updateMethod: install.canSelfUpdate ? 'self' : 'manual',
|
|
97
|
+
// What to run when we cannot do it ourselves, matched to how this
|
|
98
|
+
// particular deployment was installed.
|
|
99
|
+
updateCommand: install.command,
|
|
100
|
+
installMethod: install.method,
|
|
101
|
+
pinnedTo: install.pinnedTo,
|
|
102
|
+
releaseUrl: latest ? releaseUrl(latest) : null,
|
|
133
103
|
releaseNotes: lastKnownLatest?.notes ?? null,
|
|
134
104
|
};
|
|
135
105
|
});
|
|
@@ -150,17 +120,25 @@ nativeAccessoryUuid = null) {
|
|
|
150
120
|
ws.on('close', () => updateListeners.delete(listener));
|
|
151
121
|
});
|
|
152
122
|
// ─── Self-update apply ────────────────────────────────────────────────────
|
|
123
|
+
// Install the new version into a staging prefix, then swap directories and
|
|
124
|
+
// exit. Staging first means a failed download or a broken dependency tree
|
|
125
|
+
// never touches the copy currently running, and the previous tree is kept
|
|
126
|
+
// for rollback.
|
|
153
127
|
app.post('/api/updates/apply', async () => {
|
|
154
128
|
if (updateProgress.stage !== 'idle' && updateProgress.stage !== 'error') {
|
|
155
129
|
throw { statusCode: 409, message: 'Update already in progress' };
|
|
156
130
|
}
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
131
|
+
const install = detectInstall();
|
|
132
|
+
if (!install.canSelfUpdate) {
|
|
133
|
+
throw {
|
|
134
|
+
statusCode: 503,
|
|
135
|
+
message: install.pinnedTo !== null
|
|
136
|
+
? `This deployment is pinned to ${install.pinnedTo}. Change the pin and recreate the container to move version.`
|
|
137
|
+
: `This ${install.method} install cannot update itself. Run: ${install.command}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
162
140
|
try {
|
|
163
|
-
mkdirSync(
|
|
141
|
+
mkdirSync(STAGING_DIR, { recursive: true });
|
|
164
142
|
}
|
|
165
143
|
catch {
|
|
166
144
|
throw { statusCode: 503, message: 'Update volume not available. Mount /opt/openbridge as a Docker volume.' };
|
|
@@ -173,108 +151,58 @@ nativeAccessoryUuid = null) {
|
|
|
173
151
|
// Run update async: respond immediately
|
|
174
152
|
;
|
|
175
153
|
(async () => {
|
|
154
|
+
const { rmSync, renameSync } = await import('fs');
|
|
176
155
|
try {
|
|
177
|
-
// 1. Resolve
|
|
178
|
-
log.info('Self-update:
|
|
179
|
-
broadcast({ stage: 'downloading', progress: 0, message: '
|
|
180
|
-
const
|
|
181
|
-
if (!
|
|
182
|
-
throw new Error('Could not resolve the latest
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// 2. Download tarball
|
|
187
|
-
log.info(`Self-update: downloading ${assetName}...`);
|
|
188
|
-
broadcast({ stage: 'downloading', progress: 0.1, message: `Downloading v${version}...`, version });
|
|
189
|
-
const dlRes = await fetch(assetUrl, {
|
|
190
|
-
headers: { 'User-Agent': 'openbridge-daemon' },
|
|
191
|
-
redirect: 'follow',
|
|
192
|
-
});
|
|
193
|
-
if (dlRes.status === 404)
|
|
194
|
-
throw new Error(`Release asset ${assetName} not found. Your architecture (${arch}) may not be supported yet.`);
|
|
195
|
-
if (!dlRes.ok || !dlRes.body)
|
|
196
|
-
throw new Error(`Download failed: ${dlRes.status}`);
|
|
197
|
-
const tarballPath = join(APP_VOLUME, 'download.tar.gz');
|
|
198
|
-
const { createWriteStream } = await import('fs');
|
|
199
|
-
const { pipeline } = await import('stream/promises');
|
|
200
|
-
const { Readable } = await import('stream');
|
|
201
|
-
// Stream download with progress (size from response headers; 0 → indeterminate)
|
|
202
|
-
const totalSize = Number(dlRes.headers.get('content-length') ?? 0);
|
|
203
|
-
let downloaded = 0;
|
|
204
|
-
const progressStream = new (await import('stream')).Transform({
|
|
205
|
-
transform(chunk, _encoding, callback) {
|
|
206
|
-
downloaded += chunk.length;
|
|
207
|
-
if (totalSize > 0) {
|
|
208
|
-
const pct = Math.round((downloaded / totalSize) * 100) / 100;
|
|
209
|
-
broadcast({
|
|
210
|
-
stage: 'downloading',
|
|
211
|
-
progress: pct,
|
|
212
|
-
message: `Downloading v${version}... ${Math.round(pct * 100)}%`,
|
|
213
|
-
version,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
callback(null, chunk);
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
await pipeline(Readable.fromWeb(dlRes.body), progressStream, createWriteStream(tarballPath));
|
|
220
|
-
log.info(`Downloaded ${assetName} (${(downloaded / 1024 / 1024).toFixed(1)} MB)`);
|
|
221
|
-
// 3. Extract
|
|
222
|
-
broadcast({ stage: 'extracting', message: `Extracting v${version}...`, version });
|
|
223
|
-
// Clean staging
|
|
224
|
-
const { rmSync } = await import('fs');
|
|
225
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
226
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
227
|
-
await new Promise((resolve, reject) => {
|
|
228
|
-
const tar = spawn('tar', ['xzf', tarballPath, '-C', stagingDir]);
|
|
229
|
-
tar.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited with ${code}`))));
|
|
230
|
-
tar.on('error', reject);
|
|
231
|
-
});
|
|
232
|
-
// Clean up tarball
|
|
233
|
-
rmSync(tarballPath, { force: true });
|
|
234
|
-
log.info(`Extracted to staging directory`);
|
|
235
|
-
// 4. Swap: current → previous, staging → current
|
|
236
|
-
broadcast({ stage: 'swapping', message: 'Installing update...', version });
|
|
237
|
-
rmSync(previousDir, { recursive: true, force: true });
|
|
238
|
-
if (existsSync(currentDir)) {
|
|
239
|
-
const { renameSync } = await import('fs');
|
|
240
|
-
renameSync(currentDir, previousDir);
|
|
241
|
-
}
|
|
242
|
-
const { renameSync } = await import('fs');
|
|
243
|
-
renameSync(stagingDir, currentDir);
|
|
244
|
-
// Fix node-pty permissions
|
|
245
|
-
try {
|
|
246
|
-
const { execSync } = await import('child_process');
|
|
247
|
-
execSync(`find "${currentDir}/apps/daemon/node_modules" -name "spawn-helper" -exec chmod +x {} \\;`, {
|
|
248
|
-
stdio: 'ignore',
|
|
249
|
-
});
|
|
156
|
+
// 1. Resolve the target version from the registry
|
|
157
|
+
log.info('Self-update: resolving the latest version from npm...');
|
|
158
|
+
broadcast({ stage: 'downloading', progress: 0, message: 'Checking npm for the latest version...' });
|
|
159
|
+
const version = await fetchLatestVersion();
|
|
160
|
+
if (!version)
|
|
161
|
+
throw new Error('Could not resolve the latest version from the npm registry');
|
|
162
|
+
if (version === OPENBRIDGE_VERSION) {
|
|
163
|
+
broadcast({ stage: 'idle', message: `Already on v${version}` });
|
|
164
|
+
return;
|
|
250
165
|
}
|
|
251
|
-
|
|
252
|
-
|
|
166
|
+
// 2. Install into staging. npm reports no usable progress, so the bar
|
|
167
|
+
// stays indeterminate rather than inventing a percentage.
|
|
168
|
+
log.info(`Self-update: installing ${PACKAGE_NAME}@${version}...`);
|
|
169
|
+
broadcast({ stage: 'downloading', message: `Installing v${version} from npm...`, version });
|
|
170
|
+
rmSync(STAGING_DIR, { recursive: true, force: true });
|
|
171
|
+
mkdirSync(STAGING_DIR, { recursive: true });
|
|
172
|
+
await npmInstall(STAGING_DIR, version, (line) => {
|
|
173
|
+
if (line)
|
|
174
|
+
log.debug(`npm: ${line}`);
|
|
175
|
+
});
|
|
176
|
+
const staged = installedVersionAt(STAGING_DIR);
|
|
177
|
+
if (staged !== version) {
|
|
178
|
+
throw new Error(`Staged tree reports ${staged ?? 'no version'}, expected ${version}`);
|
|
253
179
|
}
|
|
254
|
-
//
|
|
180
|
+
// 3. Swap: current → previous, staging → current
|
|
181
|
+
broadcast({ stage: 'swapping', message: 'Installing update...', version });
|
|
182
|
+
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
|
|
183
|
+
if (existsSync(CURRENT_DIR))
|
|
184
|
+
renameSync(CURRENT_DIR, PREVIOUS_DIR);
|
|
185
|
+
renameSync(STAGING_DIR, CURRENT_DIR);
|
|
186
|
+
// 4. Record what is now on the volume, for version.ts and for rollback
|
|
255
187
|
writeFileSync(VERSION_FILE, JSON.stringify({
|
|
256
188
|
version,
|
|
257
|
-
arch,
|
|
189
|
+
arch: os.arch(),
|
|
258
190
|
source: 'self-update',
|
|
259
191
|
installedAt: new Date().toISOString(),
|
|
260
192
|
previousVersion: OPENBRIDGE_VERSION,
|
|
261
193
|
}, null, 2));
|
|
262
194
|
log.info(`Update to v${version} installed successfully: restarting...`);
|
|
263
195
|
broadcast({ stage: 'restarting', message: `Restarting with v${version}...`, version });
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}, 500);
|
|
196
|
+
// 5. Restart by exiting: the container restart policy brings us back on
|
|
197
|
+
// the swapped-in tree. Self-update is only offered where that policy
|
|
198
|
+
// exists, so this is not a way to end up stopped.
|
|
199
|
+
setTimeout(() => process.exit(0), 500);
|
|
269
200
|
}
|
|
270
201
|
catch (err) {
|
|
271
202
|
log.error(`Self-update failed: ${err.message}`);
|
|
272
203
|
broadcast({ stage: 'error', message: err.message });
|
|
273
|
-
// Clean up staging on failure
|
|
274
204
|
try {
|
|
275
|
-
|
|
276
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
277
|
-
rmSync(join(APP_VOLUME, 'download.tar.gz'), { force: true });
|
|
205
|
+
rmSync(STAGING_DIR, { recursive: true, force: true });
|
|
278
206
|
}
|
|
279
207
|
catch {
|
|
280
208
|
/* ignore */
|
|
@@ -285,44 +213,26 @@ nativeAccessoryUuid = null) {
|
|
|
285
213
|
});
|
|
286
214
|
// ─── Rollback ─────────────────────────────────────────────────────────────
|
|
287
215
|
app.post('/api/updates/rollback', async () => {
|
|
288
|
-
|
|
289
|
-
const previousDir = join(APP_VOLUME, 'previous');
|
|
290
|
-
if (!existsSync(previousDir)) {
|
|
216
|
+
if (!existsSync(PREVIOUS_DIR)) {
|
|
291
217
|
throw { statusCode: 404, message: 'No previous version available for rollback' };
|
|
292
218
|
}
|
|
293
219
|
const { rmSync, renameSync } = await import('fs');
|
|
294
220
|
const rollbackDir = join(APP_VOLUME, 'rollback-tmp');
|
|
221
|
+
// Read the version we are going back to before the swap destroys the record
|
|
222
|
+
const rolledBackVersion = installedVersionAt(PREVIOUS_DIR) ?? 'unknown';
|
|
295
223
|
// Swap: current → rollback-tmp, previous → current
|
|
296
|
-
if (existsSync(
|
|
297
|
-
renameSync(
|
|
298
|
-
renameSync(
|
|
224
|
+
if (existsSync(CURRENT_DIR))
|
|
225
|
+
renameSync(CURRENT_DIR, rollbackDir);
|
|
226
|
+
renameSync(PREVIOUS_DIR, CURRENT_DIR);
|
|
299
227
|
rmSync(rollbackDir, { recursive: true, force: true });
|
|
300
|
-
// Read version from rolled-back files
|
|
301
|
-
let rolledBackVersion = 'unknown';
|
|
302
|
-
try {
|
|
303
|
-
const vf = JSON.parse(readFileSync(VERSION_FILE, 'utf8'));
|
|
304
|
-
rolledBackVersion = vf.previousVersion ?? 'unknown';
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
/* ignore */
|
|
308
|
-
}
|
|
309
228
|
writeFileSync(VERSION_FILE, JSON.stringify({
|
|
310
229
|
version: rolledBackVersion,
|
|
311
|
-
arch: os.arch()
|
|
230
|
+
arch: os.arch(),
|
|
312
231
|
source: 'rollback',
|
|
313
232
|
installedAt: new Date().toISOString(),
|
|
314
233
|
}, null, 2));
|
|
315
234
|
log.info(`Rolled back to v${rolledBackVersion}: restarting...`);
|
|
316
|
-
|
|
317
|
-
setTimeout(() => {
|
|
318
|
-
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
319
|
-
detached: true,
|
|
320
|
-
stdio: 'inherit',
|
|
321
|
-
env: process.env,
|
|
322
|
-
});
|
|
323
|
-
child.unref();
|
|
324
|
-
process.exit(0);
|
|
325
|
-
}, 300);
|
|
235
|
+
setTimeout(() => process.exit(0), 300);
|
|
326
236
|
return { rollingBack: true, version: rolledBackVersion };
|
|
327
237
|
});
|
|
328
238
|
// ─── System info ──────────────────────────────────────────────────────────
|