codex-to-im 1.0.20 → 1.0.22
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/README.md +44 -4
- package/README_EN.md +44 -10
- package/config.env.example +22 -9
- package/dist/cli.mjs +370 -3
- package/dist/daemon.mjs +645 -317
- package/dist/ui-server.mjs +1486 -728
- package/docs/install-windows.md +41 -17
- package/package.json +1 -1
- package/scripts/doctor.sh +12 -5
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
它的主路径不是“改造 Codex 本体”,而是:
|
|
8
8
|
|
|
9
9
|
1. 在本机启动一个 Web 工作台和 bridge
|
|
10
|
-
2.
|
|
10
|
+
2. 在工作台中新增并配置一个或多个通道实例
|
|
11
11
|
3. 把桌面里的 Codex 会话接到 IM
|
|
12
12
|
4. 在 IM 中继续对话、切线程、查看状态
|
|
13
13
|
|
|
@@ -22,8 +22,16 @@
|
|
|
22
22
|
|
|
23
23
|
## 支持的通道
|
|
24
24
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
25
|
+
- 飞书:支持创建多个机器人实例、连通性测试、共享线程、流式卡片、图片和文件发送。
|
|
26
|
+
- 微信:支持创建多个实例、扫码登录、共享线程和文本反馈。
|
|
27
|
+
|
|
28
|
+
每个通道实例都可以配置自己的别名,例如:
|
|
29
|
+
|
|
30
|
+
- `飞书主号`
|
|
31
|
+
- `飞书备份号`
|
|
32
|
+
- `微信工作号`
|
|
33
|
+
|
|
34
|
+
这些别名只用于区分不同聊天入口,不会改变 Codex 会话本身的语义。
|
|
27
35
|
|
|
28
36
|
## 快速开始
|
|
29
37
|
|
|
@@ -50,6 +58,31 @@ npm install -g codex-to-im
|
|
|
50
58
|
codex-to-im
|
|
51
59
|
```
|
|
52
60
|
|
|
61
|
+
如果只想启动后台 bridge,不打开 UI:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
codex-to-im start
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 开机自启动(Windows)
|
|
68
|
+
|
|
69
|
+
当前支持把 **bridge** 注册为 Windows 开机启动任务,UI 仍然按需通过 `codex-to-im` 打开。
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
codex-to-im autostart status
|
|
73
|
+
codex-to-im autostart install
|
|
74
|
+
codex-to-im autostart uninstall
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
说明:
|
|
78
|
+
|
|
79
|
+
- `codex-to-im autostart install` 和 `codex-to-im autostart uninstall` 需要在**管理员 PowerShell / 终端**中执行。
|
|
80
|
+
- 安装时会要求输入当前 Windows 登录密码,用于创建开机任务。
|
|
81
|
+
- 自动启动只拉起 bridge,不会自动打开 Web UI。
|
|
82
|
+
- 手动再次执行 `codex-to-im` 只会补启动 UI,不会重复启动 bridge。
|
|
83
|
+
- 当前实现基于 Windows 自带任务计划程序,不依赖 WinSW、NSSM 等第三方组件。
|
|
84
|
+
- Web 工作台现在只展示自动启动状态;真正的启用和关闭请使用上面的管理员命令。
|
|
85
|
+
|
|
53
86
|
默认会打开本地工作台:
|
|
54
87
|
|
|
55
88
|
```text
|
|
@@ -73,7 +106,7 @@ codex-to-im stop
|
|
|
73
106
|
|
|
74
107
|
### 1. 接管桌面线程
|
|
75
108
|
|
|
76
|
-
在 Web
|
|
109
|
+
在 Web 工作台里新增好飞书或微信通道实例后,启动 bridge。
|
|
77
110
|
然后在 IM 中发送:
|
|
78
111
|
|
|
79
112
|
```text
|
|
@@ -144,6 +177,13 @@ codex-to-im stop
|
|
|
144
177
|
- 默认模型:从本机可用模型中选择。
|
|
145
178
|
- 反馈使用 markdown:控制 bridge 发到通道里的文本反馈是否走 markdown。
|
|
146
179
|
- 允许局域网访问 Web 控制台:便于手机或局域网设备访问。
|
|
180
|
+
- 通道实例:可以为飞书或微信新增多个机器人/账号入口,并给每个实例设置别名。
|
|
181
|
+
|
|
182
|
+
当前主配置保存在:
|
|
183
|
+
|
|
184
|
+
- `~/.codex-to-im/config.v2.json`
|
|
185
|
+
|
|
186
|
+
兼容保留的 `config.env` 只作为快照和旧工具兜底,不再完整表示多实例通道配置。
|
|
147
187
|
|
|
148
188
|
## 当前边界
|
|
149
189
|
|
package/README_EN.md
CHANGED
|
@@ -8,7 +8,7 @@ The product is no longer centered around a Codex skill. The main path is:
|
|
|
8
8
|
|
|
9
9
|
1. Install `codex-to-im`
|
|
10
10
|
2. Open the local web workbench
|
|
11
|
-
3.
|
|
11
|
+
3. Create one or more channel instances in the workbench
|
|
12
12
|
4. Start the bridge in the background
|
|
13
13
|
5. Bind real desktop Codex threads to Feishu or Weixin chats
|
|
14
14
|
|
|
@@ -29,8 +29,8 @@ Windows host installation guide: [docs/install-windows.md](docs/install-windows.
|
|
|
29
29
|
|
|
30
30
|
- Local background bridge service
|
|
31
31
|
- Local web workbench for configuration, testing, logs, and bindings
|
|
32
|
-
- Feishu
|
|
33
|
-
- Weixin
|
|
32
|
+
- Multi-instance Feishu bot setup and connectivity testing
|
|
33
|
+
- Multi-instance Weixin login flow
|
|
34
34
|
- Desktop session discovery from `~/.codex/sessions`
|
|
35
35
|
- Web-side binding updates for IM chats
|
|
36
36
|
|
|
@@ -85,6 +85,31 @@ codex-to-im
|
|
|
85
85
|
|
|
86
86
|
This launches the local workbench and opens it in your browser.
|
|
87
87
|
|
|
88
|
+
If you only want the background bridge without opening the UI:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
codex-to-im start
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Boot Autostart on Windows
|
|
95
|
+
|
|
96
|
+
The bridge can be registered as a Windows boot task. The Web UI remains on-demand and is still opened manually with `codex-to-im`.
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
codex-to-im autostart status
|
|
100
|
+
codex-to-im autostart install
|
|
101
|
+
codex-to-im autostart uninstall
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Notes:
|
|
105
|
+
|
|
106
|
+
- `codex-to-im autostart install` and `codex-to-im autostart uninstall` must be run from an **elevated Administrator PowerShell / terminal**.
|
|
107
|
+
- Installation prompts for the current Windows account password so the startup task can be created.
|
|
108
|
+
- Autostart only launches the bridge; it does not open the Web UI.
|
|
109
|
+
- Running `codex-to-im` manually later only starts the UI if needed and will not duplicate the bridge.
|
|
110
|
+
- The current implementation uses Windows Task Scheduler and does not require WinSW, NSSM, or PM2.
|
|
111
|
+
- The Web UI is read-only for autostart status; enable/disable it from the administrator terminal commands above.
|
|
112
|
+
|
|
88
113
|
By default the workbench runs at:
|
|
89
114
|
|
|
90
115
|
```text
|
|
@@ -123,12 +148,13 @@ codex-to-im stop
|
|
|
123
148
|
## Main Workflow
|
|
124
149
|
|
|
125
150
|
1. Open the workbench
|
|
126
|
-
2.
|
|
127
|
-
3.
|
|
128
|
-
4.
|
|
129
|
-
5.
|
|
130
|
-
6.
|
|
131
|
-
7.
|
|
151
|
+
2. Create a Feishu or Weixin channel instance in the workbench
|
|
152
|
+
3. Give the instance an alias such as `Feishu Main` or `Weixin Work`
|
|
153
|
+
4. Save config and test connectivity
|
|
154
|
+
5. Start the bridge
|
|
155
|
+
6. Open the desktop sessions section
|
|
156
|
+
7. Bind a Feishu or Weixin chat to the target thread
|
|
157
|
+
8. Continue the same Codex thread from IM
|
|
132
158
|
|
|
133
159
|
If LAN access is enabled, the easiest path is to copy the LAN login link from the local workbench and open it on your phone or another device on the same network.
|
|
134
160
|
|
|
@@ -179,7 +205,13 @@ The configuration page also includes Codex runtime controls:
|
|
|
179
205
|
- global default reasoning level
|
|
180
206
|
- can be overridden per IM session with `/reasoning`
|
|
181
207
|
- official runtime levels are `minimal`, `low`, `medium`, `high`, `xhigh`
|
|
182
|
-
|
|
208
|
+
- IM numeric aliases are `1=minimal`, `2=low`, `3=medium`, `4=high`, `5=xhigh`
|
|
209
|
+
|
|
210
|
+
The primary persisted configuration now lives in:
|
|
211
|
+
|
|
212
|
+
- `~/.codex-to-im/config.v2.json`
|
|
213
|
+
|
|
214
|
+
The legacy `config.env` file is still written as a compatibility snapshot, but it no longer fully represents multi-instance channel setup.
|
|
183
215
|
|
|
184
216
|
If you are using `codex-to-im` on your own development machine for real coding work, the more aggressive recommended setup is:
|
|
185
217
|
|
|
@@ -193,6 +225,8 @@ The channel pages also expose a “Use Markdown for bridge feedback” switch:
|
|
|
193
225
|
- disabled by default for WeChat
|
|
194
226
|
- affects text sent through the bridge, including normal replies, shared-thread mirror messages, and system feedback such as `/h`, `/status`, and `/threads`
|
|
195
227
|
|
|
228
|
+
Each channel instance can have its own alias. The alias only identifies which IM entry point handled the chat; it does not change Codex session semantics or model behavior.
|
|
229
|
+
|
|
196
230
|
## Update
|
|
197
231
|
|
|
198
232
|
On Windows, `npm update -g codex-to-im` can fail with `EBUSY` if the background UI or bridge is still running from the global install directory.
|
package/config.env.example
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
# Codex-to-IM
|
|
2
|
-
#
|
|
1
|
+
# Codex-to-IM legacy env snapshot / migration reference
|
|
2
|
+
# The primary runtime config is now ~/.codex-to-im/config.v2.json and is
|
|
3
|
+
# normally managed from the Web workbench.
|
|
4
|
+
# This file is kept only for:
|
|
5
|
+
# 1. migrating old single-instance setups to v2
|
|
6
|
+
# 2. compatibility snapshots / old tooling fallback
|
|
7
|
+
# It no longer fully represents multi-instance channel configuration.
|
|
3
8
|
|
|
4
9
|
# Runtime backend: claude | codex | auto
|
|
5
10
|
# claude — uses Claude Code CLI + @anthropic-ai/claude-agent-sdk
|
|
@@ -7,7 +12,9 @@
|
|
|
7
12
|
# auto — tries Claude first, falls back to Codex if CLI not found
|
|
8
13
|
CTI_RUNTIME=codex
|
|
9
14
|
|
|
10
|
-
#
|
|
15
|
+
# Legacy provider enable list (still used by non-v2 providers such as
|
|
16
|
+
# telegram / discord / qq; Feishu and Weixin are now configured as channel
|
|
17
|
+
# instances in config.v2.json).
|
|
11
18
|
CTI_ENABLED_CHANNELS=feishu
|
|
12
19
|
|
|
13
20
|
# Default workspace root for `/new proj1` style project creation.
|
|
@@ -72,10 +79,13 @@ CTI_TG_CHAT_ID=your-chat-id
|
|
|
72
79
|
# CTI_DISCORD_ALLOWED_CHANNELS=channel_id_1
|
|
73
80
|
# CTI_DISCORD_ALLOWED_GUILDS=guild_id_1
|
|
74
81
|
|
|
75
|
-
# ── Feishu / Lark ──
|
|
76
|
-
#
|
|
82
|
+
# ── Feishu / Lark (legacy single-instance migration fields) ──
|
|
83
|
+
# For new setups, add Feishu/Lark channel instances in the Web workbench and
|
|
84
|
+
# choose the site there. These env vars are only used when migrating an old
|
|
85
|
+
# single-instance setup into config.v2.json.
|
|
86
|
+
# CTI_FEISHU_APP_ID=your-app-id
|
|
77
87
|
# CTI_FEISHU_APP_SECRET=your-app-secret
|
|
78
|
-
#
|
|
88
|
+
# CTI_FEISHU_SITE=feishu
|
|
79
89
|
# CTI_FEISHU_ALLOWED_USERS=user_id_1,user_id_2
|
|
80
90
|
# Enable streaming response cards in Feishu (default true).
|
|
81
91
|
# Requires published Feishu permissions such as:
|
|
@@ -100,9 +110,12 @@ CTI_TG_CHAT_ID=your-chat-id
|
|
|
100
110
|
# Max image size in MB (default 20)
|
|
101
111
|
# CTI_QQ_MAX_IMAGE_SIZE=20
|
|
102
112
|
|
|
103
|
-
# ── WeChat / 微信 ──
|
|
104
|
-
#
|
|
105
|
-
#
|
|
113
|
+
# ── WeChat / 微信 (legacy single-instance migration fields) ──
|
|
114
|
+
# For new setups, add Weixin channel instances in the Web workbench. These env
|
|
115
|
+
# vars are only used when migrating an old single-instance setup into
|
|
116
|
+
# config.v2.json.
|
|
117
|
+
# No static token is required here. Use the QR login helper to add accounts:
|
|
118
|
+
# npm run weixin:login
|
|
106
119
|
# Optional protocol overrides (normally leave unset)
|
|
107
120
|
# CTI_WEIXIN_BASE_URL=https://ilinkai.weixin.qq.com
|
|
108
121
|
# CTI_WEIXIN_CDN_BASE_URL=https://novac2c.cdn.weixin.qq.com/c2c
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from 'module'; const require = createRequire(import.meta.url);
|
|
3
3
|
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
6
|
+
|
|
4
7
|
// src/service-manager.ts
|
|
5
8
|
import fs2 from "node:fs";
|
|
6
9
|
import os2 from "node:os";
|
|
@@ -22,6 +25,7 @@ function resolveDefaultCtiHome() {
|
|
|
22
25
|
}
|
|
23
26
|
var CTI_HOME = process.env.CTI_HOME || resolveDefaultCtiHome();
|
|
24
27
|
var CONFIG_PATH = path.join(CTI_HOME, "config.env");
|
|
28
|
+
var CONFIG_V2_PATH = path.join(CTI_HOME, "config.v2.json");
|
|
25
29
|
function parseEnvFile(content) {
|
|
26
30
|
const entries = /* @__PURE__ */ new Map();
|
|
27
31
|
for (const line of content.split("\n")) {
|
|
@@ -55,6 +59,9 @@ var bridgePidFile = path2.join(runtimeDir, "bridge.pid");
|
|
|
55
59
|
var bridgeStatusFile = path2.join(runtimeDir, "status.json");
|
|
56
60
|
var uiStatusFile = path2.join(runtimeDir, "ui-server.json");
|
|
57
61
|
var uiPort = 4781;
|
|
62
|
+
var bridgeAutostartTaskName = "CodexToIMBridge";
|
|
63
|
+
var bridgeAutostartLauncherFile = path2.join(runtimeDir, "bridge-autostart.ps1");
|
|
64
|
+
var npmUninstallLogFile = path2.join(runtimeDir, "npm-uninstall.log");
|
|
58
65
|
var WINDOWS_HIDE = process.platform === "win32" ? { windowsHide: true } : {};
|
|
59
66
|
function ensureDirs() {
|
|
60
67
|
fs2.mkdirSync(runtimeDir, { recursive: true });
|
|
@@ -88,6 +95,153 @@ function isProcessAlive(pid) {
|
|
|
88
95
|
function sleep(ms) {
|
|
89
96
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
90
97
|
}
|
|
98
|
+
function getCurrentWindowsUser() {
|
|
99
|
+
const user = process.env.USERNAME || os2.userInfo().username;
|
|
100
|
+
const domain = process.env.USERDOMAIN;
|
|
101
|
+
return domain ? `${domain}\\${user}` : user;
|
|
102
|
+
}
|
|
103
|
+
function escapePowerShellSingleQuoted(value) {
|
|
104
|
+
return value.replace(/'/g, "''");
|
|
105
|
+
}
|
|
106
|
+
function runCommand(command, args, options = {}) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const child = spawn(command, args, {
|
|
109
|
+
cwd: options.cwd,
|
|
110
|
+
env: options.env,
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
...WINDOWS_HIDE
|
|
113
|
+
});
|
|
114
|
+
let stdout = "";
|
|
115
|
+
let stderr = "";
|
|
116
|
+
child.stdout.on("data", (chunk) => {
|
|
117
|
+
stdout += chunk.toString();
|
|
118
|
+
});
|
|
119
|
+
child.stderr.on("data", (chunk) => {
|
|
120
|
+
stderr += chunk.toString();
|
|
121
|
+
});
|
|
122
|
+
child.on("error", reject);
|
|
123
|
+
child.on("close", (code) => {
|
|
124
|
+
resolve({ code: code ?? 0, stdout, stderr });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function runPowerShell(script) {
|
|
129
|
+
const result = await runCommand(
|
|
130
|
+
"powershell.exe",
|
|
131
|
+
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
|
|
132
|
+
);
|
|
133
|
+
if (result.code !== 0) {
|
|
134
|
+
throw new Error((result.stderr || result.stdout || "PowerShell command failed.").trim());
|
|
135
|
+
}
|
|
136
|
+
return result.stdout.trim();
|
|
137
|
+
}
|
|
138
|
+
async function ensureWindowsAdminSession() {
|
|
139
|
+
if (process.platform !== "win32") {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const raw = await runPowerShell("([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)");
|
|
143
|
+
if (raw.trim().toLowerCase() !== "true") {
|
|
144
|
+
throw new Error("\u8BF7\u5148\u4EE5\u7BA1\u7406\u5458\u8EAB\u4EFD\u6253\u5F00 PowerShell \u6216\u7EC8\u7AEF\uFF0C\u518D\u6267\u884C\u5F00\u673A\u81EA\u542F\u52A8\u5B89\u88C5/\u5378\u8F7D\u547D\u4EE4\u3002");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function ensureBridgeAutostartLauncher() {
|
|
148
|
+
ensureDirs();
|
|
149
|
+
const content = [
|
|
150
|
+
"$ErrorActionPreference = 'Stop'",
|
|
151
|
+
`$env:CTI_HOME = '${escapePowerShellSingleQuoted(CTI_HOME)}'`,
|
|
152
|
+
"$cmd = Get-Command 'codex-to-im.cmd' -ErrorAction SilentlyContinue",
|
|
153
|
+
"if (-not $cmd) { $cmd = Get-Command 'codex-to-im' -ErrorAction SilentlyContinue }",
|
|
154
|
+
"$node = (Get-Command 'node' -ErrorAction Stop).Source",
|
|
155
|
+
"if ($cmd) {",
|
|
156
|
+
" & $cmd.Source start",
|
|
157
|
+
" exit $LASTEXITCODE",
|
|
158
|
+
"}",
|
|
159
|
+
"$npm = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue",
|
|
160
|
+
"if (-not $npm) { $npm = Get-Command 'npm' -ErrorAction SilentlyContinue }",
|
|
161
|
+
"if ($npm) {",
|
|
162
|
+
" try {",
|
|
163
|
+
" $globalRoot = (& $npm.Source root -g 2>$null).Trim()",
|
|
164
|
+
" if ($globalRoot) {",
|
|
165
|
+
" $cliPath = Join-Path (Join-Path $globalRoot 'codex-to-im') 'dist\\cli.mjs'",
|
|
166
|
+
" if (Test-Path $cliPath) {",
|
|
167
|
+
" & $node $cliPath start",
|
|
168
|
+
" exit $LASTEXITCODE",
|
|
169
|
+
" }",
|
|
170
|
+
" }",
|
|
171
|
+
" } catch { }",
|
|
172
|
+
"}",
|
|
173
|
+
`& $node '${escapePowerShellSingleQuoted(path2.join(packageRoot, "dist", "cli.mjs"))}' start`,
|
|
174
|
+
"exit $LASTEXITCODE",
|
|
175
|
+
""
|
|
176
|
+
].join("\r\n");
|
|
177
|
+
fs2.writeFileSync(bridgeAutostartLauncherFile, content, "utf-8");
|
|
178
|
+
return bridgeAutostartLauncherFile;
|
|
179
|
+
}
|
|
180
|
+
function parsePowerShellJson(raw) {
|
|
181
|
+
return JSON.parse(raw);
|
|
182
|
+
}
|
|
183
|
+
function buildDeferredGlobalNpmUninstallLaunch(options = {}) {
|
|
184
|
+
const packageName = options.packageName || "codex-to-im";
|
|
185
|
+
const logPath = options.logPath || npmUninstallLogFile;
|
|
186
|
+
const delayMs = options.delayMs ?? 1500;
|
|
187
|
+
const platform = options.platform || process.platform;
|
|
188
|
+
const npmCommand = options.npmCommand || (platform === "win32" ? "npm.cmd" : "npm");
|
|
189
|
+
const command = options.nodePath || process.execPath;
|
|
190
|
+
const cwd = options.cwd || os2.homedir();
|
|
191
|
+
const script = [
|
|
192
|
+
"const { spawn } = require('node:child_process');",
|
|
193
|
+
"const fs = require('node:fs');",
|
|
194
|
+
`const logPath = ${JSON.stringify(logPath)};`,
|
|
195
|
+
`const npmCommand = ${JSON.stringify(npmCommand)};`,
|
|
196
|
+
`const npmArgs = ['uninstall', '-g', ${JSON.stringify(packageName)}];`,
|
|
197
|
+
`const childCwd = ${JSON.stringify(cwd)};`,
|
|
198
|
+
`const delayMs = ${JSON.stringify(delayMs)};`,
|
|
199
|
+
"const writeLog = (message) => {",
|
|
200
|
+
" try { fs.appendFileSync(logPath, String(message).endsWith('\\n') ? String(message) : String(message) + '\\n'); } catch {}",
|
|
201
|
+
"};",
|
|
202
|
+
"setTimeout(() => {",
|
|
203
|
+
" let fd;",
|
|
204
|
+
" try { fd = fs.openSync(logPath, 'a'); } catch (error) { writeLog(error); process.exit(1); return; }",
|
|
205
|
+
" const child = spawn(npmCommand, npmArgs, { cwd: childCwd, detached: false, stdio: ['ignore', fd, fd], windowsHide: true });",
|
|
206
|
+
" child.on('error', (error) => { writeLog(error); process.exit(1); });",
|
|
207
|
+
" child.on('close', (code) => { process.exit(typeof code === 'number' ? code : 0); });",
|
|
208
|
+
"}, delayMs);"
|
|
209
|
+
].join("\n");
|
|
210
|
+
return {
|
|
211
|
+
command,
|
|
212
|
+
args: ["-e", script],
|
|
213
|
+
npmCommand,
|
|
214
|
+
logPath,
|
|
215
|
+
delayMs
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function launchDeferredGlobalNpmUninstall() {
|
|
219
|
+
ensureDirs();
|
|
220
|
+
const launch = buildDeferredGlobalNpmUninstallLaunch();
|
|
221
|
+
fs2.writeFileSync(
|
|
222
|
+
launch.logPath,
|
|
223
|
+
[
|
|
224
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] Scheduling global uninstall.`,
|
|
225
|
+
`${launch.npmCommand} uninstall -g codex-to-im`,
|
|
226
|
+
""
|
|
227
|
+
].join("\n"),
|
|
228
|
+
"utf-8"
|
|
229
|
+
);
|
|
230
|
+
await new Promise((resolve, reject) => {
|
|
231
|
+
const child = spawn(launch.command, launch.args, {
|
|
232
|
+
cwd: os2.homedir(),
|
|
233
|
+
detached: true,
|
|
234
|
+
stdio: "ignore",
|
|
235
|
+
...WINDOWS_HIDE
|
|
236
|
+
});
|
|
237
|
+
child.once("error", reject);
|
|
238
|
+
child.once("spawn", () => {
|
|
239
|
+
child.unref();
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
return launch;
|
|
244
|
+
}
|
|
91
245
|
function getUiServerUrl(port = uiPort) {
|
|
92
246
|
return `http://127.0.0.1:${port}`;
|
|
93
247
|
}
|
|
@@ -212,6 +366,120 @@ async function stopBridge() {
|
|
|
212
366
|
}
|
|
213
367
|
return getBridgeStatus();
|
|
214
368
|
}
|
|
369
|
+
async function getBridgeAutostartStatus() {
|
|
370
|
+
const base = {
|
|
371
|
+
supported: process.platform === "win32",
|
|
372
|
+
installed: false,
|
|
373
|
+
enabled: false,
|
|
374
|
+
mode: "startup",
|
|
375
|
+
taskName: bridgeAutostartTaskName,
|
|
376
|
+
runAsUser: process.platform === "win32" ? getCurrentWindowsUser() : void 0,
|
|
377
|
+
launcherPath: bridgeAutostartLauncherFile
|
|
378
|
+
};
|
|
379
|
+
if (process.platform !== "win32") {
|
|
380
|
+
return {
|
|
381
|
+
...base,
|
|
382
|
+
error: "\u5F53\u524D\u53EA\u652F\u6301 Windows \u81EA\u52A8\u542F\u52A8\u3002"
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const script = [
|
|
386
|
+
`$task = Get-ScheduledTask -TaskName '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}' -ErrorAction SilentlyContinue`,
|
|
387
|
+
"if (-not $task) {",
|
|
388
|
+
" [pscustomobject]@{",
|
|
389
|
+
" supported = $true",
|
|
390
|
+
" installed = $false",
|
|
391
|
+
" enabled = $false",
|
|
392
|
+
` mode = 'startup'`,
|
|
393
|
+
` taskName = '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}'`,
|
|
394
|
+
` launcherPath = '${escapePowerShellSingleQuoted(bridgeAutostartLauncherFile)}'`,
|
|
395
|
+
" } | ConvertTo-Json -Compress",
|
|
396
|
+
" exit 0",
|
|
397
|
+
"}",
|
|
398
|
+
`$info = Get-ScheduledTaskInfo -TaskName '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}' -ErrorAction SilentlyContinue`,
|
|
399
|
+
"[pscustomobject]@{",
|
|
400
|
+
" supported = $true",
|
|
401
|
+
" installed = $true",
|
|
402
|
+
" enabled = [bool]$task.Settings.Enabled",
|
|
403
|
+
` mode = 'startup'`,
|
|
404
|
+
` taskName = '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}'`,
|
|
405
|
+
` launcherPath = '${escapePowerShellSingleQuoted(bridgeAutostartLauncherFile)}'`,
|
|
406
|
+
" runAsUser = $task.Principal.UserId",
|
|
407
|
+
" state = [string]$task.State",
|
|
408
|
+
"} | ConvertTo-Json -Compress"
|
|
409
|
+
].join("\n");
|
|
410
|
+
try {
|
|
411
|
+
const raw = await runPowerShell(script);
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
...parsePowerShellJson(raw)
|
|
415
|
+
};
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return {
|
|
418
|
+
...base,
|
|
419
|
+
error: error instanceof Error ? error.message : String(error)
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function installBridgeAutostart(password) {
|
|
424
|
+
if (process.platform !== "win32") {
|
|
425
|
+
throw new Error("\u5F53\u524D\u53EA\u652F\u6301 Windows \u81EA\u52A8\u542F\u52A8\u3002");
|
|
426
|
+
}
|
|
427
|
+
if (!password) {
|
|
428
|
+
throw new Error("\u5F53\u524D Windows \u767B\u5F55\u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
429
|
+
}
|
|
430
|
+
await ensureWindowsAdminSession();
|
|
431
|
+
const launcherPath = ensureBridgeAutostartLauncher();
|
|
432
|
+
const user = getCurrentWindowsUser();
|
|
433
|
+
const script = [
|
|
434
|
+
`$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "${escapePowerShellSingleQuoted(launcherPath)}"'`,
|
|
435
|
+
"$trigger = New-ScheduledTaskTrigger -AtStartup",
|
|
436
|
+
"$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew",
|
|
437
|
+
`Register-ScheduledTask -TaskName '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}' -Action $action -Trigger $trigger -Settings $settings -User '${escapePowerShellSingleQuoted(user)}' -Password '${escapePowerShellSingleQuoted(password)}' -RunLevel Limited -Force | Out-Null`
|
|
438
|
+
].join("; ");
|
|
439
|
+
await runPowerShell(script);
|
|
440
|
+
return await getBridgeAutostartStatus();
|
|
441
|
+
}
|
|
442
|
+
async function uninstallBridgeAutostart() {
|
|
443
|
+
if (process.platform !== "win32") {
|
|
444
|
+
return await getBridgeAutostartStatus();
|
|
445
|
+
}
|
|
446
|
+
await ensureWindowsAdminSession();
|
|
447
|
+
const script = [
|
|
448
|
+
`$task = Get-ScheduledTask -TaskName '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}' -ErrorAction SilentlyContinue`,
|
|
449
|
+
"if ($task) {",
|
|
450
|
+
` Unregister-ScheduledTask -TaskName '${escapePowerShellSingleQuoted(bridgeAutostartTaskName)}' -Confirm:$false`,
|
|
451
|
+
"}"
|
|
452
|
+
].join("; ");
|
|
453
|
+
await runPowerShell(script);
|
|
454
|
+
try {
|
|
455
|
+
if (fs2.existsSync(bridgeAutostartLauncherFile)) {
|
|
456
|
+
fs2.unlinkSync(bridgeAutostartLauncherFile);
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
}
|
|
460
|
+
return await getBridgeAutostartStatus();
|
|
461
|
+
}
|
|
462
|
+
async function uninstallCodexToImPackage() {
|
|
463
|
+
const autostartBefore = await getBridgeAutostartStatus();
|
|
464
|
+
if (process.platform === "win32" && autostartBefore.installed) {
|
|
465
|
+
await ensureWindowsAdminSession();
|
|
466
|
+
}
|
|
467
|
+
const ui = await stopUiServer();
|
|
468
|
+
const bridge = await stopBridge();
|
|
469
|
+
const autostart = autostartBefore.installed ? await uninstallBridgeAutostart() : autostartBefore;
|
|
470
|
+
if (autostart.installed) {
|
|
471
|
+
throw new Error(`\u672A\u80FD\u5220\u9664\u5F00\u673A\u81EA\u542F\u52A8\u4EFB\u52A1 ${autostart.taskName}\uFF0C\u5DF2\u53D6\u6D88 npm \u5168\u5C40\u5378\u8F7D\u3002`);
|
|
472
|
+
}
|
|
473
|
+
const launch = await launchDeferredGlobalNpmUninstall();
|
|
474
|
+
return {
|
|
475
|
+
ui,
|
|
476
|
+
bridge,
|
|
477
|
+
autostart,
|
|
478
|
+
npmCommand: launch.npmCommand,
|
|
479
|
+
logPath: launch.logPath,
|
|
480
|
+
scheduled: true
|
|
481
|
+
};
|
|
482
|
+
}
|
|
215
483
|
async function ensureUiServerRunning() {
|
|
216
484
|
ensureDirs();
|
|
217
485
|
const current = getUiServerStatus();
|
|
@@ -296,9 +564,53 @@ function openBrowser(url) {
|
|
|
296
564
|
}
|
|
297
565
|
|
|
298
566
|
// src/cli.ts
|
|
567
|
+
async function promptHidden(question) {
|
|
568
|
+
if (!input.isTTY || !output.isTTY) {
|
|
569
|
+
throw new Error("\u5F53\u524D\u7EC8\u7AEF\u4E0D\u652F\u6301\u9690\u85CF\u8F93\u5165\uFF0C\u8BF7\u5728\u53EF\u4EA4\u4E92\u7EC8\u7AEF\u4E2D\u6267\u884C\u3002");
|
|
570
|
+
}
|
|
571
|
+
output.write(question);
|
|
572
|
+
input.resume();
|
|
573
|
+
input.setEncoding("utf8");
|
|
574
|
+
input.setRawMode?.(true);
|
|
575
|
+
return await new Promise((resolve, reject) => {
|
|
576
|
+
let value = "";
|
|
577
|
+
const onData = (chunk) => {
|
|
578
|
+
for (const ch of chunk) {
|
|
579
|
+
if (ch === "") {
|
|
580
|
+
cleanup();
|
|
581
|
+
reject(new Error("\u5DF2\u53D6\u6D88\u3002"));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (ch === "\r" || ch === "\n") {
|
|
585
|
+
cleanup();
|
|
586
|
+
output.write("\n");
|
|
587
|
+
resolve(value);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (ch === "\b" || ch === "\x7F") {
|
|
591
|
+
value = value.slice(0, -1);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
value += ch;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const cleanup = () => {
|
|
598
|
+
input.off("data", onData);
|
|
599
|
+
input.setRawMode?.(false);
|
|
600
|
+
input.pause();
|
|
601
|
+
};
|
|
602
|
+
input.on("data", onData);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
299
605
|
async function main() {
|
|
300
606
|
const command = process.argv[2] || "open";
|
|
301
607
|
switch (command) {
|
|
608
|
+
case "start": {
|
|
609
|
+
const status = await startBridge();
|
|
610
|
+
process.stdout.write(`Bridge started. PID: ${status.pid || "-"}
|
|
611
|
+
`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
302
614
|
case "open": {
|
|
303
615
|
const status = await ensureUiServerRunning();
|
|
304
616
|
const url = getUiServerUrl(status.port);
|
|
@@ -337,28 +649,83 @@ async function main() {
|
|
|
337
649
|
return;
|
|
338
650
|
}
|
|
339
651
|
case "stop": {
|
|
340
|
-
const bridge = await stopBridge();
|
|
341
652
|
const ui = await stopUiServer();
|
|
653
|
+
const bridge = await stopBridge();
|
|
342
654
|
process.stdout.write(
|
|
343
655
|
`Stopped services. UI running=${ui.running ? "yes" : "no"}, Bridge running=${bridge.running ? "yes" : "no"}
|
|
344
656
|
`
|
|
345
657
|
);
|
|
346
658
|
return;
|
|
347
659
|
}
|
|
660
|
+
case "uninstall": {
|
|
661
|
+
const result = await uninstallCodexToImPackage();
|
|
662
|
+
process.stdout.write(
|
|
663
|
+
[
|
|
664
|
+
`Stopped services. UI running=${result.ui.running ? "yes" : "no"}, Bridge running=${result.bridge.running ? "yes" : "no"}`,
|
|
665
|
+
result.autostart.installed ? `Bridge autostart still installed: ${result.autostart.taskName}` : "Bridge autostart removed.",
|
|
666
|
+
`Global npm uninstall scheduled via ${result.npmCommand}.`,
|
|
667
|
+
`Log: ${result.logPath}`,
|
|
668
|
+
"\u5F53\u524D\u547D\u4EE4\u9000\u51FA\u540E\uFF0C\u540E\u53F0\u4F1A\u7EE7\u7EED\u6267\u884C\u5168\u5C40\u5378\u8F7D\u3002"
|
|
669
|
+
].join("\n") + "\n"
|
|
670
|
+
);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
348
673
|
case "status": {
|
|
349
674
|
const ui = getUiServerStatus();
|
|
350
675
|
const bridge = getBridgeStatus();
|
|
351
676
|
const url = getCurrentUiServerUrl();
|
|
677
|
+
const autostart = await getBridgeAutostartStatus();
|
|
352
678
|
process.stdout.write(
|
|
353
679
|
[
|
|
354
680
|
`UI: ${ui.running ? "running" : "stopped"}${url ? ` (${url})` : ""}`,
|
|
355
|
-
`Bridge: ${bridge.running ? "running" : "stopped"}
|
|
681
|
+
`Bridge: ${bridge.running ? "running" : "stopped"}`,
|
|
682
|
+
`Bridge Autostart: ${autostart.installed ? autostart.enabled ? "enabled" : "disabled" : "not installed"}`
|
|
356
683
|
].join("\n") + "\n"
|
|
357
684
|
);
|
|
358
685
|
return;
|
|
359
686
|
}
|
|
687
|
+
case "autostart": {
|
|
688
|
+
const subcommand = process.argv[3] || "status";
|
|
689
|
+
switch (subcommand) {
|
|
690
|
+
case "status": {
|
|
691
|
+
const status = await getBridgeAutostartStatus();
|
|
692
|
+
process.stdout.write(
|
|
693
|
+
[
|
|
694
|
+
`Supported: ${status.supported ? "yes" : "no"}`,
|
|
695
|
+
`Installed: ${status.installed ? "yes" : "no"}`,
|
|
696
|
+
`Enabled: ${status.enabled ? "yes" : "no"}`,
|
|
697
|
+
`Mode: ${status.mode}`,
|
|
698
|
+
`Task: ${status.taskName}`,
|
|
699
|
+
status.runAsUser ? `Run As: ${status.runAsUser}` : void 0,
|
|
700
|
+
status.state ? `State: ${status.state}` : void 0,
|
|
701
|
+
status.error ? `Error: ${status.error}` : void 0
|
|
702
|
+
].filter(Boolean).join("\n") + "\n"
|
|
703
|
+
);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
case "install": {
|
|
707
|
+
await ensureWindowsAdminSession();
|
|
708
|
+
const password = await promptHidden("\u8BF7\u8F93\u5165\u5F53\u524D Windows \u767B\u5F55\u5BC6\u7801\uFF08\u7528\u4E8E\u521B\u5EFA\u5F00\u673A\u542F\u52A8\u4EFB\u52A1\uFF09: ");
|
|
709
|
+
const status = await installBridgeAutostart(password);
|
|
710
|
+
process.stdout.write(`Bridge autostart installed. Task: ${status.taskName}
|
|
711
|
+
`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
case "uninstall": {
|
|
715
|
+
const status = await uninstallBridgeAutostart();
|
|
716
|
+
process.stdout.write(
|
|
717
|
+
status.installed ? `Bridge autostart task still exists: ${status.taskName}
|
|
718
|
+
` : "Bridge autostart removed.\n"
|
|
719
|
+
);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
default:
|
|
723
|
+
process.stdout.write("Usage: codex-to-im autostart [status|install|uninstall]\n");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
360
727
|
default:
|
|
361
|
-
process.stdout.write("Usage: codex-to-im [open|url|stop|status]\n");
|
|
728
|
+
process.stdout.write("Usage: codex-to-im [start|open|url|stop|status|autostart|uninstall]\n");
|
|
362
729
|
}
|
|
363
730
|
}
|
|
364
731
|
main().catch((error) => {
|