cabletidy 0.2.0 → 0.3.0-rc.2

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 CHANGED
@@ -10,7 +10,22 @@ CableTidy 的核心不是“提供一个统一 OpenAI API”,而是把复杂
10
10
  - 由内部 Route 把一个本地 Virtual Provider 连接到对应的上游和模型绑定。
11
11
  - 让 Codex 或 Claude Code 连接本地 endpoint,并保留客户端选择的模型名。
12
12
 
13
- 项目使用 Node.js 实现,使用 `toml-eslint-parser` 按语法树增量修改 Codex 配置。
13
+ 服务端使用 Rust 实现:Tokio / Axum 提供本地 HTTP 服务,reqwest 负责上游转发,`toml_edit` 按语法树增量修改 Codex 配置。Web 管理台资源嵌入二进制,原生程序不依赖 Node.js;npm 安装保留一个 Node.js 薄启动器。CableTidy 继续使用 `config.json` 和 `secrets.json`,不引入 TOML 应用配置或 OS keyring。
14
+
15
+ ## 风险监测与审计
16
+
17
+ 本地管理台的“安全”页面使用统一的审计日志列表,每行对应一次请求或操作,显示最高风险等级和风险发现数量,固定为“仅记录”模式。第一版默认观察经过代理的 Responses / Messages 请求、流式与普通响应,以及配置提交、配置启停、客户端配置应用和恢复操作。检测结果不触发通知、阻断或人工授权,也不会额外调用模型。
18
+
19
+ - 风险分为敏感数据与凭据、破坏性操作、权限与安全配置变更、外部代码执行、疑似指令操纵;详情分别显示严重程度、置信度、判断依据和检查完整性。
20
+ - 支持“仅看有风险”及时间、配置、最高严重程度、分类、证据阶段等统一筛选;一条记录不因命中多个风险而重复展示。有风险的记录数与风险发现数分别统计,详情始终展示全部关联风险。页面手动刷新,读取本身不增加审计记录。
21
+ - 区分本轮工具调用提议、历史调用、客户端报告的工具结果和文本内容。工具的真实执行状态为未知;未命中规则也不表示已证明安全。
22
+ - 审计存入数据目录中的 `audit.sqlite3`,默认保留 30 天,容量预算 128 MiB,接近容量时可能提前清理较旧记录。页面显示实际保留范围、写入状态和记录缺口。
23
+ - 保留网关观察到的请求与响应正文,包括对话、模型输出、工具参数和客户端报告的工具结果;凭据、认证头、Cookie、私钥等在入队前定向脱敏并标记位置。点击风险可定位、高亮对应内容并查看上下文;流式命中保存检测当时的独立快照。普通对话、命令和路径保持可读。
24
+ - 正文不再设单条固定长度上限,网关也不再按 8 MiB 拒绝请求。采用有界内存、临时加密暂存、分段脱敏落盘和正文按页加载;后台完整扫描保留的可检测内容,不再只检查前 1 MiB。共享内存、临时空间或审计存储预算不足时明确显示进度、失败和缺口;旧记录没有正文时不作补造。
25
+
26
+ 检查使用本地规则和有界缓冲。未知工具、动态脚本、未覆盖的推理内容、图片、加密内容以及超限情况会显示覆盖不足。数据库不可用或队列满时代理继续运行,页面显示审计退化状态;正常退出会排空已接收的写入,强制退出可能丢失未提交记录。它是本地可追溯日志,不提供零丢失或防篡改保证。
27
+
28
+ 分类调研、规则清单、固定预算和 HTTP 查询接口见 [代理安全 V1 设计与实现](docs/agent-security-v1.md)。此功能纳入 `0.3.0-rc.2` 候选版;候选版发布与验收状态见 [发布说明](docs/releases/0.3.0-rc.2.md)。
14
29
 
15
30
  ## 模型透传与可选设置
16
31
 
@@ -31,9 +46,9 @@ Codex 的可选模型设置目前只支持与本机官方 GPT 目录明确对应
31
46
 
32
47
  ## 安装与运行
33
48
 
34
- 推荐 Node.js 24,也支持 Node.js 22.13 及以上的 22.x 版本;完整版本约束见 `package.json`。CI 在 Linux、macOS 和 Windows 上执行源码测试与安装冒烟测试,平台验证结果以对应提交的 CI 为准。Windows 使用系统自带的 Windows PowerShell 查询进程身份;Codex 模型目录查询还需要允许 PowerShell `Add-Type` 调用 Windows Job Object API。
49
+ npm 安装方式推荐 Node.js 24,也支持 Node.js 22.13 及以上的 22.x 版本;完整版本约束见 `package.json`。原生程序可独立运行。发行包支持 Linux x64 / arm64、macOS x64 / arm64、Windows x64;源码构建需要稳定版 Rust 和系统 C 编译器 / 链接器。CI 执行源码测试与安装冒烟测试,跨平台结果以对应提交的 CI 为准。Windows 使用原生进程 API 查询进程身份;Codex 目录查询仍需要系统 Windows PowerShell,并允许 `Add-Type` 调用 Windows Job Object API。
35
50
 
36
- `0.2.0` 的变更与升级注意事项见 [发布说明](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/releases/0.2.0.md)。
51
+ 当前源码版本为 `0.3.0-rc.2`,正在准备候选发布;本页的原生程序、后台运行和安全审计说明对应此版本。npm 的 `latest` 仍为 Node 实现的 `0.2.0`,默认安装命令不会选择候选版。候选版发布后可按 [发布说明](docs/releases/0.3.0-rc.2.md) 指定版本安装;GitHub Pre-release 可用不代表 npm 已发布。历史版本见 [0.2.0 发布说明](docs/releases/0.2.0.md)。
37
52
 
38
53
  ### 从 npm 官方源安装
39
54
 
@@ -51,10 +66,10 @@ npm exec --yes --registry=https://registry.npmjs.org/ --package=cabletidy -- cab
51
66
 
52
67
  ### 从本地安装包使用
53
68
 
54
- 拿到 `.tgz` 安装包后,无需 clone 仓库即可安装:
69
+ 从对应 GitHub Release 下载 `package.tgz` 并按 `SHA256SUMS` 校验后,无需 clone 仓库即可安装:
55
70
 
56
71
  ```bash
57
- npm install -g /absolute/path/cabletidy-0.2.0.tgz
72
+ npm install -g /absolute/path/package.tgz
58
73
  cabletidy --version
59
74
  cabletidy start
60
75
  ```
@@ -62,56 +77,100 @@ cabletidy start
62
77
  也可以不做全局安装,直接运行同一个安装包:
63
78
 
64
79
  ```bash
65
- npm exec --yes --package=/absolute/path/cabletidy-0.2.0.tgz -- cabletidy start
80
+ npm exec --yes --package=/absolute/path/package.tgz -- cabletidy start
66
81
  ```
67
82
 
68
- 安装包仍依赖本机 Node.js;npm 会下载尚未缓存的运行时依赖,因此 `.tgz` 本身不代表完全离线安装。安装过程不会启动服务或修改客户端配置。
83
+ npm 启动器需要本机 Node.js;发行包自带对应平台的原生二进制,没有 npm 运行时依赖,安装时不下载二进制、不编译 Rust。也可直接运行 `native/<平台>/cabletidy`(Windows 为 `.exe`)。安装过程不会启动服务或修改客户端配置。
69
84
 
70
85
  ### 启动、升级与卸载
71
86
 
72
- `cabletidy start` 在前台运行,打印管理台地址;按 Ctrl+C 或收到 SIGTERM 后停止接受新连接,等待已开始的请求、子进程和配置写入完成,再清理 `runtime.json` 并退出,不设置退出倒计时。等待期间再次按 Ctrl+C 会强制退出,退出码为 130;此时可能保留过期的 `runtime.json`,状态检查会检测服务是否仍在线。当前不包含后台常驻或开机自启。
87
+ 安装后的 `cabletidy start` 会在后台启动 daemon,确认管理接口就绪后返回管理台地址和日志路径;关闭终端后继续运行。日志写入数据目录中的 `daemon.log`,运行期间自动轮转:单文件上限 10 MiB,保留 `daemon.log.1` 至 `.3` 三份历史文件(`.1` 最新),总量不超过 40 MiB;超出保留范围的旧日志自动删除。升级时已有的超大日志仅保留末尾 10 MiB。使用 `cabletidy stop` 停止服务;daemon 会停止接受新连接,等待已开始的请求、子进程和配置写入完成,再清理 `runtime.json` 并释放实例锁;`stop` 等待排空完成,不设置强制退出倒计时。 Ctrl+C 在停止请求原子发布前取消操作,不发送请求;发布后仅取消客户端等待,保留停止请求让 daemon 继续关闭。取消时退出码为 130,并提示是否已提交;SIGTERM 同样取消操作或等待,退出码为 143。未运行时 `stop` 成功返回提示,身份无法确认时拒绝停止。此功能不包含开机自启或崩溃自动重启。源码调试时的 `npm start` 和 `cargo run --bin cabletidy -- start` 仍在前台运行,便于查看日志和使用 Ctrl+C。安装后的 CLI 也可使用 `cabletidy start --foreground` 临时前台运行。
73
88
 
74
89
  在另一个终端可以运行:
75
90
 
76
91
  ```bash
77
92
  cabletidy --help
78
93
  cabletidy status
94
+ cabletidy stop
79
95
  ```
80
96
 
81
97
  `status` 同时显示 daemon 是否在线、管理台 URL、配置版本和当前配置套装列表;每套配置包含 ID、名称、目标 CLI、本地接入 URL 和启用状态,不再需要单独的 URL 或 Web 状态命令。
82
98
  `status` 和不带参数的命令只读取状态,不创建数据目录、配置、密钥或备份。尚未初始化时,只提示未启动以及运行 `cabletidy start`,不返回预设的管理台地址。
83
99
 
84
- 升级时先停止服务,再安装最新版本并重新启动:
100
+ 升级时先停止服务,再安装目标版本并重新启动。从 `0.2.0` 升级时,先在原运行终端按 Ctrl+C,或通过原进程管理器停止;该版本没有 `stop` 命令。以下命令适用于已经支持 `stop` 的版本,且 `latest` 只选择稳定版;候选版升级步骤见 [发布说明](docs/releases/0.3.0-rc.2.md):
85
101
 
86
102
  ```bash
103
+ cabletidy stop
87
104
  npm install -g cabletidy@latest --registry=https://registry.npmjs.org/
88
105
  cabletidy start
89
106
  ```
90
107
 
91
- 从本地安装包升级时,将安装目标换成新版本 `.tgz`。卸载使用 `npm uninstall -g cabletidy`,不会删除 `~/.cabletidy` 或撤销已应用的客户端配置;彻底停用前应先把客户端切换到其他接入。
108
+ 从本地安装包升级时,将安装目标换成新版本 `.tgz`。卸载前先执行 `cabletidy stop`,再使用 `npm uninstall -g cabletidy`,不会删除 `~/.cabletidy` 或撤销已应用的客户端配置;彻底停用前应先把客户端切换到其他接入。
92
109
 
93
110
  ### 从源码运行与打包
94
111
 
95
112
  ```bash
96
113
  npm ci
114
+ npm run build:debug
97
115
  npm start
98
116
 
99
- # 生成可安装的 cabletidy-0.2.0.tgz。
117
+ # 不使用 Node.js,直接运行 Rust CLI。
118
+ cargo run --bin cabletidy -- start
119
+ cargo run --bin cabletidy -- status
120
+
121
+ # 生成包含当前平台二进制的本地测试包。
122
+ npm run build
100
123
  npm pack
101
124
  ```
102
125
 
103
- `npm start` 与 `cabletidy start` 使用相同的启动和退出逻辑。源码用户也可以通过 `node bin/cabletidy.mjs --help` 调用完整 CLI。
126
+ 源码中的 `npm start` 与 `cargo run --bin cabletidy -- start` 使用前台 Rust CLI;安装包中的 `cabletidy start` 由启动器放到后台运行。源码启动器优先使用 `target/debug/cabletidy`;`npm run build` 生成 release 二进制并复制到 `native/<平台>/`。Web 文件在编译时嵌入,修改后需要重新编译。`Cargo.toml` 是 Rust 构建清单,不是 CableTidy 用户配置。
127
+
128
+ 本地先运行 `npm run build` 构建当前平台,再运行 `npm pack` 打包,适用于同平台试用。打包和发布钩子不会重新构建或覆盖 `native/` 中的产物。完整发行包由 `Build Native Package` 工作流在同一提交的完整测试通过后汇总五个平台的产物,Linux 使用 musl 构建;各平台验证实际发行二进制,汇总后直接安装最终 tarball 冒烟。工作流生成可下载的 npm tarball、提交信息和校验摘要,并在 tag 构建通过后自动创建 GitHub Release、附上这三个文件;候选版本标记为 Pre-release,不自动发布 npm。`npm run check:version` 核对四个版本文件及版本 tag,`npm run check:release` 进一步校验各平台二进制、编译目标、版本和摘要,缺少平台或 Linux 目标不是 musl 时阻止发布。发布前需要同步递增 `Cargo.toml`、`Cargo.lock` 和 npm 包版本。候选版验收、正式发布与回退步骤见 [维护者发布流程](docs/release-process.md)。
129
+
130
+ #### 开发与调试
131
+
132
+ 同机运行正式版和开发版时,使用独立的开发入口。它会构建 Rust 程序,并在 Rust / Web 文件变更后排空旧实例、重新编译和启动:
133
+
134
+ ```bash
135
+ npm run dev
136
+
137
+ # 在另一个终端查看开发实例状态和管理台地址。
138
+ npm run dev:status
139
+ ```
140
+
141
+ | 项目 | 正式运行(`cabletidy start` / `npm start`) | 开发运行(`npm run dev`) |
142
+ | --- | --- | --- |
143
+ | 运行方式 | 安装版后台;源码 `npm start` 前台 | 前台,支持热重启 |
144
+ | 数据目录 | `CABLETIDY_HOME` 或 `~/.cabletidy` | 仓库内 `.cabletidy-debug/` |
145
+ | 首次首选端口 | `43100` | `43101` |
146
+ | Codex 配置目录 | `CODEX_HOME` 或 `~/.codex` | `.cabletidy-debug/codex/` |
147
+ | Claude Code 配置目录 | `CLAUDE_CONFIG_DIR` 或 `~/.claude` | `.cabletidy-debug/claude/` |
148
+
149
+ 开发实例的配置、密钥、备份和实例锁全部保存在独立数据目录中,该目录已被 Git 忽略。开发入口以脚本所在的仓库为准,因此不同 clone / worktree 各自独立。开发启动和状态查询均使用 `CABLETIDY_DEV_HOME` 覆盖开发数据目录,不沿用环境中已有的 `CABLETIDY_HOME`;两个测试客户端目录也始终位于开发数据目录下,不沿用 `CODEX_HOME` / `CLAUDE_CONFIG_DIR` 指定的写入位置。例如:
150
+
151
+ ```bash
152
+ CABLETIDY_DEV_HOME="$PWD/.cabletidy-debug/experiment" npm run dev
153
+ CABLETIDY_DEV_HOME="$PWD/.cabletidy-debug/experiment" npm run dev:status
154
+ ```
155
+
156
+ 只有首次启动且开发目录中没有 `config.json` 时才尝试 `43101`,占用则由系统分配空闲端口并保存;后续启动、代码变更触发的重启均复用保存的端口。已保存端口被占用时明确报错,不会更换客户端地址。原有 `.cabletidy-debug/config.json` 会继续使用,包括其中的端口;如需调整,停止开发实例后修改该文件的 `web.port`,再重启并重新应用测试客户端配置。
157
+
158
+ 启动日志会显示实际管理台地址、数据目录和两个测试客户端配置目录。开发管理台中的“应用到 Codex / Claude Code”只写入对应测试目录;要验证真实客户端请求,需让测试客户端使用日志中的对应目录(Codex 的 `CODEX_HOME`、Claude Code 的 `CLAUDE_CONFIG_DIR`)。这些目录不会自动复制日常客户端的配置或登录信息。管理台查询本机 Codex 官方模型目录仍通过 `PATH` 中的 `codex debug models --bundled` 执行。
159
+
160
+ 调试服务端时使用 Rust 调试器或 CodeLLDB,目标为 `target/debug/cabletidy`,并将三个数据 / 客户端目录环境变量设为开发目录;Node.js 调试器只覆盖 npm 启动脚本。
104
161
 
105
162
  验证源码和安装产物:
106
163
 
107
164
  ```bash
165
+ cargo fmt --check
166
+ cargo clippy --locked --all-targets --features test-support -- -D warnings
108
167
  npm test
109
168
  npm run test:package
110
169
  ```
111
170
 
112
- 安装冒烟测试会在临时目录打包、安装、启动和卸载,验证安装后的 CLI、Web 资源、重复启动、重启地址及用户数据保留;需要能够获取 npm 依赖。Linux / macOS 验证 SIGINT / SIGTERM;Windows 直接执行 npm 的 `.cmd` 入口,验证强制终止后的旧锁恢复。Windows 的退出处理逻辑通过测试 IPC 触发验证,真实终端 Ctrl+C 的事件投递仍需交互验收。分发方案的取舍、维护者发布流程与后续服务管理设计见 [安装与分发决策](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/installation-research.md)。
171
+ `npm test` 先构建测试二进制、运行 Rust 单元测试,再运行直接调用 Rust 的 JavaScript 契约 / HTTP / 管理页测试;发行包不包含测试桥接程序。安装冒烟测试会在临时目录打包、安装、启动和卸载,验证安装后的原生 CLI、内嵌 Web 资源、重复启动、重启地址及用户数据保留。安装冒烟测试在各平台验证后台启动返回、`stop` 优雅退出及重启,Windows 直接执行 npm 的 `.cmd` 入口。生命周期测试另行验证 SIGINT / SIGTERM、长连接排空与过期 PID 防护。Windows 的退出处理逻辑通过测试 IPC 触发验证,真实终端 Ctrl+C 的事件投递仍需交互验收。分发方案的取舍、维护者发布流程与后续服务管理设计见 [安装与分发决策](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/installation-research.md)。
113
172
 
114
- 使用 Codex 可选模型设置时,本机需要可执行支持 `debug models --bundled` 的 Codex CLI,daemon 的 `PATH` 必须包含它。目录读取失败不会阻止纯透传配置的创建、代理启动、预览或应用,但会阻止新增模型设置或应用包含模型设置的 Codex 接入;管理台可在更新 Codex 后刷新模型列表。已在 Codex 0.154.0 验证目录加载和实际请求。
173
+ 使用 Codex 可选模型设置时,本机需要可执行支持 `debug models --bundled` 的 Codex CLI,daemon 的 `PATH` 必须包含它。目录读取失败不会阻止纯透传配置的创建、代理启动、预览或应用,但会阻止新增模型设置或应用包含模型设置的 Codex 接入;管理台可在更新 Codex 后刷新模型列表。Rust 版本的目录、配置生成与代理行为由本地契约和模拟上游测试覆盖,真实上游兼容性需按具体接入验证。
115
174
 
116
175
  管理台默认只监听本机回环地址。启动后可从 `cabletidy status` 输出中取得地址。
117
176
 
@@ -127,7 +186,7 @@ CABLETIDY_HOME="$PWD/.cabletidy-dev" cabletidy start
127
186
 
128
187
  已有配置中的端口(包括手动指定的端口)被占用时,启动会明确报错,不会自动更换。可以停止占用端口的服务,或修改 `web.port` 后重启,并重新应用客户端配置。实际地址以启动输出或 `cabletidy status` 为准。
129
188
 
130
- 同一份数据目录只允许运行一个实例,启动和退出清理期间均持有 `daemon.lock`。锁记录主机名、持有者 PID 和进程启动标识:Linux 使用系统启动 ID 与进程启动时钟,macOS 使用 `ps` 的启动时间,Windows 使用 PowerShell 的 `StartTime`。正常退出或启动失败后释放;只有能识别锁的代次、确认原进程已退出或 PID 已被复用,且锁至少 10 秒未更新时才自动回收。被暂停的实例仍然持有锁,恢复后不会因锁被误回收而崩溃。
189
+ 同一份数据目录只允许运行一个实例,启动和退出清理期间均持有 `daemon.lock`。锁记录主机名、持有者 PID 和进程启动标识:Linux 使用系统启动 ID 与进程启动时钟,macOS 使用 `ps` 的启动时间,Windows 使用 `GetProcessTimes` 的创建时间,并转换为兼容既有锁记录的 UTC ticks。正常退出或启动失败后释放;只有能识别锁的代次、确认原进程已退出或 PID 已被复用,且锁至少 10 秒未更新时才自动回收。被暂停的实例仍然持有锁,恢复后不会因锁被误回收而崩溃。
131
190
 
132
191
  旧版本的空锁目录、缺少启动标识的存活 PID、进程查询不可用或来自另一主机的锁,可能无法确认归属。此时启动返回 `ELOCKUNKNOWN` 并列出锁目录路径;继续等待不会补全缺失信息。请检查并停止使用该数据目录的 CableTidy,确认没有运行中或暂停中的实例后,手动删除提示的 `daemon.lock` 目录并重新启动,保留 `config.json` 和 `secrets.json`。不要仅凭 `status` 离线就删除锁,暂停中的实例也可能无法响应探测。
133
192
 
@@ -149,6 +208,8 @@ Web 管理台与所有 Virtual Provider 共用最终分配的端口。以下示
149
208
 
150
209
  列表显示 CLI、模型设置数量(无设置时显示“直接透传”)、本地地址和本地服务状态。列表与详情页的状态根据 Virtual Provider 的运行情况显示“已启动”“已暂停”或“未启动”,不受上游连通性测试结果影响。详情页依次展示唯一上游的连接设置、可选模型设置和客户端接入;每行模型设置包含客户端模型及可选的上游模型 ID,底部接入区域集中展示本地地址、Virtual Provider ID 和服务启停、预览、应用操作。“保存上游”和“保存模型设置”分别直接保存对应修改;模型设置可全部删除,恢复直接透传。失败时保留输入并在当前表单显示错误。“诊断”页面提供模型解析测试和事件记录,解析使用已保存的配置。
151
210
 
211
+ 左下角显示当前运行的 CableTidy 软件版本。配置修订号显示在“诊断”的对应事件条目中,例如 `配置修订 12`,代表该次配置保存或服务启停产生的修订,不随后续配置变更而改变。
212
+
152
213
  仅在存在实际未保存修改时,切换页面、返回列表或关闭页面会提示确认;改回原值后不再拦截。点击“刷新模型列表”同步更新沿用官方定义的元数据和最新限制,保留手动覆盖值与其他表单输入。
153
214
 
154
215
  “应用到 Codex”和“应用到 Claude Code”保持独立,只有主动点击时才修改客户端配置文件。测试连通性和配置预览使用已保存的配置。
@@ -303,7 +364,7 @@ CABLETIDY_CLAUDE_BIN=/absolute/path/claude npm run test:claude
303
364
  ## CLI
304
365
 
305
366
  ```bash
306
- node src/cli.mjs status
367
+ node bin/cabletidy.mjs status
307
368
  npm test
308
369
  ```
309
370
 
package/bin/cabletidy.mjs CHANGED
@@ -1,2 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import "../src/cli.mjs";
2
+ import { existsSync } from "node:fs";
3
+ import { launch, root, startBackground } from "./native.mjs";
4
+
5
+ try {
6
+ const args = process.argv.slice(2);
7
+ const command = args[0] || "status";
8
+ if (["start", "stop"].includes(command) && args.length > 1 && !(command === "start" && args.length === 2 && args[1] === "--foreground")) {
9
+ throw new Error("Usage: cabletidy start [--foreground] | stop");
10
+ }
11
+ if (command === "start" && args[1] !== "--foreground" && !existsSync(`${root}/Cargo.toml`)) {
12
+ const runtime = await startBackground();
13
+ console.log(`CableTidy started: ${runtime.web.url}\nLogs: ${runtime.logPath}`);
14
+ } else {
15
+ process.exitCode = await launch(command === "start" ? ["start"] : args).closed;
16
+ }
17
+ } catch (error) {
18
+ console.error(error.message);
19
+ process.exitCode = 1;
20
+ }
package/bin/native.mjs ADDED
@@ -0,0 +1,110 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+
9
+ export const root = fileURLToPath(new URL("../", import.meta.url));
10
+ export const platform = `${process.platform}-${process.arch}`;
11
+ export const binaryName = process.platform === "win32" ? "cabletidy.exe" : "cabletidy";
12
+
13
+ export function executable() {
14
+ const source = existsSync(path.join(root, "Cargo.toml"));
15
+ if (source) {
16
+ const debug = path.join(root, "target", "debug", binaryName);
17
+ if (existsSync(debug)) return debug;
18
+ }
19
+ const packaged = path.join(root, "native", platform, binaryName);
20
+ if (existsSync(packaged)) return packaged;
21
+ throw new Error(source
22
+ ? "Build the Rust executable first: npm run build:debug"
23
+ : `This package has no CableTidy executable for ${platform}. Install a supported release or build from source.`);
24
+ }
25
+
26
+ export function launch(args, { env = process.env, binary = executable(), signals = true } = {}) {
27
+ const child = spawn(binary, args, {
28
+ env: { ...env, CABLETIDY_MANAGED_STDIN: "1" },
29
+ stdio: ["pipe", "inherit", "inherit"],
30
+ // The launcher receives terminal signals once and forwards them over its private pipe.
31
+ detached: true,
32
+ windowsHide: true,
33
+ });
34
+ child.stdin.on("error", () => {});
35
+ const stop = signal => { if (!child.stdin.destroyed) child.stdin.write(`${signal}\n`); };
36
+ const interrupt = () => stop("SIGINT");
37
+ const terminate = () => stop("SIGTERM");
38
+ if (signals) {
39
+ process.on("SIGINT", interrupt);
40
+ process.on("SIGTERM", terminate);
41
+ }
42
+ const closed = new Promise((resolve, reject) => {
43
+ child.once("error", reject);
44
+ child.once("close", (code, signal) => {
45
+ process.removeListener("SIGINT", interrupt);
46
+ process.removeListener("SIGTERM", terminate);
47
+ resolve(code ?? (signal === "SIGINT" ? 130 : 1));
48
+ });
49
+ });
50
+ return { child, stop, closed };
51
+ }
52
+
53
+ function home(env) {
54
+ return env.CABLETIDY_HOME || path.join(os.homedir(), ".cabletidy");
55
+ }
56
+
57
+ async function runtimeInfo(env) {
58
+ try {
59
+ return JSON.parse(await fs.readFile(path.join(home(env), "runtime.json"), "utf8"));
60
+ } catch (error) {
61
+ if (error.code === "ENOENT") return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ export async function startBackground({ env = process.env, binary = executable() } = {}) {
67
+ const directory = path.resolve(home(env));
68
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
69
+ const logPath = path.join(directory, "daemon.log");
70
+ const childEnv = { ...env, CABLETIDY_HOME: directory, CABLETIDY_BACKGROUND_LOG: "1" };
71
+ delete childEnv.CABLETIDY_MANAGED_STDIN;
72
+ let spawnError;
73
+ let startupError = "";
74
+ const child = spawn(binary, ["start"], {
75
+ env: childEnv, stdio: ["ignore", "ignore", "pipe"], detached: true, windowsHide: true,
76
+ });
77
+ child.once("error", error => { spawnError = error; });
78
+ child.stderr.setEncoding("utf8");
79
+ child.stderr.on("data", chunk => { startupError = (startupError + chunk).slice(-16384); });
80
+ const closed = new Promise(resolve => child.once("close", resolve));
81
+ try {
82
+ const deadline = Date.now() + 15000;
83
+ while (Date.now() < deadline) {
84
+ if (spawnError) throw spawnError;
85
+ if (child.exitCode !== null || child.signalCode !== null) {
86
+ await closed;
87
+ throw new Error(startupError.trim() || "CableTidy daemon failed to start");
88
+ }
89
+ const runtime = await runtimeInfo(childEnv);
90
+ if (runtime?.pid === child.pid) {
91
+ try {
92
+ const response = await fetch(`${runtime.web.url}api/v1/runtime`, { signal: AbortSignal.timeout(500) });
93
+ const live = await response.json();
94
+ if (response.ok && live.pid === child.pid && live.startedAt === runtime.startedAt) {
95
+ child.stderr.destroy();
96
+ child.unref();
97
+ return { ...runtime, logPath };
98
+ }
99
+ } catch { /* The listener may still be entering its serving loop. */ }
100
+ }
101
+ await delay(50);
102
+ }
103
+ throw new Error(`Timed out waiting for CableTidy daemon; see ${logPath}`);
104
+ } catch (error) {
105
+ child.stderr.destroy();
106
+ if (child.pid && child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
107
+ child.unref();
108
+ throw error;
109
+ }
110
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.3.0-rc.2",
3
+ "platform": "darwin-arm64",
4
+ "target": "aarch64-apple-darwin",
5
+ "sha256": "8b005ebd071aaaae40a12da409e32de4c11bb39decb195b3265d7057de4a62ca"
6
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.3.0-rc.2",
3
+ "platform": "darwin-x64",
4
+ "target": "x86_64-apple-darwin",
5
+ "sha256": "3e6fd347928ac46eb06ed07c9bb08bc7be685527584ff812ef01edc5f24e86fe"
6
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.3.0-rc.2",
3
+ "platform": "linux-arm64",
4
+ "target": "aarch64-unknown-linux-musl",
5
+ "sha256": "9835e88c885b3603e934a5a64e92e1fc3703a0844c4dfcecc23135db61d76504"
6
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.3.0-rc.2",
3
+ "platform": "linux-x64",
4
+ "target": "x86_64-unknown-linux-musl",
5
+ "sha256": "7c8976df53b43f56d84732ef5978a8b96955967119498e4bae1a2d89017a18a9"
6
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.3.0-rc.2",
3
+ "platform": "win32-x64",
4
+ "target": "x86_64-pc-windows-msvc",
5
+ "sha256": "a90eef9d983f8c794209336c5da1375146ecfab9140ec392266ffc1cb67e9bee"
6
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabletidy",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-rc.2",
4
4
  "description": "A local multi-target coding CLI gateway and provider manager.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,25 +21,29 @@
21
21
  },
22
22
  "files": [
23
23
  "bin/",
24
- "src/",
25
- "web/"
24
+ "native/"
26
25
  ],
27
26
  "engines": {
28
27
  "node": "^22.13.0 || >=24"
29
28
  },
30
29
  "scripts": {
31
- "start": "node src/server.mjs",
32
- "dev": "node --watch src/server.mjs",
33
- "test": "node --test test/*.test.mjs",
34
- "test:package": "node --test scripts/package-smoke.mjs",
35
- "test:claude": "node --test scripts/claude-smoke.mjs",
36
- "prepublishOnly": "npm test && npm run test:package"
37
- },
38
- "dependencies": {
39
- "proper-lockfile": "^4.1.2",
40
- "toml-eslint-parser": "^1.0.3"
30
+ "start": "node bin/cabletidy.mjs start",
31
+ "build": "node scripts/build-native.mjs",
32
+ "build:debug": "node scripts/build-native.mjs --debug",
33
+ "dev": "npm run build:debug && node scripts/dev.mjs --watch",
34
+ "dev:status": "node scripts/dev.mjs status",
35
+ "test": "node scripts/test.mjs",
36
+ "test:security:browser": "node scripts/build-native.mjs --debug --test && node scripts/security-browser.mjs",
37
+ "test:package": "npm run build && node --test scripts/package-smoke.mjs",
38
+ "test:claude": "node scripts/build-native.mjs --debug --test && node --test scripts/claude-smoke.mjs",
39
+ "check:release": "node scripts/check-release.mjs",
40
+ "check:version": "node scripts/check-release.mjs --metadata-only",
41
+ "pack:release": "node scripts/pack-release.mjs",
42
+ "prepublishOnly": "npm test && npm run check:release && npm run test:package:built",
43
+ "test:package:built": "node --test scripts/package-smoke.mjs"
41
44
  },
42
45
  "devDependencies": {
43
- "cross-spawn": "^7.0.6"
46
+ "cross-spawn": "^7.0.6",
47
+ "playwright": "^1.63.0"
44
48
  }
45
49
  }
@@ -1,172 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import lockfile from "proper-lockfile";
6
- import { backupFile, writeJsonAtomic } from "./config.mjs";
7
-
8
- const STATE_FILE = ".cabletidy-settings.json";
9
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
- const digest = (value) => crypto.createHash("sha256").update(value ?? "<absent>").digest("hex");
11
- const snapshot = (env, key) => Object.hasOwn(env, key) ? { present: true, value: env[key] } : { present: false };
12
- const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
13
-
14
- function parseSettings(text, file) {
15
- try { return JSON.parse(text); }
16
- catch { throw new Error(`Invalid JSON in ${file}; the file was left unchanged.`); }
17
- }
18
-
19
- export function claudeConfigHome(options = {}) {
20
- return path.resolve(options.claudeHome || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"));
21
- }
22
-
23
- async function readOptional(file) {
24
- try {
25
- const stat = await fs.lstat(file);
26
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Expected a regular file: ${file}`);
27
- return await fs.readFile(file, "utf8");
28
- } catch (error) {
29
- if (error.code === "ENOENT") return null;
30
- throw error;
31
- }
32
- }
33
-
34
- async function readState(home) {
35
- const file = path.join(home, "settings.json");
36
- const text = await readOptional(file);
37
- const settings = text === null ? {} : parseSettings(text, file);
38
- if (!record(settings) || (settings.env !== undefined && !record(settings.env))) {
39
- throw new Error(`Invalid Claude settings object: ${file}`);
40
- }
41
- const stateText = await readOptional(path.join(home, STATE_FILE));
42
- let state = stateText === null ? null : parseSettings(stateText, path.join(home, STATE_FILE));
43
- if (state?.pending) {
44
- // Recover either side of an interrupted atomic settings replacement.
45
- if (digest(text) === state.pending.before) state = state.pending.previous;
46
- else if (digest(text) === state.pending.after) delete state.pending;
47
- else throw new Error("Claude settings changed during an interrupted apply; restore from backup before retrying.");
48
- }
49
- if (state !== null && (state.version !== 1 || !record(state.fields) || typeof state.bindingId !== "string")) {
50
- throw new Error(`Invalid CableTidy ownership record: ${path.join(home, STATE_FILE)}`);
51
- }
52
- for (const [key, field] of Object.entries(state?.fields || {})) {
53
- if (!key.startsWith("ANTHROPIC_") && !key.startsWith("CLAUDE_CODE_")) throw new Error("Invalid managed environment key");
54
- if (!record(field) || !record(field.before) || typeof field.applied !== "string") throw new Error("Invalid managed environment record");
55
- }
56
- return { file, text, settings, state, stateText };
57
- }
58
-
59
- function plan(snapshotData, vars, bindingId) {
60
- const { settings, state } = snapshotData;
61
- const next = structuredClone(settings);
62
- const env = next.env || {};
63
- const fields = {};
64
- const conflicts = [];
65
- const changed = [];
66
- for (const [key, field] of Object.entries(state?.fields || {})) {
67
- if (!same(snapshot(env, key), { present: true, value: field.applied })) conflicts.push(`env.${key}`);
68
- }
69
- for (const key of new Set([...Object.keys(state?.fields || {}), ...Object.keys(vars)])) {
70
- const current = snapshot(env, key);
71
- const old = state?.fields[key];
72
- if (Object.hasOwn(vars, key)) {
73
- fields[key] = { before: old?.before || current, applied: vars[key] };
74
- env[key] = vars[key];
75
- } else if (old.before.present) env[key] = old.before.value;
76
- else delete env[key];
77
- if (!same(current, snapshot(env, key))) changed.push(`env.${key}`);
78
- }
79
- if (Object.keys(env).length || (state?.hadEnv ?? Object.hasOwn(settings, "env"))) next.env = env;
80
- else delete next.env;
81
- return {
82
- next, conflicts, changed,
83
- state: {
84
- version: 1, bindingId, fields,
85
- hadEnv: state?.hadEnv ?? Object.hasOwn(settings, "env"),
86
- },
87
- };
88
- }
89
-
90
- function diagnostics(settings, vars) {
91
- const warnings = [];
92
- const env = settings.env || {};
93
- for (const key of Object.keys(vars)) {
94
- if (Object.hasOwn(env, key) && env[key] !== vars[key]) warnings.push(`Will replace env.${key}; its original value is retained for restore.`);
95
- }
96
- if (settings.apiKeyHelper) warnings.push("An existing apiKeyHelper is preserved. Verify the active credential source with /status.");
97
- if (settings.forceLoginMethod) warnings.push("An existing forceLoginMethod may require a different login method.");
98
- warnings.push("Project settings, --settings and managed policies can override user settings. Restart Claude Code and check /status after applying.");
99
- return warnings;
100
- }
101
-
102
- export async function prepareClaudeSettings(artifacts, options = {}) {
103
- const home = claudeConfigHome(options);
104
- const current = await readState(home);
105
- const staged = plan(current, artifacts.environment.vars, artifacts.bindingId);
106
- return {
107
- ...artifacts,
108
- settingsPath: current.file,
109
- changes: staged.changed,
110
- canApply: staged.conflicts.length === 0,
111
- canRestore: current.state?.bindingId === artifacts.bindingId,
112
- conflicts: staged.conflicts,
113
- warnings: [...(artifacts.warnings || []), ...diagnostics(current.settings, artifacts.environment.vars)],
114
- };
115
- }
116
-
117
- async function withSettingsLock(options, operation) {
118
- const home = claudeConfigHome(options);
119
- await fs.mkdir(home, { recursive: true, mode: 0o700 });
120
- const release = await lockfile.lock(home, { realpath: true, retries: { retries: 20, minTimeout: 25, maxTimeout: 100 } });
121
- try { return await operation(home); }
122
- finally { await release(); }
123
- }
124
-
125
- async function commit(home, current, staged, options, restoring) {
126
- if (staged.conflicts.length) {
127
- throw new Error(`Claude settings were edited after applying CableTidy: ${staged.conflicts.join(", ")}. Preserve your edits before retrying.`);
128
- }
129
- const backups = options.paths?.backups || path.join(os.homedir(), ".cabletidy", "backups");
130
- await fs.mkdir(backups, { recursive: true, mode: 0o700 });
131
- const stateFile = path.join(home, STATE_FILE);
132
- const backup = await backupFile(current.file, backups, "claude-settings.json");
133
- await backupFile(stateFile, backups, "claude-settings-state.json");
134
- if (await readOptional(current.file) !== current.text || await readOptional(stateFile) !== current.stateText) {
135
- throw new Error("Claude settings changed during apply; preview and retry.");
136
- }
137
- const contents = `${JSON.stringify(staged.next, null, 2)}\n`;
138
- await writeJsonAtomic(stateFile, {
139
- ...staged.state,
140
- pending: { before: digest(current.text), after: digest(contents), previous: current.state },
141
- });
142
- try {
143
- if (await readOptional(current.file) !== current.text) throw new Error("Claude settings changed during apply; preview and retry.");
144
- await writeJsonAtomic(current.file, staged.next);
145
- } catch (error) {
146
- if (current.stateText !== null) await writeJsonAtomic(stateFile, JSON.parse(current.stateText));
147
- else await fs.unlink(stateFile);
148
- throw error;
149
- }
150
- // A pending record remains recoverable if finalizing the ledger fails.
151
- if (restoring) await fs.unlink(stateFile);
152
- else await writeJsonAtomic(stateFile, staged.state);
153
- return { applied: [current.file], backup, changes: staged.changed, mode: restoring ? "restored" : "managed_proxy" };
154
- }
155
-
156
- export function applyClaudeSettings(artifacts, options = {}) {
157
- return withSettingsLock(options, async (home) => {
158
- const current = await readState(home);
159
- const staged = plan(current, artifacts.environment.vars, artifacts.bindingId);
160
- const result = await commit(home, current, staged, options, false);
161
- return { ...result, environment: artifacts.environment, warnings: diagnostics(current.settings, artifacts.environment.vars) };
162
- });
163
- }
164
-
165
- export function restoreClaudeSettings(bindingId, options = {}) {
166
- return withSettingsLock(options, async (home) => {
167
- const current = await readState(home);
168
- if (!current.state) throw new Error("No Claude settings are managed by CableTidy.");
169
- if (current.state.bindingId !== bindingId) throw new Error(`Claude Code currently uses another CableTidy configuration: ${current.state.bindingId}`);
170
- return commit(home, current, plan(current, {}, bindingId), options, true);
171
- });
172
- }