zback 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +39 -2
- package/bin/zback.js +151 -0
- package/package.json +14 -4
- package/src/agent.js +98 -0
- package/src/runtime.js +39 -0
- package/test/client.test.js +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZBack contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ZBack CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Global command for Node.js 22+. No npm dependencies, login, API key, or password.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g .
|
|
9
|
+
zback
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The first time, ZBack asks exactly once for permission to access **your own shell**. Answer `y` to run it as a detached background process. When connected, it prints:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Okay, here's your session ID "..."
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Closing the terminal does not normally stop the agent. Later calls to `zback` show its current session ID without requesting permission again. To view the connection state or terminate the worker:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
zback --status
|
|
22
|
+
zback --stop
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Advanced options at first launch (or after stopping it):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
zback --url wss://zb.soymaycol.icu/ws --cwd /path/to/project
|
|
29
|
+
zback --timeout 120000 --max-output 131072
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Local development: `zback --url ws://127.0.0.1:8787/ws`.
|
|
33
|
+
|
|
34
|
+
The agent automatically reconnects, potentially receiving a **new session ID**; check `zback --status` if the previous ID stops working. There are no per-command approvals after initial permission. The session ID grants anyone who possesses it remote shell access for the lifetime of that session; **keep it private**. TLS (`wss://`) is required remotely.
|
|
35
|
+
|
|
36
|
+
Status, consent, and log files are stored in the OS user state directory (`~/.local/state/zback` on most Linux systems or `%LOCALAPPDATA%/zback` on Windows). They are local and not published by the backend. Optionally set `ZBACK_STATE_DIR` to change this location.
|
|
37
|
+
|
|
38
|
+
Closing a terminal normally leaves the detached worker running. Force-stopping an Android app, battery restrictions, shutdown or network loss may still terminate or interrupt it; this CLI does not auto-start itself on device boot.
|
|
39
|
+
|
|
40
|
+
Test with `npm test`.
|
package/bin/zback.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { openSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { stdin, stdout } from 'node:process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { connectAgent, execute } from '../src/agent.js';
|
|
10
|
+
import { activeSession, alive, consentFile, hasConsent, initStateDir, logFile, readJson, sameSession, saveJson, settingsFile, stateFile } from '../src/runtime.js';
|
|
11
|
+
|
|
12
|
+
const ENTRY = fileURLToPath(import.meta.url);
|
|
13
|
+
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
14
|
+
|
|
15
|
+
function help() {
|
|
16
|
+
console.log(`ZBack 1.1.0\n\nUsage: zback [options]\n\n --url <wss://.../ws> WebSocket endpoint (default: ${DEFAULT_URL})\n --cwd <directory> Directory for shell commands\n --timeout <ms> Command timeout (1000-300000)\n --max-output <bytes> Maximum command output (1024-262144)\n --no-reconnect Disable automatic reconnection\n --status Show background agent status and session ID\n --stop Stop background agent\n --help Display help\n\nThe first launch asks for shell access permission only once.\nThe agent runs in the background until stopped.`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseArgs(args) {
|
|
20
|
+
const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
|
|
21
|
+
const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
|
|
22
|
+
let action = 'start';
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const flag = args[i];
|
|
25
|
+
if (flag === '--help' || flag === '-h') return { help: true };
|
|
26
|
+
if (flag === '--worker') return { worker: true };
|
|
27
|
+
if (flag === '--status' || flag === '--stop') {
|
|
28
|
+
if (action !== 'start') throw new Error('Specify one action');
|
|
29
|
+
action = flag.slice(2);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (flag === '--no-reconnect') { options.reconnect = false; continue; }
|
|
33
|
+
if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
|
|
34
|
+
const value = args[++i];
|
|
35
|
+
if (flag === '--url') options.url = value;
|
|
36
|
+
if (flag === '--cwd') options.cwd = resolve(value);
|
|
37
|
+
if (flag === '--timeout') options.timeout = Number(value);
|
|
38
|
+
if (flag === '--max-output') options.maxOutput = Number(value);
|
|
39
|
+
}
|
|
40
|
+
if (action !== 'start') return { action };
|
|
41
|
+
if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout must be between 1000 and 300000 ms');
|
|
42
|
+
if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output must be between 1024 and 262144 bytes');
|
|
43
|
+
if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
|
|
44
|
+
const url = new URL(options.url);
|
|
45
|
+
if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Use wss:// for remote connections or ws:// on localhost');
|
|
46
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected a WebSocket URL ending in /ws');
|
|
47
|
+
return { ...options, action };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function confirmOnce() {
|
|
51
|
+
if (hasConsent()) return true;
|
|
52
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error('An interactive terminal is required for initial permission.');
|
|
53
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
54
|
+
try {
|
|
55
|
+
const answer = await rl.question('This module accesses your shell. Do you allow it to use your shell? Permission will not be requested again. [y/N] ');
|
|
56
|
+
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
|
|
57
|
+
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
58
|
+
return true;
|
|
59
|
+
} finally { rl.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function worker() {
|
|
63
|
+
if (!hasConsent()) throw new Error('Permission has not been granted. Run zback interactively first.');
|
|
64
|
+
const settings = readJson(settingsFile);
|
|
65
|
+
if (!settings || !settings.url || !settings.cwd) throw new Error('Missing agent settings');
|
|
66
|
+
const started = process.env.ZBACK_WORKER_NONCE;
|
|
67
|
+
if (!started) throw new Error('Not a valid ZBack background worker');
|
|
68
|
+
const mark = data => {
|
|
69
|
+
const current = readJson(stateFile);
|
|
70
|
+
if (current && current.status !== 'stopped' && current.pid !== process.pid && alive(current.pid)) return;
|
|
71
|
+
saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
|
|
72
|
+
};
|
|
73
|
+
mark({ status: 'connecting', id: null });
|
|
74
|
+
const hardStop = () => {
|
|
75
|
+
if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
|
|
76
|
+
setTimeout(() => process.exit(0), 150).unref();
|
|
77
|
+
};
|
|
78
|
+
process.once('SIGTERM', hardStop);
|
|
79
|
+
process.once('SIGINT', hardStop);
|
|
80
|
+
try { await connectAgent(settings, mark); }
|
|
81
|
+
finally {
|
|
82
|
+
if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function start(options) {
|
|
87
|
+
const running = activeSession();
|
|
88
|
+
if (running) {
|
|
89
|
+
console.log(running.id ? `Okay, here's your session ID "${running.id}"` : `ZBack is ${running.status}.`);
|
|
90
|
+
console.log('Use zback --status to inspect the agent or zback --stop to stop it.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!(await confirmOnce())) { console.log('Permission declined.'); return; }
|
|
94
|
+
initStateDir();
|
|
95
|
+
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
96
|
+
const fd = openSync(logFile, 'a', 0o600);
|
|
97
|
+
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
98
|
+
let child;
|
|
99
|
+
try {
|
|
100
|
+
child = spawn(process.execPath, [ENTRY, '--worker'], {
|
|
101
|
+
detached: true, windowsHide: true,
|
|
102
|
+
stdio: ['ignore', fd, fd],
|
|
103
|
+
env: { ...process.env, ZBACK_WORKER_NONCE: started }
|
|
104
|
+
});
|
|
105
|
+
child.unref();
|
|
106
|
+
} finally {
|
|
107
|
+
const { closeSync } = await import('node:fs');
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
if (!child?.pid) throw new Error('Could not launch background process');
|
|
111
|
+
for (let i = 0; i < 80; i++) {
|
|
112
|
+
await new Promise(r => setTimeout(r, 100));
|
|
113
|
+
const state = readJson(stateFile);
|
|
114
|
+
if (state?.pid === child.pid && state.started === started && state.id && state.status === 'connected') {
|
|
115
|
+
console.log(`Okay, here's your session ID "${state.id}"`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) {
|
|
119
|
+
throw new Error(`Background process exited. See ${logFile}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
console.log(`Background agent started (PID ${child.pid}); waiting for the server.`);
|
|
123
|
+
console.log('Use zback --status to see the session ID once connected.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function status() {
|
|
127
|
+
const state = activeSession();
|
|
128
|
+
if (!state) return console.log('ZBack is stopped.');
|
|
129
|
+
console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function stop() {
|
|
133
|
+
const state = activeSession();
|
|
134
|
+
if (!state) return console.log('ZBack is not running.');
|
|
135
|
+
process.kill(state.pid, 'SIGTERM');
|
|
136
|
+
console.log('ZBack stop requested.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { execute };
|
|
140
|
+
export async function main(args = process.argv.slice(2)) {
|
|
141
|
+
try {
|
|
142
|
+
const options = parseArgs(args);
|
|
143
|
+
if (options.help) return help();
|
|
144
|
+
if (options.worker) return worker();
|
|
145
|
+
if (options.action === 'status') return status();
|
|
146
|
+
if (options.action === 'stop') return stop();
|
|
147
|
+
return await start(options);
|
|
148
|
+
} catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (process.argv[1] && resolve(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Background WebSocket shell agent with one-time local consent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zback": "bin/zback.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.0.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const children = new Set();
|
|
4
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
+
|
|
6
|
+
function killTree(child) {
|
|
7
|
+
if (!child?.pid) return;
|
|
8
|
+
try {
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
11
|
+
killer.unref();
|
|
12
|
+
} else process.kill(-child.pid, 'SIGKILL');
|
|
13
|
+
} catch { try { child.kill('SIGKILL'); } catch {} }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function killRunningCommands() { for (const child of children) killTree(child); }
|
|
17
|
+
|
|
18
|
+
export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
19
|
+
return new Promise(resolveResult => {
|
|
20
|
+
let finished = false, timedOut = false, truncated = false;
|
|
21
|
+
const chunks = { stdout: [], stderr: [] };
|
|
22
|
+
let collected = 0;
|
|
23
|
+
let child;
|
|
24
|
+
try {
|
|
25
|
+
child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
26
|
+
children.add(child);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const add = (kind, chunk) => {
|
|
32
|
+
const remaining = maxOutput - collected;
|
|
33
|
+
if (remaining <= 0) { truncated = true; return; }
|
|
34
|
+
if (chunk.length > remaining) truncated = true;
|
|
35
|
+
const part = chunk.subarray(0, remaining);
|
|
36
|
+
collected += part.length;
|
|
37
|
+
chunks[kind].push(part);
|
|
38
|
+
};
|
|
39
|
+
child.stdout.on('data', b => add('stdout', b));
|
|
40
|
+
child.stderr.on('data', b => add('stderr', b));
|
|
41
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
|
|
42
|
+
const finish = (code, message = '') => {
|
|
43
|
+
if (finished) return;
|
|
44
|
+
finished = true;
|
|
45
|
+
children.delete(child);
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
|
|
48
|
+
stdout: Buffer.concat(chunks.stdout).toString('utf8'),
|
|
49
|
+
stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
|
|
50
|
+
truncated, timedOut });
|
|
51
|
+
};
|
|
52
|
+
child.once('error', error => finish(null, `\n${error.message}`));
|
|
53
|
+
child.once('close', code => finish(code));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function connectAgent(options, onState) {
|
|
58
|
+
let stopped = false, retries = 0, socket;
|
|
59
|
+
const shutdown = () => { stopped = true; try { socket?.close(); } catch {} killRunningCommands(); };
|
|
60
|
+
process.once('SIGINT', shutdown);
|
|
61
|
+
process.once('SIGTERM', shutdown);
|
|
62
|
+
while (!stopped) {
|
|
63
|
+
try {
|
|
64
|
+
socket = new WebSocket(options.url);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
socket.addEventListener('open', resolve, { once: true });
|
|
67
|
+
socket.addEventListener('error', () => reject(new Error('Connection failed')), { once: true });
|
|
68
|
+
});
|
|
69
|
+
if (stopped) break;
|
|
70
|
+
retries = 0;
|
|
71
|
+
onState({ status: 'connected', id: null });
|
|
72
|
+
let queue = Promise.resolve();
|
|
73
|
+
socket.addEventListener('message', event => {
|
|
74
|
+
let message;
|
|
75
|
+
try { message = JSON.parse(event.data); } catch { return; }
|
|
76
|
+
if (message.type === 'welcome' && typeof message.id === 'string') {
|
|
77
|
+
onState({ status: 'connected', id: message.id });
|
|
78
|
+
console.log(`[zback] Connected; session ${message.id}`);
|
|
79
|
+
}
|
|
80
|
+
if (message.type === 'command' && typeof message.requestId === 'string') {
|
|
81
|
+
queue = queue.then(async () => {
|
|
82
|
+
const command = message.command;
|
|
83
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
|
|
84
|
+
const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
|
|
85
|
+
console.log(`[zback] Executing request ${message.requestId}`);
|
|
86
|
+
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
87
|
+
if (socket.readyState === WebSocket.OPEN && !stopped) socket.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
88
|
+
}).catch(error => console.error(`[zback] Request failed: ${error.message}`));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await new Promise(resolve => socket.addEventListener('close', resolve, { once: true }));
|
|
92
|
+
} catch (error) { if (!stopped) console.error(`[zback] ${error.message}`); }
|
|
93
|
+
if (stopped || !options.reconnect) break;
|
|
94
|
+
onState({ status: 'reconnecting', id: null });
|
|
95
|
+
await wait(Math.min(30000, 1000 * 2 ** Math.min(retries++, 5)));
|
|
96
|
+
}
|
|
97
|
+
onState({ status: 'stopped', id: null });
|
|
98
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const stateDir = process.env.ZBACK_STATE_DIR || join(process.env.XDG_STATE_HOME || (process.platform === 'win32' ? process.env.LOCALAPPDATA || homedir() : join(homedir(), '.local', 'state')), 'zback');
|
|
6
|
+
export const consentFile = join(stateDir, 'consent.json');
|
|
7
|
+
export const stateFile = join(stateDir, 'session.json');
|
|
8
|
+
export const settingsFile = join(stateDir, 'settings.json');
|
|
9
|
+
export const logFile = join(stateDir, 'zback.log');
|
|
10
|
+
|
|
11
|
+
export function initStateDir() { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
|
|
12
|
+
export function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
|
|
13
|
+
export function saveJson(file, data) {
|
|
14
|
+
initStateDir();
|
|
15
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
16
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
17
|
+
renameSync(tmp, file);
|
|
18
|
+
}
|
|
19
|
+
export function hasConsent() { return readJson(consentFile)?.approved === true; }
|
|
20
|
+
export function alive(pid) {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
if (process.platform === 'linux') {
|
|
25
|
+
try { if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false; } catch {}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
30
|
+
}
|
|
31
|
+
export function activeSession() {
|
|
32
|
+
const data = readJson(stateFile);
|
|
33
|
+
return data && data.status !== 'stopped' && alive(data.pid) ? data : null;
|
|
34
|
+
}
|
|
35
|
+
export function sameSession(pid, started) {
|
|
36
|
+
const data = readJson(stateFile);
|
|
37
|
+
return data?.pid === pid && data?.started === started;
|
|
38
|
+
}
|
|
39
|
+
export function hasState() { return existsSync(stateFile); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { parseArgs, execute } from '../bin/zback.js';
|
|
4
|
+
|
|
5
|
+
test('default secure endpoint', () => {
|
|
6
|
+
const args = parseArgs([]);
|
|
7
|
+
assert.equal(args.url, 'wss://zb.soymaycol.icu/ws');
|
|
8
|
+
assert.equal(args.reconnect, true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('unsafe remote ws URL blocked', () => {
|
|
12
|
+
assert.throws(() => parseArgs(['--url', 'ws://example.com/ws']), /wss/);
|
|
13
|
+
assert.equal(parseArgs(['--url', 'ws://127.0.0.1:8787/ws']).timeout, 30000);
|
|
14
|
+
assert.throws(() => parseArgs(['--timeout', '0']), /timeout/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('subprocess respects success and returned output', async () => {
|
|
18
|
+
const res = await execute('node -p "3*7"', process.cwd(), 5000, 1024);
|
|
19
|
+
assert.equal(res.ok, true);
|
|
20
|
+
assert.equal(res.stdout.trim(), '21');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('subprocess output limit is enforced', async () => {
|
|
24
|
+
const res = await execute('node -e "process.stdout.write(\'a\'.repeat(2500))"', process.cwd(), 5000, 1024);
|
|
25
|
+
assert.equal(res.ok, true);
|
|
26
|
+
assert.equal(res.stdout.length, 1024);
|
|
27
|
+
assert.equal(res.truncated, true);
|
|
28
|
+
});
|