@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/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 latest version is resolved from the github.com releases redirect,
73
- // which is NOT subject to the API's 60 req/hour per-IP rate limit
74
- // (api.github.com regularly 403s on home networks that share an IP).
75
- // Release notes still come from the API, but only best-effort.
76
- // Cache the last successful check so failed requests can still report a
77
- // known latest version instead of "up to date".
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 found = await fetchLatestVersion();
94
- if (found && (found.version !== lastKnownLatest?.version || lastKnownLatest.notes === null)) {
95
- let notes = null;
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 updateAvailable = latest !== null && latest !== OPENBRIDGE_VERSION;
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
- releaseUrl: lastKnownLatest?.url ?? null,
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 arch = os.arch() === 'x64' ? 'amd64' : os.arch() === 'arm64' ? 'arm64' : os.arch();
158
- const currentDir = join(APP_VOLUME, 'current');
159
- const stagingDir = join(APP_VOLUME, 'staging');
160
- const previousDir = join(APP_VOLUME, 'previous');
161
- // Check volume is writable
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(stagingDir, { recursive: true });
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 latest version (github.com redirect, not rate-limited like the API)
178
- log.info('Self-update: fetching release info...');
179
- broadcast({ stage: 'downloading', progress: 0, message: 'Fetching release info...' });
180
- const found = await fetchLatestVersion();
181
- if (!found)
182
- throw new Error('Could not resolve the latest release from GitHub');
183
- const version = found.version;
184
- const assetName = `openbridge-v${version}-linux-${arch}.tar.gz`;
185
- const assetUrl = `https://github.com/nubisco/openbridge/releases/download/v${version}/${assetName}`;
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
- catch {
252
- /* ignore */
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
- // 5. Write version.json
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
- // 6. Restart: just exit; Docker's restart policy will bring us back
265
- // The entrypoint will see source:"self-update" in version.json and keep the updated files
266
- setTimeout(() => {
267
- process.exit(0);
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
- const { rmSync } = await import('fs');
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
- const currentDir = join(APP_VOLUME, 'current');
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(currentDir))
297
- renameSync(currentDir, rollbackDir);
298
- renameSync(previousDir, currentDir);
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() === 'x64' ? 'amd64' : 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
- // Restart
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 ──────────────────────────────────────────────────────────