@m2canvas/connect 0.3.1 → 0.4.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,73 @@
1
+ /**
2
+ * bridge 自己的 agent 检测与启动规格(ACP 可行性评估 §4 的表)。
3
+ *
4
+ * 与 connect 现有的 detectClients(clients.ts)是两套判据,**不要合并**:
5
+ * - connect 的检测用于写 MCP 配置,只装了 Cursor IDE(有 ~/.cursor)也算 —— IDE 也读那份配置;
6
+ * - 这里判断「能不能用 ACP 拉起」,Cursor 只认 CLI(`cursor-agent`,或真实路径指向 cursor-agent 的 `agent`)。
7
+ * `agent` 是很泛的命令名,别的工具也可能叫这个,所以必须看真实路径。
8
+ * - Qoder 先找 `qodercli`(PATH,再 ~/.local/bin),最后兼容 `qoder`(官方文档的写法,PoC 本机没有)。
9
+ *
10
+ * 「能运行」= `--version` 在超时内退出码 0(与 connect 的 firstRunnable 同一判据:PATH 上的 shim 可能是坏的)。
11
+ */
12
+ import { realpathSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { isExecutable, which } from '../system.js';
15
+ export const VERSION_TIMEOUT_MS = 15_000;
16
+ /** claude / codex 经适配器拉起(不用检测到的本机二进制);其余三家用自带的 ACP 模式 */
17
+ export const LAUNCH_KIND = {
18
+ claude: 'adapter',
19
+ codex: 'adapter',
20
+ cursor: 'native',
21
+ opencode: 'native',
22
+ qoder: 'native',
23
+ };
24
+ /** 原生 ACP 模式的启动参数(实测:PoC 报告 §2.3–2.5) */
25
+ export const NATIVE_ACP_ARGS = {
26
+ cursor: ['acp'],
27
+ opencode: ['acp'],
28
+ qoder: ['--acp'],
29
+ };
30
+ function realpathOrSelf(path, realpath) {
31
+ try {
32
+ return realpath(path);
33
+ }
34
+ catch {
35
+ return path;
36
+ }
37
+ }
38
+ function candidates(id, deps) {
39
+ const onPath = (command) => ({ path: deps.which(command), command });
40
+ const fixed = (path) => ({ path, command: path });
41
+ switch (id) {
42
+ case 'claude':
43
+ return [onPath('claude')];
44
+ case 'codex':
45
+ // Codex 桌面端(ChatGPT.app)自带 CLI,也算已安装(拉起时仍用适配器自带的 codex)
46
+ return [onPath('codex'), fixed('/Applications/ChatGPT.app/Contents/Resources/codex')];
47
+ case 'cursor':
48
+ return [
49
+ onPath('cursor-agent'),
50
+ { ...onPath('agent'), accept: (path) => realpathOrSelf(path, deps.realpath).includes('cursor-agent') },
51
+ ];
52
+ case 'opencode':
53
+ return [onPath('opencode'), fixed('/Applications/OpenCode.app/Contents/Resources/opencode-cli')];
54
+ case 'qoder':
55
+ return [onPath('qodercli'), fixed(join(deps.home, '.local', 'bin', 'qodercli')), onPath('qoder')];
56
+ }
57
+ }
58
+ export async function detectAgent(id, deps) {
59
+ const resolved = { which: deps.which ?? which, realpath: deps.realpath ?? realpathSync, home: deps.home };
60
+ for (const candidate of candidates(id, resolved)) {
61
+ const path = candidate.path;
62
+ if (!path || !isExecutable(path))
63
+ continue;
64
+ if (candidate.accept && !candidate.accept(path))
65
+ continue;
66
+ const result = await deps.exec(path, ['--version'], { env: deps.env, timeoutMs: VERSION_TIMEOUT_MS, maxOutput: 4096 });
67
+ if (result.code !== 0)
68
+ continue;
69
+ const firstLine = `${result.stdout}\n${result.stderr}`.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
70
+ return { id, installed: true, path, command: candidate.command, version: firstLine ?? null };
71
+ }
72
+ return { id, installed: false, path: null, command: null, version: null };
73
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * bridge 拉起的所有子进程(agent、预检 CLI、m2/configure、适配器安装)只继承这里放行的环境变量。
3
+ *
4
+ * 为什么是白名单而不是黑名单:用户可能在 Claude Code / Codex 之类宿主的终端里运行 bridge,宿主注入的变量会改变子 agent
5
+ * 的鉴权与路由(ACP PoC 第一轮 Claude 结果因此作废)。实测宿主注入的除了 ANTHROPIC_BASE_URL、几十个 CLAUDE_CODE_*,
6
+ * 还有 SUPABASE_ACCESS_TOKEN、USE_STAGING_OAUTH 这类密钥和开关,黑名单列不全。
7
+ *
8
+ * 维护约束:
9
+ * - 预检和 agent 用同一份环境,两者看到的配置才一致。新增放行项前先确认它不是宿主注入的鉴权 / 路由变量。
10
+ * - CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_* 是「配置在哪」:connect 写 MCP 配置时读它们(clients.ts),agent 必须读同一处。
11
+ * - 依赖环境变量登录的用户(例如 QODER_PERSONAL_ACCESS_TOKEN)会被挡掉,这是有意的取舍(FU-2026-10-02-121)。
12
+ */
13
+ /** 精确名(非 Windows 区分大小写) */
14
+ const ALLOWED_NAMES = [
15
+ 'HOME',
16
+ 'PATH',
17
+ 'USER',
18
+ 'LOGNAME',
19
+ 'SHELL',
20
+ 'TMPDIR',
21
+ 'TEMP',
22
+ 'TMP',
23
+ 'TERM',
24
+ 'LANG',
25
+ 'LANGUAGE',
26
+ 'TZ',
27
+ '__CF_USER_TEXT_ENCODING',
28
+ 'SSH_AUTH_SOCK',
29
+ 'HTTP_PROXY',
30
+ 'HTTPS_PROXY',
31
+ 'ALL_PROXY',
32
+ 'NO_PROXY',
33
+ 'http_proxy',
34
+ 'https_proxy',
35
+ 'all_proxy',
36
+ 'no_proxy',
37
+ 'NODE_EXTRA_CA_CERTS',
38
+ 'NODE_USE_SYSTEM_CA',
39
+ 'SSL_CERT_FILE',
40
+ 'SSL_CERT_DIR',
41
+ 'CODEX_HOME',
42
+ 'CLAUDE_CONFIG_DIR',
43
+ 'XDG_CONFIG_HOME',
44
+ 'XDG_DATA_HOME',
45
+ 'XDG_CACHE_HOME',
46
+ 'XDG_STATE_HOME',
47
+ 'XDG_RUNTIME_DIR',
48
+ ];
49
+ /** Windows 系统变量(Windows 的环境变量名不区分大小写,按大写比较) */
50
+ const WINDOWS_NAMES = [
51
+ 'SYSTEMROOT',
52
+ 'WINDIR',
53
+ 'APPDATA',
54
+ 'LOCALAPPDATA',
55
+ 'USERPROFILE',
56
+ 'HOMEDRIVE',
57
+ 'HOMEPATH',
58
+ 'PATHEXT',
59
+ 'COMSPEC',
60
+ 'PROGRAMDATA',
61
+ 'PROGRAMFILES',
62
+ 'PROGRAMFILES(X86)',
63
+ ];
64
+ const ALLOWED_PREFIXES = ['LC_'];
65
+ /**
66
+ * 适配器安装(npm ci)额外需要的 npm 配置:用户用 `npx --registry=…` 启动时,npm 以这些变量传给子进程。
67
+ * 不放行 npm_config_prefix / npm_config_local_prefix 等:它们会把安装改到别的目录。
68
+ */
69
+ const NPM_CONFIG_NAMES = [
70
+ 'npm_config_registry',
71
+ 'npm_config_cache',
72
+ 'npm_config_userconfig',
73
+ 'npm_config_proxy',
74
+ 'npm_config_https_proxy',
75
+ 'npm_config_noproxy',
76
+ 'npm_config_cafile',
77
+ 'npm_config_strict_ssl',
78
+ ];
79
+ function isAllowed(name, platform) {
80
+ if (platform === 'win32') {
81
+ const upper = name.toUpperCase();
82
+ return (ALLOWED_NAMES.some((allowed) => allowed.toUpperCase() === upper) ||
83
+ WINDOWS_NAMES.includes(upper) ||
84
+ ALLOWED_PREFIXES.some((prefix) => upper.startsWith(prefix)));
85
+ }
86
+ return ALLOWED_NAMES.includes(name) || ALLOWED_PREFIXES.some((prefix) => name.startsWith(prefix));
87
+ }
88
+ function pick(source, keep) {
89
+ const env = {};
90
+ for (const [name, value] of Object.entries(source)) {
91
+ if (value !== undefined && keep(name))
92
+ env[name] = value;
93
+ }
94
+ return env;
95
+ }
96
+ /** agent 进程的环境:只有白名单,不添加任何变量 */
97
+ export function buildAgentEnv(source = process.env, platform = process.platform) {
98
+ return pick(source, (name) => isAllowed(name, platform));
99
+ }
100
+ /** 预检命令的环境:agent 环境 + NO_COLOR(只为输出好解析;OpenCode 实测仍带 ANSI,解析前另行剥离) */
101
+ export function buildProbeEnv(source = process.env, platform = process.platform) {
102
+ return { ...buildAgentEnv(source, platform), NO_COLOR: '1' };
103
+ }
104
+ /** 适配器安装的环境:agent 环境 + 少数 npm 配置 */
105
+ export function buildNpmEnv(source = process.env, platform = process.platform) {
106
+ return { ...buildAgentEnv(source, platform), ...pick(source, (name) => NPM_CONFIG_NAMES.includes(name)) };
107
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * bridge 内执行一次性命令(--version、状态检查、mcp list、npm ci、connect 写配置)。
3
+ *
4
+ * 必须异步:bridge 同时在转发 agent 的 ACP 流,connect 原有的 run()(spawnSync)会把事件循环卡住几秒到几十秒
5
+ * (`claude mcp list` 实测约 10 秒),期间所有页面都收不到消息。
6
+ *
7
+ * stdin 一律 'ignore':被调命令看到的是非 TTY,不会停下来等输入;connect 的 cli.js 也据此走非交互分支(m2/configure 依赖这一点)。
8
+ *
9
+ * 超时必须真的封顶:命令可能再拉起子进程(`claude mcp list` 会启动用户配置的 stdio MCP 做健康检查,npm ci 有自己的子进程),
10
+ * 孙进程继承了输出管道时,'close' 要等它们全部退出才触发。所以 POSIX 下每条命令是独立进程组,超时对整组 SIGKILL 并**立即**返回,
11
+ * 不再等 'close'。bridge 退出时 killActiveExecs 清掉仍在运行的命令(main.ts 的退出路径调用)。
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ const DEFAULT_MAX_OUTPUT = 1024 * 1024;
15
+ const GROUPS = process.platform !== 'win32';
16
+ /** 正在运行的命令(bridge 退出时要清理) */
17
+ const active = new Set();
18
+ function killTree(child) {
19
+ if (child.pid === undefined)
20
+ return;
21
+ try {
22
+ if (GROUPS)
23
+ process.kill(-child.pid, 'SIGKILL');
24
+ else
25
+ child.kill('SIGKILL');
26
+ }
27
+ catch {
28
+ // ESRCH:已经退出。这里是超时 / 退出清理路径,再失败也没有更好的处理,结果已按超时上报
29
+ }
30
+ }
31
+ /** 同步杀掉所有仍在运行的命令及其子进程(bridge 退出时调用) */
32
+ export function killActiveExecs() {
33
+ for (const child of active)
34
+ killTree(child);
35
+ active.clear();
36
+ }
37
+ export const execCommand = (command, args, options) => new Promise((resolve) => {
38
+ const max = options.maxOutput ?? DEFAULT_MAX_OUTPUT;
39
+ let stdout = '';
40
+ let stderr = '';
41
+ let settled = false;
42
+ let timer;
43
+ let child;
44
+ const finish = (result, timedOut = false) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ if (timer)
49
+ clearTimeout(timer);
50
+ if (child)
51
+ active.delete(child);
52
+ resolve({ ...result, stdout, stderr, timedOut });
53
+ };
54
+ try {
55
+ child = spawn(command, args, {
56
+ cwd: options.cwd,
57
+ env: options.env,
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ shell: options.shell ?? false,
60
+ detached: GROUPS,
61
+ windowsHide: true,
62
+ });
63
+ }
64
+ catch (error) {
65
+ // 同步抛出只发生在参数非法时;按启动失败处理
66
+ finish({ code: null, signal: null, error: error instanceof Error ? error.message : String(error) });
67
+ return;
68
+ }
69
+ const running = child;
70
+ active.add(running);
71
+ timer = setTimeout(() => {
72
+ killTree(running);
73
+ finish({ code: null, signal: null, error: `timed out after ${options.timeoutMs}ms` }, true);
74
+ }, options.timeoutMs);
75
+ running.stdout?.setEncoding('utf8');
76
+ running.stderr?.setEncoding('utf8');
77
+ running.stdout?.on('data', (chunk) => {
78
+ stdout = (stdout + chunk).slice(-max);
79
+ });
80
+ running.stderr?.on('data', (chunk) => {
81
+ stderr = (stderr + chunk).slice(-max);
82
+ });
83
+ running.on('error', (error) => finish({ code: null, signal: null, error: error.message }));
84
+ running.on('close', (code, signal) => finish({ code, signal, error: null }));
85
+ });
86
+ /** stdout 与 stderr 合并(有些 CLI 把状态写在 stderr,例如 `codex login status`) */
87
+ export function combinedOutput(result) {
88
+ const parts = [result.stdout, result.stderr, result.error ?? ''].filter((part) => part.length > 0);
89
+ return parts.join(parts.length > 1 ? '\n' : '');
90
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * bridge 进程的退出信号处理。
3
+ *
4
+ * SIGINT(Ctrl+C)、SIGTERM、SIGHUP(关终端窗口)的默认处理都会直接终止进程,不触发 'exit' 事件,
5
+ * 所以三个信号都必须挂监听器走清理:agent 与一次性命令都在各自的进程组里(detached),收不到终端发来的信号。
6
+ *
7
+ * 第一次信号走正常收尾(关连接、SIGTERM 进程组、等宽限期)。之后的任何信号走立即清理(SIGKILL 进程组后退出):
8
+ * 用户连按两次 Ctrl+C 很常见,如果用 once 挂监听器,第二个信号会落回默认处理、直接终止进程,跳过清理,留下孤儿进程
9
+ * (正在跑的 npm ci 可能长达 15 分钟、几百 MB 下载)。所以监听器用 on,一直挂着。
10
+ */
11
+ export const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
12
+ export function installShutdownSignals(target, handlers) {
13
+ let received = false;
14
+ for (const signal of SHUTDOWN_SIGNALS) {
15
+ target.on(signal, () => {
16
+ if (received) {
17
+ handlers.immediate();
18
+ return;
19
+ }
20
+ received = true;
21
+ handlers.graceful();
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 从 `{ mcpServers: { <name>: { url | serverUrl } } }` 形状的配置文件里找出所有指向 M2 的配置项名。
3
+ * 只给 Cursor 的预检用:`agent mcp list` 只输出名字不输出 URL,必须先从配置文件知道哪几个名字是 m2。
4
+ *
5
+ * 与 connect 的 findM2Entry(config-files.ts)判据相同(按 URL host),区别是这里要全部名字而不是第一个:
6
+ * 实测同一份 ~/.cursor/mcp.json 里会同时有 `m2` 和 `m2-test`,项目级 .cursor/mcp.json 里还可能再有一个。
7
+ *
8
+ * 读不了的文件(JSON 损坏)不能静默跳过:跳过会让预检报 not_configured、页面引导用户去「添加 m2」,
9
+ * 而真正的问题是配置文件坏了。所以把读不了的文件连同原因一起返回,由调用方报 unknown。
10
+ */
11
+ import { isM2Url } from '../config-files.js';
12
+ import { readJson } from '../system.js';
13
+ function isObject(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ export function m2NamesIn(config) {
17
+ if (!isObject(config) || !isObject(config.mcpServers))
18
+ return [];
19
+ return Object.entries(config.mcpServers)
20
+ .filter(([, entry]) => isObject(entry) && (isM2Url(entry.url) || isM2Url(entry.serverUrl)))
21
+ .map(([name]) => name);
22
+ }
23
+ /** 按给定顺序读多个配置文件(不存在的跳过),名字去重 */
24
+ export function findM2Names(paths) {
25
+ const names = [];
26
+ const unreadable = [];
27
+ for (const path of paths) {
28
+ const read = readJson(path);
29
+ if (read.kind === 'invalid')
30
+ unreadable.push(`${path}: ${read.error}`);
31
+ if (read.kind !== 'ok')
32
+ continue;
33
+ for (const name of m2NamesIn(read.value))
34
+ if (!names.includes(name))
35
+ names.push(name);
36
+ }
37
+ return { names, unreadable };
38
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `npx @m2canvas/connect bridge`:在本机运行 ACP bridge,让 M2 页面经 ws://127.0.0.1 拉起并驱动本机的 agent。
3
+ *
4
+ * 只由 cli.ts 在第一个参数是 `bridge` 时动态加载:默认的 MCP 配置流程(Copy to agent 起手式)不会加载这里的任何代码与依赖。
5
+ *
6
+ * 本文件负责组装与终端交互:参数、启动横幅、配对码打印、信号处理、退出兜底清理。协议在 server.ts,安全判定在 security.ts / pairing.ts。
7
+ * 终端输出用英文,与 connect 现有输出一致。
8
+ */
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { home, which } from '../system.js';
13
+ import { AdapterManager, ADAPTERS } from './adapters.js';
14
+ import { detectAgent } from './agents.js';
15
+ import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
16
+ import { execCommand, killActiveExecs } from './exec.js';
17
+ import { installShutdownSignals } from './lifecycle.js';
18
+ import { PairingManager, createFilePairingStore } from './pairing.js';
19
+ import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
20
+ import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
21
+ import { startBridgeServer } from './server.js';
22
+ import { createAgentService } from './service.js';
23
+ import { KILL_GRACE_MS } from './processes.js';
24
+ const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
25
+ const LABELS = {
26
+ claude: 'Claude Code',
27
+ codex: 'Codex',
28
+ cursor: 'Cursor',
29
+ opencode: 'OpenCode',
30
+ qoder: 'Qoder',
31
+ };
32
+ const HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev]
33
+ npx @m2canvas/connect bridge --unpair
34
+
35
+ Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
36
+ installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
37
+
38
+ It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
39
+ The first time, M2 asks for the pairing code shown here. A paired page can make
40
+ these agents read and change files and run commands as you, so only pair M2.
41
+ Agents run in the directory you start the bridge from (or --cwd), with your own logins.
42
+
43
+ --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
44
+ --cwd <dir> working directory for agent sessions (default: current directory)
45
+ --verbose also print agent stderr and connection details
46
+ --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
47
+ any app served on that port could then ask to pair
48
+ --unpair forget all paired pages and exit
49
+ -h, --help show this help`;
50
+ function parseArgs(argv) {
51
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false };
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const arg = argv[i];
54
+ if (arg === '-h' || arg === '--help')
55
+ args.help = true;
56
+ else if (arg === '--verbose')
57
+ args.verbose = true;
58
+ else if (arg === '--unpair')
59
+ args.unpair = true;
60
+ else if (arg === '--dev')
61
+ args.dev = true;
62
+ else if (arg === '--port' || arg === '--cwd') {
63
+ const value = argv[++i];
64
+ if (value === undefined)
65
+ return `${arg} needs a value`;
66
+ if (arg === '--cwd')
67
+ args.cwd = resolve(value);
68
+ else {
69
+ const port = Number(value);
70
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
71
+ return `invalid --port ${value}`;
72
+ args.port = port;
73
+ }
74
+ }
75
+ else
76
+ return `unknown option ${arg}`;
77
+ }
78
+ return args;
79
+ }
80
+ /** 包根目录:dist/bridge/*.js 与 src/bridge/*.ts 到包根的相对深度相同 */
81
+ const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
82
+ function packageVersion() {
83
+ const parsed = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
84
+ const version = typeof parsed === 'object' && parsed !== null ? parsed.version : undefined;
85
+ return typeof version === 'string' ? version : '0.0.0';
86
+ }
87
+ function printCode(info) {
88
+ const until = new Date(info.expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
89
+ const code = `Pairing code: ${info.display} (valid until ${until})`;
90
+ if (info.reason === 'startup')
91
+ console.log(`\n ${code}\n Enter it in M2 when it asks to connect to this bridge.\n`);
92
+ else if (info.reason === 'requested')
93
+ console.log(`\n→ ${info.origin ?? 'A page'} wants to pair with this bridge. ${code}\n Only enter it if you opened M2 yourself.\n`);
94
+ else if (info.reason === 'expired')
95
+ console.log(`\n→ The pairing code expired. New ${code}\n`);
96
+ else
97
+ console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
98
+ }
99
+ function printLocked(origin) {
100
+ const from = origin ? ` (the last ones came from ${origin})` : '';
101
+ console.log(`\n→ Too many wrong pairing codes${from}. New pages can't pair until you restart the bridge (Ctrl+C, then run it again); ` +
102
+ `pages that are already paired keep working.${origin ? ` If you didn't open ${origin}, close that page first.` : ''}\n`);
103
+ }
104
+ function describeAdapter(agent, info) {
105
+ const name = `${LABELS[agent]} adapter (${ADAPTERS[agent].package}@${ADAPTERS[agent].version})`;
106
+ if (info.state === 'installing')
107
+ return `Installing the ${name}. First time only; it downloads the bundled agent and can take a few minutes…`;
108
+ if (info.state === 'ready')
109
+ return `${name} is ready.`;
110
+ if (info.state === 'failed')
111
+ return `Installing the ${name} failed: ${info.error ?? 'unknown error'}`;
112
+ return null;
113
+ }
114
+ export async function runBridge(argv) {
115
+ const parsed = parseArgs(argv);
116
+ if (typeof parsed === 'string') {
117
+ console.error(`${parsed}\n\n${HELP}`);
118
+ return 2;
119
+ }
120
+ if (parsed.help) {
121
+ console.log(HELP);
122
+ return 0;
123
+ }
124
+ const m2Dir = join(home, '.m2canvas');
125
+ const store = createFilePairingStore(join(m2Dir, 'bridge-pairings.json'), (message) => console.warn(message));
126
+ if (parsed.unpair) {
127
+ store.clear();
128
+ console.log('Forgot all paired pages. M2 will ask for a new pairing code next time.');
129
+ return 0;
130
+ }
131
+ if (!existsSync(parsed.cwd) || !statSync(parsed.cwd).isDirectory()) {
132
+ console.error(`--cwd ${parsed.cwd} is not a directory.`);
133
+ return 2;
134
+ }
135
+ const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
136
+ const probeEnv = buildProbeEnv();
137
+ const npm = which('npm') ?? (process.platform === 'win32' ? 'npm.cmd' : 'npm');
138
+ let server = null;
139
+ // 配对锁定只能靠重启 bridge 解除:bridge 不读终端(放到后台时读终端会被系统挂起),页面也没有任何途径解锁
140
+ const pairing = new PairingManager({
141
+ store,
142
+ onCodeIssued: printCode,
143
+ onLocked: printLocked,
144
+ onProblem: (message) => console.warn(message),
145
+ });
146
+ const adapters = new AdapterManager({
147
+ sourceRoot: join(PACKAGE_ROOT, 'adapters'),
148
+ installRoot: join(m2Dir, 'acp-adapters'),
149
+ runNpmCi: (dir) => execCommand(npm, ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'], {
150
+ cwd: dir,
151
+ env: buildNpmEnv(),
152
+ timeoutMs: NPM_CI_TIMEOUT_MS,
153
+ maxOutput: 64 * 1024,
154
+ shell: process.platform === 'win32',
155
+ }),
156
+ onState: (agent, info) => {
157
+ const line = describeAdapter(agent, info);
158
+ if (line)
159
+ log(line);
160
+ server?.broadcast('adapter/state', { agent, adapter: info });
161
+ },
162
+ });
163
+ const detect = (id) => detectAgent(id, { exec: execCommand, env: probeEnv, home });
164
+ const service = createAgentService({
165
+ detect,
166
+ exec: execCommand,
167
+ probeEnv,
168
+ cwd: parsed.cwd,
169
+ home,
170
+ adapters,
171
+ nodePath: process.execPath,
172
+ nodeVersion: process.versions.node,
173
+ cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
174
+ });
175
+ const version = packageVersion();
176
+ const origins = allowedOrigins(parsed.dev);
177
+ try {
178
+ server = await startBridgeServer({
179
+ port: parsed.port,
180
+ cwd: parsed.cwd,
181
+ bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
182
+ pairing,
183
+ service,
184
+ agentEnv: buildAgentEnv(),
185
+ allowedOrigins: origins,
186
+ log,
187
+ verbose: parsed.verbose,
188
+ });
189
+ }
190
+ catch (error) {
191
+ const code = error.code;
192
+ if (code === 'EADDRINUSE') {
193
+ console.error(`Port ${parsed.port} is already in use. Another bridge may already be running; stop it, or start this one with --port <n>.`);
194
+ return 1;
195
+ }
196
+ throw error;
197
+ }
198
+ const running = server;
199
+ // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
200
+ // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
201
+ process.on('exit', () => {
202
+ running.processes.killAllNow();
203
+ killActiveExecs();
204
+ });
205
+ // 关终端窗口后再往终端写会报 EIO / EPIPE;没有监听器时这会变成未捕获异常,打断清理。输出丢了无所谓,清理不能断
206
+ process.stdout.on('error', () => undefined);
207
+ process.stderr.on('error', () => undefined);
208
+ // 信号处理必须在服务器开始监听后立刻装好:下面的启动横幅要跑一遍 agent 检测(某个 --version 卡住时最长 15 秒),
209
+ // 这期间按 Ctrl+C 或关终端也要走清理(已配对的页面此时就可能拉起 agent,检测命令本身也在独立进程组里)
210
+ let resolveExit = () => undefined;
211
+ const exited = new Promise((resolve) => {
212
+ resolveExit = resolve;
213
+ });
214
+ const stop = async () => {
215
+ log('Stopping the bridge and its agents…');
216
+ // 一次性命令(npm ci、预检、检测)没有「正常收尾」可言,先同步杀掉,不等 close
217
+ killActiveExecs();
218
+ await running.close();
219
+ const deadline = Date.now() + KILL_GRACE_MS;
220
+ while (running.processes.size > 0 && Date.now() < deadline)
221
+ await new Promise((r) => setTimeout(r, 100));
222
+ running.processes.killAllNow();
223
+ resolveExit(0);
224
+ };
225
+ installShutdownSignals(process, {
226
+ graceful: () => void stop(),
227
+ // 收尾期间又来一个信号(连按 Ctrl+C):不再等宽限期,立刻清理退出(exit 钩子也会再清一遍)
228
+ immediate: () => {
229
+ running.processes.killAllNow();
230
+ killActiveExecs();
231
+ process.exit(130);
232
+ },
233
+ });
234
+ console.log(`M2 local agent bridge ${version}`);
235
+ console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
236
+ console.log(` Working directory for agents: ${parsed.cwd}`);
237
+ console.log(` Accepts pages from: ${origins.join(', ')}`);
238
+ if (parsed.dev)
239
+ console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
240
+ pairing.issueCode('startup');
241
+ const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
242
+ console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
243
+ console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
244
+ return exited;
245
+ }