delivery-update.mjs
92 lines · archify/bin/delivery-update.mjs at a406594
1import { spawn } from 'node:child_process';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
5const childPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'delivery-update-child.mjs');
6const DEADLINE_MS = 1_000;
7const MAX_OUTPUT_BYTES = 16 * 1_024;
9function unavailable(reason) {
10 return { status: 'unavailable', installedVersion: null, availableVersion: null,
11 releaseNotes: null, checkedAt: null, source: null, noticeRequired: false,
12 noticeText: null, reason };
13}
15function normalize(result) {
16 if (result?.status === 'current') return {
17 status: 'current', installedVersion: result.installedVersion,
18 availableVersion: result.availableVersion, releaseNotes: null,
19 checkedAt: result.checkedAt, source: result.source,
20 noticeRequired: false, noticeText: null,
21 };
22 if (result?.status !== 'update_available') {
23 return unavailable(result?.reason || (result?.status === 'silent' ? 'no-update' : 'invalid-result'));
24 }
25 const noticeRequired = result.noticeRequired !== false;
26 const cached = result.source === 'cache';
27 const noticeText = noticeRequired
28 ? `Archify ${result.severity === 'security' ? 'security update' : 'update'}: ${result.installedVersion} → ${result.latestVersion}. ${cached
29 ? `A previous check at ${result.checkedAt} found a newer release.`
30 : 'A newer release is available.'} Release notes: ${result.releaseNotes}. Would you like me to update Archify?`
31 : null;
32 return {
33 status: 'update_available',
34 installedVersion: result.installedVersion,
35 availableVersion: result.latestVersion,
36 releaseNotes: result.releaseNotes,
37 severity: result.severity,
38 summary: result.summary ?? null,
39 checkedAt: result.checkedAt,
40 source: result.source,
41 noticeRequired,
42 noticeText,
43 ...(result.eventKey ? { eventKey: result.eventKey } : {}),
44 ...(result.reason ? { reason: result.reason } : {}),
45 ...(result.suppressedUntil ? { suppressedUntil: result.suppressedUntil } : {}),
46 };
47}
49export function startDeliveryUpdateCheck({
50 env = process.env, deadlineMs = DEADLINE_MS, checkerPath = childPath,
51} = {}) {
52 if (env.ARCHIFY_UPDATE_CHECK_DISABLED === '1') return Promise.resolve(unavailable('disabled'));
53 return new Promise((resolve) => {
54 let child;
55 try {
56 const deadlineNs = process.hrtime.bigint() + BigInt(Math.floor(deadlineMs * 1_000_000));
57 child = spawn(process.execPath, [checkerPath, String(deadlineNs)], {
58 env,
59 stdio: ['ignore', 'pipe', 'ignore'],
60 windowsHide: true,
61 });
62 } catch {
63 resolve(unavailable('runtime-unavailable'));
64 return;
65 }
66 let output = '';
67 let timedOut = false;
68 let overflow = false;
69 const timer = setTimeout(() => {
70 timedOut = true;
71 child.kill('SIGKILL');
72 }, deadlineMs);
73 child.stdout.on('data', (chunk) => {
74 if (Buffer.byteLength(output) + chunk.length > MAX_OUTPUT_BYTES) {
75 overflow = true;
76 child.kill('SIGKILL');
77 } else output += chunk.toString('utf8');
78 });
79 child.on('error', () => {});
80 child.on('close', (code) => {
81 clearTimeout(timer);
82 if (overflow) return resolve(unavailable('invalid-result'));
83 // A synchronous renderer can delay both this callback and the parent's
84 // timer. A complete child result wins even if the timer fired meanwhile.
85 if (code === 0 && output.endsWith('\n')) {
86 try { return resolve(normalize(JSON.parse(output))); }
87 catch { return resolve(unavailable('invalid-result')); }
88 }
89 resolve(unavailable(timedOut || child.signalCode === 'SIGKILL' ? 'timeout' : 'check-failed'));
90 });
91 });
92}