@mmmbuto/nexuscrew 0.7.7 → 0.8.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.
Files changed (47) hide show
  1. package/README.md +156 -20
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +415 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +16 -3
  12. package/lib/cli/init.js +39 -6
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +55 -1
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/builtin.js +141 -24
  22. package/lib/fleet/definitions.js +26 -0
  23. package/lib/fleet/managed.js +211 -0
  24. package/lib/fleet/routes.js +5 -4
  25. package/lib/mcp/server.js +362 -0
  26. package/lib/nodes/commands.js +396 -0
  27. package/lib/nodes/store.js +358 -0
  28. package/lib/nodes/tunnel-supervisor.js +102 -0
  29. package/lib/nodes/tunnel.js +300 -0
  30. package/lib/notify/asks.js +150 -0
  31. package/lib/notify/events.js +59 -0
  32. package/lib/notify/notifier.js +37 -0
  33. package/lib/notify/persist.js +73 -0
  34. package/lib/notify/push.js +289 -0
  35. package/lib/notify/routes.js +260 -0
  36. package/lib/proxy/node-proxy.js +292 -0
  37. package/lib/pty/attach.js +34 -9
  38. package/lib/pty/provider.js +21 -6
  39. package/lib/server.js +206 -12
  40. package/lib/settings/routes.js +473 -0
  41. package/lib/ws/bridge.js +10 -1
  42. package/package.json +7 -1
  43. package/skills/nexuscrew-agent/SKILL.md +83 -0
  44. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  45. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  46. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  47. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-EVa9bnAC.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DUbtTZMj.css">
14
+ <script type="module" crossorigin src="/assets/index-DQrDBogT.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BBOgTCoO.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1,3 +1,32 @@
1
1
  self.addEventListener('install', () => self.skipWaiting());
2
2
  self.addEventListener('activate', () => self.clients.claim());
3
3
  self.addEventListener('fetch', (e) => e.respondWith(fetch(e.request)));
4
+
5
+ // Web Push del MCP bridge: payload JSON {title, body?, url?} dal server.
6
+ // tag fisso 'nexuscrew': le notifiche si sostituiscono invece di accumularsi.
7
+ self.addEventListener('push', (e) => {
8
+ let data = {};
9
+ try { data = e.data ? e.data.json() : {}; } catch (_) { /* payload non JSON: ignora */ }
10
+ const title = typeof data.title === 'string' && data.title ? data.title : 'NexusCrew';
11
+ e.waitUntil(self.registration.showNotification(title, {
12
+ body: typeof data.body === 'string' ? data.body : '',
13
+ tag: 'nexuscrew',
14
+ data: { url: typeof data.url === 'string' ? data.url : '/' },
15
+ }));
16
+ });
17
+
18
+ // Click sulla notifica: focus di una finestra gia' aperta (deep-link via
19
+ // navigate) oppure apertura di una nuova su data.url (es. /#ask=<id>).
20
+ self.addEventListener('notificationclick', (e) => {
21
+ e.notification.close();
22
+ const url = (e.notification.data && e.notification.data.url) || '/';
23
+ e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => {
24
+ for (const w of wins) {
25
+ if ('focus' in w) {
26
+ if ('navigate' in w) w.navigate(url).catch(() => {});
27
+ return w.focus();
28
+ }
29
+ }
30
+ return self.clients.openWindow ? self.clients.openWindow(url) : undefined;
31
+ }));
32
+ });
@@ -0,0 +1 @@
1
+ {"version":"0.8.0"}
@@ -7,9 +7,15 @@ function bearerFrom(req) {
7
7
  }
8
8
 
9
9
  // 401 uniforme: nessun dettaglio su cosa è andato storto.
10
+ // `token` puo' essere una stringa (storico) o un holder live {get}: l'holder si
11
+ // rilegge ad OGNI richiesta, cosi' una rotazione (audit F7 / §4b(3)) invalida il
12
+ // vecchio token senza restart. backward-compat: la stringa resta accettata.
10
13
  function requireToken(token) {
14
+ const read = (token && typeof token === 'object' && typeof token.get === 'function')
15
+ ? token.get
16
+ : () => token;
11
17
  return (req, res, next) => {
12
- if (verify(token, bearerFrom(req))) return next();
18
+ if (verify(read(), bearerFrom(req))) return next();
13
19
  res.status(401).json({ error: 'unauthorized' });
14
20
  };
15
21
  }
package/lib/auth/token.js CHANGED
@@ -53,6 +53,36 @@ function loadOrCreateToken(tokenPath) {
53
53
  return tok;
54
54
  }
55
55
 
56
+ // Rotazione token: genera un NUOVO segreto e sostituisce il file in modo ATOMICO
57
+ // (tmp stessa dir -> chmod 0600 -> rename), no-follow symlink. [A2 §4b(3)]
58
+ // A differenza di loadOrCreateToken NON preserva l'esistente: overwrite voluto.
59
+ // L'invalidazione reale delle sessioni attive la fa il chiamante (restart service:
60
+ // il server carica il token solo allo startup). Ritorna il nuovo token (il chiamante
61
+ // NON deve stamparlo: si usa `nexuscrew url`).
62
+ function rotateToken(tokenPath) {
63
+ // reject symlink target preesistente (no-follow), ENOENT ok (nuovo file)
64
+ try {
65
+ const st = fs.lstatSync(tokenPath);
66
+ if (st.isSymbolicLink()) {
67
+ throw new Error(`refusing symlink token path: ${tokenPath}`);
68
+ }
69
+ } catch (e) {
70
+ if (e.code !== 'ENOENT') throw e;
71
+ }
72
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
73
+ const tok = crypto.randomBytes(24).toString('base64url');
74
+ const tmp = tokenPath + '.tmp.' + process.pid;
75
+ try {
76
+ fs.writeFileSync(tmp, tok + '\n', { flag: 'wx', mode: 0o600 }); // exclusive temp
77
+ fs.chmodSync(tmp, 0o600);
78
+ fs.renameSync(tmp, tokenPath); // atomic replace
79
+ } catch (e) {
80
+ try { fs.unlinkSync(tmp); } catch (_) {} // cleanup temp su failure
81
+ throw e;
82
+ }
83
+ return tok;
84
+ }
85
+
56
86
  function verify(expected, given) {
57
87
  if (typeof expected !== 'string' || typeof given !== 'string') return false;
58
88
  const a = Buffer.from(expected);
@@ -61,4 +91,4 @@ function verify(expected, given) {
61
91
  return crypto.timingSafeEqual(a, b);
62
92
  }
63
93
 
64
- module.exports = { loadOrCreateToken, readTokenSafe, verify };
94
+ module.exports = { loadOrCreateToken, readTokenSafe, rotateToken, verify };
@@ -7,29 +7,70 @@ const { execFileSync, spawn } = require('node:child_process');
7
7
  const fs = require('node:fs');
8
8
  const path = require('node:path');
9
9
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
10
+ const { installPath: serviceInstallPath } = require('./service.js');
10
11
  const pidf = require('./pidfile.js');
11
12
  const { runInit } = require('./init.js');
13
+ const { rotateToken } = require('../auth/token.js');
14
+ const urlmod = require('./url.js');
15
+ const { doctor } = require('./doctor.js');
16
+ const nodesCmds = require('../nodes/commands.js');
17
+ const nodesStore = require('../nodes/store.js');
18
+ const nodesTunnel = require('../nodes/tunnel.js');
19
+
20
+ // Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
21
+ // Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
22
+ const VALUE_FLAGS = new Set([
23
+ 'ssh', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
24
+ ]);
12
25
 
13
26
  const HELP = `NexusCrew (portable) — browser tmux client.
14
27
 
15
28
  Usage:
29
+ nexuscrew smart-up: init se serve -> start -> URL + QR
16
30
  nexuscrew init [--dry-run] [--port N] setup: detect + config + token + service + URL
17
31
  nexuscrew serve [--pidfile] HTTP server foreground (dev / ExecStart)
18
- nexuscrew start avvia il servizio (systemctl / launchctl / nohup+pidfile)
19
- nexuscrew stop stop del servizio (service manager / pidfile verificato)
20
- nexuscrew status stato: platform + service + porta + URL
32
+ nexuscrew start | up avvia il servizio (systemctl / launchctl / nohup+pidfile)
33
+ nexuscrew stop | down stop del servizio (service manager / pidfile verificato)
34
+ nexuscrew status [--json] stato: platform + service + porta + URL + ruoli + nodi
35
+ nexuscrew url [--qr] ristampa URL con #token (+ QR ASCII) — UNICO posto col token
36
+ nexuscrew token rotate ruota il token (atomico) + invalida le sessioni attive
37
+ nexuscrew logs [-f] journalctl-user / logfile (-f = segue)
38
+ nexuscrew doctor auto-diagnosi: node, tmux, PTY, service, boot, token, ssh
39
+ nexuscrew mcp server MCP stdio per sessioni AI (notify/ask/file/status)
40
+ nexuscrew update npm i -g @mmmbuto/nexuscrew@latest + restart
21
41
  nexuscrew fleet-boot companion di boot: avvia le celle boot:true (config-driven)
42
+ nexuscrew nodes add <name> --ssh u@h aggiunge un nodo (genera chiave dedicata + authorized_keys)
43
+ nexuscrew nodes list [--json] elenca i nodi + stato tunnel (token redatti)
44
+ nexuscrew nodes remove|test <name> rimuove / testa un nodo (test: tunnel/health/token remoto)
45
+ nexuscrew nodes up|down|restart <name> lifecycle del singolo tunnel (non tocca la config)
46
+ nexuscrew nodes set-token <name> aggiorna il token remoto (da stdin/env, mai argv)
47
+ nexuscrew node on|off ruolo "nodo raggiungibile" (reverse tunnel)
22
48
 
23
49
  Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
24
- Bind loopback 127.0.0.1 — raggiungibile via tunnel SSH/VPN.`;
50
+ Bind loopback 127.0.0.1 — raggiungibile via tunnel SSH/VPN.
51
+ Il token appare SOLO in \`url\`/\`url --qr\` — mai in status, logs, service output.`;
25
52
 
26
- function parseFlags(argv) {
53
+ // valueFlags (Set|array opzionale): flag che consumano il token successivo nella
54
+ // forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
55
+ function parseFlags(argv, valueFlags) {
56
+ const vf = valueFlags instanceof Set ? valueFlags
57
+ : (Array.isArray(valueFlags) ? new Set(valueFlags) : null);
27
58
  const flags = {};
28
59
  const rest = [];
29
- for (const a of argv) {
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const a = argv[i];
30
62
  if (a.startsWith('--')) {
31
- const [k, v] = a.slice(2).split('=');
32
- flags[k] = v !== undefined ? v : true;
63
+ const eq = a.indexOf('=');
64
+ if (eq >= 0) {
65
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
66
+ } else {
67
+ const key = a.slice(2);
68
+ if (vf && vf.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
69
+ flags[key] = argv[i + 1]; i += 1; // consuma il valore (forma "--k v")
70
+ } else {
71
+ flags[key] = true;
72
+ }
73
+ }
33
74
  } else rest.push(a);
34
75
  }
35
76
  return { flags, rest };
@@ -68,8 +109,22 @@ function start(opts = {}) {
68
109
  return { platform, started: true };
69
110
  }
70
111
  if (platform === 'mac') {
71
- const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
72
- execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
112
+ const domain = `gui/${opts.uid || uid()}`;
113
+ const label = `${domain}/com.mmmbuto.nexuscrew`;
114
+ try {
115
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
116
+ } catch (_) {
117
+ try {
118
+ const home = opts.home || require('node:os').homedir();
119
+ const plist = opts.installPath || serviceInstallPath('mac', home);
120
+ execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
121
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
122
+ } catch (e) {
123
+ const reason = String(e.message || e);
124
+ log(`start: launchctl fallito: ${reason}`);
125
+ return { platform, started: false, reason };
126
+ }
127
+ }
73
128
  log(`start: launchctl kickstart ${label}`);
74
129
  return { platform, started: true };
75
130
  }
@@ -108,9 +163,16 @@ function stop(opts = {}) {
108
163
  }
109
164
  if (platform === 'mac') {
110
165
  const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
111
- execImpl('launchctl', ['kill', 'SIGTERM', label], { stdio: 'ignore' });
112
- log(`stop: launchctl kill SIGTERM ${label}`);
113
- return { platform, stopped: true };
166
+ try {
167
+ // bootout scarica il job: KeepAlive non puo' rianimare uno stop volontario.
168
+ execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
169
+ log(`stop: launchctl bootout ${label}`);
170
+ return { platform, stopped: true };
171
+ } catch (e) {
172
+ const reason = String(e.message || e);
173
+ log(`stop: launchctl bootout fallito: ${reason}`);
174
+ return { platform, stopped: false, reason };
175
+ }
114
176
  }
115
177
  if (platform === 'termux') {
116
178
  // kill via pidfile verificato (no broad match) + wake-lock-release
@@ -123,13 +185,43 @@ function stop(opts = {}) {
123
185
  throw new Error(`stop: platform ${platform} non supportata`);
124
186
  }
125
187
 
188
+ // running-check per-platform (no log) — riusato da smart-up / token rotate / update.
189
+ function isServiceRunning(opts = {}) {
190
+ const platform = opts.platform || detectPlatform();
191
+ const execImpl = opts.execImpl || execFileSync;
192
+ if (platform === 'linux') {
193
+ try { return execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'active'; }
194
+ catch (_) { return false; }
195
+ }
196
+ if (platform === 'mac') {
197
+ try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
198
+ catch (_) { return false; }
199
+ }
200
+ if (platform === 'termux') {
201
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath());
202
+ return !!(meta && pidf.isAlive(meta));
203
+ }
204
+ return false;
205
+ }
206
+
207
+ // Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
208
+ function readRoles(configPath) {
209
+ try {
210
+ const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
211
+ const r = c && c.roles;
212
+ return { client: !!(r && r.client), node: !!(r && r.node) };
213
+ } catch (_) { return { client: false, node: false }; }
214
+ }
215
+
126
216
  function status(opts = {}) {
127
217
  const platform = opts.platform || detectPlatform();
128
218
  const execImpl = opts.execImpl || execFileSync;
129
219
  const log = opts.log || console.log;
130
220
  const home = opts.home || require('node:os').homedir();
221
+ const { configPath, configDir } = urlmod.resolvePaths(opts);
222
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
131
223
 
132
- const out = { platform, service: null, running: null, port: null, url: null };
224
+ const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
133
225
 
134
226
  if (platform === 'linux') {
135
227
  try {
@@ -151,18 +243,204 @@ function status(opts = {}) {
151
243
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
152
244
  }
153
245
 
154
- // porta da config.json
246
+ out.port = urlmod.loadPort(opts);
247
+ out.url = urlmod.buildUrl(out.port, null, { withToken: false }); // MAI il token in status
248
+ out.roles = readRoles(configPath);
249
+
250
+ // nodes[] con stato tunnel REALE. Token per-nodo SEMPRE redatti (mai in status).
251
+ // nodes.json assente/invalido -> nodes [] (non fa fallire status).
155
252
  try {
156
- const { loadConfig } = require('../config.js');
157
- const cfg = loadConfig();
158
- out.port = cfg.port;
159
- out.url = `http://127.0.0.1:${cfg.port}/`;
160
- } catch (_) {}
253
+ const st = nodesStore.loadStore(nodesPath);
254
+ if (st) {
255
+ out.nodeId = st.nodeId;
256
+ out.nodes = st.nodes.map((n) => {
257
+ const red = nodesStore.redactNode(n); // hasToken, mai il token
258
+ return {
259
+ name: red.name, roles: red.roles,
260
+ remotePort: red.remotePort, localPort: red.localPort,
261
+ hasToken: red.hasToken,
262
+ tunnel: nodesTunnel.readTunnelState(home, n.name),
263
+ };
264
+ });
265
+ }
266
+ } catch (_) { /* nodes opzionale: non rompe status */ }
161
267
 
162
- log(JSON.stringify(out, null, 2));
268
+ if (opts.json) {
269
+ log(JSON.stringify(out, null, 2));
270
+ } else {
271
+ log(`platform: ${out.platform}`);
272
+ log(`service: ${out.service}`);
273
+ log(`running: ${out.running}`);
274
+ log(`port: ${out.port}`);
275
+ log(`url: ${out.url}`);
276
+ log(`roles: client=${out.roles.client} node=${out.roles.node}`);
277
+ log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
278
+ if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
279
+ }
163
280
  return out;
164
281
  }
165
282
 
283
+ // restart per-platform (shared da token rotate / update). execImpl per test.
284
+ function restart(opts = {}) {
285
+ const platform = opts.platform || detectPlatform();
286
+ const execImpl = opts.execImpl || execFileSync;
287
+ const log = opts.log || console.log;
288
+ if (platform === 'linux') {
289
+ execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
290
+ log('restart: systemctl --user restart nexuscrew');
291
+ return { platform, restarted: true };
292
+ }
293
+ if (platform === 'mac') {
294
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
295
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
296
+ log(`restart: launchctl kickstart -k ${label}`);
297
+ return { platform, restarted: true };
298
+ }
299
+ if (platform === 'termux') {
300
+ stop({ execImpl, log, platform, uid: opts.uid });
301
+ start({ execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
302
+ return { platform, restarted: true };
303
+ }
304
+ throw new Error(`restart: platform ${platform} non supportata`);
305
+ }
306
+
307
+ // smart-up (design §3.2): nexuscrew nudo. init minimale (zero domande) se mai
308
+ // inizializzato -> start service (se non gia' attivo) -> stampa URL con #token + QR.
309
+ // Idempotente: se gia' attivo stampa solo l'URL. Seam iniettabili per test.
310
+ function smartUp(opts = {}) {
311
+ const log = opts.log || console.log;
312
+ const platform = opts.platform || detectPlatform();
313
+ const { configPath, tokenPath } = urlmod.resolvePaths(opts);
314
+ const runInitImpl = opts.runInitImpl || runInit;
315
+ const startImpl = opts.startImpl || start;
316
+
317
+ const initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
318
+ if (!initialized) {
319
+ log('smart-up: mai inizializzato -> init minimale (zero domande; il resto dal wizard UI)');
320
+ // printUrl:false -> init non stampa il token; l'URL/QR lo presenta smart-up sotto.
321
+ runInitImpl({ ...opts, log, platform, printUrl: false });
322
+ }
323
+
324
+ const running = isServiceRunning({ ...opts, platform });
325
+ if (!running) {
326
+ startImpl({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
327
+ } else {
328
+ log('smart-up: servizio gia\' attivo');
329
+ }
330
+
331
+ // Output: URL base (SENZA token in chiaro — smart-up puo' girare al boot/service)
332
+ // + QR che INCORPORA il token (scan-to-login, killer feature Termux). Il token in
333
+ // chiaro resta esclusivo di `nexuscrew url` (§3, invariante "token mai in output servizio").
334
+ const port = urlmod.loadPort(opts);
335
+ const token = urlmod.readToken(tokenPath);
336
+ const baseUrl = urlmod.buildUrl(port, null, { withToken: false });
337
+ const fullUrl = urlmod.buildUrl(port, token, { withToken: true });
338
+ log(baseUrl);
339
+ if (token) {
340
+ log(urlmod.renderQr(fullUrl, { qrcode: opts.qrcode }));
341
+ log('scansiona il QR (contiene il token) o usa `nexuscrew url` per l\'URL completo');
342
+ }
343
+ return { platform, initialized, running, url: baseUrl, port };
344
+ }
345
+
346
+ // url [--qr]: ristampa URL completo con #token — UNICO comando che mostra il token.
347
+ function url(opts = {}) {
348
+ const log = opts.log || console.log;
349
+ const { tokenPath } = urlmod.resolvePaths(opts);
350
+ const port = urlmod.loadPort(opts);
351
+ const token = urlmod.readToken(tokenPath);
352
+ const full = urlmod.buildUrl(port, token, { withToken: true });
353
+ log(full);
354
+ if (opts.qr) {
355
+ if (token) log(urlmod.renderQr(full, { qrcode: opts.qrcode }));
356
+ else log('url: nessun token (esegui init) — QR non generato');
357
+ }
358
+ return { url: full, port, hasToken: !!token };
359
+ }
360
+
361
+ // token rotate (§4b(3)): scrittura atomica nuovo token + invalidazione reale delle
362
+ // sessioni attive (restart se il service e' attivo). Il nuovo token NON si stampa.
363
+ function tokenRotate(opts = {}) {
364
+ const log = opts.log || console.log;
365
+ const platform = opts.platform || detectPlatform();
366
+ const { tokenPath } = urlmod.resolvePaths(opts);
367
+ const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
368
+ if (readonly) {
369
+ log('token rotate: READONLY, rotazione bloccata');
370
+ return { rotated: false, reason: 'readonly' };
371
+ }
372
+ rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
373
+ log(`token: nuovo segreto scritto (${tokenPath})`);
374
+ const running = isServiceRunning({ ...opts, platform });
375
+ if (running) {
376
+ restart({ ...opts, platform, log });
377
+ log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
378
+ } else {
379
+ // il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
380
+ // vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
381
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath());
382
+ if (meta && pidf.isAlive(meta)) {
383
+ log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
384
+ } else {
385
+ log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
386
+ }
387
+ }
388
+ log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
389
+ return { rotated: true, running };
390
+ }
391
+
392
+ // logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
393
+ function logs(opts = {}) {
394
+ const platform = opts.platform || detectPlatform();
395
+ const spawnImpl = opts.spawnImpl || spawn;
396
+ const log = opts.log || console.log;
397
+ const home = opts.home || require('node:os').homedir();
398
+ const follow = !!opts.follow;
399
+
400
+ let bin; let args;
401
+ if (platform === 'linux') {
402
+ bin = 'journalctl';
403
+ args = ['--user', '-u', 'nexuscrew', '--no-pager'];
404
+ if (follow) args.push('-f');
405
+ } else {
406
+ // mac (launchd StandardOutPath) / termux (nohup logfile): stesso path.
407
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
408
+ bin = 'tail';
409
+ args = follow ? ['-f', logPath] : ['-n', '200', logPath];
410
+ }
411
+ log(`logs: ${bin} ${args.join(' ')}`);
412
+ const child = spawnImpl(bin, args, { stdio: 'inherit' });
413
+ // passthrough: quando il figlio esce (o subito, se non-follow), esce anche la CLI.
414
+ if (child && typeof child.on === 'function') {
415
+ child.on('exit', (code) => process.exit(code || 0));
416
+ }
417
+ return { platform, bin, args, follow, keepAlive: true };
418
+ }
419
+
420
+ // update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
421
+ function update(opts = {}) {
422
+ const execImpl = opts.execImpl || execFileSync;
423
+ const log = opts.log || console.log;
424
+ const platform = opts.platform || detectPlatform();
425
+ const pkg = '@mmmbuto/nexuscrew@latest';
426
+ try {
427
+ execImpl('npm', ['i', '-g', pkg], { stdio: 'inherit' });
428
+ log(`update: ${pkg} installato`);
429
+ } catch (e) {
430
+ log(`update: npm install fallito — ${e && e.message ? e.message : e}`);
431
+ log('update: controlla npm/rete/permessi globali, poi riprova: npm i -g @mmmbuto/nexuscrew@latest');
432
+ return { updated: false, error: String(e && e.message ? e.message : e), code: 1 };
433
+ }
434
+ const running = isServiceRunning({ ...opts, platform });
435
+ if (running) {
436
+ restart({ ...opts, platform, log });
437
+ log('update: servizio riavviato sul nuovo codice');
438
+ } else {
439
+ log('update: servizio non attivo (nessun restart)');
440
+ }
441
+ return { updated: true, running, code: 0 };
442
+ }
443
+
166
444
  // B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
167
445
  // dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
168
446
  // viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
@@ -207,14 +485,20 @@ function dispatchFleetBoot(opts) {
207
485
  }
208
486
 
209
487
  function dispatch(argv, opts = {}) {
210
- const { flags, rest } = parseFlags(argv);
488
+ const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
211
489
  const cmd = rest[0];
212
490
  const log = opts.log || console.log;
213
491
 
214
- if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
492
+ // help esplicito
493
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
215
494
  log(HELP);
216
495
  return { code: 0 };
217
496
  }
497
+ // nexuscrew nudo = smart-up (design §3.2)
498
+ if (!cmd) {
499
+ smartUp({ ...opts, log });
500
+ return { code: 0 };
501
+ }
218
502
  if (cmd === 'init') {
219
503
  runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
220
504
  return { code: 0 };
@@ -223,23 +507,123 @@ function dispatch(argv, opts = {}) {
223
507
  serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
224
508
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
225
509
  }
226
- if (cmd === 'start') {
227
- start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform: opts.platform, uid: opts.uid });
228
- return { code: 0 };
510
+ if (cmd === 'start' || cmd === 'up') {
511
+ const r = start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log,
512
+ platform: opts.platform, uid: opts.uid, home: opts.home, installPath: opts.installPath });
513
+ return { code: r.started === false ? 1 : 0 };
229
514
  }
230
- if (cmd === 'stop') {
231
- stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
232
- return { code: 0 };
515
+ if (cmd === 'stop' || cmd === 'down') {
516
+ const r = stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
517
+ return { code: r.stopped === false ? 1 : 0 };
233
518
  }
234
519
  if (cmd === 'status') {
235
- status({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid, home: opts.home });
520
+ status({ ...opts, json: !!flags.json, log });
236
521
  return { code: 0 };
237
522
  }
523
+ if (cmd === 'url') {
524
+ url({ ...opts, qr: !!flags.qr, log });
525
+ return { code: 0 };
526
+ }
527
+ if (cmd === 'token') {
528
+ if (rest[1] === 'rotate') {
529
+ tokenRotate({ ...opts, log });
530
+ return { code: 0 };
531
+ }
532
+ log('usage: nexuscrew token rotate');
533
+ return { code: 1 };
534
+ }
535
+ if (cmd === 'logs') {
536
+ const follow = !!(flags.f || flags.follow) || rest.includes('-f');
537
+ const r = logs({ ...opts, follow, log });
538
+ return { code: 0, keepAlive: !!r.keepAlive };
539
+ }
540
+ if (cmd === 'doctor') {
541
+ const r = doctor({ ...opts, log });
542
+ return { code: r.code };
543
+ }
544
+ if (cmd === 'update') {
545
+ const r = update({ ...opts, log });
546
+ return { code: r.code || 0 };
547
+ }
548
+ if (cmd === 'mcp') {
549
+ // Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
550
+ // NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
551
+ const startMcpImpl = opts.startMcpImpl || require('../mcp/server.js').startMcp;
552
+ startMcpImpl();
553
+ return { code: 0, keepAlive: true };
554
+ }
238
555
  if (cmd === 'fleet-boot') {
239
556
  return dispatchFleetBoot({ ...opts, log });
240
557
  }
558
+ if (cmd === 'nodes') {
559
+ return dispatchNodes(rest, flags, { ...opts, log });
560
+ }
561
+ if (cmd === 'node') {
562
+ return dispatchNode(rest, flags, { ...opts, log });
563
+ }
241
564
  log(`unknown command: ${cmd}\n\n${HELP}`);
242
565
  return { code: 1 };
243
566
  }
244
567
 
245
- module.exports = { dispatch, serve, start, stop, status, parseFlags, HELP, runFleetBoot, dispatchFleetBoot };
568
+ // nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
569
+ function dispatchNodes(rest, flags, opts) {
570
+ const log = opts.log;
571
+ const sub = rest[1];
572
+ const name = rest[2];
573
+ const common = { ...opts, name };
574
+ switch (sub) {
575
+ case 'add':
576
+ return { code: nodesCmds.nodesAdd({
577
+ ...common, ssh: flags.ssh, remotePort: flags['remote-port'],
578
+ key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
579
+ }).code };
580
+ case undefined:
581
+ case 'list':
582
+ return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
583
+ case 'remove':
584
+ return { code: nodesCmds.nodesRemove(common).code };
585
+ case 'up':
586
+ return { code: nodesCmds.nodesUp(common).code };
587
+ case 'down':
588
+ return { code: nodesCmds.nodesDown(common).code };
589
+ case 'restart':
590
+ return { code: nodesCmds.nodesRestart(common).code };
591
+ case 'set-token':
592
+ return { code: nodesCmds.nodesSetToken(common).code };
593
+ case 'test': {
594
+ // async: tieni vivo il processo finche' il probe non risolve, poi exit.
595
+ const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
596
+ nodesCmds.nodesTest(common)
597
+ .then((r) => exit(r.code))
598
+ .catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
599
+ return { code: 0, keepAlive: true };
600
+ }
601
+ default:
602
+ log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
603
+ return { code: 1 };
604
+ }
605
+ }
606
+
607
+ // node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
608
+ function dispatchNode(rest, flags, opts) {
609
+ const log = opts.log;
610
+ const sub = rest[1];
611
+ if (sub === 'on') {
612
+ return { code: nodesCmds.nodeOn({
613
+ ...opts, rendezvousSsh: flags.rendezvous,
614
+ publishedPort: flags['published-port'], key: flags.key,
615
+ }).code };
616
+ }
617
+ if (sub === 'off') {
618
+ return { code: nodesCmds.nodeOff({ ...opts }).code };
619
+ }
620
+ log('usage: nexuscrew node on|off');
621
+ return { code: 1 };
622
+ }
623
+
624
+ module.exports = {
625
+ dispatch, serve, start, stop, status, parseFlags, HELP,
626
+ smartUp, url, tokenRotate, logs, doctor, update, restart,
627
+ isServiceRunning, readRoles,
628
+ runFleetBoot, dispatchFleetBoot,
629
+ };