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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.1.1",
3
+ "version": "0.3.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, '..', '..')];
@@ -106,15 +108,38 @@ function listProjects(cfg) {
106
108
  return out;
107
109
  }
108
110
 
109
- function insideRoots(cfg, p) {
110
- const r = path.resolve(p);
111
- return cfg.roots.some((root) => r === root || r.startsWith(root + path.sep));
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 (!insideRoots(cfg, cwd)) throw httpErr(403, 'Aquesta carpeta no és de les que has compartit en engegar nio');
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());