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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
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());