@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,245 @@
1
+ /**
2
+ * 端口被占时识别并接管正在运行的 bridge(change acp-terminal-free-auth,design D20,`connect/local-bridge` 契约 13)。
3
+ *
4
+ * 场景:页面已经连上一个 bridge、只是没配对,用户却找不到那个 bridge 的终端(在别的窗口、被别的工具在后台拉起),
5
+ * 拿不到配对码;新运行的命令又撞端口。这里让新命令认出占端口的是不是 M2 bridge,给出 pid 与终端,或者在 `--replace` 时接管。
6
+ *
7
+ * 维护约束:
8
+ * - 状态文件只放 pid / port / startedAt,**不放配对码、token、承诺**:契约 2 要求码只出现在终端,
9
+ * 能读 `~/.m2canvas`、又能连本机端口的进程(例如沙箱里的 agent)拿到码就能自己配对。
10
+ * - 「认定」要同时满足:文件端口相符、pid 存活、`ps` 算出的启动时刻与 startedAt 相差 ≤ START_TOLERANCE_MS。
11
+ * 崩溃留下的旧文件、被复用的 pid 都过不了这一关;认不出来就什么都不停。
12
+ * - 停止只发 SIGTERM(第一次走旧 bridge 的正常收尾,第二次走它「连按两次」的立即清理),**不发 SIGKILL**:
13
+ * SIGKILL 跳过清理,agent 都在各自的进程组里,会变成孤儿(契约 5)。
14
+ * - Windows 没有 `ps`,一律不认定(D1 / CO-1)。
15
+ * - 认不出来时只提示:用 lsof 查到占端口的 pid 与命令行给用户看,让用户自己决定停不停;这里从不停认不出来的进程。
16
+ */
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ /** startedAt 是进程启动时刻(不是写文件时刻),与 ps 的 etime 只差秒级取整;留 3 秒余量 */
20
+ export const START_TOLERANCE_MS = 3_000;
21
+ /** 第一次 SIGTERM 后等多久:旧 bridge 正常收尾先关连接、停 agent,再等 agent 退出(宽限 3 秒),还要留出启动横幅里检测命令的余量 */
22
+ export const STOP_GRACEFUL_WAIT_MS = 8_000;
23
+ /** 第二次 SIGTERM(立即清理)后再等多久 */
24
+ export const STOP_IMMEDIATE_WAIT_MS = 3_000;
25
+ const STOP_POLL_MS = 100;
26
+ const PS_PATH = '/bin/ps';
27
+ const PS_TIMEOUT_MS = 5_000;
28
+ const LSOF_PATHS = ['/usr/sbin/lsof', '/usr/bin/lsof'];
29
+ const COMMAND_DISPLAY_CHARS = 160;
30
+ export function instanceFilePath(m2Dir, port) {
31
+ return join(m2Dir, `bridge-${port}.json`);
32
+ }
33
+ /** 本进程的启动时刻 */
34
+ export function processStartedAt() {
35
+ return Math.round(Date.now() - process.uptime() * 1000);
36
+ }
37
+ /**
38
+ * 只写这四个字段:调用方多带的任何字段都不落盘(契约 2:文件里不能有配对码、token、承诺)。
39
+ * 这也是跨版本接口:用户机器上的启动器 `~/.m2canvas/bin/m2-bridge` 只随重装更新,它按「JSON 对象里的 "pid"」核对
40
+ * 拉起的是不是这个进程(change acp-bridge-one-time-install review r1 R1-S2)。改格式前先确认已装出去的启动器仍认得,
41
+ * `install-launcher-real.test.ts` 用真实 bridge 锁住了这一点。
42
+ */
43
+ export function writeInstanceRecord(path, record) {
44
+ const { pid, port, startedAt } = record;
45
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
+ chmodSync(dirname(path), 0o700);
47
+ writeFileSync(path, `${JSON.stringify({ version: 1, pid, port, startedAt })}\n`, { mode: 0o600 });
48
+ chmodSync(path, 0o600);
49
+ }
50
+ function isPositiveInteger(value) {
51
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
52
+ }
53
+ export function readInstanceRecord(path) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
62
+ return null;
63
+ const record = parsed;
64
+ if (record.version !== 1 || !isPositiveInteger(record.pid) || !isPositiveInteger(record.port) || !isPositiveInteger(record.startedAt))
65
+ return null;
66
+ return { version: 1, pid: record.pid, port: record.port, startedAt: record.startedAt };
67
+ }
68
+ /** 只删属于这个 pid 的文件:接管它的新 bridge 可能已经写了自己的 */
69
+ export function removeInstanceRecord(path, pid) {
70
+ if (readInstanceRecord(path)?.pid !== pid)
71
+ return;
72
+ rmSync(path, { force: true });
73
+ }
74
+ /** `ps -o etime=` 的格式 `[[dd-]hh:]mm:ss` → 秒 */
75
+ export function parseEtime(value) {
76
+ const match = /^(?:(?:(\d+)-)?(\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(value.trim());
77
+ if (!match)
78
+ return null;
79
+ const [, days, hours, minutes, seconds] = match;
80
+ const [d, h, m, s] = [days, hours, minutes, seconds].map((part) => (part === undefined ? 0 : Number(part)));
81
+ if (m === undefined || s === undefined || s > 59 || m > 59)
82
+ return null;
83
+ return (d ?? 0) * 86_400 + (h ?? 0) * 3_600 + m * 60 + s;
84
+ }
85
+ export function createPsProbe(exec, env, now = Date.now) {
86
+ return {
87
+ async inspect(pid) {
88
+ const result = await exec(PS_PATH, ['-o', 'etime=,tty=', '-p', String(pid)], {
89
+ env: { ...env, LC_ALL: 'C' },
90
+ timeoutMs: PS_TIMEOUT_MS,
91
+ maxOutput: 1024,
92
+ });
93
+ if (result.code !== 0)
94
+ return null;
95
+ const [etime, tty] = result.stdout.trim().split(/\s+/);
96
+ const seconds = etime === undefined ? null : parseEtime(etime);
97
+ if (seconds === null)
98
+ return null;
99
+ const terminal = tty && !/^[?-]+$/.test(tty) ? tty : null;
100
+ return { startedAt: now() - seconds * 1000, terminal };
101
+ },
102
+ };
103
+ }
104
+ /** 用 lsof 查占端口的进程(只读)。没有 lsof(多数 Linux 默认没装)、没有监听者、输出认不出时 null */
105
+ export function createLsofProbe(exec, env, exists = existsSync) {
106
+ return {
107
+ async find(port) {
108
+ const lsof = LSOF_PATHS.find((path) => exists(path));
109
+ if (!lsof)
110
+ return null;
111
+ const options = { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 4096 };
112
+ const listed = await exec(lsof, ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'], options);
113
+ const pid = Number(/^p(\d+)$/m.exec(listed.stdout)?.[1]);
114
+ if (listed.code !== 0 || !Number.isInteger(pid) || pid <= 0)
115
+ return null;
116
+ const ps = await exec(PS_PATH, ['-o', 'command=', '-p', String(pid)], options);
117
+ const full = ps.code === 0 ? ps.stdout.trim() : '';
118
+ const command = full.length > COMMAND_DISPLAY_CHARS ? `${full.slice(0, COMMAND_DISPLAY_CHARS - 1)}…` : full;
119
+ return { pid, command: command || null };
120
+ },
121
+ };
122
+ }
123
+ export async function identifyRunningBridge(path, port, probe, platform) {
124
+ if (platform === 'win32')
125
+ return null;
126
+ const record = readInstanceRecord(path);
127
+ if (!record || record.port !== port)
128
+ return null;
129
+ const info = await probe.inspect(record.pid);
130
+ if (!info || Math.abs(info.startedAt - record.startedAt) > START_TOLERANCE_MS)
131
+ return null;
132
+ return { pid: record.pid, port: record.port, startedAt: record.startedAt, terminal: info.terminal };
133
+ }
134
+ /**
135
+ * 本机进程的实现。存活要两步判断:`kill(pid, 0)` 报 ESRCH 就是不在了;否则(成功或 EPERM)再看 `ps` 的状态,
136
+ * 僵尸(Z,已退出、父进程还没回收)按已退出处理 —— 端口已经释放,而 `kill(pid, 0)` 对僵尸照样成功。
137
+ * 被别的工具在后台拉起、父进程不及时回收的 bridge 会停在僵尸状态(实测:测试脚本拉起的 bridge 退出后一直是 Z)。
138
+ */
139
+ export function createStopDeps(exec, env, kill = process.kill.bind(process)) {
140
+ return {
141
+ signal: (pid, signal) => {
142
+ kill(pid, signal);
143
+ },
144
+ async isAlive(pid) {
145
+ try {
146
+ kill(pid, 0);
147
+ }
148
+ catch (error) {
149
+ if (error.code === 'ESRCH')
150
+ return false;
151
+ }
152
+ const result = await exec(PS_PATH, ['-o', 'stat=', '-p', String(pid)], { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 256 });
153
+ if (result.code !== 0)
154
+ return false;
155
+ return !result.stdout.trim().startsWith('Z');
156
+ },
157
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
158
+ now: Date.now,
159
+ };
160
+ }
161
+ async function waitForExit(pid, deps, timeoutMs) {
162
+ const deadline = deps.now() + timeoutMs;
163
+ while (await deps.isAlive(pid)) {
164
+ if (deps.now() >= deadline)
165
+ return false;
166
+ await deps.sleep(STOP_POLL_MS);
167
+ }
168
+ return true;
169
+ }
170
+ function sendTerm(pid, deps) {
171
+ try {
172
+ deps.signal(pid, 'SIGTERM');
173
+ }
174
+ catch {
175
+ // 不在这里判断:已经退出(ESRCH)会被随后的存活检查认成「已停止」,发不了信号(EPERM)会被认成「没停掉」
176
+ }
177
+ }
178
+ /** 停掉认定的 bridge:SIGTERM → 等正常收尾 → 再一次 SIGTERM(立即清理)→ 再等。从不 SIGKILL。返回是否已退出 */
179
+ export async function stopRunningBridge(pid, deps) {
180
+ sendTerm(pid, deps);
181
+ if (await waitForExit(pid, deps, STOP_GRACEFUL_WAIT_MS))
182
+ return true;
183
+ sendTerm(pid, deps);
184
+ return waitForExit(pid, deps, STOP_IMMEDIATE_WAIT_MS);
185
+ }
186
+ function isAddressInUse(error) {
187
+ return error?.code === 'EADDRINUSE';
188
+ }
189
+ function describe(bridge, clock) {
190
+ const where = bridge.terminal ? `in terminal ${bridge.terminal}` : 'in the background';
191
+ return `pid ${bridge.pid}, started at ${clock(bridge.startedAt)} ${where}`;
192
+ }
193
+ /** 认不出来时的提示:只给可操作的查找 / 停止方法,不停任何进程 */
194
+ function unrecognizedMessage(port, listener, platform, replace) {
195
+ const note = replace ? ' --replace only stops a bridge it can recognize (0.6.0 or later, not on Windows), so nothing was stopped.' : '';
196
+ if (listener) {
197
+ return (`Port ${port} is already in use by pid ${listener.pid}${listener.command ? ` (${listener.command})` : ''}.\n` +
198
+ `If that's an older M2 bridge, stop it with "kill ${listener.pid}" (or press Ctrl+C in its terminal), then run this command again. ` +
199
+ `Otherwise start this one with --port <n>.${note}`);
200
+ }
201
+ const find = platform === 'win32'
202
+ ? `find it with "netstat -ano | findstr :${port}" and stop it with "taskkill /PID <pid>"`
203
+ : `find it with "lsof -nP -iTCP:${port} -sTCP:LISTEN" and stop it with "kill <pid>"`;
204
+ return (`Port ${port} is already in use. If another bridge is running, ${find} (or press Ctrl+C in its terminal), then run this command again. ` +
205
+ `Otherwise start this one with --port <n>.${note}`);
206
+ }
207
+ /**
208
+ * 监听;撞端口时认定占用者并按 `--replace` 处理。返回 null 表示已打印原因、调用方以退出码 1 结束。
209
+ * 不是 EADDRINUSE 的错误照常抛出。
210
+ */
211
+ export async function listenWithReplace(listen, ctx) {
212
+ try {
213
+ return await listen();
214
+ }
215
+ catch (error) {
216
+ if (!isAddressInUse(error))
217
+ throw error;
218
+ }
219
+ const running = await ctx.identify();
220
+ if (!running) {
221
+ ctx.error(unrecognizedMessage(ctx.port, await ctx.findListener(), ctx.platform, ctx.replace));
222
+ return null;
223
+ }
224
+ if (!ctx.replace) {
225
+ ctx.alreadyRunning?.(running);
226
+ const stopIt = running.terminal ? `press Ctrl+C in that terminal or run "kill ${running.pid}"` : `stop it with "kill ${running.pid}"`;
227
+ ctx.error(`Port ${ctx.port} is already in use by another M2 bridge (${describe(running, ctx.clock)}).\n` +
228
+ `To start this one instead, ${stopIt}, then run this command again. Or run it again with --replace to stop that bridge first.`);
229
+ return null;
230
+ }
231
+ ctx.log(`Stopping the M2 bridge already running on port ${ctx.port} (${describe(running, ctx.clock)})…`);
232
+ if (!(await ctx.stop(running.pid))) {
233
+ ctx.error(`The M2 bridge on port ${ctx.port} (pid ${running.pid}) didn't stop. Stop it with "kill ${running.pid}" (or press Ctrl+C in its terminal), then run this command again.`);
234
+ return null;
235
+ }
236
+ try {
237
+ return await listen();
238
+ }
239
+ catch (error) {
240
+ if (!isAddressInUse(error))
241
+ throw error;
242
+ ctx.error(`Port ${ctx.port} is still in use after stopping the old bridge. Wait a few seconds and run this command again.`);
243
+ return null;
244
+ }
245
+ }
@@ -5,23 +5,51 @@
5
5
  *
6
6
  * 本文件负责组装与终端交互:参数、启动横幅、配对码打印、信号处理、退出兜底清理。协议在 server.ts,安全判定在 security.ts / pairing.ts。
7
7
  * 终端输出用英文,与 connect 现有输出一致。
8
+ *
9
+ * 后台模式(`--background`,只由启动器 `~/.m2canvas/bin/m2-bridge` 使用,change acp-bridge-one-time-install):输出写进日志文件,
10
+ * 所以不出配对码(契约 2)、不提示「保持窗口打开」;页面经 `welcome.bridge.background` 得知,改走「重新连接」。
11
+ * 端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
8
12
  */
9
13
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
14
  import { join, resolve } from 'node:path';
11
15
  import { fileURLToPath } from 'node:url';
12
- import { home, which } from '../system.js';
16
+ import { home, isExecutable, which } from '../system.js';
13
17
  import { AdapterManager, ADAPTERS } from './adapters.js';
14
18
  import { detectAgent } from './agents.js';
19
+ import { EXPECT_PATH } from './authorize.js';
15
20
  import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
16
21
  import { execCommand, killActiveExecs } from './exec.js';
22
+ import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
17
23
  import { installShutdownSignals } from './lifecycle.js';
18
24
  import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
19
25
  import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
26
+ import { resolveNpmCommand } from './npm-command.js';
20
27
  import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
28
+ import { selfTestProblems } from './self-test.js';
29
+ import { runCheckUpdate } from './update-io.js';
21
30
  import { startBridgeServer } from './server.js';
22
31
  import { createAgentService } from './service.js';
23
32
  import { KILL_GRACE_MS } from './processes.js';
24
33
  const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
34
+ /**
35
+ * 后台模式下,端口上已有认定的 M2 bridge、又没带 `--replace` 时的退出码(change acp-bridge-one-time-install R1)。
36
+ * 启动器把它当「已在运行」(成功);认不出的占用者、停不掉等失败仍是 1,不能并进来。终端模式不用它(照旧 1)。
37
+ */
38
+ export const EXIT_ALREADY_RUNNING = 3;
39
+ /**
40
+ * 后台模式下,端口被占、又认不出占用者是 M2 bridge(或 `--replace` 停不掉旧的、停掉后端口仍被占)时的退出码(review r1 R1-S1)。
41
+ * 这些都是环境问题,不是这个版本坏了:启动器据此**不回退、不拉黑**刚更新的版本,只把原因写进结果行。终端模式仍是 1。
42
+ */
43
+ export const EXIT_PORT_UNAVAILABLE = 4;
44
+ /**
45
+ * 后台模式加 `--dev`(本机开发页联调用,design R6)时的寿命(review r1 R1-S6,用户 2026-10-05 拍板)。
46
+ * 开发域名的 Origin 与 localStorage 被本机所有 Vite 项目共用,契约 1 因此只在 `--dev` 时放行、且否决了「常开」;
47
+ * 后台 bridge 没有终端、一直运行到注销,不限时就等于常开。到时走正常收尾退出,需要时再用启动器拉起。
48
+ */
49
+ export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
50
+ export function backgroundLifetimeMs(args) {
51
+ return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
52
+ }
25
53
  const LABELS = {
26
54
  claude: 'Claude Code',
27
55
  codex: 'Codex',
@@ -29,7 +57,7 @@ const LABELS = {
29
57
  opencode: 'OpenCode',
30
58
  qoder: 'Qoder',
31
59
  };
32
- export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>]
60
+ export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
33
61
  npx @m2canvas/connect bridge --unpair
34
62
 
35
63
  Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
@@ -40,6 +68,8 @@ The command M2 gives you pairs that page automatically; other pages ask for the
40
68
  pairing code shown here. A paired page can make these agents read and change
41
69
  files and run commands as you, so only run commands you copied from M2.
42
70
  Agents run in the directory you start the bridge from (or --cwd), with your own logins.
71
+ On macOS a paired page can also start an agent's own sign-in; you finish it in
72
+ your browser.
43
73
 
44
74
  --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
45
75
  --cwd <dir> working directory for agent sessions (default: current directory)
@@ -48,11 +78,23 @@ Agents run in the directory you start the bridge from (or --cwd), with your own
48
78
  any app served on that port could then ask to pair
49
79
  --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
50
80
  valid for 10 minutes, once)
81
+ --replace if an M2 bridge (0.6.0 or later) is already running on the port,
82
+ stop it first (its agent sessions end) and start this one instead;
83
+ not on Windows
51
84
  --unpair forget all paired pages and exit
52
- -h, --help show this help`;
85
+ -h, --help show this help
86
+
87
+ Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
88
+ --background run without a terminal: no pairing codes are shown; exits with
89
+ code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port, ${EXIT_PORT_UNAVAILABLE} if
90
+ something else holds the port; with --dev it stops by itself after 2 hours
91
+ --check-update update this installation to the version M2 publishes, then exit
92
+ --self-test check that this installation loads completely, print its version and exit
93
+ --stop stop the M2 bridge running on the port (only one it can recognize; it's
94
+ sent SIGTERM so it cleans up), then exit`;
53
95
  /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
54
96
  export function parseBridgeArgs(argv) {
55
- const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null };
97
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
56
98
  for (let i = 0; i < argv.length; i++) {
57
99
  const arg = argv[i];
58
100
  if (arg === '-h' || arg === '--help')
@@ -63,6 +105,16 @@ export function parseBridgeArgs(argv) {
63
105
  args.unpair = true;
64
106
  else if (arg === '--dev')
65
107
  args.dev = true;
108
+ else if (arg === '--replace')
109
+ args.replace = true;
110
+ else if (arg === '--background')
111
+ args.background = true;
112
+ else if (arg === '--self-test')
113
+ args.selfTest = true;
114
+ else if (arg === '--check-update')
115
+ args.checkUpdate = true;
116
+ else if (arg === '--stop')
117
+ args.stop = true;
66
118
  else if (arg === '--pair') {
67
119
  const value = argv[++i];
68
120
  if (value === undefined)
@@ -132,6 +184,22 @@ function describeAdapter(agent, info) {
132
184
  return `Installing the ${name} failed: ${info.error ?? 'unknown error'}`;
133
185
  return null;
134
186
  }
187
+ /**
188
+ * `--stop`:卸载脚本先停掉正在运行的 bridge(change acp-bridge-one-time-install D23 ①)。
189
+ * 认定与停止都复用契约 13 的实现(instance.ts):只停认定的 M2 bridge,只发 SIGTERM(走它自己的清理),认不出就什么都不停。
190
+ * 不在卸载脚本里用 sh 再写一遍认定(要用 `ps` 推算启动时刻,两份实现会漂;理由同 design R1)。
191
+ */
192
+ async function stopRecognizedBridge(port) {
193
+ const probeEnv = buildProbeEnv();
194
+ const running = await identifyRunningBridge(instanceFilePath(join(home, '.m2canvas'), port), port, createPsProbe(execCommand, probeEnv), process.platform);
195
+ if (!running) {
196
+ console.log(`No M2 bridge that this version can recognize is running on port ${port}; nothing was stopped.`);
197
+ return 0;
198
+ }
199
+ const stopped = await stopRunningBridge(running.pid, createStopDeps(execCommand, probeEnv));
200
+ console.log(stopped ? `Stopped the M2 bridge on port ${port} (pid ${running.pid}).` : `The M2 bridge on port ${port} (pid ${running.pid}) didn't stop.`);
201
+ return stopped ? 0 : 1;
202
+ }
135
203
  export async function runBridge(argv) {
136
204
  const parsed = parseBridgeArgs(argv);
137
205
  if (typeof parsed === 'string') {
@@ -142,6 +210,20 @@ export async function runBridge(argv) {
142
210
  console.log(BRIDGE_HELP);
143
211
  return 0;
144
212
  }
213
+ if (parsed.selfTest) {
214
+ const version = packageVersion();
215
+ const problems = selfTestProblems({ packageRoot: PACKAGE_ROOT, execPath: process.execPath, version });
216
+ if (problems.length > 0) {
217
+ console.error(`M2 bridge self-test failed: ${problems.join('; ')}`);
218
+ return 1;
219
+ }
220
+ console.log(`M2 bridge self-test ok ${version}`);
221
+ return 0;
222
+ }
223
+ if (parsed.checkUpdate)
224
+ return runCheckUpdate();
225
+ if (parsed.stop)
226
+ return stopRecognizedBridge(parsed.port);
145
227
  const m2Dir = join(home, '.m2canvas');
146
228
  const store = createFilePairingStore(join(m2Dir, 'bridge-pairings.json'), (message) => console.warn(message));
147
229
  if (parsed.unpair) {
@@ -155,11 +237,14 @@ export async function runBridge(argv) {
155
237
  }
156
238
  const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
157
239
  const probeEnv = buildProbeEnv();
158
- const npm = which('npm') ?? (process.platform === 'win32' ? 'npm.cmd' : 'npm');
240
+ // 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3);终端模式照旧用 PATH 上的
241
+ const npm = resolveNpmCommand({ background: parsed.background, execPath: process.execPath, platform: process.platform, which });
159
242
  let server = null;
160
243
  // 配对锁定只能靠重启 bridge 解除:bridge 不读终端(放到后台时读终端会被系统挂起),页面也没有任何途径解锁
161
244
  const pairing = new PairingManager({
162
245
  store,
246
+ // 后台模式没有终端、输出写进日志文件:不出码(契约 2「码只出现在终端」),配对只剩命令承诺与已有 token(D17 / D18)
247
+ issueCodes: !parsed.background,
163
248
  onCodeIssued: printCode,
164
249
  onLocked: printLocked,
165
250
  onProblem: (message) => console.warn(message),
@@ -167,20 +252,27 @@ export async function runBridge(argv) {
167
252
  });
168
253
  // 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
169
254
  const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
255
+ // 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
256
+ const loggedState = new Map();
170
257
  const adapters = new AdapterManager({
171
258
  sourceRoot: join(PACKAGE_ROOT, 'adapters'),
172
259
  installRoot: join(m2Dir, 'acp-adapters'),
173
- runNpmCi: (dir) => execCommand(npm, ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], {
174
- cwd: dir,
175
- env: buildNpmEnv(),
176
- timeoutMs: NPM_CI_TIMEOUT_MS,
177
- maxOutput: 64 * 1024,
178
- shell: process.platform === 'win32',
179
- }),
260
+ runNpmCi: (dir) => 'error' in npm
261
+ ? Promise.reject(new Error(npm.error))
262
+ : execCommand(npm.command, [...npm.prefix, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], {
263
+ cwd: dir,
264
+ env: buildNpmEnv(),
265
+ timeoutMs: NPM_CI_TIMEOUT_MS,
266
+ maxOutput: 64 * 1024,
267
+ shell: npm.shell,
268
+ }),
180
269
  onState: (agent, info) => {
181
- const line = describeAdapter(agent, info);
182
- if (line)
183
- log(line);
270
+ if (loggedState.get(agent) !== info.state) {
271
+ loggedState.set(agent, info.state);
272
+ const line = describeAdapter(agent, info);
273
+ if (line)
274
+ log(line);
275
+ }
184
276
  server?.broadcast('adapter/state', { agent, adapter: info });
185
277
  },
186
278
  });
@@ -195,34 +287,67 @@ export async function runBridge(argv) {
195
287
  nodePath: process.execPath,
196
288
  nodeVersion: process.versions.node,
197
289
  cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
290
+ platform: process.platform,
291
+ expectAvailable: () => isExecutable(EXPECT_PATH),
198
292
  });
199
293
  const version = packageVersion();
200
294
  const origins = allowedOrigins(parsed.dev);
295
+ // 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
296
+ const instancePath = instanceFilePath(m2Dir, parsed.port);
297
+ let alreadyRunning = false;
298
+ server = await listenWithReplace(() => startBridgeServer({
299
+ port: parsed.port,
300
+ cwd: parsed.cwd,
301
+ bridge: {
302
+ name: '@m2canvas/connect',
303
+ version,
304
+ platform: process.platform,
305
+ arch: process.arch,
306
+ node: process.versions.node,
307
+ ...(parsed.background ? { background: true } : {}),
308
+ },
309
+ pairing,
310
+ service,
311
+ agentEnv: buildAgentEnv(),
312
+ allowedOrigins: origins,
313
+ log,
314
+ verbose: parsed.verbose,
315
+ }), {
316
+ port: parsed.port,
317
+ replace: parsed.replace,
318
+ identify: () => identifyRunningBridge(instancePath, parsed.port, createPsProbe(execCommand, probeEnv), process.platform),
319
+ findListener: () => createLsofProbe(execCommand, probeEnv).find(parsed.port),
320
+ platform: process.platform,
321
+ stop: (pid) => stopRunningBridge(pid, createStopDeps(execCommand, probeEnv)),
322
+ log,
323
+ error: (message) => console.error(message),
324
+ clock,
325
+ alreadyRunning: () => {
326
+ alreadyRunning = true;
327
+ },
328
+ });
329
+ // listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开
330
+ if (!server)
331
+ return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
332
+ const running = server;
201
333
  try {
202
- server = await startBridgeServer({
203
- port: parsed.port,
204
- cwd: parsed.cwd,
205
- bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
206
- pairing,
207
- service,
208
- agentEnv: buildAgentEnv(),
209
- allowedOrigins: origins,
210
- log,
211
- verbose: parsed.verbose,
212
- });
334
+ // startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
335
+ writeInstanceRecord(instancePath, { version: 1, pid: process.pid, port: parsed.port, startedAt: processStartedAt() });
213
336
  }
214
337
  catch (error) {
215
- const code = error.code;
216
- if (code === 'EADDRINUSE') {
217
- console.error(`Port ${parsed.port} is already in use. Another bridge may already be running; stop it, or start this one with --port <n>.`);
218
- return 1;
219
- }
220
- throw error;
338
+ console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
221
339
  }
222
- const running = server;
223
340
  // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
224
341
  // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
225
342
  process.on('exit', () => {
343
+ // 只删属于本进程的状态文件;删不掉只会让之后的认定落到「pid 不在」,不影响退出
344
+ try {
345
+ removeInstanceRecord(instancePath, process.pid);
346
+ }
347
+ catch {
348
+ // 见上
349
+ }
350
+ running.authorizations.killAllNow();
226
351
  running.processes.killAllNow();
227
352
  killActiveExecs();
228
353
  });
@@ -237,7 +362,9 @@ export async function runBridge(argv) {
237
362
  });
238
363
  const stop = async () => {
239
364
  log('Stopping the bridge and its agents…');
240
- // 一次性命令(npm ci、预检、检测)没有「正常收尾」可言,先同步杀掉,不等 close
365
+ // 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
366
+ // 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
367
+ running.authorizations.killAllNow();
241
368
  killActiveExecs();
242
369
  await running.close();
243
370
  const deadline = Date.now() + KILL_GRACE_MS;
@@ -250,11 +377,19 @@ export async function runBridge(argv) {
250
377
  graceful: () => void stop(),
251
378
  // 收尾期间又来一个信号(连按 Ctrl+C):不再等宽限期,立刻清理退出(exit 钩子也会再清一遍)
252
379
  immediate: () => {
380
+ running.authorizations.killAllNow();
253
381
  running.processes.killAllNow();
254
382
  killActiveExecs();
255
383
  process.exit(130);
256
384
  },
257
385
  });
386
+ const lifetime = backgroundLifetimeMs(parsed);
387
+ if (lifetime !== null) {
388
+ setTimeout(() => {
389
+ log('Dev mode in the background reached its 2-hour limit; stopping. Start it again with ~/.m2canvas/bin/m2-bridge if you still need it.');
390
+ void stop();
391
+ }, lifetime);
392
+ }
258
393
  console.log(`M2 local agent bridge ${version}`);
259
394
  console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
260
395
  console.log(` Working directory for agents: ${parsed.cwd}`);
@@ -263,12 +398,17 @@ export async function runBridge(argv) {
263
398
  console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
264
399
  // 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
265
400
  // 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
401
+ if (parsed.background)
402
+ console.log(' Started in the background by the M2 launcher: no pairing codes are shown; pair from M2 with a new start command.');
403
+ if (lifetime !== null)
404
+ console.log(` Dev mode in the background: stops by itself at ${clock(Date.now() + lifetime)}.`);
266
405
  if (pairUntil !== null)
267
406
  console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
268
- else
407
+ else if (!parsed.background)
269
408
  pairing.issueCode('startup');
270
409
  const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
271
410
  console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
272
- console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
411
+ if (!parsed.background)
412
+ console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
273
413
  return exited;
274
414
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 适配器安装(`npm ci`)用哪个 npm(change acp-bridge-one-time-install R3)。
3
+ *
4
+ * - 终端模式(`npx @m2canvas/connect bridge`):沿用 PATH 上的 npm —— 用户能运行 npx,就一定有 npm;不改既有行为。
5
+ * - 后台模式(`bridge --background`,由启动器用 `~/.m2canvas/runtime` 里的私有 Node 拉起):目标用户往往根本没装 Node,
6
+ * PATH 上没有 npm。必须用运行 bridge 的那份 Node(`process.execPath`)去跑同一 Node 包里的 `lib/node_modules/npm/bin/npm-cli.js`。
7
+ * 不能直接运行 `bin/npm`:它的 shebang 是 `#!/usr/bin/env node`,又会去 PATH 找 node。
8
+ * 找不到 npm-cli.js 就报错,**不退回 PATH 上的 npm**:那会让组件装进一份与 bridge 不同的 Node 环境里,失败时也更难查。
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ /** 官方 Node 二进制包的布局:`<prefix>/bin/node` 与 `<prefix>/lib/node_modules/npm/bin/npm-cli.js` */
13
+ export function bundledNpmCli(execPath) {
14
+ return join(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
15
+ }
16
+ export function resolveNpmCommand(input) {
17
+ if (input.background) {
18
+ const cli = bundledNpmCli(input.execPath);
19
+ if (!(input.exists ?? existsSync)(cli))
20
+ return { error: `npm was not found next to this bridge's Node.js (${cli}); reinstall the M2 local connection` };
21
+ return { command: input.execPath, prefix: [cli], shell: false };
22
+ }
23
+ const windows = input.platform === 'win32';
24
+ return { command: input.which('npm') ?? (windows ? 'npm.cmd' : 'npm'), prefix: [], shell: windows };
25
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * bridge 方法 `pair`(页面输码换 token):校验参数、调用 PairingManager.pair、把失败映射成页面看得懂的错误码。
3
+ * 只负责「这次输码对不对」;配对成功后把连接标为已配对、记日志、回 cwd 由 server.ts 负责(连接状态只在那里改)。
4
+ *
5
+ * 错误码的语义是页面协议的一部分(`protocol.ts` 的 BridgeErrorCode,`connect/local-bridge` 契约 9):
6
+ * - pairing_invalid:错码,带剩余次数;
7
+ * - pairing_code_rotated:码过期或错太多次被换掉,新码已打印在终端;
8
+ * - pairing_locked:累计错码太多,只有重启 bridge 才解除;
9
+ * - pairing_code_unavailable:后台模式(`bridge --background`)根本不出码,页面要改走「重新连接」(change acp-bridge-one-time-install D17)。
10
+ * 不要把后两种并进 pairing_code_rotated:页面据此决定是让用户看终端,还是让 agent 重新拉起 bridge。
11
+ */
12
+ import { BridgeFailure } from './errors.js';
13
+ /** 成功时返回新签发的 token;失败抛 BridgeFailure */
14
+ export function handlePairCall(pairing, origin, params) {
15
+ const code = params.code;
16
+ if (typeof code !== 'string')
17
+ throw new BridgeFailure('bad_message', 'pair needs a string "code"');
18
+ const result = pairing.pair(code, origin);
19
+ if (result.ok)
20
+ return result.token;
21
+ switch (result.code) {
22
+ case 'pairing_invalid':
23
+ throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
24
+ case 'pairing_locked':
25
+ throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
26
+ case 'pairing_code_unavailable':
27
+ throw new BridgeFailure('pairing_code_unavailable', 'this bridge was started in the background and shows no pairing code; reconnect from M2 to pair');
28
+ case 'pairing_code_rotated':
29
+ throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
30
+ }
31
+ }