@ailuntz/minilink 0.8.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/src/update.mjs ADDED
@@ -0,0 +1,119 @@
1
+ import { readFileSync, writeFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
2
+ import { join, resolve, dirname } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { spawn, execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { PACKAGE_NAME, VERSION, codexHome, atomicJSON } from './config.mjs';
7
+ import { setting } from './legacy.mjs';
8
+ import { installation, installRoot, updateInProgress } from './install.mjs';
9
+
10
+ const name = PACKAGE_NAME, registry = 'https://registry.npmjs.org/';
11
+ const interval = 24 * 60 * 60 * 1000;
12
+ const version = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) && value.split('.').every(n => Number.isSafeInteger(Number(n)));
13
+ const disabled = () => Boolean(setting('NO_UPDATE_CHECK') || process.env.CI || process.env.NODE_TEST_CONTEXT);
14
+ const pathFor = home => join(home, 'minilink-update.json');
15
+ const execute = promisify(execFile);
16
+
17
+ export function isNewer(latest, current) {
18
+ if (!version(latest) || !version(current)) return false;
19
+ const left = latest.split('.').map(Number), right = current.split('.').map(Number);
20
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i];
21
+ return false;
22
+ }
23
+ function read(home) {
24
+ try { return JSON.parse(readFileSync(pathFor(home), 'utf8')); } catch { return {}; }
25
+ }
26
+ const due = (state, now) => !Number.isFinite(state.checkedAt) || now < state.checkedAt || now - state.checkedAt >= interval;
27
+ export function updateStatus(home = codexHome()) {
28
+ const state = read(home);
29
+ return { current: VERSION, latest: version(state.latest) ? state.latest : null, installed: version(state.installed) ? state.installed : null, checkedAt: state.checkedAt || null };
30
+ }
31
+
32
+ // Stage and validate before npm touches the installed package. npm verifies the
33
+ // archive integrity; the rollback archive is local and does not need a network.
34
+ export async function upgrade(latest, state, { root = installRoot(), exec = execute } = {}) {
35
+ if (!version(latest) || !state?.npm || !state?.runtime || !state?.prefix || !existsSync(state.source)) return false;
36
+ if (resolve(state.source) !== join(resolve(state.prefix), 'lib/node_modules', name)) return false;
37
+ const directory = mkdtempSync(join(root, '.update-'));
38
+ const env = { ...process.env, PATH: `${dirname(state.node)}:${process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin'}`, MINILINK_NO_UPDATE_CHECK: '1', CODEX_HOME: state.codexHome, MINILINK_CONFIG: state.configPath };
39
+ const npm = async args => (await exec(state.node, [state.npm, ...args, `--registry=${registry}`, '--no-audit', '--no-fund', '--logs-max=0', '--fetch-retries=0', '--fetch-timeout=15000'], { env, cwd: directory, timeout: 180000, maxBuffer: 1024 * 1024 })).stdout;
40
+ const pack = async spec => {
41
+ const result = JSON.parse(await npm(['pack', spec, '--ignore-scripts', '--json', '--pack-destination', directory]));
42
+ const filename = result[0]?.filename;
43
+ if (result[0]?.name !== name || !filename || filename !== filename.split('/').pop() || !filename.endsWith('.tgz')) throw new Error('Invalid package archive');
44
+ return join(directory, filename);
45
+ };
46
+ let rollback, installing = false;
47
+ try {
48
+ const candidate = await pack(`${name}@${latest}`);
49
+ const stage = join(directory, 'stage');
50
+ await npm(['install', '--prefix', stage, candidate, '--ignore-scripts', '--omit=dev', '--engine-strict']);
51
+ const source = join(stage, 'node_modules', name);
52
+ const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8'));
53
+ if (manifest.name !== name || manifest.version !== latest) throw new Error('Package version mismatch');
54
+ await exec(state.node, ['--input-type=module', '-e', `await import(${JSON.stringify(pathToFileURL(join(source, 'src/config.mjs')).href)}); await import(${JSON.stringify(pathToFileURL(join(source, 'src/proxy.mjs')).href)});`], { env, timeout: 15000 });
55
+ await exec(state.node, ['--check', join(source, 'bin/minilink.mjs')], { env, timeout: 15000 });
56
+ const backup = join(directory, 'rollback');
57
+ cpSync(state.runtime, backup, { recursive: true });
58
+ const original = JSON.parse(readFileSync(join(backup, 'package.json'), 'utf8'));
59
+ atomicJSON(join(backup, 'package.json'), { ...original, bundledDependencies: Object.keys(original.dependencies || {}) });
60
+ rollback = await pack(backup);
61
+ // A manual uninstall during the download must not be undone by the worker.
62
+ if (!existsSync(state.source) || installation(root)?.installedAt !== state.installedAt) return false;
63
+ installing = true;
64
+ await npm(['install', '-g', '--prefix', state.prefix, candidate, '--offline', '--engine-strict', '--ignore-scripts=false']);
65
+ const active = installation(root);
66
+ if (!active?.runtime || JSON.parse(readFileSync(join(active.runtime, 'package.json'), 'utf8')).version !== latest) throw new Error('Desktop activation failed');
67
+ return true;
68
+ } catch {
69
+ if (installing && rollback) {
70
+ try { await npm(['install', '-g', '--prefix', state.prefix, rollback, '--offline', '--ignore-scripts']); } catch { /* The independent Desktop runtime remains usable. */ }
71
+ }
72
+ return false;
73
+ } finally { rmSync(directory, { recursive: true, force: true }); }
74
+ }
75
+
76
+ export async function checkForUpdates({ root = installRoot(), home = codexHome(), fetcher = fetch, now = Date.now(), skip = disabled(), state = installation(root), apply = upgrade } = {}) {
77
+ const cached = read(home);
78
+ if (skip || !state?.npm || !due(cached, now)) return updateStatus(home);
79
+ const lock = join(root, 'update.lock');
80
+ let owned = false;
81
+ try {
82
+ mkdirSync(root, { recursive: true, mode: 0o700 });
83
+ // Only the serial login agent removes abandoned locks. Workers never
84
+ // unlink a competitor's lock between checking it and taking ownership.
85
+ writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 }); owned = true;
86
+ if (!due(read(home), now)) return updateStatus(home);
87
+ atomicJSON(pathFor(home), { ...cached, checkedAt: now });
88
+ const response = await fetcher(`${registry}${encodeURIComponent(name)}/latest`, {
89
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(2500), redirect: 'error'
90
+ });
91
+ if (!response.ok) { await response.body?.cancel(); return updateStatus(home); }
92
+ const chunks = []; let size = 0;
93
+ for await (const chunk of response.body) { size += chunk.length; if (size > 65536) throw new Error('Oversized registry response'); chunks.push(chunk); }
94
+ const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8'));
95
+ if (manifest.name !== name || !version(manifest.version)) return updateStatus(home);
96
+ const next = { ...cached, checkedAt: now, latest: manifest.version };
97
+ atomicJSON(pathFor(home), next);
98
+ const current = JSON.parse(readFileSync(join(state.runtime, 'package.json'), 'utf8')).version;
99
+ if (isNewer(manifest.version, current) && await apply(manifest.version, state, { root })) atomicJSON(pathFor(home), { ...next, installed: manifest.version });
100
+ } catch { /* Offline, permissions, registry and install failures stay silent. */ }
101
+ finally { if (owned) rmSync(lock, { force: true }); }
102
+ return updateStatus(home);
103
+ }
104
+
105
+ export function startUpdate() {
106
+ if (disabled()) return;
107
+ try {
108
+ const state = installation();
109
+ if (!state?.npm || !state.runtime || !existsSync(state.source) || !due(read(state.codexHome), Date.now()) || updateInProgress()) return;
110
+ const child = spawn(state.node, [join(state.runtime, 'src/update.mjs')], {
111
+ detached: true, stdio: 'ignore', env: { ...process.env, CODEX_HOME: state.codexHome }
112
+ });
113
+ child.on('error', () => {}); child.unref();
114
+ } catch { /* Startup must not depend on updates. */ }
115
+ }
116
+
117
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
118
+ try { await checkForUpdates(); } catch { /* A background worker never logs errors. */ }
119
+ }