nio-cat 0.3.0 → 0.3.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 +8 -8
- package/package.json +1 -1
- package/src/pair.js +42 -16
- package/src/server.js +2 -2
- package/web/app/app.js +27 -9
- package/web/app/style.css +2 -0
package/bin/nio.js
CHANGED
|
@@ -11,7 +11,7 @@ const { Hub } = require('../src/hub');
|
|
|
11
11
|
const { createServer } = require('../src/server');
|
|
12
12
|
const { detectAll } = require('../src/agents');
|
|
13
13
|
const { startTunnel } = require('../src/tunnel');
|
|
14
|
-
const { pairLink, terminalQr, writePairPage } = require('../src/pair');
|
|
14
|
+
const { pairLink, terminalQr, writePairPage, makeCode } = require('../src/pair');
|
|
15
15
|
const { which } = require('../src/proc');
|
|
16
16
|
|
|
17
17
|
const argv = process.argv.slice(2);
|
|
@@ -81,13 +81,14 @@ function openDashboard() {
|
|
|
81
81
|
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function showPair(publicUrl) {
|
|
85
|
-
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
|
|
86
|
-
const file = writePairPage(link);
|
|
84
|
+
async function showPair(publicUrl) {
|
|
85
|
+
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token);
|
|
86
|
+
const file = writePairPage(link, code);
|
|
87
87
|
console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
|
|
88
88
|
console.log(terminalQr(link));
|
|
89
|
-
console.log(`\n ${c.
|
|
90
|
-
console.log(c.dim('
|
|
89
|
+
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
90
|
+
console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
|
|
91
|
+
console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
|
|
91
92
|
if (flags.open) {
|
|
92
93
|
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
93
94
|
try {
|
|
@@ -98,7 +99,6 @@ function showPair(publicUrl) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
102
|
// nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
|
|
103
103
|
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
104
104
|
function remoteScript(port, dir) {
|
|
@@ -260,7 +260,7 @@ async function start() {
|
|
|
260
260
|
console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
|
|
261
261
|
break;
|
|
262
262
|
}
|
|
263
|
-
showPair(flags.url || cfg.lastUrl);
|
|
263
|
+
await showPair(flags.url || cfg.lastUrl);
|
|
264
264
|
break;
|
|
265
265
|
case 'reset-token':
|
|
266
266
|
cfg.token = crypto.randomBytes(24).toString('base64url');
|
package/package.json
CHANGED
package/src/pair.js
CHANGED
|
@@ -12,6 +12,18 @@ function pairLink(app, url, token) {
|
|
|
12
12
|
return `${app.replace(/\/$/, '')}/app#u=${u}&t=${encodeURIComponent(token)}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Codi de connexió: l'adreça i la clau dins d'una sola cadena («nio-…»). No cal cap servidor.
|
|
16
|
+
// La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
|
|
17
|
+
const CF = '.trycloudflare.com';
|
|
18
|
+
function makeCode(url, token) {
|
|
19
|
+
const raw = Buffer.from(token, 'base64url');
|
|
20
|
+
const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
|
|
21
|
+
const bytes = raw.length === 24 && raw.toString('base64url') === token && sub && /^[\w-]+$/.test(sub)
|
|
22
|
+
? Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)])
|
|
23
|
+
: Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
|
|
24
|
+
return `nio-${bytes.toString('base64url')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
function qr(text) {
|
|
16
28
|
const q = qrcode(0, 'L');
|
|
17
29
|
q.addData(text);
|
|
@@ -19,34 +31,49 @@ function qr(text) {
|
|
|
19
31
|
return q;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
|
-
// QR per al terminal:
|
|
23
|
-
//
|
|
34
|
+
// QR per al terminal: cada mòdul són dos espais amb color de fons (negre o blanc). No depèn de cap glif,
|
|
35
|
+
// així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
|
|
24
36
|
function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
|
|
25
37
|
const q = qr(text);
|
|
26
38
|
const n = q.getModuleCount();
|
|
27
39
|
const M = 2; // marge blanc al voltant, en mòduls
|
|
28
40
|
const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
|
|
29
41
|
const lines = [];
|
|
30
|
-
for (let r = -M; r < n + M; r
|
|
31
|
-
let line = '';
|
|
42
|
+
for (let r = -M; r < n + M; r++) {
|
|
43
|
+
let line = '', prev = null;
|
|
32
44
|
for (let c = -M; c < n + M; c++) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
const d = dark(r, c);
|
|
46
|
+
if (color) {
|
|
47
|
+
if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
|
|
48
|
+
line += ' ';
|
|
49
|
+
} else line += d ? ' ' : '██';
|
|
50
|
+
prev = d;
|
|
37
51
|
}
|
|
38
|
-
lines.push(
|
|
52
|
+
lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
|
|
39
53
|
}
|
|
40
54
|
return lines.join('\n');
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
// SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
|
|
58
|
+
// (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
|
|
43
59
|
function qrSvg(text) {
|
|
44
|
-
|
|
60
|
+
const q = qr(text), n = q.getModuleCount(), M = 4;
|
|
61
|
+
let d = '';
|
|
62
|
+
for (let r = 0; r < n; r++) {
|
|
63
|
+
for (let c = 0; c < n; c++) {
|
|
64
|
+
if (!q.isDark(r, c)) continue;
|
|
65
|
+
let w = 1;
|
|
66
|
+
while (c + w < n && q.isDark(r, c + w)) w++;
|
|
67
|
+
d += `M${c + M} ${r + M}h${w}v1h-${w}z`;
|
|
68
|
+
c += w - 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const size = n + M * 2;
|
|
72
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" aria-label="Codi QR"><rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
function writePairPage(link) {
|
|
48
|
-
const
|
|
49
|
-
const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
|
|
75
|
+
function writePairPage(link, code) {
|
|
76
|
+
const svg = qrSvg(link);
|
|
50
77
|
const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
|
|
51
78
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
|
|
52
79
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
|
|
@@ -55,11 +82,10 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
|
|
|
55
82
|
.qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
|
|
56
83
|
code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
|
|
57
84
|
<body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
|
|
58
|
-
<p>
|
|
59
|
-
<small>Aquest enllaç dona accés als teus agents. Guarda’l com una contrasenya.</small></main></body></html>`;
|
|
85
|
+
<p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
|
|
60
86
|
const file = path.join(HOME, 'pair.html');
|
|
61
87
|
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
62
88
|
return file;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
|
|
91
|
+
module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode };
|
package/src/server.js
CHANGED
|
@@ -10,7 +10,7 @@ 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 { pairLink, qrSvg } = require('./pair');
|
|
13
|
+
const { pairLink, qrSvg, makeCode } = require('./pair');
|
|
14
14
|
|
|
15
15
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
16
16
|
const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
|
|
@@ -232,7 +232,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
232
232
|
const pub = getPublicUrl();
|
|
233
233
|
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
234
234
|
const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
|
|
235
|
-
return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
235
|
+
return { link, code: makeCode(pub, cfg.token), svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
236
236
|
}
|
|
237
237
|
if (p === '/api/github' && method === 'GET') return github.status();
|
|
238
238
|
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
package/web/app/app.js
CHANGED
|
@@ -191,6 +191,19 @@ function parsePair(text) {
|
|
|
191
191
|
if (!url || !token) return null;
|
|
192
192
|
return { url: url.replace(/\/$/, ''), token };
|
|
193
193
|
}
|
|
194
|
+
// Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
|
|
195
|
+
const CF = '.trycloudflare.com';
|
|
196
|
+
function parseCode(text) {
|
|
197
|
+
const m = String(text || '').replace(/\s+/g, '').match(/^nio-([\w-]+)$/i); if (!m) return null;
|
|
198
|
+
try {
|
|
199
|
+
const bin = atob(m[1].replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (m[1].length % 4)) % 4)), u = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
200
|
+
if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') };
|
|
201
|
+
if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
|
|
202
|
+
} catch { /* codi malmès */ }
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
// Un codi o un enllaç de connexió (#u=…&t=…).
|
|
206
|
+
const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
194
207
|
function addMachine({ url, token, name }) {
|
|
195
208
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
196
209
|
if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
|
|
@@ -285,9 +298,9 @@ function viewNoMachine(what) {
|
|
|
285
298
|
<div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
|
|
286
299
|
}
|
|
287
300
|
function pasteForm(open) {
|
|
288
|
-
return `<details class="paste" ${open ? 'open' : ''}><summary>
|
|
289
|
-
<form class="pair-form" id="pairForm"><input type="
|
|
290
|
-
<p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
|
|
301
|
+
return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
|
|
302
|
+
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
303
|
+
<p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
|
|
291
304
|
}
|
|
292
305
|
function viewConnect() {
|
|
293
306
|
const CMD = 'npx nio-cat', cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
@@ -312,7 +325,7 @@ function viewConnect() {
|
|
|
312
325
|
<p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
|
|
313
326
|
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
314
327
|
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
315
|
-
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia
|
|
328
|
+
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
|
|
316
329
|
${pasteForm(true)}
|
|
317
330
|
<p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
|
|
318
331
|
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
@@ -674,8 +687,8 @@ function addMachineSheet() {
|
|
|
674
687
|
<div class="stack-lg">
|
|
675
688
|
<div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
|
|
676
689
|
<div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
|
|
677
|
-
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia
|
|
678
|
-
<form class="pair-form" id="pairForm"><input type="
|
|
690
|
+
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
|
|
691
|
+
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
679
692
|
</div>`);
|
|
680
693
|
}
|
|
681
694
|
let qrTimer = 0;
|
|
@@ -690,7 +703,7 @@ function mobileQrSheet() {
|
|
|
690
703
|
if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
|
|
691
704
|
return;
|
|
692
705
|
}
|
|
693
|
-
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><button class="btn" type="button" data-copy="${esc(r.
|
|
706
|
+
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
|
|
694
707
|
};
|
|
695
708
|
openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
696
709
|
paint();
|
|
@@ -1017,8 +1030,8 @@ document.addEventListener('submit', (e) => {
|
|
|
1017
1030
|
if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
|
|
1018
1031
|
if (e.target.id !== 'pairForm') return;
|
|
1019
1032
|
e.preventDefault();
|
|
1020
|
-
const pair =
|
|
1021
|
-
if (!pair) return toast('Aquest
|
|
1033
|
+
const pair = pairFrom($('#pairInput', e.target).value);
|
|
1034
|
+
if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
|
|
1022
1035
|
closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
|
|
1023
1036
|
});
|
|
1024
1037
|
|
|
@@ -1211,6 +1224,11 @@ const qs = new URLSearchParams(location.search).get('s');
|
|
|
1211
1224
|
if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
|
|
1212
1225
|
// Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
|
|
1213
1226
|
try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
|
|
1227
|
+
// A la còpia local (127.0.0.1, localhost) la icona té el fons groc suau, per distingir-la de www.nio.cat.
|
|
1228
|
+
if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
|
|
1229
|
+
const ico = document.querySelector('link[rel="icon"]');
|
|
1230
|
+
if (ico) ico.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#f6e9a0"/><circle cx="16" cy="16" r="10.2" fill="none" stroke="#272224" stroke-width="3.6"/><rect x="11.9" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/><rect x="17.5" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/></svg>');
|
|
1231
|
+
}
|
|
1214
1232
|
setNav(false);
|
|
1215
1233
|
if (!checkPairHash()) state.route = parseRoute();
|
|
1216
1234
|
if (machine()) connect();
|
package/web/app/style.css
CHANGED
|
@@ -417,3 +417,5 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
|
|
|
417
417
|
}
|
|
418
418
|
#fsForm { display: flex; gap: 8px; }
|
|
419
419
|
#fsForm input { flex: 1; min-width: 0; }
|
|
420
|
+
.pair-code { display: grid; gap: 4px; text-align: center; }
|
|
421
|
+
.pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }
|