@znmin/claude-remote-cli 0.0.0-stage → 0.1.1
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 +147 -2
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/claude-remote.js +3 -0
- package/bin/claude.js +3 -0
- package/package.json +54 -4
- package/scripts/postinstall.js +11 -0
- package/src/cli.js +94 -0
- package/src/client.js +208 -0
- package/src/installer.js +81 -0
- package/src/remote-claude.js +39 -0
- package/src/system.js +83 -0
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# claude-remote-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在本地任意项目目录运行 `claude`,自动将目录双向同步到你指定的 SSH 服务器,并在服务器上运行真正的 Claude Code。
|
|
4
|
+
|
|
5
|
+
本项目是独立的 SSH/同步封装,不是 Anthropic 官方客户端。它不提供服务器、Claude 账号或 API 服务。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
要求:Node.js **20.11 或更高版本**、npm、OpenSSH(`ssh`/`scp`)和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内安装使用。本地无需 Python、Homebrew 或 Claude Code。
|
|
10
|
+
|
|
11
|
+
npm 包名为 `@znmin/claude-remote-cli`,安装后提供 `claude` 和 `claude-remote` 命令。
|
|
12
|
+
|
|
13
|
+
通过 npm 安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @znmin/claude-remote-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
也可以直接从 GitHub 安装:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g git+https://github.com/znmin/claude-remote-cli.git
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
也可以从已下载的分发包安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g ./znmin-claude-remote-cli-0.1.1.tgz
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.1.1.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
|
|
32
|
+
|
|
33
|
+
npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
|
|
34
|
+
|
|
35
|
+
安装会自动准备 Mutagen 同步工具:复用 PATH 中的 0.18.1 版本,或从官方 GitHub Release 下载固定版本并校验内置 SHA-256。下载需要网络,工具和远程代理合计占用约百 MB 磁盘。安装时网络不可用不会让 npm 安装失败,第一次连接时会重试。
|
|
36
|
+
|
|
37
|
+
如果用户已经安装其他提供 `claude` 命令的软件,应先卸载旧客户端,避免 npm 命令冲突或 PATH 指向旧程序。无需使用 `--force` 覆盖。
|
|
38
|
+
|
|
39
|
+
## 开始使用
|
|
40
|
+
|
|
41
|
+
服务器需要 Linux/macOS、可用的 SSH/SCP 和 Bash。启动时会检查 Claude Code:
|
|
42
|
+
|
|
43
|
+
- 已安装:直接使用已有程序。
|
|
44
|
+
- 默认 `claude` 命令不存在:通过服务器上的 `curl` 或 `wget` 下载并执行 [Anthropic 官方原生安装器](https://github.com/anthropics/claude-code#get-started),无需服务器安装 Node.js,也不会使用 `sudo`。
|
|
45
|
+
- 安装完成:检查命令已可用,再开始目录同步并启动 Claude。首次启动时按 Claude 的提示完成账号认证;不会复制本机账号凭据。
|
|
46
|
+
- 下载失败、安装失败或缺少 `curl`/`wget`:停止启动并报告原因,不建立新的项目同步会话。自动安装最多等待 10 分钟,可修复网络/权限后重试。
|
|
47
|
+
|
|
48
|
+
安装过程只在默认命令缺失时触发,不会自动重装或升级已有 Claude Code。
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 配置你的服务器,不会立即连接或上传文件
|
|
52
|
+
claude-remote configure user@example.com
|
|
53
|
+
|
|
54
|
+
# 自定义 SSH 端口
|
|
55
|
+
claude-remote configure user@example.com --port 2222
|
|
56
|
+
|
|
57
|
+
# 也支持 ~/.ssh/config 中的别名,包括密钥和跳板机设置
|
|
58
|
+
claude-remote configure my-server
|
|
59
|
+
|
|
60
|
+
# 在任意具体项目目录运行
|
|
61
|
+
cd /path/to/project
|
|
62
|
+
claude
|
|
63
|
+
|
|
64
|
+
# 参数直接传递给远程 Claude Code
|
|
65
|
+
claude --continue
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
未配置服务器时,也可以直接运行 `claude`,按终端提示输入服务器地址和端口。请勿把密码或私钥填入配置;登录使用 OpenSSH 原有认证流程和主机身份验证。
|
|
69
|
+
|
|
70
|
+
若需要使用特定的 Claude 可执行文件,可指定绝对路径;自定义路径不存在时会提示修正配置,不会自动改用其他程序:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
claude-remote configure my-server --claude /absolute/path/to/claude
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 同步管理
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude-remote status # 查看全部项目、同步错误和文件冲突
|
|
80
|
+
claude-remote flush # 等待当前项目完成一次同步
|
|
81
|
+
claude-remote stop # 停止当前目录在当前配置服务器上的同步
|
|
82
|
+
claude-remote stop --all # 停止本工具的所有同步会话
|
|
83
|
+
claude-remote stop --name 名称 # 按 status 中的名称停止旧服务器上的映射
|
|
84
|
+
claude-remote doctor # 检查本地安装和服务器配置,不连接服务器
|
|
85
|
+
claude-remote install-sync # 手动重试同步工具准备
|
|
86
|
+
claude-remote --help
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
退出 Claude 后继续后台同步,`stop` 会终止映射并保留两端文件。切换服务器前,先停止旧映射;`configure` 不会自动停止它们。映射的同一个目录及其父子目录不允许同时连到多个服务器。
|
|
90
|
+
|
|
91
|
+
可以临时覆盖服务器,配置文件保持不变:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
CLAUDE_REMOTE_HOST=another-server CLAUDE_REMOTE_PORT=2222 claude
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 文件行为
|
|
98
|
+
|
|
99
|
+
- 每台本机、每个 SSH 地址/端口、每个项目目录有独立的稳定编号,远程默认映射到 `~/.local/share/claude-remote/projects/<编号>`。启动时会显示两端路径。移动项目或更换 SSH 别名会产生新的编号。
|
|
100
|
+
- 启动远程 Claude 前等待首次同步,退出时也尝试完成一次同步。连接或最终同步失败会返回非零退出码;会话保留以便检查和重连。
|
|
101
|
+
- 使用 `two-way-safe` 双向同步。两边同时改同一文件会保留冲突,不自动选择覆盖版本。通过 `status` 查看路径,备份需要的内容,手动将两边统一,再运行 `flush`。运行期间可在另一个终端查看状态。
|
|
102
|
+
- 正常修改和删除都会双向传播。此工具不提供文件版本备份。
|
|
103
|
+
- 默认忽略 `.DS_Store`、`node_modules`、`.venv`、`__pycache__`,项目依赖需在服务器安装。
|
|
104
|
+
- 其他文件(包括 `.env`、未提交文件及普通仓库的 `.git`)默认同步;不自动读取 `.gitignore`。尽量避免两端同时执行 Git 写操作。
|
|
105
|
+
- 支持配置额外忽略规则:`claude-remote configure my-server --ignore '.env*' --ignore 'dist'`。忽略配置仅在新建映射时生效,修改后应先停止旧映射再启动。
|
|
106
|
+
- 可通过 `--root /srv/claude-projects` 自定义远程父目录。不会直接占用服务器的既有同名项目目录。
|
|
107
|
+
- 同步存在网络和扫描延迟,不等同于共享磁盘。Git worktree/子模块的 `.git` 外部引用暂不支持;指向目录外的符号链接也可能无法同步。macOS/Linux 间的文件名大小写差异可能产生冲突。
|
|
108
|
+
- 不允许同步整个本机用户目录或磁盘根目录。不会同步用户目录中的 Claude 登录信息;远程使用自己的登录状态和工具配置。
|
|
109
|
+
- 本版不提供 tmux 会话恢复。网络中断后文件同步会尝试重连,远程 Claude 进程是否继续运行取决于服务器的进程行为。
|
|
110
|
+
|
|
111
|
+
## 高级环境变量
|
|
112
|
+
|
|
113
|
+
| 变量 | 用途 |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `CLAUDE_REMOTE_HOST` | 临时 SSH 别名或 user@host |
|
|
116
|
+
| `CLAUDE_REMOTE_PORT` | 临时 SSH 端口 |
|
|
117
|
+
| `CLAUDE_REMOTE_CONFIG_DIR` | 配置和映射索引目录,默认 `~/.config/claude-remote` |
|
|
118
|
+
| `CLAUDE_REMOTE_CACHE_DIR` | 下载缓存父目录,默认 `~/.cache/claude-remote` |
|
|
119
|
+
| `CLAUDE_REMOTE_MUTAGEN` | 使用自行准备的 Mutagen 可执行文件绝对路径(应为 0.18.1,并放置配套代理包) |
|
|
120
|
+
| `CLAUDE_REMOTE_SKIP_DOWNLOAD=1` | npm 安装阶段跳过准备,首次连接时仍会自动准备 |
|
|
121
|
+
| `CLAUDE_REMOTE_DEBUG=1` | 出错时显示调试信息 |
|
|
122
|
+
|
|
123
|
+
`npm install --ignore-scripts` 也受支持,首次连接会补装同步工具。无法访问 GitHub 时,可先在可联网环境准备工具,或使用 `CLAUDE_REMOTE_MUTAGEN` 指向完整的本机安装。
|
|
124
|
+
|
|
125
|
+
卸载:先用 `claude-remote stop --all` 停止后台同步,再 `npm uninstall -g @znmin/claude-remote-cli`。npm 卸载保留本地配置、缓存和两端项目文件。
|
|
126
|
+
|
|
127
|
+
## 开发、打包与发布
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm ci --ignore-scripts
|
|
131
|
+
npm test
|
|
132
|
+
|
|
133
|
+
# 可选:本机真实 Mutagen 双向同步与冲突测试
|
|
134
|
+
CLAUDE_REMOTE_INTEGRATION=1 npm test
|
|
135
|
+
|
|
136
|
+
npm pack
|
|
137
|
+
npm install -g ./znmin-claude-remote-cli-0.1.1.tgz
|
|
138
|
+
|
|
139
|
+
# 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
|
|
140
|
+
npm login
|
|
141
|
+
npm publish --access public
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
GitHub 仓库名为 `claude-remote-cli`,npm 包名为 `@znmin/claude-remote-cli`,命令名为 `claude` 和 `claude-remote`。发布账号必须拥有 npm 用户作用域 `@znmin`,或拥有同名 npm 组织的发布权限;GitHub 身份不会自动授予 npm 权限。当前 `license` 为 `UNLICENSED`,尚未指定开源许可证。
|
|
145
|
+
|
|
146
|
+
测试覆盖全局安装、命令入口、项目隔离、参数引用、冲突阻断、下载校验、本地真实双向同步,以及使用隔离环境模拟的远程自动安装、下载失败和安装失败。由于尚未提供服务器,真实 SSH 认证、服务器上的官方安装与 Claude 交互、Linux 和其他 CPU 架构尚未实测。
|
|
147
|
+
|
|
148
|
+
参考:[npm 命令入口配置](https://docs.npmjs.com/cli/configuring-npm/package-json#bin)、[Mutagen 同步机制](https://mutagen.io/documentation/synchronization/)、[Mutagen SSH 支持](https://mutagen.io/documentation/transports/ssh/)。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Third-party components
|
|
2
|
+
|
|
3
|
+
The npm package contains the JavaScript CLI source and depends on `proper-lockfile` (MIT) and its npm-managed dependencies. Their license files are included in their respective npm packages.
|
|
4
|
+
|
|
5
|
+
Mutagen binaries are **not bundled in this npm package**. At setup/first use, the tool can reuse an existing Mutagen 0.18.1 installation, or download the matching v0.18.1 archive from the official release repository. SHA-256 values are pinned in `src/installer.js` from the official release's `SHA256SUMS` file.
|
|
6
|
+
|
|
7
|
+
- Mutagen project: https://github.com/mutagen-io/mutagen
|
|
8
|
+
- Pinned release: https://github.com/mutagen-io/mutagen/releases/tag/v0.18.1
|
|
9
|
+
- Release checksums: https://github.com/mutagen-io/mutagen/releases/download/v0.18.1/SHA256SUMS
|
|
10
|
+
- Upstream license: https://github.com/mutagen-io/mutagen/blob/v0.18.1/LICENSE
|
|
11
|
+
|
|
12
|
+
The upstream license states that, from v0.17 onward, official release builds include SSPL-licensed code by default; other source is generally MIT or as indicated upstream. Mutagen and the automatically deployed remote agents remain subject to the upstream terms independently of this JavaScript wrapper.
|
|
13
|
+
|
|
14
|
+
Claude Code is a separate product installed and authenticated on the user's server. This project does not bundle Claude Code and is not affiliated with or endorsed by Anthropic.
|
package/bin/claude.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znmin/claude-remote-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run Claude Code on your SSH server with safe bidirectional project synchronization",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20.11"
|
|
9
|
+
},
|
|
10
|
+
"os": [
|
|
11
|
+
"darwin",
|
|
12
|
+
"linux"
|
|
13
|
+
],
|
|
14
|
+
"cpu": [
|
|
15
|
+
"x64",
|
|
16
|
+
"arm64"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"claude": "bin/claude.js",
|
|
20
|
+
"claude-remote": "bin/claude-remote.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"src/",
|
|
25
|
+
"scripts/postinstall.js",
|
|
26
|
+
"README.md",
|
|
27
|
+
"THIRD_PARTY_NOTICES.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"postinstall": "node scripts/postinstall.js",
|
|
31
|
+
"test": "node --test test/*.test.js",
|
|
32
|
+
"prepublishOnly": "npm test"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"claude",
|
|
36
|
+
"ssh",
|
|
37
|
+
"remote",
|
|
38
|
+
"cli",
|
|
39
|
+
"sync"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"registry": "https://registry.npmjs.org/"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"proper-lockfile": "4.1.2"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/znmin/claude-remote-cli.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/znmin/claude-remote-cli#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/znmin/claude-remote-cli/issues"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ensureMutagen } from '../src/installer.js';
|
|
2
|
+
|
|
3
|
+
if (process.env.CLAUDE_REMOTE_SKIP_DOWNLOAD !== '1') {
|
|
4
|
+
try {
|
|
5
|
+
await ensureMutagen();
|
|
6
|
+
console.log('claude 已安装。运行 claude-remote configure 用户名@服务器,或直接运行 claude 开始配置。');
|
|
7
|
+
} catch (error) {
|
|
8
|
+
// Offline installation and npm --ignore-scripts remain supported: retry at first use.
|
|
9
|
+
console.warn(`CLI 已安装,同步工具准备未完成:${error.message}\n首次连接时会重试。`);
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { Client, DEFAULTS } from './client.js';
|
|
4
|
+
import { cachedBinary, ensureMutagen, VERSION } from './installer.js';
|
|
5
|
+
import { exists, readJSON, which } from './system.js';
|
|
6
|
+
|
|
7
|
+
const HELP = `@znmin/claude-remote-cli 0.1.1(非 Anthropic 官方客户端)
|
|
8
|
+
|
|
9
|
+
安装后使用:
|
|
10
|
+
claude 首次引导设置服务器,之后启动远程 Claude Code
|
|
11
|
+
claude --continue 将参数传给服务器上的 Claude
|
|
12
|
+
claude-remote configure [user@host] [--port 2222] [--claude /路径/claude]
|
|
13
|
+
claude-remote status 查看全部同步状态和冲突
|
|
14
|
+
claude-remote flush 等待当前目录同步完成
|
|
15
|
+
claude-remote stop [--all | --name 名称]
|
|
16
|
+
claude-remote doctor 检查本地环境,不连接服务器
|
|
17
|
+
claude-remote install-sync 准备同步工具(通常自动完成)
|
|
18
|
+
claude-remote run [Claude 参数] 启动远程 Claude
|
|
19
|
+
|
|
20
|
+
configure 可选:--root 远程根目录、--ignore 忽略规则(可重复)。
|
|
21
|
+
服务器缺少默认 Claude Code 时会自动安装;首次启动需完成认证。
|
|
22
|
+
此工具不提供服务器或 Claude 账号。
|
|
23
|
+
退出 Claude 后会继续后台同步;停止请运行 claude-remote stop。
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
async function configure(client, args) {
|
|
27
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
28
|
+
port: { type: 'string' }, root: { type: 'string' }, claude: { type: 'string' },
|
|
29
|
+
ignore: { type: 'string', multiple: true }
|
|
30
|
+
} });
|
|
31
|
+
if (positionals.length > 1) throw new Error('仅接受一个 SSH 别名或 user@host。');
|
|
32
|
+
let host = positionals[0], port = values.port;
|
|
33
|
+
if (!host) {
|
|
34
|
+
if (!process.stdin.isTTY) throw new Error('请指定服务器:claude-remote configure 用户名@服务器');
|
|
35
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
36
|
+
try {
|
|
37
|
+
console.log('设置你的 SSH 服务器(不要填写密码或私钥)。');
|
|
38
|
+
host = (await prompt.question('SSH 别名或 user@host:')).trim();
|
|
39
|
+
if (port === undefined) port = (await prompt.question('端口(留空使用 SSH 默认配置):')).trim() || undefined;
|
|
40
|
+
} finally { prompt.close(); }
|
|
41
|
+
}
|
|
42
|
+
return client.configure({ host, port: port === undefined ? null : Number(port),
|
|
43
|
+
root: values.root || DEFAULTS.root, claude: values.claude || DEFAULTS.claude, ignores: values.ignore || [] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function main(asClaude, args = process.argv.slice(2), client = new Client()) {
|
|
47
|
+
if (asClaude) {
|
|
48
|
+
const saved = await readJSON(client.configFile, {});
|
|
49
|
+
if (!saved.host && !client.env.CLAUDE_REMOTE_HOST) {
|
|
50
|
+
if (args.includes('--help') || args.includes('-h')) { console.log(HELP); return 0; }
|
|
51
|
+
if (args.includes('--version')) { console.log('@znmin/claude-remote-cli 0.1.1'); return 0; }
|
|
52
|
+
if (process.stdin.isTTY) await configure(client, []);
|
|
53
|
+
}
|
|
54
|
+
return client.launch(await client.settings(), args);
|
|
55
|
+
}
|
|
56
|
+
const [action, ...rest] = args;
|
|
57
|
+
if (!action || ['--help', '-h', 'help'].includes(action)) { console.log(HELP); return 0; }
|
|
58
|
+
if (['--version', '-v'].includes(action)) { console.log('0.1.1'); return 0; }
|
|
59
|
+
if (action === 'configure') { await configure(client, rest); return 0; }
|
|
60
|
+
if (action === 'run') return client.launch(await client.settings(), rest[0] === '--' ? rest.slice(1) : rest);
|
|
61
|
+
if (action === 'stop') {
|
|
62
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true,
|
|
63
|
+
options: { all: { type: 'boolean' }, name: { type: 'string' } } });
|
|
64
|
+
if (positionals.length || values.all && values.name) throw new Error('用法:claude-remote stop [--all | --name 名称]');
|
|
65
|
+
await client.stop(values); return 0;
|
|
66
|
+
}
|
|
67
|
+
if (rest.length) throw new Error(`${action} 不接受额外参数。`);
|
|
68
|
+
if (action === 'status') {
|
|
69
|
+
await client.sync(['list', '--long', '--label-selector', 'claude-remote=true'], { capture: false }); return 0;
|
|
70
|
+
}
|
|
71
|
+
if (action === 'flush') {
|
|
72
|
+
await client.flush((await client.project(await client.settings())).name); return 0;
|
|
73
|
+
}
|
|
74
|
+
if (action === 'install-sync') { console.log('同步工具:' + await ensureMutagen({ env: client.env })); return 0; }
|
|
75
|
+
if (action === 'doctor') {
|
|
76
|
+
console.log(`Node.js:${process.version}\n平台:${process.platform}/${process.arch}`);
|
|
77
|
+
for (const tool of ['ssh', 'scp', 'tar']) console.log(`${tool}:${await which(tool, client.env.PATH) || '未安装'}`);
|
|
78
|
+
const cached = cachedBinary(client.env);
|
|
79
|
+
console.log(`同步工具:${client.env.CLAUDE_REMOTE_MUTAGEN || (await exists(cached) ? cached : await which('mutagen', client.env.PATH)) || '首次使用时自动下载'}(目标版本 ${VERSION})`);
|
|
80
|
+
const config = await readJSON(client.configFile, {});
|
|
81
|
+
console.log(`配置:${client.configFile}\n服务器:${client.env.CLAUDE_REMOTE_HOST || config.host || '尚未配置'}`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`未知命令 ${action}。运行 claude-remote --help 查看用法。`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function entry(asClaude) {
|
|
88
|
+
try { process.exitCode = await main(asClaude); }
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error('claude-remote:' + error.message);
|
|
91
|
+
if (process.env.CLAUDE_REMOTE_DEBUG === '1') console.error(error.stack);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON } from './system.js';
|
|
6
|
+
import { ensureMutagen } from './installer.js';
|
|
7
|
+
import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
root: '.local/share/claude-remote/projects', claude: 'claude',
|
|
11
|
+
ignores: ['.DS_Store', 'node_modules', '.venv', '__pycache__']
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function validate(config) {
|
|
15
|
+
if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
|
|
16
|
+
throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
|
|
17
|
+
if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
|
|
18
|
+
throw new Error('端口必须在 1–65535 之间。');
|
|
19
|
+
if (typeof config.root !== 'string' || !/^\/?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(config.root) ||
|
|
20
|
+
config.root.split('/').some(part => ['.', '..'].includes(part)))
|
|
21
|
+
throw new Error('远程根目录仅支持字母、数字、点、短横线、下划线和斜杠,不支持 ..。');
|
|
22
|
+
if (typeof config.claude !== 'string' || !config.claude || /[\r\n\0]/.test(config.claude) || config.claude.startsWith('-'))
|
|
23
|
+
throw new Error('远程 Claude 程序路径无效。');
|
|
24
|
+
if (!Array.isArray(config.ignores) || config.ignores.some(pattern => typeof pattern !== 'string' || !pattern))
|
|
25
|
+
throw new Error('忽略规则需要是非空字符串列表。');
|
|
26
|
+
if (config.client_id != null && (typeof config.client_id !== 'string' || !config.client_id))
|
|
27
|
+
throw new Error('配置文件中的 client_id 无效。');
|
|
28
|
+
return config;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
|
|
32
|
+
|
|
33
|
+
export function sshArgs(config, script, { interactive = false, tty = false } = {}) {
|
|
34
|
+
const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
|
|
35
|
+
if (!interactive) args.push('-n'); // Never consume piped Claude prompts during preflight.
|
|
36
|
+
if (tty) args.push('-tt');
|
|
37
|
+
if (config.port != null) args.push('-p', String(config.port));
|
|
38
|
+
args.push(config.host, 'bash -lc ' + quote(script));
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertSynchronized(current) {
|
|
43
|
+
if (!current) throw new Error('同步会话不存在。');
|
|
44
|
+
if (current.conflicts?.length || current.excludedConflicts)
|
|
45
|
+
throw new Error('发现双向修改冲突,未自动覆盖。运行 claude-remote status 查看路径,备份后将两边文件统一,再运行 claude-remote flush。');
|
|
46
|
+
for (const side of ['alpha', 'beta']) {
|
|
47
|
+
const state = current[side] || {};
|
|
48
|
+
if (state.scanProblems?.length || state.transitionProblems?.length || state.excludedScanProblems || state.excludedTransitionProblems)
|
|
49
|
+
throw new Error('同步端点有文件处理错误,请运行 claude-remote status 查看详情。');
|
|
50
|
+
if (state.connected === false) throw new Error('同步端点已断开,请检查连接后重试。');
|
|
51
|
+
}
|
|
52
|
+
if (current.lastError) throw new Error('同步尚有错误:' + current.lastError);
|
|
53
|
+
if (current.paused) throw new Error('同步会话已暂停,请重新运行 claude 恢复。');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Client {
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
this.env = options.env || process.env;
|
|
59
|
+
this.home = options.home || os.homedir();
|
|
60
|
+
this.cwd = options.cwd || process.cwd();
|
|
61
|
+
this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
|
|
62
|
+
this.configFile = path.join(this.directory, 'config.json');
|
|
63
|
+
this.registryFile = path.join(this.directory, 'projects.json');
|
|
64
|
+
this.lockFile = path.join(this.directory, 'npm-state');
|
|
65
|
+
this.execute = options.execute || execute;
|
|
66
|
+
this.ensureMutagen = options.ensureMutagen || ensureMutagen;
|
|
67
|
+
this.log = options.log || console.error;
|
|
68
|
+
this.warn = options.warn || console.error;
|
|
69
|
+
this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async configure(input) {
|
|
73
|
+
return withLock(this.lockFile, async () => {
|
|
74
|
+
const old = await readJSON(this.configFile, {});
|
|
75
|
+
const config = validate({ ...DEFAULTS, ...input,
|
|
76
|
+
ignores: [...DEFAULTS.ignores, ...(input.ignores || [])],
|
|
77
|
+
client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
|
|
78
|
+
await writeJSON(this.configFile, config);
|
|
79
|
+
this.log(`已配置服务器:${config.host}。进入项目目录后运行 claude。`);
|
|
80
|
+
this.log('旧服务器的后台同步不会自动停止;切换前可运行 claude-remote stop --all。');
|
|
81
|
+
return config;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async settings() {
|
|
86
|
+
return withLock(this.lockFile, async () => {
|
|
87
|
+
const stored = await readJSON(this.configFile, {});
|
|
88
|
+
const config = { ...DEFAULTS, ...stored };
|
|
89
|
+
if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
|
|
90
|
+
if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
|
|
91
|
+
if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
|
|
92
|
+
validate(config);
|
|
93
|
+
if (!config.client_id) {
|
|
94
|
+
config.client_id = randomUUID().replaceAll('-', '').slice(0, 12);
|
|
95
|
+
// Temporary environment overrides must not change the saved server.
|
|
96
|
+
await writeJSON(this.configFile, { ...stored, client_id: config.client_id });
|
|
97
|
+
}
|
|
98
|
+
return config;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async project(config) {
|
|
103
|
+
const local = await fs.realpath(this.cwd);
|
|
104
|
+
const home = await fs.realpath(this.home);
|
|
105
|
+
if (local === home || local === path.parse(local).root)
|
|
106
|
+
throw new Error('请进入具体项目目录;不允许同步整个用户目录或磁盘根目录。');
|
|
107
|
+
const git = path.join(local, '.git');
|
|
108
|
+
if (await exists(git) && (await fs.stat(git)).isFile())
|
|
109
|
+
throw new Error('当前目录的 .git 指向外部仓库,暂不支持 Git worktree/子模块;请使用普通独立仓库。');
|
|
110
|
+
// Keep the original Python implementation's stable project identifiers.
|
|
111
|
+
const identity = '[' + [config.client_id, config.host, config.port ?? null, config.root, local]
|
|
112
|
+
.map(value => JSON.stringify(value)).join(', ') + ']';
|
|
113
|
+
const key = createHash('sha256').update(identity).digest('hex').slice(0, 24);
|
|
114
|
+
return { name: 'claude-remote-' + key, local, remote: config.root + '/' + key,
|
|
115
|
+
host: config.host, port: config.port ?? null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async prepare() {
|
|
119
|
+
if (!this.mutagen) this.mutagen = await this.ensureMutagen({ env: this.env });
|
|
120
|
+
return this.mutagen;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async sync(args, options = {}) {
|
|
124
|
+
const binary = await this.prepare();
|
|
125
|
+
return this.execute(binary, ['sync', ...args], { env: this.env, capture: true, ...options });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async sessions() {
|
|
129
|
+
const result = await this.sync(['list', '--label-selector', 'claude-remote=true', '--template', '{{json .}}'],
|
|
130
|
+
{ capture: true, timeout: 30000 });
|
|
131
|
+
return JSON.parse(result.stdout) || [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async flush(name) {
|
|
135
|
+
await this.sync(['flush', name], { timeout: 120000 });
|
|
136
|
+
assertSynchronized((await this.sessions()).find(session => session.name === name));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async remote(config, script, interactive = false, { timeout = 180000 } = {}) {
|
|
140
|
+
return this.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && this.tty }),
|
|
141
|
+
{ env: this.env, timeout: interactive ? 0 : timeout, check: !interactive });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async launch(config, extra) {
|
|
145
|
+
for (const tool of ['ssh', 'scp']) if (!await which(tool, this.env.PATH))
|
|
146
|
+
throw new Error(`找不到系统 ${tool},请安装 OpenSSH 客户端。`);
|
|
147
|
+
const item = await this.project(config);
|
|
148
|
+
await this.prepare();
|
|
149
|
+
await withLock(this.lockFile, async () => {
|
|
150
|
+
const existing = await this.sessions();
|
|
151
|
+
const registry = await readJSON(this.registryFile, {});
|
|
152
|
+
for (const session of existing) {
|
|
153
|
+
if (session.name === item.name) continue;
|
|
154
|
+
const storedPath = session.alpha?.path || registry[session.name]?.local;
|
|
155
|
+
if (!storedPath) continue;
|
|
156
|
+
let other;
|
|
157
|
+
try { other = await fs.realpath(storedPath); }
|
|
158
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; other = path.resolve(storedPath); }
|
|
159
|
+
const relative = path.relative(other, item.local);
|
|
160
|
+
const reverse = path.relative(item.local, other);
|
|
161
|
+
const inside = value => value === '' || (!value.startsWith('..' + path.sep) && value !== '..' && !path.isAbsolute(value));
|
|
162
|
+
if (inside(relative) || inside(reverse))
|
|
163
|
+
throw new Error(`目录与正在同步的项目重叠:${other}\n先运行:claude-remote stop --name ${session.name}`);
|
|
164
|
+
}
|
|
165
|
+
await this.remote(config, ensureRemoteClaude(config.claude) +
|
|
166
|
+
'mkdir -p -- ' + remotePath(item.remote), false, { timeout: 600000 });
|
|
167
|
+
if (existing.some(session => session.name === item.name)) {
|
|
168
|
+
await this.sync(['resume', item.name], { timeout: 60000, capture: !this.tty });
|
|
169
|
+
} else {
|
|
170
|
+
const endpoint = config.host + ':' + (config.port != null ? config.port + ':' : '') +
|
|
171
|
+
(item.remote.startsWith('/') ? item.remote : '~/' + item.remote);
|
|
172
|
+
const args = ['create', '--name', item.name, '--label', 'claude-remote=true', '--mode', 'two-way-safe',
|
|
173
|
+
'--no-global-configuration', '--no-ignore-vcs', '--watch-polling-interval', '2'];
|
|
174
|
+
for (const pattern of config.ignores) args.push('--ignore', pattern);
|
|
175
|
+
await this.sync([...args, item.local, endpoint], { timeout: 180000, capture: !this.tty });
|
|
176
|
+
}
|
|
177
|
+
registry[item.name] = item;
|
|
178
|
+
await writeJSON(this.registryFile, registry);
|
|
179
|
+
});
|
|
180
|
+
this.log(`本地:${item.local}\n远程:${config.host}:${item.remote}\n正在完成双向同步……`);
|
|
181
|
+
await this.flush(item.name);
|
|
182
|
+
this.log('开始远程 Claude Code。退出后继续后台同步;停止请运行 claude-remote stop。');
|
|
183
|
+
let code = 1;
|
|
184
|
+
try {
|
|
185
|
+
code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
|
|
186
|
+
' && exec ' + shellJoin([config.claude, ...extra]), true)).code;
|
|
187
|
+
} finally {
|
|
188
|
+
try { await this.flush(item.name); }
|
|
189
|
+
catch (error) {
|
|
190
|
+
this.warn(`退出时同步未确认完成:${error.message}\n会话保留,可用 claude-remote status 检查。`);
|
|
191
|
+
if (code === 0) code = 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return code;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async stop({ all = false, name } = {}) {
|
|
198
|
+
const selected = all || name ? name : (await this.project(await this.settings())).name;
|
|
199
|
+
return withLock(this.lockFile, async () => {
|
|
200
|
+
const available = (await this.sessions()).map(session => session.name);
|
|
201
|
+
const targets = all ? available : [selected];
|
|
202
|
+
if (!targets.length || targets.some(target => !available.includes(target)))
|
|
203
|
+
throw new Error('没有找到对应同步会话;运行 claude-remote status 查看。');
|
|
204
|
+
await this.sync(['terminate', ...targets], { timeout: 30000 });
|
|
205
|
+
this.log('已停止同步,保留本地和远程文件。');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { createWriteStream, createReadStream } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { Readable, Transform } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execute, exists, which, withLock } from './system.js';
|
|
9
|
+
|
|
10
|
+
export const VERSION = '0.18.1';
|
|
11
|
+
// SHA256SUMS from the official v0.18.1 release, pinned in this package.
|
|
12
|
+
export const RELEASES = {
|
|
13
|
+
'darwin-x64': ['darwin_amd64', '7d06f7d8fcfe90bc7e55cc834a2f2f20c2e0af9ea9bc35911fc4341ad56a9bbf'],
|
|
14
|
+
'darwin-arm64': ['darwin_arm64', '6f810416d9e5fc4fd5e18431146f8b3c5a2056ba5a24f76c1e66da86eb3257e2'],
|
|
15
|
+
'linux-x64': ['linux_amd64', '7735286c778cc438418209f24d03a64f3a0151c8065ef0fe079cfaf093af6f8f'],
|
|
16
|
+
'linux-arm64': ['linux_arm64', 'bcba735aebf8cbc11da9b3742118a665599ac697fa06bc5751cac8dcd540db8a']
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function cachedBinary(env = process.env) {
|
|
20
|
+
return path.join(env.CLAUDE_REMOTE_CACHE_DIR || path.join(os.homedir(), '.cache', 'claude-remote'),
|
|
21
|
+
`mutagen-${VERSION}-${process.platform}-${process.arch}`, 'mutagen');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function verifyChecksum(file, expected) {
|
|
25
|
+
const hash = createHash('sha256');
|
|
26
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
27
|
+
if (hash.digest('hex') !== expected) throw new Error('同步工具 SHA-256 校验失败,已拒绝安装。');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function download(url, file) {
|
|
31
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(180000) });
|
|
32
|
+
if (!response.ok || !response.body) throw new Error(`下载同步工具失败:HTTP ${response.status}`);
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
const limit = new Transform({ transform(chunk, encoding, done) {
|
|
35
|
+
bytes += chunk.length;
|
|
36
|
+
done(bytes > 200 * 1024 * 1024 ? new Error('同步工具下载大小异常。') : null, chunk);
|
|
37
|
+
} });
|
|
38
|
+
await pipeline(Readable.fromWeb(response.body), limit, createWriteStream(file, { flags: 'wx', mode: 0o600 }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function ensureMutagen({ env = process.env, forceDownload = false } = {}) {
|
|
42
|
+
if (!RELEASES[`${process.platform}-${process.arch}`]) throw new Error('此版本支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 中使用。');
|
|
43
|
+
if (env.CLAUDE_REMOTE_MUTAGEN) {
|
|
44
|
+
const binary = path.resolve(env.CLAUDE_REMOTE_MUTAGEN);
|
|
45
|
+
await execute(binary, ['version'], { capture: true, timeout: 10000, env });
|
|
46
|
+
return binary;
|
|
47
|
+
}
|
|
48
|
+
const binary = cachedBinary(env);
|
|
49
|
+
if (await exists(binary) && await exists(path.join(path.dirname(binary), 'mutagen-agents.tar.gz'))) return binary;
|
|
50
|
+
if (!forceDownload) {
|
|
51
|
+
const system = await which('mutagen', env.PATH);
|
|
52
|
+
if (system) {
|
|
53
|
+
const result = await execute(system, ['version'], { capture: true, timeout: 10000, env, check: false });
|
|
54
|
+
if (result.code === 0 && result.stdout.trim() === VERSION) return system;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const destination = path.dirname(binary);
|
|
58
|
+
return withLock(destination + '.lock', async () => {
|
|
59
|
+
if (await exists(binary) && await exists(path.join(destination, 'mutagen-agents.tar.gz'))) return binary;
|
|
60
|
+
const tar = await which('tar', env.PATH);
|
|
61
|
+
if (!tar) throw new Error('找不到系统 tar,无法解压同步工具。请安装 tar 后重试。');
|
|
62
|
+
const [platform, checksum] = RELEASES[`${process.platform}-${process.arch}`];
|
|
63
|
+
const archiveName = `mutagen_${platform}_v${VERSION}.tar.gz`;
|
|
64
|
+
const temporary = await fs.mkdtemp(path.join(path.dirname(destination), '.mutagen-install-'));
|
|
65
|
+
try {
|
|
66
|
+
const archive = path.join(temporary, 'download.tar.gz');
|
|
67
|
+
console.error(`正在下载同步工具 Mutagen ${VERSION}(首次使用,可能需要几分钟)……`);
|
|
68
|
+
await download(`https://github.com/mutagen-io/mutagen/releases/download/v${VERSION}/${archiveName}`, archive);
|
|
69
|
+
await verifyChecksum(archive, checksum);
|
|
70
|
+
// Extract only the two required files from the authenticated archive.
|
|
71
|
+
const unpacked = path.join(temporary, 'unpacked');
|
|
72
|
+
await fs.mkdir(unpacked);
|
|
73
|
+
await execute(tar, ['-xzf', archive, '-C', unpacked, 'mutagen', 'mutagen-agents.tar.gz'], { timeout: 60000, env });
|
|
74
|
+
await fs.chmod(path.join(unpacked, 'mutagen'), 0o755);
|
|
75
|
+
await execute(path.join(unpacked, 'mutagen'), ['version'], { capture: true, timeout: 10000, env });
|
|
76
|
+
if (await exists(destination)) await fs.rename(destination, path.join(temporary, 'old-cache'));
|
|
77
|
+
await fs.rename(unpacked, destination);
|
|
78
|
+
return binary;
|
|
79
|
+
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
|
|
4
|
+
|
|
5
|
+
export function ensureRemoteClaude(command) {
|
|
6
|
+
const missing = command === 'claude' ? `
|
|
7
|
+
echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
|
|
8
|
+
(
|
|
9
|
+
set -e
|
|
10
|
+
installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
|
|
11
|
+
trap 'rm -f -- "$installer"' EXIT
|
|
12
|
+
if command -v curl >/dev/null 2>&1; then
|
|
13
|
+
curl -fSL --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
14
|
+
--output "$installer" https://claude.ai/install.sh
|
|
15
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
16
|
+
wget --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
17
|
+
else
|
|
18
|
+
echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
|
|
19
|
+
exit 127
|
|
20
|
+
fi
|
|
21
|
+
if ! bash "$installer" >&2; then
|
|
22
|
+
echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
)
|
|
26
|
+
hash -r
|
|
27
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
28
|
+
echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
|
|
29
|
+
exit 127
|
|
30
|
+
fi
|
|
31
|
+
echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
|
|
32
|
+
` : `
|
|
33
|
+
echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
|
|
34
|
+
exit 127
|
|
35
|
+
`;
|
|
36
|
+
// A failed download/installer must stop before any project directory is created or synced.
|
|
37
|
+
return 'set -e\n' + remoteEnvironment + '\n' +
|
|
38
|
+
`if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
|
|
39
|
+
}
|
package/src/system.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import lockfile from 'proper-lockfile';
|
|
7
|
+
|
|
8
|
+
export const quote = value => "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
|
|
9
|
+
export const shellJoin = args => args.map(quote).join(' ');
|
|
10
|
+
|
|
11
|
+
export async function exists(file) {
|
|
12
|
+
try { await fs.access(file); return true; }
|
|
13
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function which(name, search = process.env.PATH || '') {
|
|
17
|
+
for (const directory of search.split(path.delimiter)) {
|
|
18
|
+
const file = path.resolve(directory || '.', name);
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(file, constants.X_OK);
|
|
21
|
+
if ((await fs.stat(file)).isFile()) return file;
|
|
22
|
+
} catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) throw error; }
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readJSON(file, fallback) {
|
|
28
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
29
|
+
catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeJSON(file, value) {
|
|
33
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
+
const temp = file + '.' + randomUUID() + '.tmp';
|
|
35
|
+
try {
|
|
36
|
+
await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
37
|
+
await fs.rename(temp, file);
|
|
38
|
+
} finally { await fs.rm(temp, { force: true }); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env } = {}) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(command, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', env });
|
|
44
|
+
let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
|
|
45
|
+
const interrupt = () => child.kill('SIGINT');
|
|
46
|
+
process.on('SIGINT', interrupt);
|
|
47
|
+
const timer = timeout ? setTimeout(() => {
|
|
48
|
+
timedOut = true;
|
|
49
|
+
child.kill('SIGTERM');
|
|
50
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
51
|
+
}, timeout) : null;
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
clearTimeout(timer); clearTimeout(killTimer);
|
|
54
|
+
process.off('SIGINT', interrupt);
|
|
55
|
+
};
|
|
56
|
+
if (capture) {
|
|
57
|
+
child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
|
|
58
|
+
child.stdout.on('data', chunk => {
|
|
59
|
+
stdout += chunk;
|
|
60
|
+
if (stdout.length > 32 * 1024 * 1024) { overflow = true; child.kill('SIGKILL'); }
|
|
61
|
+
});
|
|
62
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-65536); });
|
|
63
|
+
}
|
|
64
|
+
child.once('error', error => { cleanup(); reject(new Error(`无法执行 ${command}:${error.message}`)); });
|
|
65
|
+
child.once('close', (status, signal) => {
|
|
66
|
+
cleanup();
|
|
67
|
+
const code = status ?? (signal === 'SIGINT' ? 130 : 1);
|
|
68
|
+
if (timedOut) reject(new Error(`${path.basename(command)} 执行超时;请检查连接或同步状态。`));
|
|
69
|
+
else if (overflow) reject(new Error('命令输出过大,请使用 claude-remote status 检查。'));
|
|
70
|
+
else if (check && code) reject(new Error(`${path.basename(command)} 执行失败(${code})${stderr ? ':' + stderr.trim() : ''}`));
|
|
71
|
+
else resolve({ code, stdout, stderr });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Heartbeat locks survive long downloads and recover after an interrupted process.
|
|
77
|
+
export async function withLock(directory, callback) {
|
|
78
|
+
await fs.mkdir(path.dirname(directory), { recursive: true, mode: 0o700 });
|
|
79
|
+
const release = await lockfile.lock(directory, { realpath: false, stale: 30000, update: 10000,
|
|
80
|
+
retries: { retries: 240, minTimeout: 1000, maxTimeout: 1000 } });
|
|
81
|
+
try { return await callback(); }
|
|
82
|
+
finally { await release(); }
|
|
83
|
+
}
|