@m2canvas/connect 0.5.0 → 0.7.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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `bridge --check-update` 的真实 IO 与命令入口(change acp-bridge-one-time-install D20、R7)。更新的判断都在 update.ts,这里只做外部调用:
3
+ * - 读清单、下载:一律 `/usr/bin/curl`。安装脚本也用 curl,两边代理行为相同 —— curl 读 HTTPS_PROXY / ALL_PROXY / NO_PROXY 与
4
+ * `~/.curlrc`,Node 的 fetch 默认不读代理变量。`-f`:HTTP 错误码当失败,不把错误页写成「下载好的文件」。
5
+ * - 解压:`/usr/bin/tar`(macOS 的 bsdtar 自带 xz;内容已按清单哈希校验过)。
6
+ * - 冒烟:用新版本自己的 Node 跑新版本的 `cli.js bridge --self-test`。
7
+ * 子进程环境由调用方给:生产用 env.ts 的白名单(代理与证书变量都在里面),不把宿主注入的变量带给 curl。
8
+ *
9
+ * 不提供清单地址、下载地址的覆盖参数或环境变量(design R8):测试经 UpdateDeps 注入,或直接对本模块的原语传本机地址。
10
+ */
11
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
12
+ import { home } from '../system.js';
13
+ import { buildAgentEnv } from './env.js';
14
+ import { combinedOutput, execCommand } from './exec.js';
15
+ import { installLayout } from './install-layout.js';
16
+ import { tail } from './precheck-parse.js';
17
+ import { checkForUpdate } from './update.js';
18
+ const CURL = '/usr/bin/curl';
19
+ const TAR = '/usr/bin/tar';
20
+ /** 单个文件的下载上限:Node 包约 27MB,慢网(约 100KB/s)也要下得完 */
21
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
22
+ const EXTRACT_TIMEOUT_MS = 2 * 60 * 1000;
23
+ /** 冒烟时限(D20 ④) */
24
+ const SELF_TEST_TIMEOUT_MS = 20_000;
25
+ /** update.log 超过这个大小先轮转成 `.1`(每次启动只写几行,很少触发) */
26
+ const UPDATE_LOG_MAX_BYTES = 1024 * 1024;
27
+ function failure(what, result) {
28
+ const reason = result.timedOut ? 'timed out' : result.code === null ? (result.error ?? 'did not start') : `exited ${result.code}`;
29
+ const output = tail(combinedOutput(result).trim());
30
+ return new Error(`${what} ${reason}${output ? `: ${output}` : ''}`);
31
+ }
32
+ export function createUpdateIO(options) {
33
+ const { exec, env } = options;
34
+ return {
35
+ async fetchText(url, timeoutMs) {
36
+ const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
37
+ const result = await exec(CURL, ['-fsSL', '--connect-timeout', '5', '--max-time', seconds, url], { env, timeoutMs: timeoutMs + 2_000, maxOutput: 256 * 1024 });
38
+ if (result.code !== 0)
39
+ throw failure('curl', result);
40
+ return result.stdout;
41
+ },
42
+ async download(url, dest) {
43
+ const seconds = String(DOWNLOAD_TIMEOUT_MS / 1000);
44
+ // 低于 10KB/s 持续 30 秒就放弃、换下一个源:官方源只有涓流速度时不能拖到总时限(review r1 R1-S4,与安装脚本相同)
45
+ const result = await exec(CURL, ['-fsSL', '--connect-timeout', '10', '--speed-limit', '10240', '--speed-time', '30', '--max-time', seconds, '-o', dest, url], {
46
+ env,
47
+ timeoutMs: DOWNLOAD_TIMEOUT_MS + 5_000,
48
+ maxOutput: 16 * 1024,
49
+ });
50
+ if (result.code !== 0) {
51
+ // curl 失败时可能留下半截或空文件:删掉,免得被当成下载好的内容
52
+ rmSync(dest, { force: true });
53
+ throw failure('curl', result);
54
+ }
55
+ },
56
+ async extract(archive, dir) {
57
+ const result = await exec(TAR, ['-xf', archive, '-C', dir], { env, timeoutMs: EXTRACT_TIMEOUT_MS, maxOutput: 16 * 1024 });
58
+ if (result.code !== 0)
59
+ throw failure('tar', result);
60
+ },
61
+ async selfTest(nodePath, cliPath) {
62
+ const result = await exec(nodePath, [cliPath, 'bridge', '--self-test'], { env, timeoutMs: SELF_TEST_TIMEOUT_MS, maxOutput: 16 * 1024 });
63
+ return { ok: result.code === 0, output: result.timedOut ? `self-test timed out after ${SELF_TEST_TIMEOUT_MS / 1000}s` : combinedOutput(result) };
64
+ },
65
+ };
66
+ }
67
+ /** 追加一行到 `logs/update.log`(目录 0700、文件 0600,与 bridge.log 相同);超过上限先轮转 */
68
+ export function appendUpdateLog(layout, line, maxBytes = UPDATE_LOG_MAX_BYTES) {
69
+ mkdirSync(layout.logs, { recursive: true, mode: 0o700 });
70
+ chmodSync(layout.logs, 0o700);
71
+ if (existsSync(layout.updateLog) && statSync(layout.updateLog).size > maxBytes)
72
+ renameSync(layout.updateLog, `${layout.updateLog}.1`);
73
+ appendFileSync(layout.updateLog, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
74
+ chmodSync(layout.updateLog, 0o600);
75
+ }
76
+ function describeOutcome(outcome) {
77
+ switch (outcome.kind) {
78
+ case 'unsupported':
79
+ return 'skipped (only macOS is supported)';
80
+ case 'not_installed':
81
+ return 'skipped (not installed by the M2 installer)';
82
+ case 'manifest_failed':
83
+ return `kept the current version (couldn't read the manifest: ${outcome.reason})`;
84
+ case 'up_to_date':
85
+ return `up to date (${outcome.version})`;
86
+ case 'rejected':
87
+ return `kept the current version (${outcome.version} failed to start before)`;
88
+ case 'failed':
89
+ return `kept the current version (update to ${outcome.version} failed: ${outcome.reason})`;
90
+ case 'updated':
91
+ return `updated ${outcome.from} -> ${outcome.to}`;
92
+ }
93
+ }
94
+ /**
95
+ * 命令入口(由启动器调用)。日志写进 update.log;标准输出只打一行摘要(启动器把它追加进 bridge.log)。
96
+ * 退出码只供排障:启动器不论成败都用 current 继续,是否刚切换过版本由它自己比较 current 的前后指向得出。
97
+ */
98
+ export async function runCheckUpdate() {
99
+ const layout = installLayout(home);
100
+ const io = createUpdateIO({ exec: execCommand, env: buildAgentEnv() });
101
+ const outcome = await checkForUpdate({
102
+ layout,
103
+ platform: process.platform,
104
+ arch: process.arch,
105
+ ...io,
106
+ log: (line) => {
107
+ try {
108
+ appendUpdateLog(layout, line);
109
+ }
110
+ catch {
111
+ // 日志写不进(磁盘满、权限)不能拦住更新判断;摘要仍会打到标准输出
112
+ }
113
+ },
114
+ now: Date.now,
115
+ });
116
+ console.log(`M2 bridge update: ${describeOutcome(outcome)}`);
117
+ return outcome.kind === 'failed' || outcome.kind === 'manifest_failed' ? 1 : 0;
118
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * `bridge --check-update`:启动器每次拉起 bridge 之前调用(change acp-bridge-one-time-install D9 / D13 / D14 / D20)。
3
+ * 运行它的是**当前版本**的代码;它把新版本下载、校验、冒烟后切成 current,下一步由启动器用新版本拉起。
4
+ *
5
+ * 不变量(`connect/local-bridge` 契约 16 delta):
6
+ * - 只更新到 m2ui.ai 清单指定的版本(清单版本比当前低也照做,这就是回滚),不跟随 npm latest;
7
+ * - 清单只给版本与哈希,下载地址由 release-manifest.ts 固定拼出;任何下载内容与清单哈希不符都换下一个源,全部不符就放弃;
8
+ * - 读清单失败、超时、格式认不出:什么都不改,用当前版本继续;
9
+ * - 新版本先冒烟(`--self-test`),通过才切换;切换用「先建临时软链再改名」,任何时刻 current 都指向一个完整的版本;
10
+ * - 「新版本拉起后没起来」的回退不在这里:新版本可能连模块都加载不了,回退判定在很少改动的启动器里(契约 16 delta);
11
+ * - 不加清单地址、下载地址的覆盖开关:那会让「只读 m2ui.ai 清单」多出一条旁路(design R8)。测试经 UpdateDeps 注入下载。
12
+ *
13
+ * 并发(design N1):不加锁。所有写入先写临时目录 / 临时软链再改名;两个更新同时跑时各自下到自己的临时目录,
14
+ * 后完成的发现版本目录已完整就丢掉自己的,发现 current 已经切好就不再切。
15
+ */
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { ADAPTERS, ADAPTER_READY_MARKER, adapterInstallDirName } from './adapters.js';
20
+ import { NODE_PATH_FILE, connectCliPath, connectPackageDir } from './install-layout.js';
21
+ import { readInstanceRecord } from './instance.js';
22
+ import { tail } from './precheck-parse.js';
23
+ import { MANIFEST_URL, connectTarballUrls, isReleaseVersion, nodeDirName, nodeTarballUrls, parseReleaseManifest, wsTarballUrls, } from './release-manifest.js';
24
+ /** 读清单的总时限(不含下载):读不到就用当前版本继续,不能拖住启动(D20 ①) */
25
+ export const MANIFEST_TIMEOUT_MS = 10_000;
26
+ /** 临时目录超过这个时长就当作被杀掉的更新留下的残留(正在进行的更新不会这么久) */
27
+ const STALE_TEMP_MS = 60 * 60 * 1000;
28
+ const NODE_DIR_RE = /^node-v\d+\.\d+\.\d+-darwin-(arm64|x64)$/;
29
+ class UpdateError extends Error {
30
+ }
31
+ const message = (error) => (error instanceof Error ? error.message : String(error));
32
+ const tempName = (label) => `.tmp-${label}-${process.pid}-${randomBytes(4).toString('hex')}`;
33
+ /** current / previous 软链指向的版本号;软链不在、内容不是版本号时为 null */
34
+ function readVersionLink(path) {
35
+ try {
36
+ const target = readlinkSync(path);
37
+ return isReleaseVersion(target) ? target : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** 启动器写入的拉黑名单:每行一个版本号,其他内容忽略 */
44
+ export function readRejected(path) {
45
+ try {
46
+ return readFileSync(path, 'utf8')
47
+ .split('\n')
48
+ .map((line) => line.trim())
49
+ .filter(isReleaseVersion);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ }
55
+ function readNodePath(versionDir) {
56
+ try {
57
+ const value = readFileSync(join(versionDir, NODE_PATH_FILE), 'utf8').trim();
58
+ return value || null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** 版本目录是否完整:node-path、connect 的 cli.js、ws 都在 */
65
+ function isCompleteVersion(dir) {
66
+ return readNodePath(dir) !== null && existsSync(connectCliPath(dir)) && existsSync(join(dir, 'node_modules', 'ws', 'package.json'));
67
+ }
68
+ /** 原子替换软链:先建临时软链再 rename 覆盖 */
69
+ function swapLink(link, target) {
70
+ const tmp = `${link}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
71
+ symlinkSync(target, tmp);
72
+ renameSync(tmp, link);
73
+ }
74
+ /** 原子写小文件:先写临时文件再 rename */
75
+ function writeFileAtomic(path, content) {
76
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
77
+ writeFileSync(tmp, content);
78
+ renameSync(tmp, path);
79
+ }
80
+ function fileHash(algorithm, path) {
81
+ return new Promise((resolve, reject) => {
82
+ const hash = createHash(algorithm);
83
+ createReadStream(path)
84
+ .on('error', reject)
85
+ .on('data', (chunk) => hash.update(chunk))
86
+ .on('end', () => resolve(hash.digest('hex')));
87
+ });
88
+ }
89
+ /** 依次试各个源(官方在前),下载失败或哈希不符都换下一个;全部不行就删掉 dest 并抛错 */
90
+ async function fetchVerified(deps, urls, algorithm, expected, dest) {
91
+ const problems = [];
92
+ for (const url of urls) {
93
+ try {
94
+ await deps.download(url, dest);
95
+ }
96
+ catch (error) {
97
+ problems.push(`download ${url} failed: ${message(error)}`);
98
+ deps.log(problems[problems.length - 1] ?? '');
99
+ continue;
100
+ }
101
+ if ((await fileHash(algorithm, dest)) === expected) {
102
+ deps.log(`downloaded ${url} (${algorithm} ok)`);
103
+ return;
104
+ }
105
+ problems.push(`${algorithm} mismatch for ${url}`);
106
+ deps.log(problems[problems.length - 1] ?? '');
107
+ }
108
+ rmSync(dest, { force: true });
109
+ throw new UpdateError(problems.join('; '));
110
+ }
111
+ /** 清单要求的 Node 已在就直接用;否则下载、校验、解到临时目录再改名(D8 / R9) */
112
+ async function ensureNode(deps, manifest, arch) {
113
+ const name = nodeDirName(manifest.node.version, arch);
114
+ const nodeDir = join(deps.layout.runtime, name);
115
+ const nodeBin = join(nodeDir, 'bin', 'node');
116
+ if (existsSync(nodeBin))
117
+ return nodeBin;
118
+ mkdirSync(deps.layout.runtime, { recursive: true });
119
+ if (existsSync(nodeDir)) {
120
+ // 残缺的旧目录(上次解到一半被打断):挪开再装,不然改名会一直失败(review r1 R1-N3);挪开的由之后的清理删除
121
+ renameSync(nodeDir, join(deps.layout.runtime, tempName('old-node')));
122
+ deps.log(`moved an incomplete ${name} aside`);
123
+ }
124
+ const tmp = join(deps.layout.runtime, tempName('node'));
125
+ mkdirSync(tmp);
126
+ try {
127
+ const archive = join(tmp, `${name}.tar.xz`);
128
+ await fetchVerified(deps, nodeTarballUrls(manifest.node.version, arch), 'sha256', manifest.node.sha256[arch], archive);
129
+ await deps.extract(archive, tmp);
130
+ if (!existsSync(join(tmp, name, 'bin', 'node')))
131
+ throw new UpdateError(`${name}.tar.xz has no bin/node`);
132
+ // 别的更新抢先装好了同一份 Node:用它的
133
+ if (!existsSync(nodeBin))
134
+ renameSync(join(tmp, name), nodeDir);
135
+ deps.log(`installed Node.js ${manifest.node.version} (${arch})`);
136
+ return nodeBin;
137
+ }
138
+ finally {
139
+ rmSync(tmp, { recursive: true, force: true });
140
+ }
141
+ }
142
+ /** 解开 npm tarball(顶层目录 `package/`)并改名到 dest */
143
+ async function unpackNpm(deps, archive, workDir, dest) {
144
+ const unpacked = join(workDir, `${basename(archive)}.d`);
145
+ mkdirSync(unpacked);
146
+ await deps.extract(archive, unpacked);
147
+ if (!existsSync(join(unpacked, 'package', 'package.json')))
148
+ throw new UpdateError(`${basename(archive)} has no package/package.json`);
149
+ mkdirSync(dirname(dest), { recursive: true });
150
+ renameSync(join(unpacked, 'package'), dest);
151
+ rmSync(unpacked, { recursive: true, force: true });
152
+ rmSync(archive, { force: true });
153
+ }
154
+ /**
155
+ * 准备目标版本目录:已完整存在(例如回滚到 previous)就复用,只把 node-path 对齐到清单的 Node;
156
+ * 否则在 `bridge/.tmp-*` 里组装(connect 与 ws 两个 tarball 就是完整的 bridge,D13),返回临时目录,由调用方冒烟后改名。
157
+ */
158
+ async function prepareVersion(deps, manifest, nodeBin) {
159
+ const versionDir = join(deps.layout.bridge, manifest.connect.version);
160
+ if (isCompleteVersion(versionDir)) {
161
+ if (readNodePath(versionDir) !== nodeBin)
162
+ writeFileAtomic(join(versionDir, NODE_PATH_FILE), `${nodeBin}\n`);
163
+ deps.log(`reusing ${versionDir}`);
164
+ return { dir: versionDir, fresh: false };
165
+ }
166
+ const tmp = join(deps.layout.bridge, tempName(manifest.connect.version));
167
+ mkdirSync(tmp, { recursive: true });
168
+ try {
169
+ const connectArchive = join(tmp, 'connect.tgz');
170
+ await fetchVerified(deps, connectTarballUrls(manifest.connect.version), 'sha512', manifest.connect.sha512, connectArchive);
171
+ await unpackNpm(deps, connectArchive, tmp, connectPackageDir(tmp));
172
+ const wsArchive = join(tmp, 'ws.tgz');
173
+ await fetchVerified(deps, wsTarballUrls(manifest.ws.version), 'sha512', manifest.ws.sha512, wsArchive);
174
+ await unpackNpm(deps, wsArchive, tmp, join(tmp, 'node_modules', 'ws'));
175
+ writeFileSync(join(tmp, NODE_PATH_FILE), `${nodeBin}\n`);
176
+ return { dir: tmp, fresh: true };
177
+ }
178
+ catch (error) {
179
+ rmSync(tmp, { recursive: true, force: true });
180
+ throw error;
181
+ }
182
+ }
183
+ /** 冒烟通过的临时目录改名为版本目录;版本目录在这期间被别的更新装好了就丢掉自己的 */
184
+ function commitVersion(deps, tmp, version) {
185
+ const versionDir = join(deps.layout.bridge, version);
186
+ if (isCompleteVersion(versionDir)) {
187
+ rmSync(tmp, { recursive: true, force: true });
188
+ return;
189
+ }
190
+ if (existsSync(versionDir)) {
191
+ // 不完整的残留(被杀掉的旧更新):先挪开再删,保证改名那一刻目标不存在
192
+ const aside = join(deps.layout.bridge, tempName('old'));
193
+ renameSync(versionDir, aside);
194
+ rmSync(aside, { recursive: true, force: true });
195
+ }
196
+ renameSync(tmp, versionDir);
197
+ }
198
+ function isStaleTemp(path, now) {
199
+ try {
200
+ return now - statSync(path).mtimeMs > STALE_TEMP_MS;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ function defaultIsAlive(pid) {
207
+ try {
208
+ process.kill(pid, 0);
209
+ return true;
210
+ }
211
+ catch (error) {
212
+ // EPERM:进程在,只是不属于当前用户 —— 也算在运行
213
+ return error.code === 'EPERM';
214
+ }
215
+ }
216
+ /** 状态文件里还活着的 bridge 的 pid(任意端口、任意版本,含终端里运行的 npx bridge) */
217
+ function runningBridgePid(deps) {
218
+ const isAlive = deps.isAlive ?? defaultIsAlive;
219
+ const names = existsSync(deps.layout.root) ? readdirSync(deps.layout.root) : [];
220
+ for (const name of names) {
221
+ if (!/^bridge-\d+\.json$/.test(name))
222
+ continue;
223
+ const record = readInstanceRecord(join(deps.layout.root, name));
224
+ if (record && isAlive(record.pid))
225
+ return record.pid;
226
+ }
227
+ return null;
228
+ }
229
+ const listDir = (dir) => (existsSync(dir) ? readdirSync(dir) : []);
230
+ function removePath(deps, path, what) {
231
+ try {
232
+ rmSync(path, { recursive: true, force: true });
233
+ deps.log(`removed ${what}`);
234
+ }
235
+ catch (error) {
236
+ deps.log(`couldn't remove ${what}: ${message(error)}`);
237
+ }
238
+ }
239
+ /**
240
+ * 过期的 `bridge/.tmp-*`、`runtime/.tmp-*`:被打断的安装 / 更新留下的半截下载,以及挪开的残缺目录(review r2 R2-N4)。
241
+ * 每次检查都清(启动器每次启动都会调用),不等版本切换:清单版本不变时它们会一直留着,慢网下每被 agent 打断一次就多一份。
242
+ * 不受「有 bridge 在运行」的约束:它们不属于任何在用的版本;正在写入的临时目录是新近的(下载一次最多 10 分钟),过不了 1 小时的门槛。
243
+ */
244
+ function removeStaleTemps(deps) {
245
+ const now = deps.now();
246
+ for (const dir of [deps.layout.bridge, deps.layout.runtime]) {
247
+ for (const name of listDir(dir)) {
248
+ const path = join(dir, name);
249
+ if (name.startsWith('.tmp-') && isStaleTemp(path, now))
250
+ removePath(deps, path, `stale ${name}`);
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * 只保留两个版本与它们用到的 Node 和适配器目录(D20「清理」、N2)。清理失败只记日志,不影响这次更新的结果。
256
+ * 有 bridge 在运行时整段跳过(review r1 R1-S3):它可能是更早的版本(bridge 运行期间启动器被调用、中间跨了两次清单更新),
257
+ * 它用的 Node、npm 与组件目录都不能删;之后某次启动时没有 bridge 在运行,再清。过期临时目录另由 `removeStaleTemps` 每次清。
258
+ */
259
+ function cleanup(deps, keep) {
260
+ const { layout } = deps;
261
+ const running = runningBridgePid(deps);
262
+ if (running !== null) {
263
+ deps.log(`skipped cleanup: an M2 bridge is running (pid ${running})`);
264
+ return;
265
+ }
266
+ const remove = (path, what) => removePath(deps, path, what);
267
+ for (const name of listDir(layout.bridge)) {
268
+ if (isReleaseVersion(name) && !keep.includes(name))
269
+ remove(join(layout.bridge, name), `bridge ${name}`);
270
+ }
271
+ const usedNode = new Set(keep.map((version) => readNodePath(join(layout.bridge, version))).flatMap((node) => (node ? [basename(dirname(dirname(node)))] : [])));
272
+ for (const name of listDir(layout.runtime)) {
273
+ if (NODE_DIR_RE.test(name) && !usedNode.has(name))
274
+ remove(join(layout.runtime, name), `runtime ${name}`);
275
+ }
276
+ // 适配器目录名由各版本自带的 lockfile 决定;任何一个保留版本的 lockfile 读不出,就整段跳过(宁可多留几百 MB,不能删掉在用的)
277
+ const keepAdapters = new Set();
278
+ try {
279
+ for (const version of keep) {
280
+ const sourceRoot = join(connectPackageDir(join(layout.bridge, version)), 'adapters');
281
+ for (const spec of Object.values(ADAPTERS))
282
+ keepAdapters.add(adapterInstallDirName(sourceRoot, spec.dir));
283
+ }
284
+ }
285
+ catch (error) {
286
+ deps.log(`skipped adapter cleanup: ${message(error)}`);
287
+ return;
288
+ }
289
+ const patterns = Object.values(ADAPTERS).map((spec) => new RegExp(`^${spec.dir}-[0-9a-f]{16}$`));
290
+ for (const name of listDir(layout.adapters)) {
291
+ if (!patterns.some((pattern) => pattern.test(name)) || keepAdapters.has(name))
292
+ continue;
293
+ if (existsSync(join(layout.adapters, name, ADAPTER_READY_MARKER)))
294
+ remove(join(layout.adapters, name), `adapter ${name}`);
295
+ }
296
+ }
297
+ export async function checkForUpdate(deps) {
298
+ const arch = deps.arch === 'arm64' || deps.arch === 'x64' ? deps.arch : null;
299
+ if (deps.platform !== 'darwin' || !arch)
300
+ return { kind: 'unsupported' };
301
+ const installed = readVersionLink(deps.layout.current);
302
+ if (!installed)
303
+ return { kind: 'not_installed' };
304
+ try {
305
+ removeStaleTemps(deps);
306
+ }
307
+ catch (error) {
308
+ deps.log(`stale temp cleanup stopped early: ${message(error)}`);
309
+ }
310
+ let manifest;
311
+ try {
312
+ const parsed = parseReleaseManifest(await deps.fetchText(MANIFEST_URL, MANIFEST_TIMEOUT_MS));
313
+ if (typeof parsed === 'string')
314
+ throw new UpdateError(parsed);
315
+ manifest = parsed;
316
+ }
317
+ catch (error) {
318
+ const reason = message(error);
319
+ deps.log(`manifest unavailable (${reason}); staying on ${installed}`);
320
+ return { kind: 'manifest_failed', reason };
321
+ }
322
+ const target = manifest.connect.version;
323
+ deps.log(`manifest says ${target}; installed ${installed}`);
324
+ if (target === installed)
325
+ return { kind: 'up_to_date', version: target };
326
+ if (readRejected(deps.layout.rejected).includes(target)) {
327
+ deps.log(`skipping ${target}: it failed to start before (listed in rejected)`);
328
+ return { kind: 'rejected', version: target };
329
+ }
330
+ try {
331
+ const nodeBin = await ensureNode(deps, manifest, arch);
332
+ const candidate = await prepareVersion(deps, manifest, nodeBin);
333
+ const test = await deps.selfTest(nodeBin, connectCliPath(candidate.dir));
334
+ if (!test.ok) {
335
+ if (candidate.fresh)
336
+ rmSync(candidate.dir, { recursive: true, force: true });
337
+ throw new UpdateError(`self-test failed: ${tail(test.output.trim())}`);
338
+ }
339
+ deps.log(`self-test ok for ${target}`);
340
+ if (candidate.fresh)
341
+ commitVersion(deps, candidate.dir, target);
342
+ const before = readVersionLink(deps.layout.current);
343
+ if (before === target) {
344
+ deps.log(`${target} is already current (another update switched it)`);
345
+ return { kind: 'up_to_date', version: target };
346
+ }
347
+ if (before)
348
+ swapLink(deps.layout.previous, before);
349
+ swapLink(deps.layout.current, target);
350
+ deps.log(`switched current ${before ?? '(none)'} -> ${target}`);
351
+ try {
352
+ cleanup(deps, before ? [target, before] : [target]);
353
+ }
354
+ catch (error) {
355
+ // 已经切换完成:清理出错只是少删了东西,不能把这次更新报成失败(review r1 R1-N4)
356
+ deps.log(`cleanup stopped early: ${message(error)}`);
357
+ }
358
+ return { kind: 'updated', from: before ?? installed, to: target };
359
+ }
360
+ catch (error) {
361
+ const reason = message(error);
362
+ deps.log(`update to ${target} failed (${reason}); staying on ${installed}`);
363
+ return { kind: 'failed', version: target, reason };
364
+ }
365
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command, and run the local bridge that lets M2 drive them over ACP.",
5
5
  "type": "module",
6
6
  "bin": {