nio-cat 0.1.1 → 0.3.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 +113 -3
- package/package.json +1 -1
- package/src/github.js +144 -0
- package/src/pair.js +5 -1
- package/src/server.js +53 -6
- package/web/app/app.js +286 -253
- package/web/app/style.css +56 -3
- package/web/board.css +17 -14
- package/web/board.js +60 -35
- package/web/index.html +1 -6
package/bin/nio.js
CHANGED
|
@@ -4,6 +4,7 @@ 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');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
const config = require('../src/config');
|
|
9
10
|
const { Hub } = require('../src/hub');
|
|
@@ -21,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
21
22
|
if (a.startsWith('--')) {
|
|
22
23
|
const [k, v] = a.slice(2).split('=');
|
|
23
24
|
if (v !== undefined) flags[k] = v;
|
|
24
|
-
else if (['port', 'url', 'app'].includes(k)) flags[k] = argv[++i];
|
|
25
|
+
else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
|
|
25
26
|
else flags[k] = true;
|
|
26
27
|
} else pos.push(a);
|
|
27
28
|
}
|
|
28
|
-
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
|
+
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
30
|
|
|
30
31
|
const c = {
|
|
31
32
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -55,6 +56,31 @@ function lanIps() {
|
|
|
55
56
|
.map((i) => i.address);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function openUrl(target) {
|
|
60
|
+
try {
|
|
61
|
+
if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', target], { stdio: 'ignore', detached: true }).unref();
|
|
62
|
+
else spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [target], { stdio: 'ignore', detached: true }).unref();
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Obre el panell al navegador d'aquest ordinador, ja connectat. En un servidor (SSH, sense pantalla) no ho fa.
|
|
70
|
+
function canOpenBrowser() {
|
|
71
|
+
if (flags['no-open'] || process.env.CI) return false;
|
|
72
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return false;
|
|
73
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function openDashboard() {
|
|
78
|
+
const local = `http://127.0.0.1:${cfg.port}`;
|
|
79
|
+
const link = pairLink(local, local, cfg.token);
|
|
80
|
+
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell:')} ${local} ${c.dim('(obert al navegador; --no-open per evitar-ho)')}`);
|
|
81
|
+
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
function showPair(publicUrl) {
|
|
59
85
|
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
|
|
60
86
|
const file = writePairPage(link);
|
|
@@ -72,6 +98,76 @@ function showPair(publicUrl) {
|
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
|
|
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
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
104
|
+
function remoteScript(port, dir) {
|
|
105
|
+
return `set -e
|
|
106
|
+
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
107
|
+
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
108
|
+
if ! up; then
|
|
109
|
+
mkdir -p "$HOME/.nio"
|
|
110
|
+
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
111
|
+
$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 &
|
|
112
|
+
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
113
|
+
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
114
|
+
fi
|
|
115
|
+
node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
function sshBase(target) {
|
|
119
|
+
const a = ['-o', 'ServerAliveInterval=30'];
|
|
120
|
+
if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
|
|
121
|
+
return a;
|
|
122
|
+
}
|
|
123
|
+
function waitPort(port, ms = 15000) {
|
|
124
|
+
const end = Date.now() + ms;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const t = () => {
|
|
127
|
+
const s = net.connect(port, '127.0.0.1');
|
|
128
|
+
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
129
|
+
s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
|
|
130
|
+
};
|
|
131
|
+
t();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function sshStart() {
|
|
135
|
+
const target = pos[0];
|
|
136
|
+
if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
|
|
137
|
+
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
|
|
141
|
+
console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
|
|
142
|
+
const out = await new Promise((resolve) => {
|
|
143
|
+
const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
144
|
+
let buf = '';
|
|
145
|
+
p.stdout.on('data', (d) => (buf += d));
|
|
146
|
+
p.on('error', () => resolve({ code: -1, buf }));
|
|
147
|
+
p.on('close', (code) => resolve({ code, buf }));
|
|
148
|
+
p.stdin.end(remoteScript(rport, flags.dir));
|
|
149
|
+
});
|
|
150
|
+
if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
|
|
151
|
+
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); }
|
|
152
|
+
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); }
|
|
153
|
+
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); }
|
|
154
|
+
const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
|
|
155
|
+
if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
|
|
156
|
+
|
|
157
|
+
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'] });
|
|
158
|
+
tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
|
|
159
|
+
const bye = () => { tun.kill(); process.exit(0); };
|
|
160
|
+
process.on('SIGINT', bye);
|
|
161
|
+
process.on('SIGTERM', bye);
|
|
162
|
+
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); }
|
|
163
|
+
|
|
164
|
+
const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
|
|
165
|
+
console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
|
|
166
|
+
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
|
|
167
|
+
else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
|
|
168
|
+
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'));
|
|
169
|
+
}
|
|
170
|
+
|
|
75
171
|
async function start() {
|
|
76
172
|
if (flags.port) cfg.port = Number(flags.port);
|
|
77
173
|
if (flags.app) cfg.app = flags.app;
|
|
@@ -94,7 +190,7 @@ async function start() {
|
|
|
94
190
|
const hub = new Hub(cfg);
|
|
95
191
|
let publicUrl = flags.url || null;
|
|
96
192
|
const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
|
|
97
|
-
const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl });
|
|
193
|
+
const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
|
|
98
194
|
|
|
99
195
|
server.on('error', (e) => {
|
|
100
196
|
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 +200,7 @@ async function start() {
|
|
|
104
200
|
|
|
105
201
|
server.listen(cfg.port, host, () => {
|
|
106
202
|
console.log(` ${c.dim('Local:')} http://127.0.0.1:${cfg.port}`);
|
|
203
|
+
openDashboard();
|
|
107
204
|
|
|
108
205
|
if (flags.lan) {
|
|
109
206
|
const ip = lanIps()[0];
|
|
@@ -152,6 +249,9 @@ async function start() {
|
|
|
152
249
|
|
|
153
250
|
(async () => {
|
|
154
251
|
switch (cmd) {
|
|
252
|
+
case 'ssh':
|
|
253
|
+
await sshStart();
|
|
254
|
+
break;
|
|
155
255
|
case 'doctor':
|
|
156
256
|
await doctor();
|
|
157
257
|
break;
|
|
@@ -177,8 +277,18 @@ async function start() {
|
|
|
177
277
|
--url <adreça> Una adreça pública teva (Tailscale o similar)
|
|
178
278
|
--no-tunnel Només des d'aquest ordinador
|
|
179
279
|
--port <n> Port local (7717 si no dius res)
|
|
280
|
+
--no-open No obris el panell al navegador d'aquest ordinador
|
|
180
281
|
--open Obre al navegador la pàgina del codi QR
|
|
181
282
|
nio pair Mostra el codi QR una altra vegada
|
|
283
|
+
|
|
284
|
+
nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
|
|
285
|
+
--port <n> Port de nio al servidor (7717)
|
|
286
|
+
--local-port <n> Port en aquest ordinador (7718)
|
|
287
|
+
--dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
|
|
288
|
+
|
|
289
|
+
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
|
290
|
+
Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
|
|
291
|
+
|
|
182
292
|
nio doctor Llista els agents instal·lats
|
|
183
293
|
nio reset-token Crea una clau nova i desconnecta tots els mòbils
|
|
184
294
|
|
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, '..', '..')];
|
|
@@ -106,15 +108,38 @@ function listProjects(cfg) {
|
|
|
106
108
|
return out;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
// Qualsevol carpeta existent d'aquest ordinador. Qui té la clau ja pot engegar agents, així que no la limitem a les compartides.
|
|
112
|
+
function isDir(p) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(path.resolve(p)).isDirectory();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Subcarpetes d'una carpeta, per a l'explorador de «Nova sessió».
|
|
121
|
+
function listDir(cfg, raw) {
|
|
122
|
+
const dir = path.resolve(raw || cfg.roots[0] || os.homedir());
|
|
123
|
+
if (!isDir(dir)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
124
|
+
let entries = [];
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
throw httpErr(403, 'No tinc permís per veure aquesta carpeta');
|
|
129
|
+
}
|
|
130
|
+
const dirs = entries
|
|
131
|
+
.filter((e) => (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(dir, e.name)))) && e.name !== 'node_modules')
|
|
132
|
+
.map((e) => ({ name: e.name, path: path.join(dir, e.name), hidden: e.name.startsWith('.') }))
|
|
133
|
+
.sort((a, b) => a.hidden - b.hidden || a.name.localeCompare(b.name, 'ca'))
|
|
134
|
+
.slice(0, 500);
|
|
135
|
+
const parent = path.dirname(dir);
|
|
136
|
+
return { path: dir, parent: parent === dir ? null : parent, home: os.homedir(), git: fs.existsSync(path.join(dir, '.git')), dirs };
|
|
112
137
|
}
|
|
113
138
|
|
|
114
139
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
115
140
|
const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
116
141
|
|
|
117
|
-
function createServer({ cfg, hub, getPublicUrl }) {
|
|
142
|
+
function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
118
143
|
const streams = new Set();
|
|
119
144
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
120
145
|
|
|
@@ -147,6 +172,7 @@ function createServer({ cfg, hub, getPublicUrl }) {
|
|
|
147
172
|
};
|
|
148
173
|
}
|
|
149
174
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
175
|
+
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
150
176
|
if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
|
|
151
177
|
if (p === '/api/ollama' && method === 'GET') {
|
|
152
178
|
const models = await ollama.listModels();
|
|
@@ -201,12 +227,26 @@ function createServer({ cfg, hub, getPublicUrl }) {
|
|
|
201
227
|
return { ok: true };
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
if (p === '/api/pair' && method === 'GET') {
|
|
231
|
+
// Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
|
|
232
|
+
const pub = getPublicUrl();
|
|
233
|
+
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
234
|
+
const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
|
|
235
|
+
return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
236
|
+
}
|
|
237
|
+
if (p === '/api/github' && method === 'GET') return github.status();
|
|
238
|
+
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
|
239
|
+
if (p === '/api/github/clone' && method === 'POST') {
|
|
240
|
+
const b = await readBody(req);
|
|
241
|
+
const r = await github.clone(cfg, b.repo);
|
|
242
|
+
return r;
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
if (p === '/api/sessions' && method === 'GET') return hub.list();
|
|
205
246
|
if (p === '/api/sessions' && method === 'POST') {
|
|
206
247
|
const b = await readBody(req);
|
|
207
248
|
const cwd = path.resolve(b.cwd || cfg.roots[0]);
|
|
208
|
-
if (!
|
|
209
|
-
if (!fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
249
|
+
if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
210
250
|
const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
|
|
211
251
|
if (b.text) hub.send(s.id, b.text, hookUrl());
|
|
212
252
|
return hub.summary(s);
|
|
@@ -225,6 +265,13 @@ function createServer({ cfg, hub, getPublicUrl }) {
|
|
|
225
265
|
return { ok: true };
|
|
226
266
|
}
|
|
227
267
|
}
|
|
268
|
+
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
269
|
+
const s = hub.get(r[1]);
|
|
270
|
+
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
271
|
+
if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
|
|
272
|
+
if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
|
|
273
|
+
if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
|
|
274
|
+
}
|
|
228
275
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/message$/)) && method === 'POST') {
|
|
229
276
|
const b = await readBody(req);
|
|
230
277
|
return hub.send(r[1], b.text, hookUrl());
|