@wu529778790/open-im 1.5.4-beta.0 → 1.5.5-beta.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/dist/cli.js CHANGED
@@ -1,88 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, execFileSync } from "node:child_process";
3
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
2
  import { main, needsSetup, runInteractiveSetup } from "./index.js";
7
3
  import { loadConfig } from "./config.js";
8
- import { runPlatformSelectionPrompt } from "./setup.js";
9
4
  import { checkAndUpdate } from "./check-update.js";
10
- import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const PID_FILE = join(APP_HOME, "open-im.pid");
13
- const PORT_FILE = join(APP_HOME, "open-im.port");
14
- const INDEX_JS = join(__dirname, "index.js");
15
- // ============================================================================
16
- // PID 文件管理
17
- // ============================================================================
18
- function getPid() {
19
- if (!existsSync(PID_FILE))
20
- return null;
21
- try {
22
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
23
- return isNaN(pid) ? null : pid;
24
- }
25
- catch {
26
- return null;
27
- }
28
- }
29
- function writePid(pid) {
30
- try {
31
- writeFileSync(PID_FILE, String(pid), "utf-8");
32
- }
33
- catch (err) {
34
- console.error("无法写入 PID 文件:", err);
35
- }
36
- }
37
- function removePid() {
38
- try {
39
- if (existsSync(PID_FILE))
40
- unlinkSync(PID_FILE);
41
- }
42
- catch {
43
- /* ignore */
44
- }
45
- }
46
- function isRunning(pid) {
47
- try {
48
- // Windows 下使用 tasklist 检查进程是否存在
49
- if (process.platform === 'win32') {
50
- const result = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
51
- stdio: 'pipe',
52
- windowsHide: true
53
- }).toString();
54
- return result.includes(String(pid));
5
+ import { runWebConfigFlow } from "./config-web.js";
6
+ import { getServiceStatus, removePid, startBackgroundService, stopBackgroundService } from "./service-control.js";
7
+ async function ensureConfigured(mode) {
8
+ const forceWeb = process.env.OPEN_IM_FORCE_WEB === "1";
9
+ if (mode !== "init" && !needsSetup()) {
10
+ try {
11
+ loadConfig();
12
+ return true;
55
13
  }
56
- // Unix 系统使用 kill 0 信号检查
57
- process.kill(pid, 0);
58
- return true;
59
- }
60
- catch {
61
- return false;
62
- }
63
- }
64
- // ============================================================================
65
- // 配置校验
66
- // ============================================================================
67
- async function validateOrSetup() {
68
- if (needsSetup()) {
69
- console.log("\n━━━ open-im 首次配置 ━━━\n");
70
- console.log("检测到尚未配置,将先进入配置向导...\n");
71
- const saved = await runInteractiveSetup();
72
- if (!saved) {
73
- console.log("配置未完成,已取消启动。");
74
- return false;
14
+ catch (error) {
15
+ console.error(error instanceof Error ? error.message : String(error));
75
16
  }
76
- console.log("");
77
17
  }
18
+ if (!process.stdin.isTTY && !forceWeb) {
19
+ return runInteractiveSetup();
20
+ }
21
+ const result = await runWebConfigFlow({ mode, cwd: process.cwd() });
22
+ if (result !== "saved")
23
+ return false;
78
24
  try {
79
25
  loadConfig();
80
26
  return true;
81
27
  }
82
- catch (err) {
83
- const msg = err instanceof Error ? err.message : String(err);
84
- console.error("配置无效或缺少必要字段:", msg);
85
- console.log("\n请运行以下命令重新配置:\n npx @wu529778790/open-im init");
28
+ catch (error) {
29
+ console.error(error instanceof Error ? error.message : String(error));
86
30
  return false;
87
31
  }
88
32
  }
@@ -90,16 +34,14 @@ async function validateOrSetup() {
90
34
  // 命令处理
91
35
  // ============================================================================
92
36
  async function cmdStart() {
93
- const pid = getPid();
94
- if (pid && isRunning(pid)) {
37
+ const status = getServiceStatus();
38
+ if (status.running && status.pid) {
95
39
  console.log("\n🟢 open-im 已在后台运行");
96
- console.log(` pid: ${pid}`);
40
+ console.log(` pid: ${status.pid}`);
97
41
  return;
98
42
  }
99
- else {
100
- removePid(); // 清理可能存在的陈旧 PID 文件
101
- }
102
- if (!(await validateOrSetup())) {
43
+ removePid();
44
+ if (!(await ensureConfigured("start"))) {
103
45
  process.exit(1);
104
46
  }
105
47
  // 检查并自动更新到最新版本
@@ -107,85 +49,38 @@ async function cmdStart() {
107
49
  if (updated) {
108
50
  process.exit(0);
109
51
  }
110
- // TTY 时在父进程让用户选择要启用的平台,再启动子进程
111
- let config = loadConfig();
112
- if (process.stdin.isTTY) {
113
- const updated = await runPlatformSelectionPrompt(config);
114
- if (!updated) {
115
- console.log("已取消启动。");
116
- process.exit(0);
117
- }
118
- }
119
- const child = spawn(process.execPath, [INDEX_JS], {
120
- detached: true,
121
- stdio: "ignore",
122
- cwd: process.cwd(),
123
- env: process.env,
124
- windowsHide: process.platform === "win32",
125
- });
126
- child.unref();
127
- writePid(child.pid);
52
+ const child = startBackgroundService(process.cwd());
128
53
  console.log("\n🟢 open-im 已在后台启动");
129
54
  console.log(` pid: ${child.pid}`);
130
55
  }
131
56
  async function cmdStop() {
132
- const pid = getPid();
133
- if (!pid) {
57
+ const status = getServiceStatus();
58
+ if (!status.pid) {
134
59
  console.log("open-im 未在后台运行");
135
60
  return;
136
61
  }
137
- if (!isRunning(pid)) {
138
- removePid();
139
- console.log("open-im 进程已不存在");
140
- return;
141
- }
142
- const port = existsSync(PORT_FILE)
143
- ? parseInt(readFileSync(PORT_FILE, "utf-8").trim(), 10) || SHUTDOWN_PORT
144
- : SHUTDOWN_PORT;
145
- try {
146
- const res = await fetch(`http://127.0.0.1:${port}/shutdown`, {
147
- signal: AbortSignal.timeout(3000),
148
- });
149
- if (res.ok) {
150
- for (let i = 0; i < 50; i++) {
151
- await new Promise((r) => setTimeout(r, 100));
152
- if (!isRunning(pid))
153
- break;
154
- }
155
- }
156
- }
157
- catch {
158
- // HTTP 失败则用 SIGTERM 兜底
159
- process.kill(pid, "SIGTERM");
160
- await new Promise((r) => setTimeout(r, 500));
161
- }
162
- if (isRunning(pid)) {
163
- process.kill(pid, "SIGKILL");
164
- }
165
- removePid();
166
- try {
167
- if (existsSync(PORT_FILE))
168
- unlinkSync(PORT_FILE);
169
- }
170
- catch {
171
- /* ignore */
172
- }
62
+ const result = await stopBackgroundService();
173
63
  console.log("\n🔴 open-im 已停止");
174
- console.log(` pid: ${pid}`);
64
+ console.log(` pid: ${result.pid}`);
175
65
  }
176
66
  async function cmdInit() {
177
- console.log("\n━━━ open-im 配置向导 ━━━\n");
178
- const saved = await runInteractiveSetup();
179
- if (saved) {
180
- console.log("\n✅ 配置完成!");
181
- console.log("\n现在可以运行以下命令启动服务:");
182
- console.log(" open-im start # 后台运行");
183
- console.log(" open-im dev # 前台运行(调试)");
184
- }
185
- else {
67
+ console.log("\n━━━ open-im 本地控制台 ━━━\n");
68
+ const saved = await ensureConfigured("init");
69
+ if (!saved) {
186
70
  console.log("\n❌ 配置未完成,已取消。");
187
71
  process.exit(1);
188
72
  }
73
+ console.log("\n✅ 配置完成!");
74
+ console.log("\n现在可以运行以下命令启动服务:");
75
+ console.log(" open-im start");
76
+ console.log(" open-im dev");
77
+ }
78
+ async function cmdDev() {
79
+ if (!(await ensureConfigured("dev"))) {
80
+ console.log("配置未完成,已取消启动。");
81
+ process.exit(1);
82
+ }
83
+ await main();
189
84
  }
190
85
  function showHelp(exitCode = 0) {
191
86
  console.log(`
@@ -194,7 +89,7 @@ function showHelp(exitCode = 0) {
194
89
  命令:
195
90
  start 后台运行服务
196
91
  stop 停止后台服务
197
- init 配置向导(首次或追加配置,保留已有平台配置并更新 config.json)
92
+ init 打开本地 Web 配置页
198
93
  dev 前台运行(调试模式),Ctrl+C 停止
199
94
 
200
95
  选项:
@@ -210,13 +105,13 @@ const commands = {
210
105
  start: cmdStart,
211
106
  stop: cmdStop,
212
107
  init: cmdInit,
213
- dev: main,
108
+ dev: cmdDev,
214
109
  };
215
110
  if (cmd === "--help" || cmd === "-h") {
216
111
  showHelp(0);
217
112
  }
218
113
  else if (cmd === undefined) {
219
- main().catch((err) => {
114
+ cmdDev().catch((err) => {
220
115
  console.error(err);
221
116
  process.exit(1);
222
117
  });
@@ -0,0 +1,16 @@
1
+ type WebFlowMode = "init" | "start" | "dev";
2
+ type WebFlowResult = "saved" | "cancel";
3
+ export interface StartedWebConfigServer {
4
+ close: () => Promise<void>;
5
+ url: string;
6
+ waitForResult: Promise<WebFlowResult>;
7
+ }
8
+ export declare function startWebConfigServer(options: {
9
+ mode: WebFlowMode;
10
+ cwd: string;
11
+ }): Promise<StartedWebConfigServer>;
12
+ export declare function runWebConfigFlow(options: {
13
+ mode: WebFlowMode;
14
+ cwd: string;
15
+ }): Promise<WebFlowResult>;
16
+ export {};
@@ -0,0 +1,455 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { URL } from "node:url";
4
+ import { CONFIG_PATH, loadConfig, loadFileConfig, saveFileConfig } from "./config.js";
5
+ import { getServiceStatus, startBackgroundService, stopBackgroundService } from "./service-control.js";
6
+ function splitCsv(value) {
7
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
8
+ }
9
+ function clean(value) {
10
+ const trimmed = value.trim();
11
+ return trimmed ? trimmed : undefined;
12
+ }
13
+ function readJson(request) {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = [];
16
+ request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
17
+ request.on("end", () => {
18
+ try {
19
+ const raw = Buffer.concat(chunks).toString("utf-8");
20
+ resolve((raw ? JSON.parse(raw) : {}));
21
+ }
22
+ catch (error) {
23
+ reject(error);
24
+ }
25
+ });
26
+ request.on("error", reject);
27
+ });
28
+ }
29
+ function json(response, statusCode, body) {
30
+ response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
31
+ response.end(JSON.stringify(body));
32
+ }
33
+ function buildInitialPayload(file) {
34
+ return {
35
+ platforms: {
36
+ telegram: {
37
+ enabled: file.platforms?.telegram?.enabled ?? Boolean(file.platforms?.telegram?.botToken),
38
+ botToken: file.platforms?.telegram?.botToken ?? "",
39
+ proxy: file.platforms?.telegram?.proxy ?? "",
40
+ allowedUserIds: (file.platforms?.telegram?.allowedUserIds ?? []).join(", "),
41
+ },
42
+ feishu: {
43
+ enabled: file.platforms?.feishu?.enabled ?? Boolean(file.platforms?.feishu?.appId && file.platforms?.feishu?.appSecret),
44
+ appId: file.platforms?.feishu?.appId ?? "",
45
+ appSecret: file.platforms?.feishu?.appSecret ?? "",
46
+ allowedUserIds: (file.platforms?.feishu?.allowedUserIds ?? []).join(", "),
47
+ },
48
+ wework: {
49
+ enabled: file.platforms?.wework?.enabled ?? Boolean(file.platforms?.wework?.corpId && file.platforms?.wework?.secret),
50
+ corpId: file.platforms?.wework?.corpId ?? "",
51
+ secret: file.platforms?.wework?.secret ?? "",
52
+ wsUrl: file.platforms?.wework?.wsUrl ?? "",
53
+ allowedUserIds: (file.platforms?.wework?.allowedUserIds ?? []).join(", "),
54
+ },
55
+ dingtalk: {
56
+ enabled: file.platforms?.dingtalk?.enabled ?? Boolean(file.platforms?.dingtalk?.clientId && file.platforms?.dingtalk?.clientSecret),
57
+ clientId: file.platforms?.dingtalk?.clientId ?? "",
58
+ clientSecret: file.platforms?.dingtalk?.clientSecret ?? "",
59
+ cardTemplateId: file.platforms?.dingtalk?.cardTemplateId ?? "",
60
+ allowedUserIds: (file.platforms?.dingtalk?.allowedUserIds ?? []).join(", "),
61
+ },
62
+ },
63
+ ai: {
64
+ aiCommand: file.aiCommand ?? "claude",
65
+ claudeCliPath: file.tools?.claude?.cliPath ?? "claude",
66
+ claudeWorkDir: file.tools?.claude?.workDir ?? process.cwd(),
67
+ claudeSkipPermissions: file.tools?.claude?.skipPermissions ?? true,
68
+ claudeTimeoutMs: file.tools?.claude?.timeoutMs ?? 600000,
69
+ claudeModel: file.tools?.claude?.model ?? "",
70
+ cursorCliPath: file.tools?.cursor?.cliPath ?? "agent",
71
+ codexCliPath: file.tools?.codex?.cliPath ?? "codex",
72
+ codexProxy: file.tools?.codex?.proxy ?? "",
73
+ defaultPermissionMode: file.defaultPermissionMode ?? "ask",
74
+ hookPort: file.hookPort ?? 35801,
75
+ logDir: file.logDir ?? "",
76
+ logLevel: file.logLevel ?? "INFO",
77
+ useSdkMode: file.useSdkMode ?? true,
78
+ },
79
+ };
80
+ }
81
+ function validatePayload(payload) {
82
+ const errors = [];
83
+ const enabledCount = Object.values(payload.platforms).filter((item) => item.enabled).length;
84
+ if (enabledCount === 0)
85
+ errors.push("At least one platform must be enabled.");
86
+ if (payload.platforms.telegram.enabled && !clean(payload.platforms.telegram.botToken))
87
+ errors.push("Telegram bot token is required.");
88
+ if (payload.platforms.feishu.enabled && !clean(payload.platforms.feishu.appId))
89
+ errors.push("Feishu app ID is required.");
90
+ if (payload.platforms.feishu.enabled && !clean(payload.platforms.feishu.appSecret))
91
+ errors.push("Feishu app secret is required.");
92
+ if (payload.platforms.wework.enabled && !clean(payload.platforms.wework.corpId))
93
+ errors.push("WeWork corp ID is required.");
94
+ if (payload.platforms.wework.enabled && !clean(payload.platforms.wework.secret))
95
+ errors.push("WeWork secret is required.");
96
+ if (payload.platforms.dingtalk.enabled && !clean(payload.platforms.dingtalk.clientId))
97
+ errors.push("DingTalk client ID is required.");
98
+ if (payload.platforms.dingtalk.enabled && !clean(payload.platforms.dingtalk.clientSecret))
99
+ errors.push("DingTalk client secret is required.");
100
+ if (!clean(payload.ai.claudeWorkDir))
101
+ errors.push("Default work directory is required.");
102
+ if (!Number.isFinite(payload.ai.claudeTimeoutMs) || payload.ai.claudeTimeoutMs <= 0)
103
+ errors.push("Claude timeout must be positive.");
104
+ if (!Number.isFinite(payload.ai.hookPort) || payload.ai.hookPort <= 0)
105
+ errors.push("Hook port must be positive.");
106
+ return errors;
107
+ }
108
+ function toFileConfig(payload, existing) {
109
+ return {
110
+ ...existing,
111
+ aiCommand: payload.ai.aiCommand,
112
+ defaultPermissionMode: payload.ai.defaultPermissionMode,
113
+ hookPort: payload.ai.hookPort,
114
+ logDir: clean(payload.ai.logDir),
115
+ logLevel: payload.ai.logLevel,
116
+ useSdkMode: payload.ai.useSdkMode,
117
+ tools: {
118
+ claude: {
119
+ ...existing.tools?.claude,
120
+ cliPath: clean(payload.ai.claudeCliPath) ?? "claude",
121
+ workDir: clean(payload.ai.claudeWorkDir) ?? process.cwd(),
122
+ skipPermissions: payload.ai.claudeSkipPermissions,
123
+ timeoutMs: payload.ai.claudeTimeoutMs,
124
+ model: clean(payload.ai.claudeModel),
125
+ },
126
+ cursor: {
127
+ ...existing.tools?.cursor,
128
+ cliPath: clean(payload.ai.cursorCliPath) ?? "agent",
129
+ skipPermissions: existing.tools?.cursor?.skipPermissions ?? payload.ai.claudeSkipPermissions,
130
+ },
131
+ codex: {
132
+ ...existing.tools?.codex,
133
+ cliPath: clean(payload.ai.codexCliPath) ?? "codex",
134
+ workDir: clean(payload.ai.claudeWorkDir) ?? process.cwd(),
135
+ skipPermissions: existing.tools?.codex?.skipPermissions ?? payload.ai.claudeSkipPermissions,
136
+ proxy: clean(payload.ai.codexProxy),
137
+ },
138
+ },
139
+ platforms: {
140
+ ...existing.platforms,
141
+ telegram: {
142
+ ...existing.platforms?.telegram,
143
+ enabled: payload.platforms.telegram.enabled,
144
+ botToken: clean(payload.platforms.telegram.botToken),
145
+ proxy: clean(payload.platforms.telegram.proxy),
146
+ allowedUserIds: splitCsv(payload.platforms.telegram.allowedUserIds),
147
+ },
148
+ feishu: {
149
+ ...existing.platforms?.feishu,
150
+ enabled: payload.platforms.feishu.enabled,
151
+ appId: clean(payload.platforms.feishu.appId),
152
+ appSecret: clean(payload.platforms.feishu.appSecret),
153
+ allowedUserIds: splitCsv(payload.platforms.feishu.allowedUserIds),
154
+ },
155
+ wework: {
156
+ ...existing.platforms?.wework,
157
+ enabled: payload.platforms.wework.enabled,
158
+ corpId: clean(payload.platforms.wework.corpId),
159
+ secret: clean(payload.platforms.wework.secret),
160
+ wsUrl: clean(payload.platforms.wework.wsUrl),
161
+ allowedUserIds: splitCsv(payload.platforms.wework.allowedUserIds),
162
+ },
163
+ dingtalk: {
164
+ ...existing.platforms?.dingtalk,
165
+ enabled: payload.platforms.dingtalk.enabled,
166
+ clientId: clean(payload.platforms.dingtalk.clientId),
167
+ clientSecret: clean(payload.platforms.dingtalk.clientSecret),
168
+ cardTemplateId: clean(payload.platforms.dingtalk.cardTemplateId),
169
+ allowedUserIds: splitCsv(payload.platforms.dingtalk.allowedUserIds),
170
+ },
171
+ },
172
+ };
173
+ }
174
+ function openBrowser(url) {
175
+ if (process.env.OPEN_IM_NO_BROWSER === "1") {
176
+ return;
177
+ }
178
+ if (process.platform === "win32") {
179
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore", windowsHide: true }).unref();
180
+ return;
181
+ }
182
+ if (process.platform === "darwin") {
183
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
184
+ return;
185
+ }
186
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
187
+ }
188
+ const PAGE_HTML = String.raw `<!doctype html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="utf-8" />
192
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
193
+ <title>open-im local control</title>
194
+ <style>
195
+ :root{--bg:#f2ead8;--panel:rgba(255,251,242,.9);--ink:#13231a;--muted:#56675f;--line:rgba(19,35,26,.12);--green:#1a6a44;--orange:#cf6f31;--red:#9d4236}
196
+ *{box-sizing:border-box}body{margin:0;font-family:Georgia,"Times New Roman",serif;color:var(--ink);background:linear-gradient(135deg,#ebe1ce,#f8f3e9)}
197
+ .shell{padding:24px 16px 40px}.frame{max-width:1180px;margin:0 auto;background:var(--panel);border:1px solid var(--line);box-shadow:0 28px 70px rgba(19,35,26,.14)}
198
+ .hero,.toolbar,.section,.footer{padding:20px 22px;border-bottom:1px solid var(--line)}.hero{background:linear-gradient(120deg,rgba(19,35,26,.96),rgba(26,106,68,.92));color:#f7f0df}
199
+ .hero h1,.hero p{margin:0}.hero h1{font-size:clamp(2rem,4vw,3.4rem);line-height:.95}.hero p{margin-top:12px;max-width:720px}
200
+ .pill{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.62);font-size:.9rem}
201
+ .toolbar,.grid,.two-col,.footer,.actions{display:grid;gap:14px}.status-row{display:flex;flex-wrap:wrap;gap:10px}.grid{grid-template-columns:repeat(auto-fit,minmax(250px,1fr))}
202
+ .panel{padding:16px;border:1px solid var(--line);background:rgba(255,255,255,.46);transition:opacity .18s ease,transform .18s ease}.panel.off{opacity:.58}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}
203
+ h2,h3{margin:0}label{display:grid;gap:6px;color:var(--muted);font-size:.92rem}input,select,textarea{width:100%;padding:11px 12px;border:1px solid rgba(19,35,26,.14);background:rgba(255,255,255,.84);font:inherit;color:var(--ink)}
204
+ textarea{min-height:74px;resize:vertical}.two-col{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.toggle{display:inline-flex;align-items:center;gap:10px;color:var(--ink)}.toggle input{width:18px;height:18px}
205
+ .actions{display:flex;flex-wrap:wrap;gap:10px}button{border:0;padding:12px 16px;font:inherit;cursor:pointer;color:#fff7eb;background:var(--ink)}button.secondary{background:var(--green)}button.warning{background:var(--orange)}button.danger{background:var(--red)}button:disabled{opacity:.5;cursor:wait}
206
+ .message{min-height:24px;color:var(--muted)}.message.success{color:var(--green)}.message.error{color:var(--red)}.mono{font-family:Consolas,monospace}.summary{color:var(--muted)}.note{border-left:4px solid var(--orange)}
207
+ </style>
208
+ </head>
209
+ <body>
210
+ <div class="shell">
211
+ <div class="frame">
212
+ <section class="hero">
213
+ <div class="pill">open-im local control</div>
214
+ <h1>Configure fast. Start clean.</h1>
215
+ <p>Local-only configuration for Telegram, Feishu, WeWork, and DingTalk. No accounts. No remote state. No database.</p>
216
+ </section>
217
+ <section class="toolbar">
218
+ <div class="status-row">
219
+ <div class="pill mono" id="configPath"></div>
220
+ <div class="pill" id="serviceState"></div>
221
+ <div class="pill" id="modeBadge"></div>
222
+ </div>
223
+ <div id="statusMeta"></div>
224
+ <div class="summary" id="liveSummary"></div>
225
+ </section>
226
+ <section class="section">
227
+ <div class="panel-head"><h2>Platforms</h2><div>Disabled platforms keep their saved values.</div></div>
228
+ <div class="grid">
229
+ <article class="panel" id="telegram-panel">
230
+ <div class="panel-head"><h3>Telegram</h3><label class="toggle"><input id="telegram-enabled" type="checkbox" /> Enabled</label></div>
231
+ <label>Bot token<input id="telegram-botToken" placeholder="123456:ABC..." /></label>
232
+ <label>Proxy<input id="telegram-proxy" placeholder="http://127.0.0.1:7890" /></label>
233
+ <label>Allowed user IDs<textarea id="telegram-allowedUserIds" placeholder="Comma-separated IDs"></textarea></label>
234
+ </article>
235
+ <article class="panel" id="feishu-panel">
236
+ <div class="panel-head"><h3>Feishu</h3><label class="toggle"><input id="feishu-enabled" type="checkbox" /> Enabled</label></div>
237
+ <label>App ID<input id="feishu-appId" /></label>
238
+ <label>App Secret<input id="feishu-appSecret" /></label>
239
+ <label>Allowed user IDs<textarea id="feishu-allowedUserIds" placeholder="Comma-separated IDs"></textarea></label>
240
+ </article>
241
+ <article class="panel" id="wework-panel">
242
+ <div class="panel-head"><h3>WeWork</h3><label class="toggle"><input id="wework-enabled" type="checkbox" /> Enabled</label></div>
243
+ <label>Corp ID / Bot ID<input id="wework-corpId" /></label>
244
+ <label>Secret<input id="wework-secret" /></label>
245
+ <label>WebSocket URL<input id="wework-wsUrl" placeholder="Optional" /></label>
246
+ <label>Allowed user IDs<textarea id="wework-allowedUserIds" placeholder="Comma-separated IDs"></textarea></label>
247
+ </article>
248
+ <article class="panel" id="dingtalk-panel">
249
+ <div class="panel-head"><h3>DingTalk</h3><label class="toggle"><input id="dingtalk-enabled" type="checkbox" /> Enabled</label></div>
250
+ <label>Client ID / AppKey<input id="dingtalk-clientId" /></label>
251
+ <label>Client Secret / AppSecret<input id="dingtalk-clientSecret" /></label>
252
+ <label>Card template ID<input id="dingtalk-cardTemplateId" placeholder="Optional" /></label>
253
+ <label>Allowed user IDs<textarea id="dingtalk-allowedUserIds" placeholder="Comma-separated IDs"></textarea></label>
254
+ </article>
255
+ </div>
256
+ </section>
257
+ <section class="section">
258
+ <div class="panel-head"><h2>AI Tooling</h2><div>WeChat is intentionally excluded from this first version.</div></div>
259
+ <article class="panel note">Claude credentials are still read from environment variables or <span class="mono">~/.claude/settings.json</span>. This page manages local bridge config, not Claude account auth.</article>
260
+ <article class="panel">
261
+ <div class="two-col">
262
+ <label>Default AI tool<select id="ai-aiCommand"><option value="claude">claude</option><option value="codex">codex</option><option value="cursor">cursor</option></select></label>
263
+ <label>Default work directory<input id="ai-claudeWorkDir" class="mono" /></label>
264
+ <label>Claude CLI path<input id="ai-claudeCliPath" class="mono" /></label>
265
+ <label>Cursor CLI path<input id="ai-cursorCliPath" class="mono" /></label>
266
+ <label>Codex CLI path<input id="ai-codexCliPath" class="mono" /></label>
267
+ <label>Codex proxy<input id="ai-codexProxy" class="mono" placeholder="Optional" /></label>
268
+ <label>Claude timeout (ms)<input id="ai-claudeTimeoutMs" type="number" min="1" /></label>
269
+ <label>Claude model<input id="ai-claudeModel" placeholder="Optional" /></label>
270
+ <label>Permission mode<select id="ai-defaultPermissionMode"><option value="ask">ask</option><option value="accept-edits">accept-edits</option><option value="plan">plan</option><option value="yolo">yolo</option></select></label>
271
+ <label>Hook port<input id="ai-hookPort" type="number" min="1" /></label>
272
+ <label>Log directory<input id="ai-logDir" class="mono" /></label>
273
+ <label>Log level<select id="ai-logLevel"><option value="DEBUG">DEBUG</option><option value="INFO">INFO</option><option value="WARN">WARN</option><option value="ERROR">ERROR</option></select></label>
274
+ </div>
275
+ <div class="actions" style="margin-top:14px">
276
+ <label class="toggle"><input id="ai-claudeSkipPermissions" type="checkbox" /> Auto-approve tool permissions</label>
277
+ <label class="toggle"><input id="ai-useSdkMode" type="checkbox" /> Use Claude SDK mode</label>
278
+ </div>
279
+ </article>
280
+ </section>
281
+ <section class="footer">
282
+ <div class="actions">
283
+ <button id="validateButton" class="warning">Validate</button>
284
+ <button id="saveButton" class="secondary">Save config</button>
285
+ <button id="startButton">Start service</button>
286
+ <button id="stopButton" class="danger">Stop service</button>
287
+ </div>
288
+ <div class="message" id="message"></div>
289
+ </section>
290
+ </div>
291
+ </div>
292
+ <script>
293
+ const ids = ["telegram-enabled","telegram-botToken","telegram-proxy","telegram-allowedUserIds","feishu-enabled","feishu-appId","feishu-appSecret","feishu-allowedUserIds","wework-enabled","wework-corpId","wework-secret","wework-wsUrl","wework-allowedUserIds","dingtalk-enabled","dingtalk-clientId","dingtalk-clientSecret","dingtalk-cardTemplateId","dingtalk-allowedUserIds","ai-aiCommand","ai-claudeCliPath","ai-claudeWorkDir","ai-claudeSkipPermissions","ai-claudeTimeoutMs","ai-claudeModel","ai-cursorCliPath","ai-codexCliPath","ai-codexProxy","ai-defaultPermissionMode","ai-hookPort","ai-logDir","ai-logLevel","ai-useSdkMode"];
294
+ const el = (id) => document.getElementById(id);
295
+ const setMessage = (text, type="") => { const node = el("message"); node.textContent = text; node.className = ("message " + type).trim(); };
296
+ const setBusy = (busy) => ["validateButton","saveButton","startButton","stopButton"].forEach((id) => { el(id).disabled = busy; });
297
+ function updateVisualState() {
298
+ const enabled = [];
299
+ [["telegram","Telegram"],["feishu","Feishu"],["wework","WeWork"],["dingtalk","DingTalk"]].forEach(([key,label]) => {
300
+ const active = el(key + "-enabled").checked;
301
+ el(key + "-panel").classList.toggle("off", !active);
302
+ if (active) enabled.push(label);
303
+ });
304
+ const aiTool = el("ai-aiCommand").value;
305
+ el("liveSummary").textContent = enabled.length
306
+ ? ("Enabled platforms: " + enabled.join(", ") + " | AI tool: " + aiTool)
307
+ : ("No platform enabled yet | AI tool: " + aiTool);
308
+ }
309
+ const payload = () => ({ platforms: { telegram: { enabled: el("telegram-enabled").checked, botToken: el("telegram-botToken").value, proxy: el("telegram-proxy").value, allowedUserIds: el("telegram-allowedUserIds").value }, feishu: { enabled: el("feishu-enabled").checked, appId: el("feishu-appId").value, appSecret: el("feishu-appSecret").value, allowedUserIds: el("feishu-allowedUserIds").value }, wework: { enabled: el("wework-enabled").checked, corpId: el("wework-corpId").value, secret: el("wework-secret").value, wsUrl: el("wework-wsUrl").value, allowedUserIds: el("wework-allowedUserIds").value }, dingtalk: { enabled: el("dingtalk-enabled").checked, clientId: el("dingtalk-clientId").value, clientSecret: el("dingtalk-clientSecret").value, cardTemplateId: el("dingtalk-cardTemplateId").value, allowedUserIds: el("dingtalk-allowedUserIds").value } }, ai: { aiCommand: el("ai-aiCommand").value, claudeCliPath: el("ai-claudeCliPath").value, claudeWorkDir: el("ai-claudeWorkDir").value, claudeSkipPermissions: el("ai-claudeSkipPermissions").checked, claudeTimeoutMs: Number(el("ai-claudeTimeoutMs").value || "0"), claudeModel: el("ai-claudeModel").value, cursorCliPath: el("ai-cursorCliPath").value, codexCliPath: el("ai-codexCliPath").value, codexProxy: el("ai-codexProxy").value, defaultPermissionMode: el("ai-defaultPermissionMode").value, hookPort: Number(el("ai-hookPort").value || "0"), logDir: el("ai-logDir").value, logLevel: el("ai-logLevel").value, useSdkMode: el("ai-useSdkMode").checked } });
310
+ async function request(path, options={}) { const response = await fetch(path, { headers: { "content-type": "application/json" }, ...options }); const body = await response.json(); if (!response.ok) throw new Error(body.error || "Request failed"); return body; }
311
+ function fill(data, meta) { el("configPath").textContent = meta.configPath; el("modeBadge").textContent = "Flow: " + meta.mode; el("telegram-enabled").checked = data.platforms.telegram.enabled; el("telegram-botToken").value = data.platforms.telegram.botToken; el("telegram-proxy").value = data.platforms.telegram.proxy; el("telegram-allowedUserIds").value = data.platforms.telegram.allowedUserIds; el("feishu-enabled").checked = data.platforms.feishu.enabled; el("feishu-appId").value = data.platforms.feishu.appId; el("feishu-appSecret").value = data.platforms.feishu.appSecret; el("feishu-allowedUserIds").value = data.platforms.feishu.allowedUserIds; el("wework-enabled").checked = data.platforms.wework.enabled; el("wework-corpId").value = data.platforms.wework.corpId; el("wework-secret").value = data.platforms.wework.secret; el("wework-wsUrl").value = data.platforms.wework.wsUrl; el("wework-allowedUserIds").value = data.platforms.wework.allowedUserIds; el("dingtalk-enabled").checked = data.platforms.dingtalk.enabled; el("dingtalk-clientId").value = data.platforms.dingtalk.clientId; el("dingtalk-clientSecret").value = data.platforms.dingtalk.clientSecret; el("dingtalk-cardTemplateId").value = data.platforms.dingtalk.cardTemplateId; el("dingtalk-allowedUserIds").value = data.platforms.dingtalk.allowedUserIds; el("ai-aiCommand").value = data.ai.aiCommand; el("ai-claudeCliPath").value = data.ai.claudeCliPath; el("ai-claudeWorkDir").value = data.ai.claudeWorkDir; el("ai-claudeSkipPermissions").checked = data.ai.claudeSkipPermissions; el("ai-claudeTimeoutMs").value = String(data.ai.claudeTimeoutMs); el("ai-claudeModel").value = data.ai.claudeModel; el("ai-cursorCliPath").value = data.ai.cursorCliPath; el("ai-codexCliPath").value = data.ai.codexCliPath; el("ai-codexProxy").value = data.ai.codexProxy; el("ai-defaultPermissionMode").value = data.ai.defaultPermissionMode; el("ai-hookPort").value = String(data.ai.hookPort); el("ai-logDir").value = data.ai.logDir; el("ai-logLevel").value = data.ai.logLevel; el("ai-useSdkMode").checked = data.ai.useSdkMode; updateVisualState(); }
312
+ async function refreshStatus() { const data = await request("/api/service/status"); el("serviceState").textContent = data.running ? ("Service running (pid " + data.pid + ")") : "Service stopped"; el("statusMeta").textContent = data.running ? "Background bridge process is active." : "No background bridge process is active."; }
313
+ async function boot() { setBusy(true); try { const data = await request("/api/config"); fill(data.payload, data.meta); await refreshStatus(); setMessage("Control surface ready.", "success"); } catch (error) { setMessage(error.message || String(error), "error"); } finally { setBusy(false); } setInterval(() => { refreshStatus().catch(() => {}); }, 5000); ids.forEach((id) => { const node = el(id); if (node) node.addEventListener("input", updateVisualState); if (node) node.addEventListener("change", updateVisualState); }); }
314
+ async function validate() { setBusy(true); try { const data = await request("/api/config/validate", { method: "POST", body: JSON.stringify(payload()) }); setMessage(data.message, "success"); } catch (error) { setMessage(error.message || String(error), "error"); } finally { setBusy(false); } }
315
+ async function save() { setBusy(true); try { const data = await request("/api/config/save?final=1", { method: "POST", body: JSON.stringify(payload()) }); setMessage(data.message, "success"); } catch (error) { setMessage(error.message || String(error), "error"); } finally { setBusy(false); } }
316
+ async function startService() { setBusy(true); try { await request("/api/config/save", { method: "POST", body: JSON.stringify(payload()) }); const data = await request("/api/service/start", { method: "POST" }); await refreshStatus(); setMessage(data.message, "success"); } catch (error) { setMessage(error.message || String(error), "error"); } finally { setBusy(false); } }
317
+ async function stopService() { setBusy(true); try { const data = await request("/api/service/stop", { method: "POST" }); await refreshStatus(); setMessage(data.message, "success"); } catch (error) { setMessage(error.message || String(error), "error"); } finally { setBusy(false); } }
318
+ el("validateButton").onclick = validate; el("saveButton").onclick = save; el("startButton").onclick = startService; el("stopButton").onclick = stopService; boot();
319
+ </script>
320
+ </body>
321
+ </html>`;
322
+ export async function startWebConfigServer(options) {
323
+ let timer = null;
324
+ let settled = false;
325
+ let settle;
326
+ const waitForResult = new Promise((resolve) => {
327
+ settle = (value) => {
328
+ if (settled)
329
+ return;
330
+ settled = true;
331
+ resolve(value);
332
+ };
333
+ });
334
+ const server = createServer(async (request, response) => {
335
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
336
+ const finishFlow = (result) => {
337
+ if (timer)
338
+ clearTimeout(timer);
339
+ server.close();
340
+ settle(result);
341
+ };
342
+ if (request.method === "GET" && requestUrl.pathname === "/") {
343
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
344
+ response.end(PAGE_HTML);
345
+ return;
346
+ }
347
+ if (request.method === "GET" && requestUrl.pathname === "/api/config") {
348
+ json(response, 200, {
349
+ payload: buildInitialPayload(loadFileConfig()),
350
+ meta: { configPath: CONFIG_PATH, mode: options.mode },
351
+ });
352
+ return;
353
+ }
354
+ if (request.method === "POST" && requestUrl.pathname === "/api/config/validate") {
355
+ try {
356
+ const body = await readJson(request);
357
+ const errors = validatePayload(body);
358
+ if (errors.length > 0) {
359
+ json(response, 400, { error: errors.join(" ") });
360
+ return;
361
+ }
362
+ json(response, 200, { message: "Configuration looks internally consistent." });
363
+ }
364
+ catch (error) {
365
+ json(response, 400, { error: error instanceof Error ? error.message : String(error) });
366
+ }
367
+ return;
368
+ }
369
+ if (request.method === "POST" && requestUrl.pathname === "/api/config/save") {
370
+ try {
371
+ const body = await readJson(request);
372
+ const errors = validatePayload(body);
373
+ if (errors.length > 0) {
374
+ json(response, 400, { error: errors.join(" ") });
375
+ return;
376
+ }
377
+ saveFileConfig(toFileConfig(body, loadFileConfig()));
378
+ loadConfig();
379
+ json(response, 200, { message: "Configuration saved." });
380
+ if (requestUrl.searchParams.get("final") === "1") {
381
+ setTimeout(() => finishFlow("saved"), 120);
382
+ }
383
+ }
384
+ catch (error) {
385
+ json(response, 400, { error: error instanceof Error ? error.message : String(error) });
386
+ }
387
+ return;
388
+ }
389
+ if (request.method === "GET" && requestUrl.pathname === "/api/service/status") {
390
+ json(response, 200, getServiceStatus());
391
+ return;
392
+ }
393
+ if (request.method === "POST" && requestUrl.pathname === "/api/service/start") {
394
+ try {
395
+ loadConfig();
396
+ const started = startBackgroundService(options.cwd);
397
+ json(response, 200, { message: `Background service started with pid ${started.pid}.`, pid: started.pid });
398
+ setTimeout(() => finishFlow("saved"), 120);
399
+ }
400
+ catch (error) {
401
+ json(response, 400, { error: error instanceof Error ? error.message : String(error) });
402
+ }
403
+ return;
404
+ }
405
+ if (request.method === "POST" && requestUrl.pathname === "/api/service/stop") {
406
+ try {
407
+ const result = await stopBackgroundService();
408
+ json(response, 200, { message: result.pid ? `Background service stopped (pid ${result.pid}).` : "No background service was running." });
409
+ }
410
+ catch (error) {
411
+ json(response, 400, { error: error instanceof Error ? error.message : String(error) });
412
+ }
413
+ return;
414
+ }
415
+ json(response, 404, { error: "Not found." });
416
+ });
417
+ await new Promise((resolve) => {
418
+ server.listen(0, "127.0.0.1", () => resolve());
419
+ });
420
+ const address = server.address();
421
+ if (!address || typeof address === "string") {
422
+ server.close();
423
+ settle("cancel");
424
+ return {
425
+ close: async () => { },
426
+ url: "",
427
+ waitForResult,
428
+ };
429
+ }
430
+ timer = setTimeout(() => {
431
+ server.close();
432
+ settle("cancel");
433
+ }, 15 * 60 * 1000);
434
+ server.on("close", () => {
435
+ if (timer)
436
+ clearTimeout(timer);
437
+ });
438
+ return {
439
+ close: async () => {
440
+ if (timer)
441
+ clearTimeout(timer);
442
+ server.close();
443
+ settle("cancel");
444
+ },
445
+ url: `http://127.0.0.1:${address.port}`,
446
+ waitForResult,
447
+ };
448
+ }
449
+ export async function runWebConfigFlow(options) {
450
+ const started = await startWebConfigServer(options);
451
+ openBrowser(started.url);
452
+ console.log(`Opened local configuration page: ${started.url}`);
453
+ console.log(process.env.OPEN_IM_NO_BROWSER === "1" ? "Browser launch disabled. Open the URL manually." : "Save the configuration in your browser to continue.");
454
+ return started.waitForResult;
455
+ }
package/dist/config.d.ts CHANGED
@@ -73,6 +73,92 @@ export interface Config {
73
73
  };
74
74
  };
75
75
  }
76
+ export interface FilePlatformTelegram {
77
+ enabled?: boolean;
78
+ botToken?: string;
79
+ allowedUserIds?: string[];
80
+ proxy?: string;
81
+ }
82
+ export interface FilePlatformFeishu {
83
+ enabled?: boolean;
84
+ appId?: string;
85
+ appSecret?: string;
86
+ allowedUserIds?: string[];
87
+ }
88
+ export interface FilePlatformWechat {
89
+ enabled?: boolean;
90
+ appId?: string;
91
+ appSecret?: string;
92
+ token?: string;
93
+ jwtToken?: string;
94
+ loginKey?: string;
95
+ guid?: string;
96
+ userId?: string;
97
+ wsUrl?: string;
98
+ allowedUserIds?: string[];
99
+ }
100
+ export interface FilePlatformWework {
101
+ enabled?: boolean;
102
+ corpId?: string;
103
+ secret?: string;
104
+ wsUrl?: string;
105
+ allowedUserIds?: string[];
106
+ }
107
+ export interface FilePlatformDingtalk {
108
+ enabled?: boolean;
109
+ clientId?: string;
110
+ clientSecret?: string;
111
+ allowedUserIds?: string[];
112
+ cardTemplateId?: string;
113
+ }
114
+ export interface FileToolClaude {
115
+ cliPath?: string;
116
+ workDir?: string;
117
+ skipPermissions?: boolean;
118
+ timeoutMs?: number;
119
+ model?: string;
120
+ }
121
+ export interface FileToolCursor {
122
+ cliPath?: string;
123
+ /** 是否跳过权限确认(默认 true,与 tools.claude 共用权限服务器) */
124
+ skipPermissions?: boolean;
125
+ }
126
+ export interface FileToolCodex {
127
+ cliPath?: string;
128
+ workDir?: string;
129
+ /** 是否跳过权限确认(默认 true) */
130
+ skipPermissions?: boolean;
131
+ /** HTTP/HTTPS 代理,用于访问 chatgpt.com(如 http://127.0.0.1:7890) */
132
+ proxy?: string;
133
+ }
134
+ export interface FileConfig {
135
+ telegramBotToken?: string;
136
+ feishuAppId?: string;
137
+ feishuAppSecret?: string;
138
+ allowedUserIds?: string[];
139
+ platforms?: {
140
+ telegram?: FilePlatformTelegram;
141
+ feishu?: FilePlatformFeishu;
142
+ wechat?: FilePlatformWechat;
143
+ wework?: FilePlatformWework;
144
+ dingtalk?: FilePlatformDingtalk;
145
+ };
146
+ env?: Record<string, string>;
147
+ aiCommand?: string;
148
+ tools?: {
149
+ claude?: FileToolClaude;
150
+ cursor?: FileToolCursor;
151
+ codex?: FileToolCodex;
152
+ };
153
+ defaultPermissionMode?: 'ask' | 'accept-edits' | 'plan' | 'yolo';
154
+ hookPort?: number;
155
+ logDir?: string;
156
+ logLevel?: LogLevel;
157
+ useSdkMode?: boolean;
158
+ }
159
+ export declare const CONFIG_PATH: string;
160
+ export declare function loadFileConfig(): FileConfig;
161
+ export declare function saveFileConfig(raw: FileConfig): void;
76
162
  /** 检查是否已配置 Claude API 凭证 */
77
163
  export declare function hasClaudeCredentials(): boolean;
78
164
  /** 检测是否需要交互式配置(无 token 且无环境变量) */
package/dist/config.js CHANGED
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process';
9
9
  import { join, dirname, isAbsolute } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { APP_HOME } from './constants.js';
12
- const CONFIG_PATH = join(APP_HOME, 'config.json');
12
+ export const CONFIG_PATH = join(APP_HOME, 'config.json');
13
13
  const CODEX_AUTH_PATHS = [
14
14
  join(homedir(), '.codex', 'auth.json'),
15
15
  join(homedir(), '.config', 'codex', 'auth.json'),
@@ -99,7 +99,7 @@ function ensureToolsSkipPermissions(raw) {
99
99
  }
100
100
  return changed;
101
101
  }
102
- function loadFileConfig() {
102
+ export function loadFileConfig() {
103
103
  try {
104
104
  const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
105
105
  if (!raw || typeof raw !== 'object')
@@ -119,6 +119,12 @@ function loadFileConfig() {
119
119
  return {};
120
120
  }
121
121
  }
122
+ export function saveFileConfig(raw) {
123
+ const dir = dirname(CONFIG_PATH);
124
+ if (!existsSync(dir))
125
+ mkdirSync(dir, { recursive: true });
126
+ writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2), 'utf-8');
127
+ }
122
128
  /** 获取用户主目录(兼容不同运行环境,如 launchd、systemd 等) */
123
129
  function getClaudeConfigHome() {
124
130
  return process.env.HOME || process.env.USERPROFILE || homedir();
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { createServer } from "node:http";
2
2
  import { writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { loadConfig, needsSetup } from "./config.js";
5
- import { runInteractiveSetup, runPlatformSelectionPrompt, runClaudeApiSetup } from "./setup.js";
5
+ import { runInteractiveSetup, runClaudeApiSetup } from "./setup.js";
6
+ import { runWebConfigFlow } from "./config-web.js";
6
7
  // 导出供 cli.ts 使用
7
8
  export { needsSetup, runInteractiveSetup };
8
9
  import { initTelegram, stopTelegram } from "./telegram/client.js";
@@ -91,7 +92,9 @@ function buildStartupMessage(platform, appVersion, aiCommand, defaultWorkDir, su
91
92
  }
92
93
  export async function main() {
93
94
  if (needsSetup()) {
94
- const saved = await runInteractiveSetup();
95
+ const saved = process.stdin.isTTY
96
+ ? (await runWebConfigFlow({ mode: "dev", cwd: process.cwd() })) === "saved"
97
+ : await runInteractiveSetup();
95
98
  if (!saved)
96
99
  process.exit(1);
97
100
  }
@@ -114,13 +117,6 @@ export async function main() {
114
117
  throw err;
115
118
  }
116
119
  }
117
- // 有 TTY 时让用户选择要启用的平台(无论单通道还是多通道)
118
- if (process.stdin.isTTY) {
119
- const updated = await runPlatformSelectionPrompt(config);
120
- if (!updated)
121
- process.exit(0);
122
- config = updated;
123
- }
124
120
  initLogger(config.logDir, config.logLevel);
125
121
  loadActiveChats();
126
122
  initPermissionModes();
@@ -0,0 +1,15 @@
1
+ export declare function getPid(): number | null;
2
+ export declare function writePid(pid: number): void;
3
+ export declare function removePid(): void;
4
+ export declare function isRunning(pid: number): boolean;
5
+ export declare function getServiceStatus(): {
6
+ running: boolean;
7
+ pid: number | null;
8
+ };
9
+ export declare function startBackgroundService(cwd: string): {
10
+ pid: number;
11
+ };
12
+ export declare function stopBackgroundService(): Promise<{
13
+ pid: number | null;
14
+ stopped: boolean;
15
+ }>;
@@ -0,0 +1,131 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, extname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const PID_FILE = join(APP_HOME, "open-im.pid");
8
+ const PORT_FILE = join(APP_HOME, "open-im.port");
9
+ function getServiceEntry() {
10
+ const extension = extname(fileURLToPath(import.meta.url));
11
+ if (extension === ".ts") {
12
+ return {
13
+ command: process.execPath,
14
+ args: ["--import", "tsx", join(__dirname, "index.ts")],
15
+ };
16
+ }
17
+ return {
18
+ command: process.execPath,
19
+ args: [join(__dirname, "index.js")],
20
+ };
21
+ }
22
+ export function getPid() {
23
+ if (!existsSync(PID_FILE))
24
+ return null;
25
+ try {
26
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
27
+ return Number.isNaN(pid) ? null : pid;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function writePid(pid) {
34
+ writeFileSync(PID_FILE, String(pid), "utf-8");
35
+ }
36
+ export function removePid() {
37
+ try {
38
+ if (existsSync(PID_FILE))
39
+ unlinkSync(PID_FILE);
40
+ }
41
+ catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+ export function isRunning(pid) {
46
+ try {
47
+ if (process.platform === "win32") {
48
+ const result = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], {
49
+ stdio: "pipe",
50
+ windowsHide: true,
51
+ }).toString();
52
+ return result.includes(String(pid));
53
+ }
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export function getServiceStatus() {
62
+ const pid = getPid();
63
+ if (!pid)
64
+ return { running: false, pid: null };
65
+ if (!isRunning(pid)) {
66
+ removePid();
67
+ return { running: false, pid: null };
68
+ }
69
+ return { running: true, pid };
70
+ }
71
+ export function startBackgroundService(cwd) {
72
+ const current = getServiceStatus();
73
+ if (current.running && current.pid) {
74
+ return { pid: current.pid };
75
+ }
76
+ removePid();
77
+ const entry = getServiceEntry();
78
+ const child = spawn(entry.command, entry.args, {
79
+ detached: true,
80
+ stdio: "ignore",
81
+ cwd,
82
+ env: process.env,
83
+ windowsHide: process.platform === "win32",
84
+ });
85
+ child.unref();
86
+ if (!child.pid) {
87
+ throw new Error("Failed to start background service.");
88
+ }
89
+ writePid(child.pid);
90
+ return { pid: child.pid };
91
+ }
92
+ export async function stopBackgroundService() {
93
+ const pid = getPid();
94
+ if (!pid)
95
+ return { pid: null, stopped: false };
96
+ if (!isRunning(pid)) {
97
+ removePid();
98
+ return { pid, stopped: true };
99
+ }
100
+ const port = existsSync(PORT_FILE)
101
+ ? parseInt(readFileSync(PORT_FILE, "utf-8").trim(), 10) || SHUTDOWN_PORT
102
+ : SHUTDOWN_PORT;
103
+ try {
104
+ const response = await fetch(`http://127.0.0.1:${port}/shutdown`, {
105
+ signal: AbortSignal.timeout(3000),
106
+ });
107
+ if (response.ok) {
108
+ for (let index = 0; index < 50; index += 1) {
109
+ await new Promise((resolve) => setTimeout(resolve, 100));
110
+ if (!isRunning(pid))
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ catch {
116
+ process.kill(pid, "SIGTERM");
117
+ await new Promise((resolve) => setTimeout(resolve, 500));
118
+ }
119
+ if (isRunning(pid)) {
120
+ process.kill(pid, "SIGKILL");
121
+ }
122
+ removePid();
123
+ try {
124
+ if (existsSync(PORT_FILE))
125
+ unlinkSync(PORT_FILE);
126
+ }
127
+ catch {
128
+ /* ignore */
129
+ }
130
+ return { pid, stopped: true };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.5.4-beta.0",
3
+ "version": "1.5.5-beta.0",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",