codex-ringside 0.1.1 → 0.3.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/CHANGELOG.md +16 -0
- package/README.md +82 -47
- package/README.zh-CN.md +90 -48
- package/bin/codex-ringside.cjs +23 -1
- package/native/bridge.swift +4 -0
- package/package.json +9 -3
- package/src/agent.cjs +3 -0
- package/src/card.js +258 -40
- package/src/install.cjs +18 -6
- package/src/launchd.cjs +15 -1
- package/src/limits.cjs +30 -2
- package/src/standalone.cjs +30 -0
- package/src/update.cjs +271 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
// A manifest is only shipped in a release bundle, never in the npm source package.
|
|
7
|
+
function inspectBundle(root) {
|
|
8
|
+
const file = path.join(root, 'standalone.json');
|
|
9
|
+
if (!fs.existsSync(file)) return null;
|
|
10
|
+
const info = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
if (info.format !== 1 || info.platform !== process.platform || info.arch !== process.arch
|
|
12
|
+
|| info.version !== JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version) {
|
|
13
|
+
throw new Error('一键安装包的平台、架构或版本不匹配,请重新下载');
|
|
14
|
+
}
|
|
15
|
+
const node = path.join(root, 'runtime/node');
|
|
16
|
+
const version = execFileSync(node, ['--version'], { encoding: 'utf8', timeout: 10000 }).trim();
|
|
17
|
+
if (version !== `v${info.nodeVersion}` || Number(info.nodeVersion.split('.')[0]) < 22) throw new Error('安装包内 Node 版本不匹配');
|
|
18
|
+
// A side-effect-free command confirms the helper can load on this macOS version.
|
|
19
|
+
const helper = JSON.parse(execFileSync(path.join(root, 'bridge'), ['version'], { encoding: 'utf8', timeout: 10000 }));
|
|
20
|
+
if (helper.protocol !== 1) throw new Error('安装包内 helper 版本不匹配');
|
|
21
|
+
return info;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function copyBundle(root, stage) {
|
|
25
|
+
fs.cpSync(path.join(root, 'runtime'), path.join(stage, 'runtime'), { recursive: true });
|
|
26
|
+
for (const name of ['bridge', 'standalone.json']) fs.copyFileSync(path.join(root, name), path.join(stage, name));
|
|
27
|
+
fs.chmodSync(path.join(stage, 'bridge'), 0o755);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { inspectBundle, copyBundle };
|
package/src/update.cjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { execFile } = require('node:child_process');
|
|
7
|
+
const { promisify } = require('node:util');
|
|
8
|
+
const launchd = require('./launchd.cjs');
|
|
9
|
+
const installer = require('./install.cjs');
|
|
10
|
+
const { inspectBundle } = require('./standalone.cjs');
|
|
11
|
+
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
const REPOSITORY = 'tccpc/codex-ringside';
|
|
14
|
+
const RELEASE_API = `https://api.github.com/repos/${REPOSITORY}/releases/latest`;
|
|
15
|
+
const CHECK_MS = 6 * 60 * 60 * 1000;
|
|
16
|
+
const RETRY_MS = 60 * 60 * 1000;
|
|
17
|
+
const SCAN_MS = 60000;
|
|
18
|
+
const STATE_FILE = 'update-state.json';
|
|
19
|
+
|
|
20
|
+
function readJson(file) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
22
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeJson(file, value) {
|
|
26
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
29
|
+
fs.renameSync(temp, file);
|
|
30
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readState(root) {
|
|
34
|
+
try { return readJson(path.join(root, STATE_FILE)) ?? {}; }
|
|
35
|
+
catch { return {}; } // A damaged diagnostic file must not disable updates forever.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function installation(root) {
|
|
39
|
+
const file = path.join(root, 'settings.json');
|
|
40
|
+
const settings = readJson(file);
|
|
41
|
+
const pkg = readJson(path.join(root, 'package.json'));
|
|
42
|
+
if (!settings || pkg?.name !== 'codex-ringside') throw Error('尚未安装,请先运行 install');
|
|
43
|
+
const stat = fs.statSync(file);
|
|
44
|
+
return { settings, version: pkg.version, stamp: `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function versionParts(value) {
|
|
48
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
49
|
+
if (!match) throw Error(`无效的版本号:${value}`);
|
|
50
|
+
return { numbers: match.slice(1, 4).map(BigInt), prerelease: !!match[4] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The remote channel only accepts stable versions; a matching stable beats its RC.
|
|
54
|
+
function isNewer(latest, current) {
|
|
55
|
+
const a = versionParts(latest);
|
|
56
|
+
const b = versionParts(current);
|
|
57
|
+
if (a.prerelease) return false;
|
|
58
|
+
for (let i = 0; i < 3; i++) {
|
|
59
|
+
if (a.numbers[i] !== b.numbers[i]) return a.numbers[i] > b.numbers[i];
|
|
60
|
+
}
|
|
61
|
+
return b.prerelease;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function curl(url, destination, signal) {
|
|
65
|
+
const args = ['--fail', '--location', '--silent', '--show-error', '--retry', '2',
|
|
66
|
+
'--connect-timeout', '20', '--max-time', destination ? '600' : '30',
|
|
67
|
+
'--max-filesize', String(destination ? 256 * 1024 * 1024 : 1024 * 1024),
|
|
68
|
+
'--proto', '=https', '--proto-redir', '=https', '--user-agent', 'codex-ringside-updater'];
|
|
69
|
+
if (destination) args.push('--output', destination);
|
|
70
|
+
else args.push('--header', 'Accept: application/vnd.github+json');
|
|
71
|
+
args.push(url);
|
|
72
|
+
try {
|
|
73
|
+
const { stdout } = await run('/usr/bin/curl', args, {
|
|
74
|
+
encoding: 'utf8', timeout: destination ? 1900000 : 120000, maxBuffer: 1024 * 1024, signal
|
|
75
|
+
});
|
|
76
|
+
return stdout;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw Error(`更新下载失败,请检查网络或稍后重试(${error.code ?? 'timeout'})`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchRelease(signal) {
|
|
83
|
+
return JSON.parse(await curl(RELEASE_API, undefined, signal));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function releaseInfo(release, arch = process.arch) {
|
|
87
|
+
if (release?.draft || release?.prerelease) return null;
|
|
88
|
+
if (!/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(release?.tag_name ?? '')) {
|
|
89
|
+
throw Error('远程发行版不是有效的正式版本');
|
|
90
|
+
}
|
|
91
|
+
if (!['arm64', 'x64'].includes(arch)) throw Error('不支持的芯片架构');
|
|
92
|
+
const version = release.tag_name.slice(1);
|
|
93
|
+
const asset = `codex-ringside-v${version}-darwin-${arch}.tar.gz`;
|
|
94
|
+
const base = `https://github.com/${REPOSITORY}/releases/download/v${version}`;
|
|
95
|
+
return { version, asset, base, assets: release.assets };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checksum(manifest, asset) {
|
|
99
|
+
const matches = manifest.split(/\r?\n/).map(line => /^([a-fA-F0-9]{64})\s+\*?(.+)$/.exec(line))
|
|
100
|
+
.filter(match => match?.[2] === asset);
|
|
101
|
+
if (matches.length !== 1) throw Error('发行版缺少唯一的安装包 SHA-256 校验值');
|
|
102
|
+
return matches[0][1].toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function digest(file) {
|
|
106
|
+
const hash = crypto.createHash('sha256');
|
|
107
|
+
for await (const bytes of fs.createReadStream(file)) hash.update(bytes);
|
|
108
|
+
return hash.digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function extractBundle(archive, work, signal) {
|
|
112
|
+
const options = { encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024, signal };
|
|
113
|
+
const { stdout: names } = await run('/usr/bin/tar', ['-tzf', archive], options);
|
|
114
|
+
const { stdout: entries } = await run('/usr/bin/tar', ['-tvzf', archive], options);
|
|
115
|
+
const safePath = name => name.startsWith('codex-ringside/')
|
|
116
|
+
&& !name.includes('\\') && !name.split('/').some(part => part === '..' || part === '.');
|
|
117
|
+
if (!names.trim() || !names.trimEnd().split('\n').every(safePath)
|
|
118
|
+
|| !entries.trimEnd().split('\n').every(line => /^[-d]/.test(line))) {
|
|
119
|
+
throw Error('安装包包含不安全的路径或链接');
|
|
120
|
+
}
|
|
121
|
+
await run('/usr/bin/tar', ['-xzf', archive, '--no-same-owner', '--no-same-permissions', '-C', work], options);
|
|
122
|
+
return path.join(work, 'codex-ringside');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function prepareBundle(release, work, { download = curl, inspect = inspectBundle, signal } = {}) {
|
|
126
|
+
for (const name of [release.asset, 'SHA256SUMS.txt']) {
|
|
127
|
+
if (!release.assets?.some(asset => asset.name === name && asset.state === 'uploaded')) {
|
|
128
|
+
throw Error(`发行版缺少安装文件:${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const sums = path.join(work, 'SHA256SUMS.txt');
|
|
132
|
+
const archive = path.join(work, release.asset);
|
|
133
|
+
await download(`${release.base}/SHA256SUMS.txt`, sums, signal);
|
|
134
|
+
const expected = checksum(fs.readFileSync(sums, 'utf8'), release.asset);
|
|
135
|
+
await download(`${release.base}/${release.asset}`, archive, signal);
|
|
136
|
+
if (await digest(archive) !== expected) throw Error('安装包 SHA-256 校验失败,未更改现有安装');
|
|
137
|
+
const payload = await extractBundle(archive, work, signal);
|
|
138
|
+
const pkg = readJson(path.join(payload, 'package.json'));
|
|
139
|
+
if (pkg?.name !== 'codex-ringside' || pkg.version !== release.version) throw Error('安装包版本不匹配');
|
|
140
|
+
const bundle = inspect(payload);
|
|
141
|
+
if (!bundle || bundle.version !== release.version) throw Error('安装包缺少有效的独立运行时');
|
|
142
|
+
return payload;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function updateStatus(root = launchd.paths.installDir) {
|
|
146
|
+
const installed = installation(root);
|
|
147
|
+
return { ...readState(root), enabled: installed.settings.autoUpdate !== false,
|
|
148
|
+
currentVersion: installed.version, source: RELEASE_API, checkIntervalHours: 6 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function setAutoUpdate(enabled, root = launchd.paths.installDir) {
|
|
152
|
+
await installer.withLock({ installDir: root }, async () => {
|
|
153
|
+
const { settings } = installation(root);
|
|
154
|
+
writeJson(path.join(root, 'settings.json'), { ...settings, autoUpdate: enabled });
|
|
155
|
+
if (enabled) writeJson(path.join(root, STATE_FILE), { ...readState(root), nextCheckAt: 0 });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runUpdate({ root = launchd.paths.installDir, automatic = false, checkOnly = false,
|
|
160
|
+
getRelease = fetchRelease, prepare = prepareBundle, install = installer.install, now = Date.now, signal } = {}) {
|
|
161
|
+
// A separate lock keeps manual checks and automatic downloads from running together.
|
|
162
|
+
return installer.withLock({ installDir: `${root}.update` }, async () => {
|
|
163
|
+
const initial = installation(root);
|
|
164
|
+
const beforeInstall = () => {
|
|
165
|
+
signal?.throwIfAborted();
|
|
166
|
+
const current = installation(root);
|
|
167
|
+
if (current.stamp !== initial.stamp || current.version !== initial.version
|
|
168
|
+
|| (automatic && current.settings.autoUpdate === false)) {
|
|
169
|
+
throw Error('安装或更新设置已变化,取消本次更新');
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const saved = readState(root);
|
|
173
|
+
if (automatic && (initial.settings.autoUpdate === false || (!isDue(saved, now()) && saved.status !== 'scheduled'))) {
|
|
174
|
+
return { status: 'skipped', currentVersion: initial.version };
|
|
175
|
+
}
|
|
176
|
+
let state = { ...readState(root), status: 'checking', lastAttemptAt: now(),
|
|
177
|
+
nextCheckAt: now() + RETRY_MS, error: null, attemptId: crypto.randomUUID() };
|
|
178
|
+
const save = patch => {
|
|
179
|
+
state = { ...state, ...patch };
|
|
180
|
+
// Never recreate an uninstalled root.
|
|
181
|
+
if (fs.existsSync(path.join(root, 'settings.json'))) writeJson(path.join(root, STATE_FILE), state);
|
|
182
|
+
};
|
|
183
|
+
save({});
|
|
184
|
+
let work;
|
|
185
|
+
try {
|
|
186
|
+
const release = releaseInfo(await getRelease(signal));
|
|
187
|
+
beforeInstall();
|
|
188
|
+
save({ lastCheckedAt: now(), latestVersion: release?.version ?? null });
|
|
189
|
+
if (!release || !isNewer(release.version, initial.version)) {
|
|
190
|
+
save({ status: 'up-to-date', nextCheckAt: now() + CHECK_MS });
|
|
191
|
+
} else if (checkOnly) {
|
|
192
|
+
save({ status: 'available', nextCheckAt: now() });
|
|
193
|
+
} else {
|
|
194
|
+
save({ status: 'downloading' });
|
|
195
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ringside-update-'));
|
|
196
|
+
const payload = await prepare(release, work, { signal });
|
|
197
|
+
beforeInstall();
|
|
198
|
+
save({ status: 'installing' });
|
|
199
|
+
const result = await install({ repoRoot: payload, env: {}, beforeInstall });
|
|
200
|
+
save({ status: 'updated', installedVersion: release.version, updatedAt: now(),
|
|
201
|
+
backup: result.backup, nextCheckAt: now() + CHECK_MS });
|
|
202
|
+
}
|
|
203
|
+
return state;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (readState(root).attemptId === state.attemptId) {
|
|
206
|
+
save({ status: 'failed', error: error.message, nextCheckAt: now() + RETRY_MS });
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
} finally {
|
|
210
|
+
if (work) fs.rmSync(work, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isDue(state, now = Date.now()) {
|
|
216
|
+
// Clamp future timestamps so a clock correction cannot suppress updates indefinitely.
|
|
217
|
+
return !Number.isFinite(state.nextCheckAt) || state.nextCheckAt <= now || state.nextCheckAt > now + CHECK_MS;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function scheduleUpdate({ root = launchd.paths.installDir, node = process.execPath,
|
|
221
|
+
logDir = launchd.paths.logDir, services = launchd, now = Date.now } = {}) {
|
|
222
|
+
const current = installation(root);
|
|
223
|
+
if (current.settings.autoUpdate === false || !isDue(readState(root), now())) return false;
|
|
224
|
+
return installer.withLock({ installDir: root }, async () => {
|
|
225
|
+
if (installation(root).settings.autoUpdate === false || !isDue(readState(root), now())) return false;
|
|
226
|
+
// Do not bootout a worker that is still downloading or completing a rollback.
|
|
227
|
+
if (/\bpid = \d+/.test(services.printJob(launchd.UPDATE_LABEL) ?? '')) return false;
|
|
228
|
+
await services.bootout(launchd.UPDATE_LABEL);
|
|
229
|
+
const file = path.join(root, `${launchd.UPDATE_LABEL}.plist`);
|
|
230
|
+
const state = { ...readState(root), status: 'scheduled', nextCheckAt: now() + RETRY_MS, error: null };
|
|
231
|
+
writeJson(path.join(root, STATE_FILE), state);
|
|
232
|
+
try {
|
|
233
|
+
services.writePlist(launchd.updateConfig({ node, installDir: root, logDir }), file);
|
|
234
|
+
services.bootstrap(file);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
writeJson(path.join(root, STATE_FILE), { ...state, status: 'failed', error: error.message });
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function startAutoUpdater({ root, signal, log }) {
|
|
244
|
+
let running = false;
|
|
245
|
+
const tick = async () => {
|
|
246
|
+
if (running || signal.aborted) return;
|
|
247
|
+
running = true;
|
|
248
|
+
try { await scheduleUpdate({ root }); }
|
|
249
|
+
catch (error) { log(`update: ${error.message}`); }
|
|
250
|
+
finally { running = false; }
|
|
251
|
+
};
|
|
252
|
+
// Let installation finish its readiness check before launching the first update job.
|
|
253
|
+
const timer = setInterval(tick, SCAN_MS);
|
|
254
|
+
signal.addEventListener('abort', () => clearInterval(timer), { once: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { CHECK_MS, RETRY_MS, RELEASE_API, isNewer, isDue, checksum, releaseInfo,
|
|
258
|
+
extractBundle, prepareBundle, runUpdate, updateStatus, setAutoUpdate, scheduleUpdate, startAutoUpdater };
|
|
259
|
+
|
|
260
|
+
if (require.main === module) {
|
|
261
|
+
const abort = new AbortController();
|
|
262
|
+
const stop = () => abort.abort(Error('更新已取消'));
|
|
263
|
+
process.once('SIGTERM', stop);
|
|
264
|
+
process.once('SIGINT', stop);
|
|
265
|
+
runUpdate({ automatic: process.argv.includes('--auto'), signal: abort.signal }).then(result => {
|
|
266
|
+
console.log(`[${new Date().toISOString()}] update: ${result.status}`);
|
|
267
|
+
}).catch(error => {
|
|
268
|
+
console.error(`[${new Date().toISOString()}] update: ${error.message}`);
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
});
|
|
271
|
+
}
|