@mmmbuto/nexuscrew 0.7.6 → 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 (59) hide show
  1. package/README.md +173 -18
  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 +462 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +319 -0
  12. package/lib/cli/init.js +107 -7
  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 +59 -2
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/boot.js +62 -0
  22. package/lib/fleet/builtin.js +594 -0
  23. package/lib/fleet/definitions.js +410 -0
  24. package/lib/fleet/index.js +49 -10
  25. package/lib/fleet/managed.js +211 -0
  26. package/lib/fleet/provider.js +102 -0
  27. package/lib/fleet/routes.js +78 -8
  28. package/lib/fs/routes.js +56 -0
  29. package/lib/mcp/server.js +362 -0
  30. package/lib/nodes/commands.js +396 -0
  31. package/lib/nodes/store.js +358 -0
  32. package/lib/nodes/tunnel-supervisor.js +102 -0
  33. package/lib/nodes/tunnel.js +300 -0
  34. package/lib/notify/asks.js +150 -0
  35. package/lib/notify/events.js +59 -0
  36. package/lib/notify/notifier.js +37 -0
  37. package/lib/notify/persist.js +73 -0
  38. package/lib/notify/push.js +289 -0
  39. package/lib/notify/routes.js +260 -0
  40. package/lib/proxy/node-proxy.js +292 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +214 -15
  44. package/lib/settings/routes.js +473 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
  51. package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
  52. package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
  53. package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
  54. package/frontend/dist/assets/index-DE67kR0M.js +0 -81
  55. package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
  56. package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
  57. package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
  58. package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
  59. package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
@@ -7,28 +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
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)
21
48
 
22
49
  Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
23
- 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.`;
24
52
 
25
- 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);
26
58
  const flags = {};
27
59
  const rest = [];
28
- for (const a of argv) {
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const a = argv[i];
29
62
  if (a.startsWith('--')) {
30
- const [k, v] = a.slice(2).split('=');
31
- 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
+ }
32
74
  } else rest.push(a);
33
75
  }
34
76
  return { flags, rest };
@@ -67,8 +109,22 @@ function start(opts = {}) {
67
109
  return { platform, started: true };
68
110
  }
69
111
  if (platform === 'mac') {
70
- const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
71
- 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
+ }
72
128
  log(`start: launchctl kickstart ${label}`);
73
129
  return { platform, started: true };
74
130
  }
@@ -107,9 +163,16 @@ function stop(opts = {}) {
107
163
  }
108
164
  if (platform === 'mac') {
109
165
  const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
110
- execImpl('launchctl', ['kill', 'SIGTERM', label], { stdio: 'ignore' });
111
- log(`stop: launchctl kill SIGTERM ${label}`);
112
- 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
+ }
113
176
  }
114
177
  if (platform === 'termux') {
115
178
  // kill via pidfile verificato (no broad match) + wake-lock-release
@@ -122,13 +185,43 @@ function stop(opts = {}) {
122
185
  throw new Error(`stop: platform ${platform} non supportata`);
123
186
  }
124
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
+
125
216
  function status(opts = {}) {
126
217
  const platform = opts.platform || detectPlatform();
127
218
  const execImpl = opts.execImpl || execFileSync;
128
219
  const log = opts.log || console.log;
129
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');
130
223
 
131
- 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: [] };
132
225
 
133
226
  if (platform === 'linux') {
134
227
  try {
@@ -150,27 +243,262 @@ function status(opts = {}) {
150
243
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
151
244
  }
152
245
 
153
- // 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).
154
252
  try {
155
- const { loadConfig } = require('../config.js');
156
- const cfg = loadConfig();
157
- out.port = cfg.port;
158
- out.url = `http://127.0.0.1:${cfg.port}/`;
159
- } 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 */ }
160
267
 
161
- 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
+ }
162
280
  return out;
163
281
  }
164
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
+
444
+ // B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
445
+ // dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
446
+ // viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
447
+ // esce col code risultante. runFleetBoot e' la unit testabile (no process.exit);
448
+ // dispatchFleetBoot e' il thin glue. Seam iniettabili: selectProvider / loadConfig
449
+ // / cfg / exit (per test, no process.exit che uccide il runner).
450
+ async function runFleetBoot(opts = {}) {
451
+ const log = opts.log || console.log;
452
+ const loadConfig = opts.loadConfig || require('../config.js').loadConfig;
453
+ const selectProvider = opts.selectProvider || require('../fleet/provider.js').selectProvider;
454
+ const { bootCells } = require('../fleet/boot.js');
455
+ const cfg = opts.cfg || loadConfig();
456
+
457
+ const sel = await selectProvider(cfg);
458
+ if (sel.mode === 'external') {
459
+ log('fleet-boot: boot gestito dal fleet esterno (nessuna azione del companion)');
460
+ return { code: 0, mode: 'external' };
461
+ }
462
+ if (sel.mode !== 'builtin' || !sel.fleet || !sel.fleet.available) {
463
+ // disabled (o builtin unavailable): niente da avviare, non e' un errore.
464
+ log(`fleet-boot: nessun boot (provider ${sel.mode}${sel.reason ? ' — ' + sel.reason : ''})`);
465
+ return { code: 0, mode: sel.mode };
466
+ }
467
+
468
+ // builtin: up() per ogni cella boot:true. READONLY fa fallire le up con 403 ->
469
+ // raccolte in failed[] -> exit 1 (nessun short-circuit speciale, design §9d).
470
+ const res = await bootCells(sel.fleet, { log });
471
+ log(`fleet-boot: ${res.started.length} started, ${res.skipped.length} skipped, ${res.failed.length} failed`);
472
+ for (const f of res.failed) log(` failed: ${f.cell} — ${f.reason}`);
473
+ return { code: res.failed.length ? 1 : 0, mode: 'builtin', summary: res };
474
+ }
475
+
476
+ function dispatchFleetBoot(opts) {
477
+ const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
478
+ runFleetBoot(opts)
479
+ .then((r) => exit(r.code))
480
+ .catch((e) => {
481
+ (opts.log || console.log)(`fleet-boot: error — ${e && e.message ? e.message : e}`);
482
+ exit(1);
483
+ });
484
+ return { code: 0, keepAlive: true }; // il processo resta vivo finche' runFleetBoot non chiama exit()
485
+ }
486
+
165
487
  function dispatch(argv, opts = {}) {
166
- const { flags, rest } = parseFlags(argv);
488
+ const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
167
489
  const cmd = rest[0];
168
490
  const log = opts.log || console.log;
169
491
 
170
- if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
492
+ // help esplicito
493
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
171
494
  log(HELP);
172
495
  return { code: 0 };
173
496
  }
497
+ // nexuscrew nudo = smart-up (design §3.2)
498
+ if (!cmd) {
499
+ smartUp({ ...opts, log });
500
+ return { code: 0 };
501
+ }
174
502
  if (cmd === 'init') {
175
503
  runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
176
504
  return { code: 0 };
@@ -179,20 +507,123 @@ function dispatch(argv, opts = {}) {
179
507
  serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
180
508
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
181
509
  }
182
- if (cmd === 'start') {
183
- start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform: opts.platform, uid: opts.uid });
184
- 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 };
185
514
  }
186
- if (cmd === 'stop') {
187
- stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
188
- 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 };
189
518
  }
190
519
  if (cmd === 'status') {
191
- status({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid, home: opts.home });
520
+ status({ ...opts, json: !!flags.json, log });
521
+ return { code: 0 };
522
+ }
523
+ if (cmd === 'url') {
524
+ url({ ...opts, qr: !!flags.qr, log });
192
525
  return { code: 0 };
193
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
+ }
555
+ if (cmd === 'fleet-boot') {
556
+ return dispatchFleetBoot({ ...opts, log });
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
+ }
194
564
  log(`unknown command: ${cmd}\n\n${HELP}`);
195
565
  return { code: 1 };
196
566
  }
197
567
 
198
- module.exports = { dispatch, serve, start, stop, status, parseFlags, HELP };
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
+ };