nio-cat 0.3.5 → 0.4.3

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/bin/nio.js CHANGED
@@ -4,13 +4,13 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const crypto = require('crypto');
7
- const net = require('net');
8
7
  const { spawn } = require('child_process');
9
8
  const config = require('../src/config');
10
9
  const { Hub } = require('../src/hub');
11
10
  const { createServer } = require('../src/server');
12
11
  const { detectAll } = require('../src/agents');
13
12
  const { startTunnel } = require('../src/tunnel');
13
+ const { addServer } = require('../src/ssh');
14
14
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
15
15
  const { which } = require('../src/proc');
16
16
 
@@ -74,12 +74,14 @@ function canOpenBrowser() {
74
74
  return true;
75
75
  }
76
76
 
77
+ let panelOpened = false;
77
78
  function openDashboard() {
78
79
  const local = `http://127.0.0.1:${cfg.port}`;
79
80
  // Per defecte s'obre l'app en línia (sempre al dia), ja connectada a aquest ordinador. Amb --local, la còpia que porta nio.
80
81
  const online = !flags.local;
81
- const link = pairLink(online ? flags.app || cfg.app : local, local, cfg.token);
82
- if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell:')} ${online ? (flags.app || cfg.app) + '/app' : local} ${c.dim('(obert al navegador; --no-open per evitar-ho' + (online ? '; --local per fer servir la còpia d\'aquest ordinador' : '') + ')')}`);
82
+ // &qr=1: el panell mostra el QR per al mòbil tot just obrir-se, a la mateixa pestanya.
83
+ const link = pairLink(online ? flags.app || cfg.app : local, local, cfg.token) + '&qr=1';
84
+ if (canOpenBrowser() && openUrl(link)) { panelOpened = true; console.log(` ${c.dim('Panell:')} ${online ? (flags.app || cfg.app) + '/app' : local} ${c.dim('(obert al navegador; --no-open per evitar-ho' + (online ? '; --local per fer servir la còpia d\'aquest ordinador' : '') + ')')}`); }
83
85
  else console.log(` ${c.dim('Panell:')} ${link}`);
84
86
  }
85
87
 
@@ -95,8 +97,7 @@ async function showPair(publicUrl) {
95
97
  console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')}\n`);
96
98
  console.log(terminalQr(qrLink, { margin }));
97
99
  } else {
98
- console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} Fes-lo més gran o obre ${file}`);
99
- if (canOpenBrowser()) openUrl(file);
100
+ console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} ${panelOpened ? 'El tens al panell del navegador.' : 'Fes-lo més gran, o obre el panell i prem «Continua al mòbil».'}`);
100
101
  }
101
102
  console.log(`\n ${c.b('Codi:')} ${code}`);
102
103
  console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
@@ -111,73 +112,26 @@ async function showPair(publicUrl) {
111
112
  }
112
113
  }
113
114
 
114
- // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
115
- const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
116
- function remoteScript(port, dir) {
117
- return `set -e
118
- command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
119
- up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
120
- if ! up; then
121
- mkdir -p "$HOME/.nio"
122
- if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
123
- $S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
124
- for i in $(seq 1 80); do up && break; sleep 0.5; done
125
- up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
126
- fi
127
- node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
128
- `;
129
- }
130
- function sshBase(target) {
131
- const a = ['-o', 'ServerAliveInterval=30'];
132
- if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
133
- return a;
134
- }
135
- function waitPort(port, ms = 15000) {
136
- const end = Date.now() + ms;
137
- return new Promise((resolve) => {
138
- const t = () => {
139
- const s = net.connect(port, '127.0.0.1');
140
- s.once('connect', () => { s.destroy(); resolve(true); });
141
- s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
142
- };
143
- t();
144
- });
145
- }
115
+ // nio ssh usuari@servidor: engega nio al servidor amb un túnel i obre el panell amb el servidor ja afegit.
146
116
  async function sshStart() {
147
117
  const target = pos[0];
148
- if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
149
- console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
118
+ if (!target) {
119
+ console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--dir /ruta/al/servidor]')}\n`);
150
120
  process.exit(1);
151
121
  }
152
- const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
153
- console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
154
- const out = await new Promise((resolve) => {
155
- const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
156
- let buf = '';
157
- p.stdout.on('data', (d) => (buf += d));
158
- p.on('error', () => resolve({ code: -1, buf }));
159
- p.on('close', (code) => resolve({ code, buf }));
160
- p.stdin.end(remoteScript(rport, flags.dir));
161
- });
162
- if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
163
- if (out.code !== 0 && !out.buf.includes('NIO_TOKEN=')) { console.error(c.warn('\n No he pogut entrar al servidor per SSH.\n')); process.exit(1); }
164
- if (out.buf.includes('NIO_NO_NODE')) { console.error(c.warn('\n Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.\n')); process.exit(1); }
165
- if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
166
- const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
167
- if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
168
-
169
- const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
170
- tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
171
- const bye = () => { tun.kill(); process.exit(0); };
172
- process.on('SIGINT', bye);
173
- process.on('SIGTERM', bye);
174
- if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
175
-
176
- const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
177
- console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
178
- if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
179
- else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
180
- console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
122
+ console.log(`\n ${c.b('nio')} ${c.dim('· afegint')} ${target}${c.dim('…')}`);
123
+ let r;
124
+ try {
125
+ r = await addServer(target, { port: Number(flags.port || 7717), dir: flags.dir, batch: false, onStep: (t) => console.log(` ${c.dim('·')} ${t}…`) });
126
+ } catch (e) {
127
+ console.error(c.warn(`\n ${e.message}\n`));
128
+ process.exit(1);
129
+ }
130
+ const app = flags.app || cfg.app, link = pairLink(app, r.url, r.token), code = makeCode(r.url, r.token);
131
+ console.log(` ${c.ok('✓')} ${r.host} és a punt${r.claude ? '' : c.warn(' (Claude Code no hi és: instal·la’l i inicia sessió al servidor)')}`);
132
+ console.log(`\n ${c.b('Codi:')} ${code}`);
133
+ console.log(c.dim(' Enganxa’l a nio.cat → Ordinadors → Afegeix, al mòbil o a l’ordinador.\n'));
134
+ if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
181
135
  }
182
136
 
183
137
  async function start() {
@@ -294,9 +248,8 @@ async function start() {
294
248
  --open Obre al navegador la pàgina del codi QR
295
249
  nio pair Mostra el codi QR una altra vegada
296
250
 
297
- nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
251
+ nio ssh usuari@servidor Afegeix un servidor (VPS) per SSH: hi engega nio amb un túnel i el pots fer servir des del mòbil.
298
252
  --port <n> Port de nio al servidor (7717)
299
- --local-port <n> Port en aquest ordinador (7718)
300
253
  --dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
301
254
 
302
255
  Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.3.5",
3
+ "version": "0.4.3",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+ // Comandes pròpies de cada agent que es poden escriure amb «/» a la sessió.
3
+ // Claude Code: les teves comandes (~/.claude/commands i .claude/commands del projecte) i les skills.
4
+ // Es passen a l'agent tal qual («/nom arguments»); el mode d'impressió les entén.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ function frontmatter(file) {
10
+ let txt = '';
11
+ try { txt = fs.readFileSync(file, 'utf8').slice(0, 4000); } catch { return {}; }
12
+ const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
13
+ const out = {};
14
+ if (m) for (const l of m[1].split('\n')) { const kv = l.match(/^([\w-]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); }
15
+ if (!out.description) {
16
+ const first = txt.replace(/^---[\s\S]*?---\s*/, '').split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
17
+ if (first) out.description = first;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function listDir(dir) {
23
+ try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
24
+ }
25
+
26
+ function claudeCommands(cwd) {
27
+ const out = new Map();
28
+ const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
29
+ for (const base of bases) {
30
+ // Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
31
+ const cdir = path.join(base, 'commands');
32
+ for (const e of listDir(cdir)) {
33
+ if (e.isFile() && e.name.endsWith('.md')) { const n = e.name.slice(0, -3); out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name)).description || '', kind: 'comanda' }); }
34
+ else if (e.isDirectory()) for (const f of listDir(path.join(cdir, e.name))) if (f.isFile() && f.name.endsWith('.md')) { const n = `${e.name}:${f.name.slice(0, -3)}`; out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name, f.name)).description || '', kind: 'comanda' }); }
35
+ }
36
+ // Skills: skills/nom/SKILL.md
37
+ const sdir = path.join(base, 'skills');
38
+ for (const e of listDir(sdir)) {
39
+ if (!e.isDirectory() && !e.isSymbolicLink()) continue;
40
+ const fm = frontmatter(path.join(sdir, e.name, 'SKILL.md'));
41
+ if (fm.description || fs.existsSync(path.join(sdir, e.name, 'SKILL.md'))) { const n = fm.name || e.name; if (/^[\w:.-]+$/.test(n)) out.set(n, { name: n, hint: fm.description || '', kind: 'skill' }); }
42
+ }
43
+ }
44
+ return [...out.values()].map((c) => ({ ...c, hint: c.hint.slice(0, 110) })).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 150);
45
+ }
46
+
47
+ function agentCommands(agent, cwd) {
48
+ return agent === 'claude' ? claudeCommands(cwd) : [];
49
+ }
50
+
51
+ module.exports = { agentCommands };
package/src/proc.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const { spawn, execFile } = require('child_process');
7
7
 
8
8
  const EXTRA_DIRS = [
9
+ path.join(os.homedir(), '.nio', 'bin'),
9
10
  path.join(os.homedir(), '.local', 'bin'),
10
11
  path.join(os.homedir(), '.npm-global', 'bin'),
11
12
  path.join(os.homedir(), '.bun', 'bin'),
package/src/server.js CHANGED
@@ -10,6 +10,8 @@ const ollama = require('./agents/ollama');
10
10
  const { save } = require('./config');
11
11
  const { httpErr } = require('./hub');
12
12
  const github = require('./github');
13
+ const { addServer, knownHosts } = require('./ssh');
14
+ const { agentCommands } = require('./commands');
13
15
  const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
14
16
 
15
17
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
@@ -172,6 +174,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
172
174
  id: cfg.id,
173
175
  };
174
176
  }
177
+ if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
175
178
  if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
176
179
  if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
177
180
  if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
@@ -267,6 +270,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
267
270
  return { ok: true };
268
271
  }
269
272
  }
273
+ if ((r = m(/^\/api\/sessions\/([\w-]+)\/commands$/)) && method === 'GET') {
274
+ const s = hub.get(r[1]);
275
+ if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
276
+ return agentCommands(s.agent, s.cwd);
277
+ }
270
278
  if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
271
279
  const s = hub.get(r[1]);
272
280
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
@@ -361,6 +369,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
361
369
  }
362
370
  }
363
371
 
372
+ // Sense clau: només diu que nio és aquí (la web ho fa servir per detectar «Aquest ordinador»).
373
+ if (url.pathname === '/api/hello' && req.method === 'GET') {
374
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
375
+ return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
376
+ }
377
+
364
378
  if (url.pathname.startsWith('/api/')) {
365
379
  if (!tokenOk(cfg, req)) {
366
380
  res.writeHead(401, { 'content-type': 'application/json' });
@@ -378,6 +392,18 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
378
392
  req.on('close', () => streams.delete(res));
379
393
  return;
380
394
  }
395
+ // Afegir un servidor per SSH: el progrés va en línies JSON (una per pas) i l'última és el resultat.
396
+ if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
397
+ res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
398
+ const send = (o) => res.write(JSON.stringify(o) + '\n');
399
+ try {
400
+ const b = await readBody(req);
401
+ send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
402
+ } catch (e) {
403
+ send({ error: e.message });
404
+ }
405
+ return res.end();
406
+ }
381
407
  try {
382
408
  const out = await api(req, res, url);
383
409
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
package/src/ssh.js ADDED
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // Afegeix un servidor (VPS) per SSH: hi entra, hi engega nio amb un túnel de Cloudflare i torna l'adreça i la clau.
3
+ // El servidor queda connectat directament al mòbil i al panell; aquest ordinador no ha de continuar enmig.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn } = require('child_process');
8
+
9
+ const TARGET = /^[A-Za-z0-9_][\w.@:\-\[\]]*$/;
10
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
11
+
12
+ function remoteScript(port, dir) {
13
+ return `set -e
14
+ export PATH="$HOME/.nio/bin:$HOME/.local/bin:$PATH"
15
+ echo "NIO_STEP=Entrant al servidor"
16
+ command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
17
+ up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
18
+ mkdir -p "$HOME/.nio/bin"
19
+ if ! command -v cloudflared >/dev/null 2>&1 && [ "$(uname -s)" = Linux ]; then
20
+ echo "NIO_STEP=Instal·lant cloudflared"
21
+ case "$(uname -m)" in x86_64) A=amd64;; aarch64|arm64) A=arm64;; *) A=;; esac
22
+ if [ -n "$A" ]; then
23
+ U="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$A"
24
+ { curl -fsSL -o "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null || wget -qO "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null; } && chmod +x "$HOME/.nio/bin/cloudflared" || rm -f "$HOME/.nio/bin/cloudflared"
25
+ fi
26
+ fi
27
+ if ! up; then
28
+ echo "NIO_STEP=Engegant nio"
29
+ node -e "const f=require('os').homedir()+'/.nio/config.json',fs=require('fs');try{const c=JSON.parse(fs.readFileSync(f));delete c.lastUrl;fs.writeFileSync(f,JSON.stringify(c,null,2))}catch{}"
30
+ if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
31
+ $S nohup npx --yes nio-cat@latest --no-open --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
32
+ for i in $(seq 1 80); do up && break; sleep 0.5; done
33
+ up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
34
+ fi
35
+ echo "NIO_STEP=Obrint el túnel"
36
+ URL=
37
+ for i in $(seq 1 60); do
38
+ URL=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).lastUrl||'')}catch{}")
39
+ [ -n "$URL" ] && break
40
+ sleep 1
41
+ done
42
+ [ -n "$URL" ] || { echo NIO_NO_TUNNEL; exit 0; }
43
+ node -e "console.log('NIO_TOKEN='+JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).token)"
44
+ echo "NIO_URL=$URL"
45
+ echo "NIO_HOST=$(hostname)"
46
+ if command -v claude >/dev/null 2>&1; then echo NIO_CLAUDE=1; else echo NIO_CLAUDE=0; fi
47
+ `;
48
+ }
49
+
50
+ // batch: sense preguntes (des del panell). Sense batch (terminal), ssh pot demanar la contrasenya.
51
+ function addServer(target, { port = 7717, dir, batch = true, onStep = () => {} } = {}) {
52
+ return new Promise((resolve, reject) => {
53
+ if (!TARGET.test(String(target || ''))) return reject(new Error('Escriu l’usuari i el servidor, per exemple root@el-meu-servidor.com'));
54
+ const args = ['-o', 'ConnectTimeout=12', '-o', 'StrictHostKeyChecking=accept-new', ...(batch ? ['-o', 'BatchMode=yes'] : []), '-T', '--', target, 'bash -l -s'];
55
+ let p;
56
+ try { p = spawn('ssh', args, { stdio: ['pipe', 'pipe', batch ? 'pipe' : 'inherit'] }); } catch { return reject(new Error('No trobo l’ordre ssh en aquest ordinador')); }
57
+ let buf = '', err = '', done = false;
58
+ const out = {};
59
+ const line = (l) => {
60
+ const m = l.match(/^NIO_(STEP|TOKEN|URL|HOST|CLAUDE)=(.*)$/);
61
+ if (!m) return;
62
+ if (m[1] === 'STEP') onStep(m[2]);
63
+ else out[m[1].toLowerCase()] = m[2].trim();
64
+ };
65
+ p.stdout.on('data', (d) => {
66
+ buf += d;
67
+ let i;
68
+ while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); }
69
+ if (/NIO_NO_(NODE|START|TUNNEL)/.test(buf + '')) out.flag = (buf.match(/NIO_NO_\w+/) || [])[0];
70
+ });
71
+ if (batch) p.stderr.on('data', (d) => (err += d));
72
+ const fail = (m) => { if (!done) { done = true; reject(new Error(m)); } };
73
+ p.on('error', () => fail('No trobo l’ordre ssh en aquest ordinador'));
74
+ p.on('close', (code) => {
75
+ if (buf) line(buf);
76
+ if (done) return;
77
+ done = true;
78
+ const text = `${err}\n${buf}`;
79
+ if (/NIO_NO_NODE/.test(text)) return reject(new Error('Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.'));
80
+ if (/NIO_NO_START/.test(text)) return reject(new Error('nio no s’ha pogut engegar al servidor. Mira ~/.nio/daemon.log allà.'));
81
+ if (/NIO_NO_TUNNEL/.test(text)) return reject(new Error('El túnel no s’ha obert. Al servidor cal Linux amb curl o wget (per instal·lar cloudflared), o una adreça pròpia amb --url.'));
82
+ if (out.token && out.url) return resolve({ url: out.url, token: out.token, host: out.host || target, claude: out.claude === '1' });
83
+ if (/Permission denied|publickey/i.test(err)) return reject(new Error(`No puc entrar sense contrasenya. Al terminal d’aquest ordinador executa: ssh-copy-id ${target}`));
84
+ if (/Could not resolve|Connection (refused|timed out)|No route|timed out/i.test(err)) return reject(new Error('No arribo al servidor. Comprova l’adreça i que el SSH hi està obert.'));
85
+ reject(new Error((err.trim().split('\n').pop() || 'No he pogut afegir el servidor') + (code ? ` (ssh ${code})` : '')));
86
+ });
87
+ p.stdin.end(remoteScript(port, dir));
88
+ });
89
+ }
90
+
91
+ // Servidors que ja tens a ~/.ssh/config (sense comodins), perquè el panell els ofereixi i no calgui escriure'ls.
92
+ function knownHosts() {
93
+ let txt = '';
94
+ try { txt = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8'); } catch { return []; }
95
+ const out = [];
96
+ let cur = null;
97
+ for (const raw of txt.split('\n')) {
98
+ const l = raw.trim();
99
+ const h = l.match(/^Host\s+(.+)$/i);
100
+ if (h) {
101
+ cur = null;
102
+ for (const name of h[1].split(/\s+/)) if (TARGET.test(name) && !/[*?!]/.test(name)) { cur = { target: name, host: '' }; out.push(cur); }
103
+ continue;
104
+ }
105
+ const hn = l.match(/^HostName\s+(\S+)/i);
106
+ if (hn && cur) cur.host = hn[1];
107
+ }
108
+ return out.slice(0, 30);
109
+ }
110
+
111
+ module.exports = { addServer, remoteScript, knownHosts, TARGET };