@znmin/claude-remote-cli 0.2.0 → 0.2.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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 安装
8
8
 
9
- 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
9
+ 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,自动配置免密登录需要 `ssh-keygen`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
10
10
 
11
11
  npm 包名为 `@znmin/claude-remote-cli`,安装后提供 `claude` 和 `claude-remote` 命令。
12
12
 
@@ -25,10 +25,10 @@ npm install -g git+https://github.com/znmin/claude-remote-cli.git
25
25
  也可以从已下载的分发包安装:
26
26
 
27
27
  ```bash
28
- npm install -g ./znmin-claude-remote-cli-0.2.0.tgz
28
+ npm install -g ./znmin-claude-remote-cli-0.2.1.tgz
29
29
  ```
30
30
 
31
- 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.0.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
31
+ 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.1.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
32
32
 
33
33
  npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
34
34
 
@@ -67,6 +67,22 @@ claude --continue
67
67
 
68
68
  未配置服务器时,也可以直接运行 `claude`,按终端提示输入服务器地址和端口。请勿把密码或私钥填入配置;登录使用 OpenSSH 原有认证流程和主机身份验证。
69
69
 
70
+ ### 自动配置 SSH 免密登录(0.2.1)
71
+
72
+ 两种执行模式每次启动都会先检查独立的公钥登录。如果已有可用的 SSH 密钥或 SSH Agent 授权,直接连接,不生成新密钥。
73
+
74
+ 尚不能免密登录时,交互终端会自动引导首次 SSH 登录:确认服务器指纹,按系统 SSH 提示输入服务器密码(或已有私钥口令)。登录成功后,为该地址和端口创建专用 Ed25519 无口令密钥,私钥保存于本机 `~/.ssh/claude-remote/`,目录权限 0700,私钥权限 0600。只把公钥追加到服务器的 `~/.ssh/authorized_keys`,保留其他已有授权;程序不读取或保存服务器密码,也不会关闭主机指纹验证。
75
+
76
+ 独立密钥登录验证成功后,在 `~/.ssh/config` 末尾添加对应 SSH 主机名或别名的 `IdentityFile` 配置,保留原有用户、端口、密钥和跳板机设置,让系统 SSH、SCP 和 Mutagen 都能使用。SSH 的 Host 规则按主机名匹配,因此同一主机其他用户或端口的连接也可能尝试该密钥,是否接受仍由各自服务器账号的授权决定。完成后再次检查默认连接,后续启动无需重复授权。
77
+
78
+ 也可以在配置服务器后单独运行:
79
+
80
+ ```bash
81
+ claude-remote setup-ssh
82
+ ```
83
+
84
+ 非交互运行且免密连接不可用时会停止并提示先在终端完成设置,不自动修改密钥。服务器必须允许公钥登录,并允许当前账号修改默认 `authorized_keys` 文件;强制额外验证码或采用自定义授权路径的服务器需要管理员配置,程序不会修改 sshd 策略。已有带口令密钥如需免提示,可预先交给本机 SSH Agent 管理。
85
+
70
86
  若需要使用特定的 Claude 可执行文件,可指定绝对路径;自定义路径不存在时会提示修正配置,不会自动改用其他程序:
71
87
 
72
88
  ```bash
@@ -184,7 +200,7 @@ npm test
184
200
  CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
185
201
 
186
202
  npm pack
187
- npm install -g ./znmin-claude-remote-cli-0.2.0.tgz
203
+ npm install -g ./znmin-claude-remote-cli-0.2.1.tgz
188
204
 
189
205
  # 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
190
206
  npm login
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znmin/claude-remote-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ const HELP = `${PACKAGE.name} ${PACKAGE.version}(非 Anthropic 官方客户端
20
20
  claude-remote flush 等待当前目录同步完成
21
21
  claude-remote stop [--all | --name 名称]
22
22
  claude-remote doctor 检查本地环境,不连接服务器
23
+ claude-remote setup-ssh 配置并验证服务器 SSH 免密登录(启动时自动检查)
23
24
  claude-remote install-sync 准备同步工具(通常自动完成)
24
25
  claude-remote install-browser 准备本地受控浏览器(本地模式自动完成)
25
26
  claude-remote run [Claude 参数] 启动远程 Claude
@@ -28,7 +29,7 @@ configure 可选:--mode local|remote、--headless/--headed、--root 远程根
28
29
  新配置默认使用本地执行;旧配置保持服务器模式,直到显式切换。
29
30
  服务器缺少默认 Claude Code 时会自动安装;首次启动需完成认证。
30
31
  此工具不提供服务器或 Claude 账号。
31
- 退出 Claude 后会继续后台同步;停止请运行 claude-remote stop。
32
+ 仅服务器同步模式会在退出后继续后台同步;停止请运行 claude-remote stop。
32
33
  `;
33
34
 
34
35
  async function configure(client, args) {
@@ -97,6 +98,9 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
97
98
  await client.stop(values); return 0;
98
99
  }
99
100
  if (rest.length) throw new Error(`${action} 不接受额外参数。`);
101
+ if (action === 'setup-ssh') {
102
+ await client.ensureSSH(client, await client.settings()); return 0;
103
+ }
100
104
  if (action === 'status') {
101
105
  await client.sync(['list', '--long', '--label-selector', 'claude-remote=true'], { capture: false }); return 0;
102
106
  }
package/src/client.js CHANGED
@@ -6,6 +6,7 @@ import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON
6
6
  import { ensureMutagen } from './installer.js';
7
7
  import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
8
  import { sshArgs } from './ssh.js';
9
+ import { ensureSSH } from './ssh-auth.js';
9
10
  export { sshArgs } from './ssh.js';
10
11
 
11
12
  export const DEFAULTS = {
@@ -58,6 +59,7 @@ export class Client {
58
59
  this.registryFile = path.join(this.directory, 'projects.json');
59
60
  this.lockFile = path.join(this.directory, 'npm-state');
60
61
  this.execute = options.execute || execute;
62
+ this.ensureSSH = options.ensureSSH || ensureSSH;
61
63
  this.ensureMutagen = options.ensureMutagen || ensureMutagen;
62
64
  this.log = options.log || console.error;
63
65
  this.warn = options.warn || console.error;
@@ -148,6 +150,7 @@ export class Client {
148
150
  }
149
151
 
150
152
  async launch(config, extra) {
153
+ await this.ensureSSH(this, config);
151
154
  if (config.mode === 'local') {
152
155
  const { launchLocal } = await import('./local-mode.js');
153
156
  return launchLocal(this, config, extra);
@@ -0,0 +1,83 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { sshBase } from './ssh.js';
6
+ import { exists, quote, which, withLock } from './system.js';
7
+
8
+ // Force a new public-key connection: an existing multiplexed/password session
9
+ // must never be mistaken for successful key authentication.
10
+ const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
11
+ '-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
12
+
13
+ export function authorizeKeyScript(publicKey) {
14
+ if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
15
+ throw new Error('专用 SSH 公钥格式无效。');
16
+ return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
17
+ 'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
18
+ 'key=' + quote(publicKey) + '; ' +
19
+ 'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
20
+ // A pre-existing file need not end with a newline.
21
+ 'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
22
+ }
23
+
24
+ export async function ensureSSH(client, config) {
25
+ if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
26
+ const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
27
+ { env: client.env, timeout: 30000, ...options });
28
+ const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
29
+ { capture: true, check: false })).code === 0;
30
+ if (await probe()) return;
31
+ if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
32
+ if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
33
+
34
+ const directory = path.join(client.home, '.ssh', 'claude-remote');
35
+ await withLock(path.join(directory, 'setup'), async () => {
36
+ if (await probe()) return;
37
+ const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
38
+ const temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
39
+ const socket = path.join(temporary, 'ssh');
40
+ try {
41
+ client.log('正在配置 SSH 免密登录。首次连接请确认服务器指纹,并按 SSH 提示输入服务器密码。');
42
+ // Authenticate once; subsequent installation reuses this private socket.
43
+ // Do not capture authentication output or handle passwords in JavaScript.
44
+ await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
45
+ '-f', '-N', config.host], { timeout: 180000, capture: false });
46
+ const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
47
+ const key = path.join(directory, 'id_ed25519_' + id);
48
+ await fs.chmod(directory, 0o700);
49
+ if (!await exists(key)) {
50
+ if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
51
+ client.log('创建仅供该服务器使用的无口令 SSH 密钥,私钥保存在本机,文件权限设为仅本人可读。');
52
+ await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
53
+ { env: client.env, capture: true, timeout: 30000 });
54
+ }
55
+ await fs.chmod(key, 0o600);
56
+ // Derive from the private key so an outdated .pub file cannot authorize
57
+ // the wrong key. Never transmit the private key.
58
+ const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
59
+ { env: client.env, capture: true, timeout: 30000 });
60
+ const publicKey = derived.stdout.trim();
61
+ const script = authorizeKeyScript(publicKey);
62
+ await run(['-S', socket, config.host, 'sh -c ' + quote(script)], { capture: false });
63
+ if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
64
+ throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
65
+
66
+ // SSH's ordinary config also makes the key available to Mutagen and scp.
67
+ // IdentityFile is additive; do not replace existing host/user/proxy rules.
68
+ const configFile = path.join(client.home, '.ssh', 'config');
69
+ const host = config.host.split('@').at(-1);
70
+ const escaped = key.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
71
+ const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
72
+ const previous = await fs.readFile(configFile, 'utf8').catch(error => {
73
+ if (error.code === 'ENOENT') return ''; throw error;
74
+ });
75
+ if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
76
+ if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
77
+ client.log('SSH 免密登录已配置并验证成功,后续连接无需输入服务器密码。');
78
+ } finally {
79
+ await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
80
+ await fs.rm(temporary, { recursive: true, force: true });
81
+ }
82
+ });
83
+ }