@znmin/claude-remote-cli 0.3.2 → 0.4.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/src/system.js CHANGED
@@ -1,179 +1,179 @@
1
- import { spawn } from 'node:child_process';
2
- import { constants } from 'node:fs';
3
- import * as fs from 'node:fs/promises';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { randomUUID } from 'node:crypto';
7
- import lockfile from 'proper-lockfile';
8
-
9
- export const quote = value => "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
10
- export const shellJoin = args => args.map(quote).join(' ');
11
-
12
- export async function exists(file) {
13
- try { await fs.access(file); return true; }
14
- catch (error) { if (error.code === 'ENOENT') return false; throw error; }
15
- }
16
-
17
- // On Windows, try the same extensions as spawn() does (.com, .exe); there is no
18
- // execute permission bit, so existence is the check.
19
- export async function which(name, search = process.env.PATH || '', { platform = process.platform } = {}) {
20
- const windows = platform === 'win32';
21
- const names = windows && !path.extname(name) ? [name + '.com', name + '.exe'] : [name];
22
- for (const entry of search.split(windows ? ';' : path.delimiter)) {
23
- const directory = windows ? entry.replace(/^"(.*)"$/, '$1') : entry;
24
- for (const candidate of names) {
25
- const file = path.resolve(directory || '.', candidate);
26
- try {
27
- await fs.access(file, windows ? constants.F_OK : constants.X_OK);
28
- if ((await fs.stat(file)).isFile()) return file;
29
- } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR', 'EINVAL'].includes(error.code)) throw error; }
30
- }
31
- }
32
- return null;
33
- }
34
-
35
- // Windows refuses to replace a file another process (editor, antivirus,
36
- // indexer) briefly holds open; retry a few times before giving up.
37
- export async function rename(from, to, { platform = process.platform, attempts = 10 } = {}) {
38
- for (let attempt = 1; ; attempt++) {
39
- try { return await fs.rename(from, to); }
40
- catch (error) {
41
- if (platform !== 'win32' || attempt >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error;
42
- await new Promise(resolve => setTimeout(resolve, 20 * attempt));
43
- }
44
- }
45
- }
46
-
47
- export async function readJSON(file, fallback) {
48
- try { return JSON.parse(await fs.readFile(file, 'utf8')); }
49
- catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
50
- }
51
-
52
- export async function writeJSON(file, value) {
53
- await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
54
- const temp = file + '.' + randomUUID() + '.tmp';
55
- try {
56
- await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
57
- await rename(temp, file);
58
- } finally { await fs.rm(temp, { force: true }); }
59
- }
60
-
61
- // Children and cleanup callbacks that must not outlive this CLI when the
62
- // terminal closes (SIGHUP) or the process is asked to stop (SIGTERM).
63
- const trackedChildren = new Set();
64
- const cleanups = new Set();
65
- let terminating = null;
66
-
67
- export function trackChild(child) {
68
- trackedChildren.add(child);
69
- child.once('close', () => trackedChildren.delete(child));
70
- child.once('error', () => trackedChildren.delete(child));
71
- return child;
72
- }
73
-
74
- export function onCleanup(callback) {
75
- cleanups.add(callback);
76
- return () => cleanups.delete(callback);
77
- }
78
-
79
- export function terminate(signal, { exit = code => process.exit(code), graceMs = 10000, terminal = process.stdout } = {}) {
80
- if (terminating) return terminating;
81
- const code = 128 + (os.constants.signals[signal] || 15);
82
- terminating = (async () => {
83
- // Cleanups first: they may still need live SSH connections (e.g. to delete
84
- // remote temporary files). Whatever is left afterwards is stopped.
85
- const pending = Promise.allSettled([...cleanups].map(callback => callback()));
86
- let timer;
87
- const limit = new Promise(resolve => { timer = setTimeout(resolve, graceMs); timer.unref?.(); });
88
- await Promise.race([pending, limit]);
89
- clearTimeout(timer);
90
- for (const child of trackedChildren) {
91
- try { child.kill('SIGTERM'); } catch { /* already gone */ }
92
- }
93
- restoreTerminal(terminal);
94
- exit(code);
95
- })();
96
- return terminating;
97
- }
98
-
99
- // A remote full-screen program that loses its connection cannot switch off the
100
- // terminal modes it enabled, so mouse moves, focus changes and keys show up as
101
- // stray escape codes. Every sequence here only turns a mode off and is a no-op
102
- // when the mode is already off. The alternate screen (?1049l) is deliberately
103
- // left alone: leaving it when not active can move the cursor on some terminals.
104
- export const TERMINAL_RESET = [
105
- '\x1b[?1000l', '\x1b[?1002l', '\x1b[?1003l', '\x1b[?1005l', '\x1b[?1006l', '\x1b[?1015l', // mouse reporting
106
- '\x1b[?1004l', // focus in/out events
107
- '\x1b[?2004l', // bracketed paste
108
- '\x1b[=0;1u', '\x1b[<99u', // kitty keyboard protocol: clear flags, pop pushed levels
109
- '\x1b[>4m', // xterm modifyOtherKeys
110
- '\x1b[?1l', '\x1b>', // normal cursor keys and keypad
111
- '\x1b[0m', '\x1b[?25h' // plain text attributes, visible cursor
112
- ].join('');
113
-
114
- export function restoreTerminal(stream = process.stdout) {
115
- if (!stream?.isTTY) return false;
116
- try { stream.write(TERMINAL_RESET); return true; }
117
- catch { return false; } // The terminal may already be gone (SIGHUP).
118
- }
119
-
120
- export function installSignalHandlers() {
121
- for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
122
- }
123
-
124
- // `prompt` captures stdout but leaves stdin and stderr on the terminal, so a
125
- // password or host-key prompt from ssh stays visible and answerable.
126
- export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input, prompt = false } = {}) {
127
- return new Promise((resolve, reject) => {
128
- const stdio = prompt ? ['inherit', 'pipe', 'inherit'] : [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
129
- capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'];
130
- const child = trackChild(spawn(command, args, { stdio, env }));
131
- if (input !== undefined) {
132
- child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
133
- child.stdin.end(input);
134
- }
135
- let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
136
- // The Windows console already delivers Ctrl+C to the child, and kill()
137
- // there would terminate it outright instead of interrupting it.
138
- const interrupt = () => { if (process.platform !== 'win32') child.kill('SIGINT'); };
139
- process.on('SIGINT', interrupt);
140
- const timer = timeout ? setTimeout(() => {
141
- timedOut = true;
142
- child.kill('SIGTERM');
143
- killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
144
- }, timeout) : null;
145
- const cleanup = () => {
146
- clearTimeout(timer); clearTimeout(killTimer);
147
- process.off('SIGINT', interrupt);
148
- };
149
- if (child.stdout) {
150
- child.stdout.setEncoding('utf8');
151
- child.stdout.on('data', chunk => {
152
- stdout += chunk;
153
- if (stdout.length > 32 * 1024 * 1024) { overflow = true; child.kill('SIGKILL'); }
154
- });
155
- }
156
- if (child.stderr) {
157
- child.stderr.setEncoding('utf8');
158
- child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-65536); });
159
- }
160
- child.once('error', error => { cleanup(); reject(new Error(`无法执行 ${command}:${error.message}`)); });
161
- child.once('close', (status, signal) => {
162
- cleanup();
163
- const code = status ?? (signal === 'SIGINT' ? 130 : 1);
164
- if (timedOut) reject(new Error(`${path.basename(command)} 执行超时;请检查连接或同步状态。`));
165
- else if (overflow) reject(new Error('命令输出过大,请使用 claude-remote status 检查。'));
166
- else if (check && code) reject(new Error(`${path.basename(command)} 执行失败(${code})${stderr ? ':' + stderr.trim() : ''}`));
167
- else resolve({ code, stdout, stderr });
168
- });
169
- });
170
- }
171
-
172
- // Heartbeat locks survive long downloads and recover after an interrupted process.
173
- export async function withLock(directory, callback) {
174
- await fs.mkdir(path.dirname(directory), { recursive: true, mode: 0o700 });
175
- const release = await lockfile.lock(directory, { realpath: false, stale: 30000, update: 10000,
176
- retries: { retries: 240, minTimeout: 1000, maxTimeout: 1000 } });
177
- try { return await callback(); }
178
- finally { await release(); }
179
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import * as fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import lockfile from 'proper-lockfile';
8
+
9
+ export const quote = value => "'" + String(value).replaceAll("'", "'\"'\"'") + "'";
10
+ export const shellJoin = args => args.map(quote).join(' ');
11
+
12
+ export async function exists(file) {
13
+ try { await fs.access(file); return true; }
14
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
15
+ }
16
+
17
+ // On Windows, try the same extensions as spawn() does (.com, .exe); there is no
18
+ // execute permission bit, so existence is the check.
19
+ export async function which(name, search = process.env.PATH || '', { platform = process.platform } = {}) {
20
+ const windows = platform === 'win32';
21
+ const names = windows && !path.extname(name) ? [name + '.com', name + '.exe'] : [name];
22
+ for (const entry of search.split(windows ? ';' : path.delimiter)) {
23
+ const directory = windows ? entry.replace(/^"(.*)"$/, '$1') : entry;
24
+ for (const candidate of names) {
25
+ const file = path.resolve(directory || '.', candidate);
26
+ try {
27
+ await fs.access(file, windows ? constants.F_OK : constants.X_OK);
28
+ if ((await fs.stat(file)).isFile()) return file;
29
+ } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR', 'EINVAL'].includes(error.code)) throw error; }
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // Windows refuses to replace a file another process (editor, antivirus,
36
+ // indexer) briefly holds open; retry a few times before giving up.
37
+ export async function rename(from, to, { platform = process.platform, attempts = 10 } = {}) {
38
+ for (let attempt = 1; ; attempt++) {
39
+ try { return await fs.rename(from, to); }
40
+ catch (error) {
41
+ if (platform !== 'win32' || attempt >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error;
42
+ await new Promise(resolve => setTimeout(resolve, 20 * attempt));
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function readJSON(file, fallback) {
48
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); }
49
+ catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
50
+ }
51
+
52
+ export async function writeJSON(file, value) {
53
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
54
+ const temp = file + '.' + randomUUID() + '.tmp';
55
+ try {
56
+ await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
57
+ await rename(temp, file);
58
+ } finally { await fs.rm(temp, { force: true }); }
59
+ }
60
+
61
+ // Children and cleanup callbacks that must not outlive this CLI when the
62
+ // terminal closes (SIGHUP) or the process is asked to stop (SIGTERM).
63
+ const trackedChildren = new Set();
64
+ const cleanups = new Set();
65
+ let terminating = null;
66
+
67
+ export function trackChild(child) {
68
+ trackedChildren.add(child);
69
+ child.once('close', () => trackedChildren.delete(child));
70
+ child.once('error', () => trackedChildren.delete(child));
71
+ return child;
72
+ }
73
+
74
+ export function onCleanup(callback) {
75
+ cleanups.add(callback);
76
+ return () => cleanups.delete(callback);
77
+ }
78
+
79
+ export function terminate(signal, { exit = code => process.exit(code), graceMs = 10000, terminal = process.stdout } = {}) {
80
+ if (terminating) return terminating;
81
+ const code = 128 + (os.constants.signals[signal] || 15);
82
+ terminating = (async () => {
83
+ // Cleanups first: they may still need live SSH connections (e.g. to delete
84
+ // remote temporary files). Whatever is left afterwards is stopped.
85
+ const pending = Promise.allSettled([...cleanups].map(callback => callback()));
86
+ let timer;
87
+ const limit = new Promise(resolve => { timer = setTimeout(resolve, graceMs); timer.unref?.(); });
88
+ await Promise.race([pending, limit]);
89
+ clearTimeout(timer);
90
+ for (const child of trackedChildren) {
91
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
92
+ }
93
+ restoreTerminal(terminal);
94
+ exit(code);
95
+ })();
96
+ return terminating;
97
+ }
98
+
99
+ // A remote full-screen program that loses its connection cannot switch off the
100
+ // terminal modes it enabled, so mouse moves, focus changes and keys show up as
101
+ // stray escape codes. Every sequence here only turns a mode off and is a no-op
102
+ // when the mode is already off. The alternate screen (?1049l) is deliberately
103
+ // left alone: leaving it when not active can move the cursor on some terminals.
104
+ export const TERMINAL_RESET = [
105
+ '\x1b[?1000l', '\x1b[?1002l', '\x1b[?1003l', '\x1b[?1005l', '\x1b[?1006l', '\x1b[?1015l', // mouse reporting
106
+ '\x1b[?1004l', // focus in/out events
107
+ '\x1b[?2004l', // bracketed paste
108
+ '\x1b[=0;1u', '\x1b[<99u', // kitty keyboard protocol: clear flags, pop pushed levels
109
+ '\x1b[>4m', // xterm modifyOtherKeys
110
+ '\x1b[?1l', '\x1b>', // normal cursor keys and keypad
111
+ '\x1b[0m', '\x1b[?25h' // plain text attributes, visible cursor
112
+ ].join('');
113
+
114
+ export function restoreTerminal(stream = process.stdout) {
115
+ if (!stream?.isTTY) return false;
116
+ try { stream.write(TERMINAL_RESET); return true; }
117
+ catch { return false; } // The terminal may already be gone (SIGHUP).
118
+ }
119
+
120
+ export function installSignalHandlers() {
121
+ for (const signal of ['SIGHUP', 'SIGTERM']) process.on(signal, () => { terminate(signal); });
122
+ }
123
+
124
+ // `prompt` captures stdout but leaves stdin and stderr on the terminal, so a
125
+ // password or host-key prompt from ssh stays visible and answerable.
126
+ export function execute(command, args = [], { capture = false, timeout = 0, check = true, env = process.env, input, prompt = false } = {}) {
127
+ return new Promise((resolve, reject) => {
128
+ const stdio = prompt ? ['inherit', 'pipe', 'inherit'] : [input !== undefined ? 'pipe' : capture ? 'ignore' : 'inherit',
129
+ capture ? 'pipe' : 'inherit', capture ? 'pipe' : 'inherit'];
130
+ const child = trackChild(spawn(command, args, { stdio, env }));
131
+ if (input !== undefined) {
132
+ child.stdin.on('error', () => {}); // An early remote failure is reported by the exit status.
133
+ child.stdin.end(input);
134
+ }
135
+ let stdout = '', stderr = '', timedOut = false, overflow = false, killTimer;
136
+ // The Windows console already delivers Ctrl+C to the child, and kill()
137
+ // there would terminate it outright instead of interrupting it.
138
+ const interrupt = () => { if (process.platform !== 'win32') child.kill('SIGINT'); };
139
+ process.on('SIGINT', interrupt);
140
+ const timer = timeout ? setTimeout(() => {
141
+ timedOut = true;
142
+ child.kill('SIGTERM');
143
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
144
+ }, timeout) : null;
145
+ const cleanup = () => {
146
+ clearTimeout(timer); clearTimeout(killTimer);
147
+ process.off('SIGINT', interrupt);
148
+ };
149
+ if (child.stdout) {
150
+ child.stdout.setEncoding('utf8');
151
+ child.stdout.on('data', chunk => {
152
+ stdout += chunk;
153
+ if (stdout.length > 32 * 1024 * 1024) { overflow = true; child.kill('SIGKILL'); }
154
+ });
155
+ }
156
+ if (child.stderr) {
157
+ child.stderr.setEncoding('utf8');
158
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-65536); });
159
+ }
160
+ child.once('error', error => { cleanup(); reject(new Error(`无法执行 ${command}:${error.message}`)); });
161
+ child.once('close', (status, signal) => {
162
+ cleanup();
163
+ const code = status ?? (signal === 'SIGINT' ? 130 : 1);
164
+ if (timedOut) reject(new Error(`${path.basename(command)} 执行超时;请检查连接或同步状态。`));
165
+ else if (overflow) reject(new Error('命令输出过大,请使用 claude-remote status 检查。'));
166
+ else if (check && code) reject(new Error(`${path.basename(command)} 执行失败(${code})${stderr ? ':' + stderr.trim() : ''}`));
167
+ else resolve({ code, stdout, stderr });
168
+ });
169
+ });
170
+ }
171
+
172
+ // Heartbeat locks survive long downloads and recover after an interrupted process.
173
+ export async function withLock(directory, callback) {
174
+ await fs.mkdir(path.dirname(directory), { recursive: true, mode: 0o700 });
175
+ const release = await lockfile.lock(directory, { realpath: false, stale: 30000, update: 10000,
176
+ retries: { retries: 240, minTimeout: 1000, maxTimeout: 1000 } });
177
+ try { return await callback(); }
178
+ finally { await release(); }
179
+ }