nio-cat 0.1.0 → 0.2.0
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 +32 -1
- package/package.json +1 -1
- package/src/github.js +144 -0
- package/src/pair.js +5 -1
- package/src/server.js +26 -1
- package/web/app/app.js +1318 -0
- package/web/app/index.html +76 -0
- package/web/app/style.css +381 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/board.css +170 -0
- package/web/board.js +1180 -0
- package/web/bot.css +60 -0
- package/web/bot.js +48 -0
- package/web/favicon.svg +1 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon-maskable-512.png +0 -0
- package/web/index.html +140 -0
- package/web/manifest.webmanifest +17 -0
- package/web/og.png +0 -0
- package/web/sw.js +62 -0
package/bin/nio.js
CHANGED
|
@@ -55,6 +55,31 @@ function lanIps() {
|
|
|
55
55
|
.map((i) => i.address);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function openUrl(target) {
|
|
59
|
+
try {
|
|
60
|
+
if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', target], { stdio: 'ignore', detached: true }).unref();
|
|
61
|
+
else spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [target], { stdio: 'ignore', detached: true }).unref();
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Obre el panell al navegador d'aquest ordinador, ja connectat. En un servidor (SSH, sense pantalla) no ho fa.
|
|
69
|
+
function canOpenBrowser() {
|
|
70
|
+
if (flags['no-open'] || process.env.CI) return false;
|
|
71
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return false;
|
|
72
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openDashboard() {
|
|
77
|
+
const local = `http://127.0.0.1:${cfg.port}`;
|
|
78
|
+
const link = pairLink(local, local, cfg.token);
|
|
79
|
+
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell:')} ${local} ${c.dim('(obert al navegador; --no-open per evitar-ho)')}`);
|
|
80
|
+
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
function showPair(publicUrl) {
|
|
59
84
|
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
|
|
60
85
|
const file = writePairPage(link);
|
|
@@ -94,7 +119,7 @@ async function start() {
|
|
|
94
119
|
const hub = new Hub(cfg);
|
|
95
120
|
let publicUrl = flags.url || null;
|
|
96
121
|
const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
|
|
97
|
-
const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl });
|
|
122
|
+
const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
|
|
98
123
|
|
|
99
124
|
server.on('error', (e) => {
|
|
100
125
|
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`));
|
|
@@ -104,6 +129,7 @@ async function start() {
|
|
|
104
129
|
|
|
105
130
|
server.listen(cfg.port, host, () => {
|
|
106
131
|
console.log(` ${c.dim('Local:')} http://127.0.0.1:${cfg.port}`);
|
|
132
|
+
openDashboard();
|
|
107
133
|
|
|
108
134
|
if (flags.lan) {
|
|
109
135
|
const ip = lanIps()[0];
|
|
@@ -177,8 +203,13 @@ async function start() {
|
|
|
177
203
|
--url <adreça> Una adreça pública teva (Tailscale o similar)
|
|
178
204
|
--no-tunnel Només des d'aquest ordinador
|
|
179
205
|
--port <n> Port local (7717 si no dius res)
|
|
206
|
+
--no-open No obris el panell al navegador d'aquest ordinador
|
|
180
207
|
--open Obre al navegador la pàgina del codi QR
|
|
181
208
|
nio pair Mostra el codi QR una altra vegada
|
|
209
|
+
|
|
210
|
+
Servidor (VPS): nio --url https://la-teva-adreça --no-open
|
|
211
|
+
Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
|
|
212
|
+
|
|
182
213
|
nio doctor Llista els agents instal·lats
|
|
183
214
|
nio reset-token Crea una clau nova i desconnecta tots els mòbils
|
|
184
215
|
|
package/package.json
CHANGED
package/src/github.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// GitHub i git des de l'ordinador: es fa servir el «gh» i el «git» que l'usuari ja té.
|
|
3
|
+
// Cap clau no passa per nio: el token queda on és (gh auth), i res no s'executa amb shell.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFile } = require('child_process');
|
|
7
|
+
const { which } = require('./proc');
|
|
8
|
+
|
|
9
|
+
function fail(status, message) {
|
|
10
|
+
const e = new Error(message);
|
|
11
|
+
e.status = status;
|
|
12
|
+
return e;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function run(cmd, args, { cwd, timeout = 30000 } = {}) {
|
|
16
|
+
const bin = which(cmd);
|
|
17
|
+
if (!bin) return Promise.reject(fail(400, `No trobo «${cmd}» en aquest ordinador`));
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile(
|
|
20
|
+
bin,
|
|
21
|
+
args,
|
|
22
|
+
{ cwd, timeout, maxBuffer: 8e6, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', NO_COLOR: '1', GH_NO_UPDATE_NOTIFIER: '1' } },
|
|
23
|
+
(err, stdout, stderr) => {
|
|
24
|
+
if (err) {
|
|
25
|
+
const msg = String(stderr || err.message || '').trim().split('\n').filter(Boolean).slice(-3).join(' ');
|
|
26
|
+
return reject(fail(400, msg || 'L’ordre ha fallat'));
|
|
27
|
+
}
|
|
28
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OWNER = /^(?![.-])[\w.-]{1,60}$/;
|
|
35
|
+
const REPO = /^(?![.-])[\w.-]{1,60}\/(?![.-])[\w.-]{1,100}$/;
|
|
36
|
+
const BRANCH = /^(?!-)[\w./-]{1,80}$/;
|
|
37
|
+
|
|
38
|
+
async function status() {
|
|
39
|
+
if (!which('gh')) return { available: false, authed: false };
|
|
40
|
+
try {
|
|
41
|
+
await run('gh', ['auth', 'status'], { timeout: 10000 });
|
|
42
|
+
} catch {
|
|
43
|
+
return { available: true, authed: false };
|
|
44
|
+
}
|
|
45
|
+
let user = '';
|
|
46
|
+
try {
|
|
47
|
+
user = (await run('gh', ['api', 'user', '--jq', '.login'], { timeout: 10000 })).stdout.trim();
|
|
48
|
+
} catch {
|
|
49
|
+
/* sense nom */
|
|
50
|
+
}
|
|
51
|
+
return { available: true, authed: true, user };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function repos(owner) {
|
|
55
|
+
const args = ['repo', 'list'];
|
|
56
|
+
if (owner) {
|
|
57
|
+
if (!OWNER.test(owner)) throw fail(400, 'Aquest propietari no és vàlid');
|
|
58
|
+
args.push(owner);
|
|
59
|
+
}
|
|
60
|
+
args.push('--limit', '100', '--json', 'nameWithOwner,description,isPrivate,isFork,updatedAt');
|
|
61
|
+
const { stdout } = await run('gh', args, { timeout: 30000 });
|
|
62
|
+
let list = [];
|
|
63
|
+
try {
|
|
64
|
+
list = JSON.parse(stdout);
|
|
65
|
+
} catch {
|
|
66
|
+
/* buit */
|
|
67
|
+
}
|
|
68
|
+
return list.map((r) => ({
|
|
69
|
+
repo: r.nameWithOwner,
|
|
70
|
+
description: String(r.description || '').slice(0, 160),
|
|
71
|
+
private: !!r.isPrivate,
|
|
72
|
+
fork: !!r.isFork,
|
|
73
|
+
updated: r.updatedAt,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function clone(cfg, repo) {
|
|
78
|
+
repo = String(repo || '').trim();
|
|
79
|
+
if (!REPO.test(repo)) throw fail(400, 'Aquest repositori no és vàlid');
|
|
80
|
+
const base = cfg.roots[0];
|
|
81
|
+
if (!base) throw fail(400, 'No hi ha cap carpeta compartida on clonar-lo');
|
|
82
|
+
const target = path.join(base, repo.split('/')[1]);
|
|
83
|
+
if (!target.startsWith(base + path.sep)) throw fail(400, 'Aquest nom de carpeta no és vàlid');
|
|
84
|
+
if (fs.existsSync(target)) {
|
|
85
|
+
if (fs.existsSync(path.join(target, '.git'))) return { path: target, existed: true };
|
|
86
|
+
throw fail(409, `Ja hi ha una carpeta «${path.basename(target)}» que no és git`);
|
|
87
|
+
}
|
|
88
|
+
await run('gh', ['repo', 'clone', repo, target], { timeout: 600000 });
|
|
89
|
+
return { path: target, existed: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function gitInfo(cwd) {
|
|
93
|
+
try {
|
|
94
|
+
await run('git', ['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10000 });
|
|
95
|
+
} catch {
|
|
96
|
+
return { repo: false };
|
|
97
|
+
}
|
|
98
|
+
const out = async (args) => {
|
|
99
|
+
try {
|
|
100
|
+
return (await run('git', args, { cwd, timeout: 15000 })).stdout.trim();
|
|
101
|
+
} catch {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const branch = (await out(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD';
|
|
106
|
+
const raw = await out(['status', '--porcelain=v1']);
|
|
107
|
+
const files = raw ? raw.split('\n').slice(0, 200).map((l) => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3) })) : [];
|
|
108
|
+
const stat = await out(['diff', '--shortstat', 'HEAD']);
|
|
109
|
+
const remote = (await out(['remote'])).split('\n').filter(Boolean)[0] || '';
|
|
110
|
+
const ahead = remote ? Number(await out(['rev-list', '--count', '@{u}..HEAD'])) || 0 : 0;
|
|
111
|
+
const gh = await status();
|
|
112
|
+
return { repo: true, branch, files, stat, remote, ahead, defaultBranch: branch === 'main' || branch === 'master', gh };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function commit(cwd, { message, branch }) {
|
|
116
|
+
message = String(message || '').trim().slice(0, 500);
|
|
117
|
+
if (!message) throw fail(400, 'Escriu un missatge per al commit');
|
|
118
|
+
if (branch) {
|
|
119
|
+
branch = String(branch).trim();
|
|
120
|
+
if (!BRANCH.test(branch)) throw fail(400, 'Aquest nom de branca no és vàlid');
|
|
121
|
+
await run('git', ['checkout', '-b', branch], { cwd });
|
|
122
|
+
}
|
|
123
|
+
await run('git', ['add', '-A'], { cwd });
|
|
124
|
+
const r = await run('git', ['commit', '-m', message], { cwd, timeout: 60000 });
|
|
125
|
+
return { ok: true, summary: r.stdout.trim().split('\n')[0] || 'Fet' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function pr(cwd, { title, body }) {
|
|
129
|
+
const info = await gitInfo(cwd);
|
|
130
|
+
if (!info.repo) throw fail(400, 'Aquesta carpeta no és un repositori git');
|
|
131
|
+
if (info.defaultBranch) throw fail(400, 'Ets a la branca principal. Fes el commit en una branca nova per poder crear el PR.');
|
|
132
|
+
if (!info.remote) throw fail(400, 'Aquest repositori no té cap remot (origin)');
|
|
133
|
+
if (!info.gh.authed) throw fail(400, 'Cal iniciar sessió a GitHub amb «gh auth login»');
|
|
134
|
+
await run('git', ['push', '-u', info.remote, 'HEAD'], { cwd, timeout: 120000 });
|
|
135
|
+
title = String(title || '').trim().slice(0, 200);
|
|
136
|
+
const args = ['pr', 'create'];
|
|
137
|
+
if (title) args.push('--title', title, '--body', String(body || '').slice(0, 5000));
|
|
138
|
+
else args.push('--fill');
|
|
139
|
+
const r = await run('gh', args, { cwd, timeout: 60000 });
|
|
140
|
+
const url = (r.stdout.match(/https:\/\/github\.com\/\S+/) || [])[0] || '';
|
|
141
|
+
return { ok: true, url };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { status, repos, clone, gitInfo, commit, pr };
|
package/src/pair.js
CHANGED
|
@@ -40,6 +40,10 @@ function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
|
|
|
40
40
|
return lines.join('\n');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function qrSvg(text) {
|
|
44
|
+
return qr(text).createSvgTag({ cellSize: 6, margin: 4, scalable: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
43
47
|
function writePairPage(link) {
|
|
44
48
|
const q = qr(link);
|
|
45
49
|
const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
|
|
@@ -58,4 +62,4 @@ code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospa
|
|
|
58
62
|
return file;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
module.exports = { pairLink, terminalQr, writePairPage };
|
|
65
|
+
module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,8 @@ const { detectAll } = require('./agents');
|
|
|
9
9
|
const ollama = require('./agents/ollama');
|
|
10
10
|
const { save } = require('./config');
|
|
11
11
|
const { httpErr } = require('./hub');
|
|
12
|
+
const github = require('./github');
|
|
13
|
+
const { pairLink, qrSvg } = require('./pair');
|
|
12
14
|
|
|
13
15
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
14
16
|
const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
|
|
@@ -114,7 +116,7 @@ function insideRoots(cfg, p) {
|
|
|
114
116
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
115
117
|
const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
116
118
|
|
|
117
|
-
function createServer({ cfg, hub, getPublicUrl }) {
|
|
119
|
+
function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
118
120
|
const streams = new Set();
|
|
119
121
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
120
122
|
|
|
@@ -201,6 +203,21 @@ function createServer({ cfg, hub, getPublicUrl }) {
|
|
|
201
203
|
return { ok: true };
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
if (p === '/api/pair' && method === 'GET') {
|
|
207
|
+
// Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
|
|
208
|
+
const pub = getPublicUrl();
|
|
209
|
+
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
210
|
+
const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
|
|
211
|
+
return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
212
|
+
}
|
|
213
|
+
if (p === '/api/github' && method === 'GET') return github.status();
|
|
214
|
+
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
|
215
|
+
if (p === '/api/github/clone' && method === 'POST') {
|
|
216
|
+
const b = await readBody(req);
|
|
217
|
+
const r = await github.clone(cfg, b.repo);
|
|
218
|
+
return r;
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
if (p === '/api/sessions' && method === 'GET') return hub.list();
|
|
205
222
|
if (p === '/api/sessions' && method === 'POST') {
|
|
206
223
|
const b = await readBody(req);
|
|
@@ -225,6 +242,14 @@ function createServer({ cfg, hub, getPublicUrl }) {
|
|
|
225
242
|
return { ok: true };
|
|
226
243
|
}
|
|
227
244
|
}
|
|
245
|
+
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
246
|
+
const s = hub.get(r[1]);
|
|
247
|
+
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
248
|
+
if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
|
|
249
|
+
if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
|
|
250
|
+
if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
|
|
251
|
+
if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
|
|
252
|
+
}
|
|
228
253
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/message$/)) && method === 'POST') {
|
|
229
254
|
const b = await readBody(req);
|
|
230
255
|
return hub.send(r[1], b.text, hookUrl());
|