nio-cat 0.5.0 → 0.6.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/bin/nio.js +150 -13
- package/package.json +2 -1
- package/src/agents/claude.js +3 -1
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/commands.js +12 -1
- package/src/directory.js +56 -0
- package/src/hub.js +60 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +93 -31
- package/src/service.js +123 -0
- package/src/tunnel.js +150 -34
- package/web/app/app.js +558 -44
- package/web/app/style.css +73 -11
- package/web/board.css +3 -2
- package/web/board.js +4 -3
- package/web/sw.js +13 -9
package/bin/nio.js
CHANGED
|
@@ -9,7 +9,14 @@ const config = require('../src/config');
|
|
|
9
9
|
const { Hub } = require('../src/hub');
|
|
10
10
|
const { createServer } = require('../src/server');
|
|
11
11
|
const { detectAll } = require('../src/agents');
|
|
12
|
-
const { startTunnel, startNamedTunnel } = require('../src/tunnel');
|
|
12
|
+
const { startTunnel, startNamedTunnel, startConfiguredTunnel, setupNamedTunnel } = require('../src/tunnel');
|
|
13
|
+
const service = require('../src/service');
|
|
14
|
+
const { Auth } = require('../src/auth');
|
|
15
|
+
const { Audit } = require('../src/audit');
|
|
16
|
+
const memory = require('../src/memory');
|
|
17
|
+
const mcp = require('../src/mcp');
|
|
18
|
+
const directory = require('../src/directory');
|
|
19
|
+
const { Jobs } = require('../src/jobs');
|
|
13
20
|
const { addServer } = require('../src/ssh');
|
|
14
21
|
const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
|
|
15
22
|
const { which } = require('../src/proc');
|
|
@@ -22,11 +29,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
22
29
|
if (a.startsWith('--')) {
|
|
23
30
|
const [k, v] = a.slice(2).split('=');
|
|
24
31
|
if (v !== undefined) flags[k] = v;
|
|
25
|
-
else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
|
|
32
|
+
else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token', 'name'].includes(k)) flags[k] = argv[++i];
|
|
26
33
|
else flags[k] = true;
|
|
27
34
|
} else pos.push(a);
|
|
28
35
|
}
|
|
29
|
-
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
|
|
36
|
+
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh', 'tunnel', 'service', 'version', 'devices', 'mcp', 'memory', 'directory'].includes(pos[0]) ? pos.shift() : 'start';
|
|
30
37
|
|
|
31
38
|
const c = {
|
|
32
39
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -36,6 +43,7 @@ const c = {
|
|
|
36
43
|
};
|
|
37
44
|
|
|
38
45
|
const cfg = config.load();
|
|
46
|
+
const auth = new Auth(undefined, cfg);
|
|
39
47
|
|
|
40
48
|
async function doctor() {
|
|
41
49
|
const agents = await detectAll(cfg, true);
|
|
@@ -87,7 +95,15 @@ function openDashboard() {
|
|
|
87
95
|
|
|
88
96
|
async function showPair(publicUrl) {
|
|
89
97
|
const app = flags.app || cfg.app;
|
|
90
|
-
|
|
98
|
+
if (cfg.lastUrl !== publicUrl) {
|
|
99
|
+
cfg.lastUrl = publicUrl;
|
|
100
|
+
config.save(cfg);
|
|
101
|
+
}
|
|
102
|
+
// L'adreça es desa (signada) a nio.cat: si canvia, el mòbil la torna a trobar sol.
|
|
103
|
+
if (cmd === 'start') directory.publish(cfg, publicUrl, (t) => console.log(c.dim(` ${t}`)));
|
|
104
|
+
// Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
|
|
105
|
+
const { code: secret } = auth.createCode({ secret: true });
|
|
106
|
+
const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
|
|
91
107
|
const file = writePairPage(link, code, qrLink);
|
|
92
108
|
// El QR ha de cabre al terminal: marge normal, marge d'1 mòdul i, si no hi cap, la pàgina del navegador.
|
|
93
109
|
const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
|
|
@@ -101,7 +117,7 @@ async function showPair(publicUrl) {
|
|
|
101
117
|
}
|
|
102
118
|
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
103
119
|
console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
|
|
104
|
-
console.log(c.dim('
|
|
120
|
+
console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
|
|
105
121
|
if (flags.open) {
|
|
106
122
|
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
107
123
|
try {
|
|
@@ -134,6 +150,23 @@ async function sshStart() {
|
|
|
134
150
|
if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
// La memòria compartida (servidor MCP de nio) es registra sola a Claude Code i Codex.
|
|
154
|
+
function registerMemory(force) {
|
|
155
|
+
memory.ensure();
|
|
156
|
+
if (cfg.memory?.register === false && !force) return;
|
|
157
|
+
if (mcp.command().unstable && !force) {
|
|
158
|
+
console.log(c.dim(' Memòria: per registrar-la a Claude Code i Codex, instal·la nio (npm i -g nio-cat) i fes «nio memory register».'));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const out = [];
|
|
162
|
+
const claude = which(cfg.agents?.claude?.bin || 'claude');
|
|
163
|
+
if (claude) out.push(`Claude Code ${mcp.registerClaude(claude, { force })}`);
|
|
164
|
+
if (which(cfg.agents?.codex?.bin || 'codex')) {
|
|
165
|
+
try { out.push(`Codex ${mcp.registerCodex()}`); } catch (e) { out.push(`Codex: ${e.message}`); }
|
|
166
|
+
}
|
|
167
|
+
console.log(` ${c.dim('Memòria:')} ${memory.DIR}${out.length ? c.dim(` (${out.join(' · ')})`) : ''}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
async function start() {
|
|
138
171
|
if (flags.port) cfg.port = Number(flags.port);
|
|
139
172
|
if (flags.app) cfg.app = flags.app;
|
|
@@ -167,10 +200,16 @@ async function start() {
|
|
|
167
200
|
}
|
|
168
201
|
const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
|
|
169
202
|
|
|
170
|
-
const
|
|
203
|
+
const audit = new Audit();
|
|
204
|
+
const hub = new Hub(cfg, { audit });
|
|
171
205
|
let publicUrl = flags.url || null;
|
|
172
206
|
const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
|
|
173
|
-
|
|
207
|
+
registerMemory();
|
|
208
|
+
const jobs = new Jobs({ cfg, hub, audit, available: () => require('../src/agents').AGENTS.filter((a) => a.id === 'demo' || require('../src/agents').binPath(cfg, a.id)).map((a) => a.id) });
|
|
209
|
+
jobs.hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
|
|
210
|
+
jobs.startTimer();
|
|
211
|
+
const routes = [...memory.routes(), ...jobs.routes()];
|
|
212
|
+
const server = createServer({ cfg, hub, auth, audit, routes, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
|
|
174
213
|
|
|
175
214
|
server.on('error', (e) => {
|
|
176
215
|
if (e.code === 'EADDRINUSE') console.error(c.warn(`\n El port ${cfg.port} ja el fa servir un altre programa. Tria\'n un altre: nio --port 7718\n`));
|
|
@@ -187,10 +226,6 @@ async function start() {
|
|
|
187
226
|
if (ip) {
|
|
188
227
|
publicUrl = publicUrl || `http://${ip}:${cfg.port}`;
|
|
189
228
|
console.log(` ${c.dim('Xarxa:')} ${publicUrl} ${c.dim('(només la mateixa wifi)')}`);
|
|
190
|
-
if (!flags.url) {
|
|
191
|
-
// A la xarxa local, l'app es carrega des del mateix daemon (mateix origen).
|
|
192
|
-
flags.app = publicUrl;
|
|
193
|
-
}
|
|
194
229
|
}
|
|
195
230
|
}
|
|
196
231
|
|
|
@@ -202,6 +237,20 @@ async function start() {
|
|
|
202
237
|
return showPair(publicUrl);
|
|
203
238
|
}
|
|
204
239
|
|
|
240
|
+
// Adreça fixa amb un túnel configurat amb «nio tunnel setup <host>».
|
|
241
|
+
const named = cfg.tunnel && cfg.tunnel.mode === 'named' && !flags.quick && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg.tunnel : null;
|
|
242
|
+
if (named) {
|
|
243
|
+
const t = startConfiguredTunnel(named, cfg.port, {
|
|
244
|
+
onReady: () => console.log(` ${c.dim('Túnel:')} https://${named.host} ${c.ok('connectat')} ${c.dim('(adreça fixa)')}`),
|
|
245
|
+
onDown: (code, wait) => console.log(c.warn(` El túnel s’ha tallat (codi ${code}). El torno a obrir d’aquí a ${Math.round(wait / 1000)} s.`)),
|
|
246
|
+
});
|
|
247
|
+
if (t) {
|
|
248
|
+
publicUrl = `https://${named.host}`;
|
|
249
|
+
console.log(c.dim(` Obrint el túnel cap a ${publicUrl}…`));
|
|
250
|
+
return showPair(publicUrl);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
205
254
|
if (fixed) {
|
|
206
255
|
const nt = startNamedTunnel(cfg.tunnelToken, () => {
|
|
207
256
|
publicUrl = cfg.fixedUrl;
|
|
@@ -246,19 +295,97 @@ async function start() {
|
|
|
246
295
|
case 'doctor':
|
|
247
296
|
await doctor();
|
|
248
297
|
break;
|
|
298
|
+
case 'mcp':
|
|
299
|
+
mcp.serve();
|
|
300
|
+
return;
|
|
301
|
+
case 'memory':
|
|
302
|
+
if (pos[0] === 'register') registerMemory(true);
|
|
303
|
+
else if (pos[0] === 'off') { cfg.memory = { ...(cfg.memory || {}), register: false }; config.save(cfg); console.log(' No registraré la memòria automàticament.'); }
|
|
304
|
+
else { memory.ensure(); console.log(` ${memory.DIR}\n`); for (const f of memory.list()) console.log(` ${f.path}`); console.log(c.dim('\n nio memory register · nio memory off')); }
|
|
305
|
+
break;
|
|
306
|
+
case 'directory':
|
|
307
|
+
if (pos[0] === 'off') { cfg.directory = false; config.save(cfg); console.log(' No desaré l’adreça a nio.cat. Si el túnel canvia, caldrà tornar a aparellar el mòbil.'); }
|
|
308
|
+
else if (pos[0] === 'on') { delete cfg.directory; config.save(cfg); console.log(' Desaré l’adreça (signada) a nio.cat perquè el mòbil la trobi si canvia.'); }
|
|
309
|
+
else console.log(` Adreça recordada a nio.cat: ${cfg.directory === false ? 'desactivada' : 'activada'} (ordinador ${directory.identity(cfg).loc}).\n nio directory on · nio directory off`);
|
|
310
|
+
break;
|
|
311
|
+
case 'version':
|
|
312
|
+
console.log(require('../package.json').version);
|
|
313
|
+
break;
|
|
249
314
|
case 'pair':
|
|
315
|
+
if (flags.check) {
|
|
316
|
+
process.exitCode = cfg.lastUrl ? 0 : 1;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
250
319
|
if (!flags.url && !cfg.lastUrl) {
|
|
251
320
|
console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
|
|
252
321
|
break;
|
|
253
322
|
}
|
|
254
323
|
await showPair(flags.url || cfg.lastUrl);
|
|
255
324
|
break;
|
|
256
|
-
case 'reset-token':
|
|
325
|
+
case 'reset-token': {
|
|
257
326
|
cfg.token = crypto.randomBytes(24).toString('base64url');
|
|
258
327
|
cfg.pushSubs = [];
|
|
259
328
|
config.save(cfg);
|
|
260
|
-
|
|
329
|
+
const n = auth.revokeAll();
|
|
330
|
+
console.log(` Clau principal nova i ${n} dispositius desconnectats. Reinicia nio i torna a aparellar els mòbils.`);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'devices': {
|
|
334
|
+
const sub = pos.shift();
|
|
335
|
+
if (sub === 'revoke') {
|
|
336
|
+
const d = auth.devices.find((x) => x.id === pos[0]) ? auth.revoke(pos[0]) : null;
|
|
337
|
+
console.log(d ? ` Accés revocat: ${d.name}. Reinicia nio perquè es tanqui la connexió oberta.` : ' No hi ha cap dispositiu amb aquest identificador.');
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
const list = auth.list().filter((d) => !d.primary);
|
|
341
|
+
if (!list.length) console.log(' Cap dispositiu aparellat amb codi. Fes «nio pair».');
|
|
342
|
+
for (const d of list) console.log(` ${d.id.padEnd(14)} ${d.name.padEnd(28)} ${c.dim(d.lastSeen ? 'vist ' + new Date(d.lastSeen).toLocaleString('ca') : 'mai vist')}`);
|
|
343
|
+
console.log(c.dim('\n nio devices revoke <id> · també des de l’app, a Ordinadors'));
|
|
261
344
|
break;
|
|
345
|
+
}
|
|
346
|
+
case 'tunnel': {
|
|
347
|
+
const sub = pos.shift();
|
|
348
|
+
if (sub === 'setup') {
|
|
349
|
+
try {
|
|
350
|
+
cfg.tunnel = setupNamedTunnel({ host: pos[0], name: flags.name });
|
|
351
|
+
config.save(cfg);
|
|
352
|
+
console.log(`\n ${c.ok('Fet.')} Aquest ordinador serà a ${c.b('https://' + cfg.tunnel.host)}.`);
|
|
353
|
+
console.log(c.dim(' Reinicia nio (o el servei) perquè faci servir l’adreça fixa. El mòbil ja no s’haurà de tornar a aparellar.\n'));
|
|
354
|
+
} catch (e) {
|
|
355
|
+
console.error(c.warn(`\n ${e.message}\n`));
|
|
356
|
+
process.exitCode = 1;
|
|
357
|
+
}
|
|
358
|
+
} else if (sub === 'off') {
|
|
359
|
+
delete cfg.tunnel;
|
|
360
|
+
config.save(cfg);
|
|
361
|
+
console.log(' Tornaré a fer servir el túnel ràpid (l’adreça canviarà a cada reinici).');
|
|
362
|
+
} else {
|
|
363
|
+
console.log(cfg.tunnel ? ` Túnel amb nom: ${cfg.tunnel.name} → https://${cfg.tunnel.host}` : ' Sense túnel amb nom: faig servir el túnel ràpid de Cloudflare.');
|
|
364
|
+
console.log(c.dim(' nio tunnel setup <host> [--name <nom>] · nio tunnel off'));
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case 'service': {
|
|
369
|
+
const sub = pos.shift();
|
|
370
|
+
try {
|
|
371
|
+
if (sub === 'uninstall') {
|
|
372
|
+
console.log(` Servei esborrat (${service.uninstall()}).`);
|
|
373
|
+
} else if (sub === 'install') {
|
|
374
|
+
const roots = (pos.length ? pos.map((p) => path.resolve(p)) : cfg.roots.length ? cfg.roots : [process.cwd()]).filter((r) => fs.existsSync(r));
|
|
375
|
+
const r = service.install({ roots });
|
|
376
|
+
console.log(`\n ${c.ok('Fet.')} nio arrencarà sol amb l’ordinador. Carpetes: ${roots.join(', ')}`);
|
|
377
|
+
console.log(c.dim(` Fitxer: ${r.file}`));
|
|
378
|
+
if (r.linger === false) console.log(c.warn(' Per arrencar sense iniciar sessió, executa: sudo loginctl enable-linger $USER'));
|
|
379
|
+
console.log(c.dim(` Registre: ${r.logs} · Codi per al mòbil: nio pair\n`));
|
|
380
|
+
} else {
|
|
381
|
+
console.log(' nio service install [carpetes…] · nio service uninstall');
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error(c.warn(`\n ${e.message}\n`));
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
262
389
|
case 'help':
|
|
263
390
|
console.log(`
|
|
264
391
|
${c.b('nio')} · agents d’IA al teu ordinador, des del mòbil
|
|
@@ -271,6 +398,7 @@ async function start() {
|
|
|
271
398
|
--forget-fixed Oblida l'adreça fixa i torna al túnel temporal
|
|
272
399
|
--port <n> Port local (7717 si no dius res)
|
|
273
400
|
--no-open No obris el panell al navegador d'aquest ordinador
|
|
401
|
+
--quick Túnel ràpid encara que n'hi hagi un amb nom
|
|
274
402
|
--local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
|
|
275
403
|
--open Obre al navegador la pàgina del codi QR
|
|
276
404
|
nio pair Mostra el codi QR una altra vegada
|
|
@@ -282,6 +410,15 @@ async function start() {
|
|
|
282
410
|
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
|
283
411
|
Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
|
|
284
412
|
|
|
413
|
+
nio tunnel setup <host> [--name <nom>]
|
|
414
|
+
Adreça fixa amb un túnel de Cloudflare (la zona ha de ser a Cloudflare)
|
|
415
|
+
nio tunnel off Torna al túnel ràpid
|
|
416
|
+
nio service install [carpetes…]
|
|
417
|
+
Arrenca nio sol amb l'ordinador (systemd o launchd)
|
|
418
|
+
nio devices Dispositius aparellats · nio devices revoke <id>
|
|
419
|
+
nio memory Memòria compartida (~/.nio/memory) · register · off
|
|
420
|
+
nio mcp Servidor MCP de la memòria (l'engeguen Claude Code i Codex)
|
|
421
|
+
nio directory on|off Adreça recordada a nio.cat (el mòbil retroba l'ordinador si el túnel canvia)
|
|
285
422
|
nio doctor Llista els agents instal·lats
|
|
286
423
|
nio reset-token Crea una clau nova i desconnecta tots els mòbils
|
|
287
424
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nio-cat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "node bin/nio.js",
|
|
22
22
|
"demo": "node bin/nio.js --lan",
|
|
23
|
+
"test": "node --test test/*.test.js",
|
|
23
24
|
"prepack": "node scripts/copy-web.js"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
package/src/agents/claude.js
CHANGED
|
@@ -77,7 +77,7 @@ function handle(ctx, line) {
|
|
|
77
77
|
}
|
|
78
78
|
case 'assistant':
|
|
79
79
|
for (const b of ev.message?.content || []) {
|
|
80
|
-
if (b.type === 'text' && b.text) ctx.endStream(b.text);
|
|
80
|
+
if (b.type === 'text' && b.text) { ctx.said = true; ctx.endStream(b.text); }
|
|
81
81
|
else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
|
|
82
82
|
}
|
|
83
83
|
break;
|
|
@@ -93,6 +93,8 @@ function handle(ctx, line) {
|
|
|
93
93
|
break;
|
|
94
94
|
case 'result':
|
|
95
95
|
if (ev.session_id) ctx.setNative(ev.session_id);
|
|
96
|
+
// Les comandes locals (/cost, /context…) no passen pel model: la resposta només és aquí.
|
|
97
|
+
if (!ctx.said && !ev.is_error && !ev.num_turns) ctx.endStream(typeof ev.result === 'string' && ev.result.trim() ? ev.result : 'Fet.');
|
|
96
98
|
ctx.result({
|
|
97
99
|
cost: ev.total_cost_usd,
|
|
98
100
|
ms: ev.duration_ms,
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Registre d'accions: què, per què, quan i qui ho ha decidit. Es guarda només en aquest
|
|
3
|
+
// ordinador (~/.nio/audit.jsonl), una línia JSON per entrada, i mai no s'esborra sol
|
|
4
|
+
// (quan passa de 5 MB es mou a audit.1.jsonl).
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { HOME } = require('./config');
|
|
8
|
+
|
|
9
|
+
const MAX = 5 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
class Audit {
|
|
12
|
+
constructor(home = HOME) {
|
|
13
|
+
this.file = path.join(home, 'audit.jsonl');
|
|
14
|
+
this.listeners = new Set();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
add(entry) {
|
|
18
|
+
const e = { t: Date.now(), ...entry };
|
|
19
|
+
try {
|
|
20
|
+
const st = fs.existsSync(this.file) ? fs.statSync(this.file) : null;
|
|
21
|
+
if (st && st.size > MAX) fs.renameSync(this.file, this.file.replace(/\.jsonl$/, '.1.jsonl'));
|
|
22
|
+
fs.appendFileSync(this.file, `${JSON.stringify(e)}\n`, { mode: 0o600 });
|
|
23
|
+
} catch {
|
|
24
|
+
/* disc ple o sense permís: no aturem l'agent per això */
|
|
25
|
+
}
|
|
26
|
+
for (const fn of this.listeners) fn(e);
|
|
27
|
+
return e;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Les últimes entrades, de més nova a més antiga.
|
|
31
|
+
recent(limit = 200, filter) {
|
|
32
|
+
let lines = [];
|
|
33
|
+
try {
|
|
34
|
+
lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const out = [];
|
|
39
|
+
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
|
|
40
|
+
try {
|
|
41
|
+
const e = JSON.parse(lines[i]);
|
|
42
|
+
if (!filter || filter(e)) out.push(e);
|
|
43
|
+
} catch {
|
|
44
|
+
/* línia malmesa */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
on(fn) {
|
|
51
|
+
this.listeners.add(fn);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { Audit };
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Dispositius aparellats i codis d'aparellament.
|
|
3
|
+
//
|
|
4
|
+
// · La clau principal (cfg.token) és la del propietari: l'obre el panell local en engegar nio i
|
|
5
|
+
// la fa servir «nio ssh». Es canvia amb `nio reset-token`.
|
|
6
|
+
// · Cada mòbil aparellat amb un codi té la seva clau. Al disc només en desem el hash (SHA-256).
|
|
7
|
+
// · El codi (dins del «nio-…» i del QR) és un secret d'un sol ús que caduca als 15 minuts.
|
|
8
|
+
// Viatja al fragment (#) de l'enllaç, que el navegador no envia a cap servidor.
|
|
9
|
+
// · Un dispositiu es pot revocar en qualsevol moment (des de l'app o amb `nio devices revoke`).
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { HOME } = require('./config');
|
|
14
|
+
|
|
15
|
+
const CODE_TTL = 15 * 60 * 1000;
|
|
16
|
+
const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789'; // sense 0/o, 1/l/i
|
|
17
|
+
const MAX_FAILS = 10;
|
|
18
|
+
const FAIL_WINDOW = 10 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
21
|
+
const rid = (n = 9) => crypto.randomBytes(n).toString('base64url');
|
|
22
|
+
|
|
23
|
+
function readJson(file, d) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeJson(file, v) {
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
32
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmp, file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function newCode() {
|
|
38
|
+
const bytes = crypto.randomBytes(12);
|
|
39
|
+
let s = '';
|
|
40
|
+
for (const b of bytes) s += ALPHA[b % ALPHA.length];
|
|
41
|
+
return `nio-${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8, 12)}`;
|
|
42
|
+
}
|
|
43
|
+
// Els codis «nio-xxxx-xxxx-xxxx» no distingeixen majúscules; els secrets en base64url, sí.
|
|
44
|
+
const normCode = (c) => { const t = String(c || '').replace(/\s+/g, ''); return /^nio-[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/i.test(t) ? t.toLowerCase() : t; };
|
|
45
|
+
|
|
46
|
+
class Auth {
|
|
47
|
+
constructor(home = HOME, cfg = null) {
|
|
48
|
+
this.cfg = cfg; // per a la clau principal
|
|
49
|
+
this.devFile = path.join(home, 'devices.json');
|
|
50
|
+
this.codeFile = path.join(home, 'pairing.json');
|
|
51
|
+
this.devices = readJson(this.devFile, []);
|
|
52
|
+
this.fails = [];
|
|
53
|
+
this.seenDirty = false;
|
|
54
|
+
this.listeners = new Set();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
persist() {
|
|
58
|
+
writeJson(this.devFile, this.devices);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(currentId) {
|
|
62
|
+
const out = this.devices.map(({ hash, ...d }) => ({ ...d, current: d.id === currentId }));
|
|
63
|
+
if (this.cfg?.token) out.unshift({ id: 'principal', name: 'Clau principal (aquest ordinador i SSH)', primary: true, created: null, lastSeen: this.primarySeen || null, current: currentId === 'principal' });
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Retorna el dispositiu d'un testimoni o null.
|
|
68
|
+
check(token) {
|
|
69
|
+
if (!token || typeof token !== 'string' || token.length > 200) return null;
|
|
70
|
+
const h = Buffer.from(sha(token), 'hex');
|
|
71
|
+
if (this.cfg?.token) {
|
|
72
|
+
const p = Buffer.from(sha(this.cfg.token), 'hex');
|
|
73
|
+
if (crypto.timingSafeEqual(p, h)) {
|
|
74
|
+
this.primarySeen = Date.now();
|
|
75
|
+
return { id: 'principal', name: 'Clau principal', primary: true };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let found = null;
|
|
79
|
+
for (const d of this.devices) {
|
|
80
|
+
const b = Buffer.from(d.hash, 'hex');
|
|
81
|
+
if (b.length === h.length && crypto.timingSafeEqual(b, h)) found = d;
|
|
82
|
+
}
|
|
83
|
+
if (found) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
if (!found.lastSeen || now - found.lastSeen > 60000) {
|
|
86
|
+
found.lastSeen = now;
|
|
87
|
+
this.seenDirty = true;
|
|
88
|
+
clearTimeout(this.seenT);
|
|
89
|
+
this.seenT = setTimeout(() => this.seenDirty && (this.persist(), (this.seenDirty = false)), 2000);
|
|
90
|
+
if (this.seenT.unref) this.seenT.unref();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fromRequest(req) {
|
|
97
|
+
const h = req.headers.authorization || '';
|
|
98
|
+
return this.check(h.startsWith('Bearer ') ? h.slice(7) : '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- codis d'aparellament (fitxer compartit: `nio pair` i el daemon poden ser processos diferents)
|
|
102
|
+
// `secret: true` dona un secret de 24 bytes en base64url, que cap compacte dins del codi «nio-…».
|
|
103
|
+
createCode({ secret = false } = {}) {
|
|
104
|
+
const code = secret ? crypto.randomBytes(24).toString('base64url') : newCode();
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const list = readJson(this.codeFile, []).filter((c) => c.expires > now);
|
|
107
|
+
list.push({ hash: sha(code), expires: now + CODE_TTL });
|
|
108
|
+
writeJson(this.codeFile, list.slice(-5));
|
|
109
|
+
return { code, expires: now + CODE_TTL };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
blocked() {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
this.fails = this.fails.filter((t) => now - t < FAIL_WINDOW);
|
|
115
|
+
return this.fails.length >= MAX_FAILS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Canvia un codi vàlid per un testimoni nou. Llença un error amb .status si no es pot.
|
|
119
|
+
redeem(code, name) {
|
|
120
|
+
if (this.blocked()) throw Object.assign(new Error('Massa intents. Espera uns minuts i torna-ho a provar.'), { status: 429 });
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const h = sha(normCode(code));
|
|
123
|
+
const list = readJson(this.codeFile, []).filter((c) => c.expires > now);
|
|
124
|
+
const i = list.findIndex((c) => c.hash === h);
|
|
125
|
+
if (i < 0) {
|
|
126
|
+
this.fails.push(now);
|
|
127
|
+
throw Object.assign(new Error('Aquest codi no és vàlid o ha caducat. Demana’n un de nou amb «nio pair».'), { status: 403 });
|
|
128
|
+
}
|
|
129
|
+
list.splice(i, 1);
|
|
130
|
+
writeJson(this.codeFile, list);
|
|
131
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
132
|
+
const d = { id: rid(), name: cleanName(name), hash: sha(token), created: now, lastSeen: now };
|
|
133
|
+
this.devices.push(d);
|
|
134
|
+
this.persist();
|
|
135
|
+
return { token, device: { id: d.id, name: d.name, created: d.created } };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
rename(id, name) {
|
|
139
|
+
const d = this.devices.find((x) => x.id === id);
|
|
140
|
+
if (!d) return null;
|
|
141
|
+
d.name = cleanName(name);
|
|
142
|
+
this.persist();
|
|
143
|
+
return { id: d.id, name: d.name };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
revoke(id) {
|
|
147
|
+
if (id === 'principal') throw Object.assign(new Error('La clau principal es canvia a l’ordinador amb «nio reset-token».'), { status: 400 });
|
|
148
|
+
const d = this.devices.find((x) => x.id === id);
|
|
149
|
+
if (!d) return null;
|
|
150
|
+
this.devices = this.devices.filter((x) => x.id !== id);
|
|
151
|
+
this.persist();
|
|
152
|
+
for (const fn of this.listeners) fn(d);
|
|
153
|
+
return { id: d.id, name: d.name };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
revokeAll() {
|
|
157
|
+
const all = this.devices;
|
|
158
|
+
this.devices = [];
|
|
159
|
+
this.persist();
|
|
160
|
+
writeJson(this.codeFile, []);
|
|
161
|
+
for (const d of all) for (const fn of this.listeners) fn(d);
|
|
162
|
+
return all.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onRevoke(fn) {
|
|
166
|
+
this.listeners.add(fn);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cleanName(name) {
|
|
171
|
+
const n = String(name || '').replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 60);
|
|
172
|
+
return n || 'Dispositiu';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { Auth, newCode, sha, CODE_TTL };
|
package/src/commands.js
CHANGED
|
@@ -23,8 +23,18 @@ function listDir(dir) {
|
|
|
23
23
|
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Comandes internes de Claude Code que també funcionen en mode headless (les altres, com /help o /status, no hi són).
|
|
27
|
+
const CLAUDE_BUILTIN = [
|
|
28
|
+
{ name: 'usage', hint: 'Ús de la subscripció i límits' },
|
|
29
|
+
{ name: 'context', hint: 'Mostra com s’omple el context' },
|
|
30
|
+
{ name: 'compact', hint: 'Resumeix la conversa per alliberar context' },
|
|
31
|
+
{ name: 'init', hint: 'Crea un CLAUDE.md al projecte' },
|
|
32
|
+
{ name: 'review', hint: 'Revisa els canvis de codi' },
|
|
33
|
+
{ name: 'security-review', hint: 'Revisió de seguretat dels canvis' },
|
|
34
|
+
];
|
|
35
|
+
|
|
26
36
|
function claudeCommands(cwd) {
|
|
27
|
-
const out = new Map();
|
|
37
|
+
const out = new Map(CLAUDE_BUILTIN.map((c) => [c.name, { ...c, kind: 'comanda' }]));
|
|
28
38
|
const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
|
|
29
39
|
for (const base of bases) {
|
|
30
40
|
// Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
|
|
@@ -45,6 +55,7 @@ function claudeCommands(cwd) {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
function agentCommands(agent, cwd) {
|
|
58
|
+
if (agent === 'codex') return [{ name: 'status', hint: 'Compte, model i carpeta de la sessió', kind: 'comanda' }];
|
|
48
59
|
return agent === 'claude' ? claudeCommands(cwd) : [];
|
|
49
60
|
}
|
|
50
61
|
|
package/src/directory.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Adreça recordada a nio.cat: l'ordinador publica la seva adreça actual (la del túnel), signada
|
|
3
|
+
// amb una clau Ed25519 pròpia. Si l'adreça canvia, el mòbil la troba allà i comprova la signatura.
|
|
4
|
+
// nio.cat no rep cap altra dada. Es desactiva amb `nio directory off` (cfg.directory = false).
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
|
|
8
|
+
const b64u = (b) => Buffer.from(b).toString('base64url');
|
|
9
|
+
|
|
10
|
+
function identity(cfg) {
|
|
11
|
+
if (!cfg.identity) {
|
|
12
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
13
|
+
cfg.identity = { pub: publicKey.export({ format: 'jwk' }).x, priv: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url') };
|
|
14
|
+
config.save(cfg);
|
|
15
|
+
}
|
|
16
|
+
const pub = cfg.identity.pub;
|
|
17
|
+
const loc = b64u(crypto.createHash('sha256').update(Buffer.from(pub, 'base64url')).digest().subarray(0, 16));
|
|
18
|
+
return { pub, loc };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sign(cfg, url, t = Date.now()) {
|
|
22
|
+
const { pub, loc } = identity(cfg);
|
|
23
|
+
const key = crypto.createPrivateKey({ key: Buffer.from(cfg.identity.priv, 'base64url'), format: 'der', type: 'pkcs8' });
|
|
24
|
+
const sig = b64u(crypto.sign(null, Buffer.from(`nio-node-v1\n${loc}\n${url}\n${t}`), key));
|
|
25
|
+
return { pub, url, t, sig };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const publicUrl = (u) => /^https:\/\//.test(u || '') && !/^https:\/\/(127\.|localhost|\[)/.test(u);
|
|
29
|
+
|
|
30
|
+
let current = null;
|
|
31
|
+
let timer = null;
|
|
32
|
+
let retry = 0;
|
|
33
|
+
|
|
34
|
+
async function publish(cfg, url, log = () => {}) {
|
|
35
|
+
if (cfg.directory === false || !publicUrl(url)) return false;
|
|
36
|
+
current = url.replace(/\/$/, '');
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const base = (cfg.directoryUrl || 'https://www.nio.cat').replace(/\/$/, '');
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${base}/api/node`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sign(cfg, current)), signal: AbortSignal.timeout(10000) });
|
|
41
|
+
if (res.status === 503) return false; // nio.cat encara no té la llibreta configurada
|
|
42
|
+
if (!res.ok && res.status !== 409) throw new Error(`nio.cat ${res.status}`);
|
|
43
|
+
retry = 0;
|
|
44
|
+
timer = setTimeout(() => publish(cfg, current, log), 6 * 3600 * 1000); // renova abans que caduqui
|
|
45
|
+
if (timer.unref) timer.unref();
|
|
46
|
+
return true;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const wait = Math.min(30 * 60 * 1000, 5000 * 2 ** retry++);
|
|
49
|
+
if (retry === 1) log(`No he pogut desar l’adreça a nio.cat (${e.message}). Ho torno a provar.`);
|
|
50
|
+
timer = setTimeout(() => current === url.replace(/\/$/, '') && publish(cfg, current, log), wait);
|
|
51
|
+
if (timer.unref) timer.unref();
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { identity, sign, publish };
|