zback 1.0.0 → 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/README.md CHANGED
@@ -1,31 +1,40 @@
1
- # zback (CLI global)
1
+ # ZBack CLI
2
2
 
3
- Agente CLI para **Node.js 22 o superior**, sin dependencias npm externas. Se conecta a tu backend por WebSocket cifrado, enseña un ID temporal y solicita **autorización presencial para cada comando**.
3
+ Global command for Node.js 22+. No npm dependencies, login, API key, or password.
4
4
 
5
- ## Instalar
5
+ ## Install
6
6
 
7
7
  ```bash
8
8
  npm install -g .
9
- zback --help
10
9
  zback
11
10
  ```
12
11
 
13
- Extrae este ZIP antes de ejecutar `npm install -g .` dentro de su directorio.
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:
14
13
 
15
- Opciones:
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:
16
19
 
17
20
  ```bash
18
- zback --url wss://zb.soymaycol.icu/ws
19
- zback --cwd /ruta/a/proyecto --timeout 15000 --max-output 32768
20
- zback --no-reconnect
21
+ zback --status
22
+ zback --stop
21
23
  ```
22
24
 
23
- Para pruebas locales: `zback --url ws://127.0.0.1:8787/ws`.
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.
24
37
 
25
- - Escribe `ACEPTO` para iniciar la sesión.
26
- - Verás un **ID nuevo por cada conexión**. No es una contraseña creada por el usuario, pero funciona como credencial temporal: mantenlo privado.
27
- - Cuando el MCP pida ejecutar un comando, verás su contenido completo y tendrás que escribir `s` para autorizarlo. Pulsar Enter, responder `n` o agotar el tiempo lo rechaza.
28
- - Ctrl+C termina la conexión; desconectarse invalida el ID.
29
- - Conexiones públicas `ws://` sin TLS están bloqueadas.
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.
30
39
 
31
- **Advertencia:** revisar antes de aprobar es fundamental: cualquier comando autorizado se ejecuta con los permisos de tu usuario. El backend no hace que un comando sea confiable.
40
+ Test with `npm test`.
package/bin/zback.js CHANGED
@@ -1,175 +1,151 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync, realpathSync, statSync } from 'node:fs';
3
+ import { openSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin, stdout } from 'node:process';
7
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';
8
11
 
12
+ const ENTRY = fileURLToPath(import.meta.url);
9
13
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
10
14
 
11
15
  function help() {
12
- console.log(`ZBack 1.0.0 - WebSocket agent (Node.js >=22)
13
-
14
- Uso: zback [opciones]
15
-
16
- --url <wss://.../ws> WebSocket de destino (default: ${DEFAULT_URL})
17
- --cwd <directorio> Directorio desde el cual ejecutar comandos
18
- --timeout <ms> Máximo tiempo de ejecución por comando (1000-300000)
19
- --max-output <bytes> Límite combinado de stdout+stderr (1024-262144)
20
- --no-reconnect Desactivar reconexión automática
21
- --help Mostrar esta ayuda
22
-
23
- Se solicita permiso al inicio y cada vez que un comando remoto llega.
24
- No hay API keys ni ejecución sin confirmación.`);
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.`);
25
17
  }
26
18
 
27
19
  export function parseArgs(args) {
28
20
  const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
29
- const set = new Set(['--url', '--cwd', '--timeout', '--max-output']);
21
+ const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
22
+ let action = 'start';
30
23
  for (let i = 0; i < args.length; i++) {
31
24
  const flag = args[i];
32
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
+ }
33
32
  if (flag === '--no-reconnect') { options.reconnect = false; continue; }
34
- if (!set.has(flag) || i + 1 >= args.length) throw new Error(`Argumento desconocido o incompleto: ${flag}`);
33
+ if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
35
34
  const value = args[++i];
36
35
  if (flag === '--url') options.url = value;
37
36
  if (flag === '--cwd') options.cwd = resolve(value);
38
37
  if (flag === '--timeout') options.timeout = Number(value);
39
38
  if (flag === '--max-output') options.maxOutput = Number(value);
40
39
  }
41
- if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout debe estar entre 1000 y 300000 ms');
42
- if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output debe estar entre 1024 y 262144 bytes');
43
- if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd debe ser un directorio existente');
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
44
  const url = new URL(options.url);
45
- if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
46
- throw new Error('Solo se permite wss:// público o ws:// local. Usa TLS para conexiones remotas.');
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 });
47
83
  }
48
- if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('URL inválida: usa una dirección WebSocket terminada en /ws sin credenciales ni parámetros');
49
- return options;
50
84
  }
51
85
 
52
- export function execute(command, cwd, timeoutMs, maxOutput) {
53
- return new Promise(resolveResult => {
54
- let finished = false, timedOut = false, truncated = false;
55
- const chunks = { stdout: [], stderr: [] };
56
- let collected = 0;
57
- let child;
58
- try {
59
- child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
60
- } catch (err) {
61
- resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
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}"`);
62
116
  return;
63
117
  }
64
- const add = (kind, chunk) => {
65
- const remaining = maxOutput - collected;
66
- if (remaining <= 0) { truncated = true; return; }
67
- if (chunk.length > remaining) truncated = true;
68
- const part = chunk.subarray(0, remaining);
69
- collected += part.length;
70
- chunks[kind].push(part);
71
- };
72
- child.stdout.on('data', b => add('stdout', b));
73
- child.stderr.on('data', b => add('stderr', b));
74
- const timer = setTimeout(() => {
75
- timedOut = true;
76
- try {
77
- if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
78
- else process.kill(-child.pid, 'SIGKILL');
79
- } catch { try { child.kill('SIGKILL'); } catch {} }
80
- }, timeoutMs);
81
- const finish = (code, message = '') => {
82
- if (finished) return;
83
- finished = true;
84
- clearTimeout(timer);
85
- resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
86
- stdout: Buffer.concat(chunks.stdout).toString('utf8'),
87
- stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
88
- truncated, timedOut });
89
- };
90
- child.once('error', error => finish(null, `\n${error.message}`));
91
- child.once('close', code => finish(code));
92
- });
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.');
93
124
  }
94
125
 
95
- export async function main(args = process.argv.slice(2)) {
96
- let options;
97
- try { options = parseArgs(args); }
98
- catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 2; return; }
99
- if (options.help) return help();
100
- if (!stdin.isTTY || !stdout.isTTY) {
101
- console.error('Error: se requiere una terminal interactiva para confirmar comandos.');
102
- process.exitCode = 2; return;
103
- }
104
- console.log('\nZBack - acceso remoto a tu shell');
105
- console.log('ADVERTENCIA: ejecutar comandos ajenos puede borrar archivos, modificar el sistema o comprometer información.');
106
- console.log('Conéctate solo a tu propio servidor. Revisa cada comando antes de aprobarlo.');
107
- console.log(`Servidor: ${options.url}`);
108
- console.log(`Directorio: ${options.cwd}`);
109
- const rl = createInterface({ input: stdin, output: stdout });
110
- const consent = await rl.question('Escribe ACEPTO para habilitar solicitudes remotas: ');
111
- if (consent.trim() !== 'ACEPTO') { console.log('Cancelado.'); rl.close(); return; }
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
+ }
112
131
 
113
- let ws, active = false, stopped = false, retries = 0;
114
- let queue = Promise.resolve();
115
- const wait = ms => new Promise(resolveWait => setTimeout(resolveWait, ms));
116
- const shutdown = () => { stopped = true; try { ws?.close(); } catch {} rl.close(); };
117
- process.once('SIGINT', shutdown);
118
- process.once('SIGTERM', shutdown);
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
+ }
119
138
 
120
- while (!stopped) {
121
- try {
122
- ws = new WebSocket(options.url);
123
- await new Promise((accept, reject) => {
124
- ws.addEventListener('open', accept, { once: true });
125
- ws.addEventListener('error', () => reject(new Error('Conexión fallida')), { once: true });
126
- });
127
- active = true;
128
- retries = 0;
129
- console.log('\nConectado. Esperando solicitudes...');
130
- const closed = new Promise(resolveClosed => ws.addEventListener('close', resolveClosed, { once: true }));
131
- ws.addEventListener('message', event => {
132
- let message;
133
- try { message = JSON.parse(event.data); } catch { return; }
134
- if (message.type === 'welcome') {
135
- console.log(`\nID temporal: ${message.id}`);
136
- console.log('Comparte el ID solo con el cliente MCP autorizado. No se mostrará en el backend.');
137
- }
138
- if (message.type === 'command' && typeof message.requestId === 'string') {
139
- queue = queue.then(async () => {
140
- const command = message.command;
141
- if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
142
- console.log(`\nSolicitud de ejecución recibida:\n ${JSON.stringify(command)}`);
143
- let approval;
144
- try {
145
- approval = await rl.question('¿Autorizar este comando? [s/N]: ', { signal: AbortSignal.timeout(Math.min(message.approvalTimeoutMs ?? 60000, 300000)) });
146
- } catch { approval = ''; }
147
- let result;
148
- if (approval.trim().toLowerCase() !== 's') {
149
- result = { ok: false, rejected: true, code: null, stdout: '', stderr: 'Rechazado por la persona conectada.' };
150
- console.log('Comando rechazado.');
151
- } else {
152
- const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) ? message.timeoutMs : options.timeout);
153
- console.log('Ejecutando...');
154
- result = await execute(command, options.cwd, timeout, options.maxOutput);
155
- console.log(result.ok ? 'Comando completado.' : 'Comando terminado con error o límite.');
156
- }
157
- if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
158
- }).catch(error => console.error(`Error al procesar solicitud: ${error.message}`));
159
- }
160
- });
161
- await closed;
162
- console.log('\nDesconectado. El ID anterior ya no es válido.');
163
- } catch (error) { console.error(`Conexión: ${error.message}`); }
164
- active = false;
165
- if (!options.reconnect || stopped) break;
166
- const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
167
- console.log(`Reconectando en ${Math.ceil(delay / 1000)} s...`);
168
- await wait(delay);
169
- }
170
- shutdown();
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; }
171
149
  }
172
150
 
173
- if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => {
174
- console.error(error.message); process.exitCode = 1;
175
- });
151
+ if (process.argv[1] && resolve(process.argv[1]) === ENTRY) main();
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.0.0",
4
- "description": "Consent-based remote shell agent via WebSocket and MCP",
3
+ "version": "1.1.1",
4
+ "description": "Background WebSocket shell agent with one-time local consent",
5
5
  "type": "module",
6
- "bin": { "zback": "./bin/zback.js" },
7
- "engines": { "node": ">=22.0.0" },
8
- "scripts": { "test": "node --test" },
6
+ "bin": {
7
+ "zback": "bin/zback.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
9
15
  "license": "MIT"
10
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); }