@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -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-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -6,30 +6,57 @@
6
6
  const { execFileSync, spawn } = require('node:child_process');
7
7
  const fs = require('node:fs');
8
8
  const path = require('node:path');
9
+ const net = require('node:net');
9
10
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
11
+ const { installPath: serviceInstallPath } = require('./service.js');
10
12
  const pidf = require('./pidfile.js');
11
13
  const { runInit } = require('./init.js');
14
+ const { rotateToken } = require('../auth/token.js');
15
+ const urlmod = require('./url.js');
16
+ const { doctor } = require('./doctor.js');
17
+ const nodesCmds = require('../nodes/commands.js');
18
+ const nodesStore = require('../nodes/store.js');
19
+ const nodesTunnel = require('../nodes/tunnel.js');
12
20
 
13
- const HELP = `NexusCrew (portable) browser tmux client.
21
+ // Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
22
+ // Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
23
+ const VALUE_FLAGS = new Set([
24
+ 'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
25
+ ]);
26
+
27
+ const HELP = `NexusCrew — PWA for local and remote AI workers.
14
28
 
15
29
  Usage:
16
- nexuscrew init [--dry-run] [--port N] setup: detect + config + token + service + URL
17
- 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
21
- nexuscrew fleet-boot companion di boot: avvia le celle boot:true (config-driven)
30
+ nexuscrew start in background; first run opens the PWA wizard
31
+ nexuscrew show start when needed and open the authenticated PWA
32
+ nexuscrew doctor run local diagnostics
33
+ nexuscrew help show this help
34
+ nexuscrew version show the installed version
22
35
 
23
- Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
24
- Bind loopback 127.0.0.1 raggiungibile via tunnel SSH/VPN.`;
36
+ Configuration, nodes, engines, providers, models and lifecycle live in the PWA.
37
+ NexusCrew binds only to 127.0.0.1 and automatically selects a free port.`;
25
38
 
26
- function parseFlags(argv) {
39
+ // valueFlags (Set|array opzionale): flag che consumano il token successivo nella
40
+ // forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
41
+ function parseFlags(argv, valueFlags) {
42
+ const vf = valueFlags instanceof Set ? valueFlags
43
+ : (Array.isArray(valueFlags) ? new Set(valueFlags) : null);
27
44
  const flags = {};
28
45
  const rest = [];
29
- for (const a of argv) {
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const a = argv[i];
30
48
  if (a.startsWith('--')) {
31
- const [k, v] = a.slice(2).split('=');
32
- flags[k] = v !== undefined ? v : true;
49
+ const eq = a.indexOf('=');
50
+ if (eq >= 0) {
51
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
52
+ } else {
53
+ const key = a.slice(2);
54
+ if (vf && vf.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
55
+ flags[key] = argv[i + 1]; i += 1; // consuma il valore (forma "--k v")
56
+ } else {
57
+ flags[key] = true;
58
+ }
59
+ }
33
60
  } else rest.push(a);
34
61
  }
35
62
  return { flags, rest };
@@ -68,8 +95,22 @@ function start(opts = {}) {
68
95
  return { platform, started: true };
69
96
  }
70
97
  if (platform === 'mac') {
71
- const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
72
- execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
98
+ const domain = `gui/${opts.uid || uid()}`;
99
+ const label = `${domain}/com.mmmbuto.nexuscrew`;
100
+ try {
101
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
102
+ } catch (_) {
103
+ try {
104
+ const home = opts.home || require('node:os').homedir();
105
+ const plist = opts.installPath || serviceInstallPath('mac', home);
106
+ execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
107
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
108
+ } catch (e) {
109
+ const reason = String(e.message || e);
110
+ log(`start: launchctl fallito: ${reason}`);
111
+ return { platform, started: false, reason };
112
+ }
113
+ }
73
114
  log(`start: launchctl kickstart ${label}`);
74
115
  return { platform, started: true };
75
116
  }
@@ -108,9 +149,16 @@ function stop(opts = {}) {
108
149
  }
109
150
  if (platform === 'mac') {
110
151
  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 };
152
+ try {
153
+ // bootout scarica il job: KeepAlive non puo' rianimare uno stop volontario.
154
+ execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
155
+ log(`stop: launchctl bootout ${label}`);
156
+ return { platform, stopped: true };
157
+ } catch (e) {
158
+ const reason = String(e.message || e);
159
+ log(`stop: launchctl bootout fallito: ${reason}`);
160
+ return { platform, stopped: false, reason };
161
+ }
114
162
  }
115
163
  if (platform === 'termux') {
116
164
  // kill via pidfile verificato (no broad match) + wake-lock-release
@@ -123,13 +171,43 @@ function stop(opts = {}) {
123
171
  throw new Error(`stop: platform ${platform} non supportata`);
124
172
  }
125
173
 
174
+ // running-check per-platform (no log) — riusato da smart-up / token rotate / update.
175
+ function isServiceRunning(opts = {}) {
176
+ const platform = opts.platform || detectPlatform();
177
+ const execImpl = opts.execImpl || execFileSync;
178
+ if (platform === 'linux') {
179
+ try { return execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'active'; }
180
+ catch (_) { return false; }
181
+ }
182
+ if (platform === 'mac') {
183
+ try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
184
+ catch (_) { return false; }
185
+ }
186
+ if (platform === 'termux') {
187
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath());
188
+ return !!(meta && pidf.isAlive(meta));
189
+ }
190
+ return false;
191
+ }
192
+
193
+ // Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
194
+ function readRoles(configPath) {
195
+ try {
196
+ const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
197
+ const r = c && c.roles;
198
+ return { client: !!(r && r.client), node: !!(r && r.node) };
199
+ } catch (_) { return { client: false, node: false }; }
200
+ }
201
+
126
202
  function status(opts = {}) {
127
203
  const platform = opts.platform || detectPlatform();
128
204
  const execImpl = opts.execImpl || execFileSync;
129
205
  const log = opts.log || console.log;
130
206
  const home = opts.home || require('node:os').homedir();
207
+ const { configPath, configDir } = urlmod.resolvePaths(opts);
208
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
131
209
 
132
- const out = { platform, service: null, running: null, port: null, url: null };
210
+ const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
133
211
 
134
212
  if (platform === 'linux') {
135
213
  try {
@@ -151,18 +229,315 @@ function status(opts = {}) {
151
229
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
152
230
  }
153
231
 
154
- // porta da config.json
232
+ out.port = urlmod.loadPort(opts);
233
+ out.url = urlmod.buildUrl(out.port, null, { withToken: false }); // MAI il token in status
234
+ out.roles = readRoles(configPath);
235
+
236
+ // nodes[] con stato tunnel REALE. Token per-nodo SEMPRE redatti (mai in status).
237
+ // nodes.json assente/invalido -> nodes [] (non fa fallire status).
155
238
  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 (_) {}
239
+ const st = nodesStore.loadStore(nodesPath);
240
+ if (st) {
241
+ out.nodeId = st.nodeId;
242
+ out.nodes = st.nodes.map((n) => {
243
+ const red = nodesStore.redactNode(n); // hasToken, mai il token
244
+ return {
245
+ name: red.name, roles: red.roles,
246
+ remotePort: red.remotePort, localPort: red.localPort,
247
+ hasToken: red.hasToken,
248
+ tunnel: nodesTunnel.readTunnelState(home, n.name),
249
+ };
250
+ });
251
+ }
252
+ } catch (_) { /* nodes opzionale: non rompe status */ }
161
253
 
162
- log(JSON.stringify(out, null, 2));
254
+ if (opts.json) {
255
+ log(JSON.stringify(out, null, 2));
256
+ } else {
257
+ log(`platform: ${out.platform}`);
258
+ log(`service: ${out.service}`);
259
+ log(`running: ${out.running}`);
260
+ log(`port: ${out.port}`);
261
+ log(`url: ${out.url}`);
262
+ log(`roles: client=${out.roles.client} node=${out.roles.node}`);
263
+ log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
264
+ if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
265
+ }
163
266
  return out;
164
267
  }
165
268
 
269
+ // restart per-platform (shared da token rotate / update). execImpl per test.
270
+ function restart(opts = {}) {
271
+ const platform = opts.platform || detectPlatform();
272
+ const execImpl = opts.execImpl || execFileSync;
273
+ const log = opts.log || console.log;
274
+ if (platform === 'linux') {
275
+ execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
276
+ log('restart: systemctl --user restart nexuscrew');
277
+ return { platform, restarted: true };
278
+ }
279
+ if (platform === 'mac') {
280
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
281
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
282
+ log(`restart: launchctl kickstart -k ${label}`);
283
+ return { platform, restarted: true };
284
+ }
285
+ if (platform === 'termux') {
286
+ stop({ execImpl, log, platform, uid: opts.uid });
287
+ start({ execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
288
+ return { platform, restarted: true };
289
+ }
290
+ throw new Error(`restart: platform ${platform} non supportata`);
291
+ }
292
+
293
+ function wizardComplete(configPath) {
294
+ try {
295
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
296
+ return cfg && cfg.wizardDone === true;
297
+ } catch (_) { return false; }
298
+ }
299
+
300
+ function servicePinsLegacyPort(platform, home, installPath) {
301
+ let target;
302
+ try { target = installPath || serviceInstallPath(platform, home); } catch (_) { return false; }
303
+ try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
304
+ }
305
+
306
+ function portAvailable(port, host = '127.0.0.1') {
307
+ return new Promise((resolve) => {
308
+ const s = net.createServer();
309
+ s.unref();
310
+ s.once('error', () => resolve(false));
311
+ s.listen(port, host, () => s.close(() => resolve(true)));
312
+ });
313
+ }
314
+
315
+ async function findAvailablePort(startPort, opts = {}) {
316
+ const available = opts.portAvailableImpl || portAvailable;
317
+ const first = Number(startPort);
318
+ if (!Number.isInteger(first) || first < 1 || first > 65535) throw new Error(`invalid port: ${startPort}`);
319
+ for (let port = first; port <= Math.min(65535, first + 200); port += 1) {
320
+ if (await available(port, '127.0.0.1')) return port;
321
+ }
322
+ throw new Error(`no free loopback port found from ${first}`);
323
+ }
324
+
325
+ async function probeNexusCrew(port, token, opts = {}) {
326
+ if (!token) return false;
327
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
328
+ if (typeof fetchImpl !== 'function') return false;
329
+ try {
330
+ const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
331
+ headers: { authorization: `Bearer ${token}` },
332
+ signal: AbortSignal.timeout?.(700),
333
+ });
334
+ return r.status === 200;
335
+ } catch (_) { return false; }
336
+ }
337
+
338
+ async function waitForNexusCrew(port, token, opts = {}) {
339
+ const probe = opts.probeImpl || probeNexusCrew;
340
+ const attempts = opts.waitAttempts === undefined ? 30 : opts.waitAttempts;
341
+ const delay = opts.waitDelayMs === undefined ? 100 : opts.waitDelayMs;
342
+ for (let i = 0; i < attempts; i += 1) {
343
+ if (await probe(port, token, opts)) return true;
344
+ if (i + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, delay));
345
+ }
346
+ return false;
347
+ }
348
+
349
+ function startPortable(opts = {}) {
350
+ const spawnImpl = opts.spawnImpl || spawn;
351
+ const home = opts.home || require('node:os').homedir();
352
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
353
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
354
+ const logFd = fs.openSync(logPath, 'a');
355
+ const child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
356
+ detached: true, stdio: ['ignore', logFd, logFd],
357
+ });
358
+ if (child && typeof child.unref === 'function') child.unref();
359
+ return { started: true, pid: child && child.pid, portable: true };
360
+ }
361
+
362
+ function openPwa(fullUrl, opts = {}) {
363
+ if (opts.openImpl) return opts.openImpl(fullUrl);
364
+ const platform = opts.platform || detectPlatform();
365
+ const commandExists = opts.commandExists || require('./path.js').commandExists;
366
+ const candidates = platform === 'termux'
367
+ ? ['termux-open-url']
368
+ : platform === 'mac' ? ['open'] : ['xdg-open', 'gio'];
369
+ const bin = candidates.find((candidate) => commandExists(candidate, opts.env || process.env));
370
+ if (!bin) throw new Error('no URL opener found; install termux-tools or xdg-utils');
371
+ const args = bin === 'gio' ? ['open', fullUrl] : [fullUrl];
372
+ const child = (opts.spawnImpl || spawn)(bin, args, { detached: true, stdio: 'ignore' });
373
+ if (child && typeof child.on === 'function') child.on('error', () => {});
374
+ if (child && typeof child.unref === 'function') child.unref();
375
+ return true;
376
+ }
377
+
378
+ // Normal entry point. It is deliberately quiet and returns after starting the
379
+ // background service. Only first-run opens the wizard; `show` always opens it.
380
+ async function smartUp(opts = {}) {
381
+ const quiet = opts.lifecycleLog || (() => {});
382
+ const platform = opts.platform || detectPlatform();
383
+ const runInitImpl = opts.runInitImpl || runInit;
384
+ const startImpl = opts.startImpl || start;
385
+ const restartImpl = opts.restartImpl || restart;
386
+ const probe = opts.probeImpl || probeNexusCrew;
387
+ const { configPath, tokenPath } = urlmod.resolvePaths(opts);
388
+ let initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
389
+ let port;
390
+
391
+ if (!initialized) {
392
+ const requested = opts.port || urlmod.loadPort(opts);
393
+ const selected = await findAvailablePort(requested, opts);
394
+ runInitImpl({ ...opts, port: selected, log: quiet, platform, printUrl: false });
395
+ port = selected;
396
+ initialized = true;
397
+ }
398
+
399
+ // 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
400
+ // config.json forever. Regenerate once so config.json becomes authoritative.
401
+ const home = opts.home || require('node:os').homedir();
402
+ if (servicePinsLegacyPort(platform, home, opts.installPath)) {
403
+ runInitImpl({ ...opts, log: quiet, platform, printUrl: false });
404
+ }
405
+
406
+ if (!port) port = urlmod.loadPort(opts);
407
+ let token = urlmod.readToken(tokenPath);
408
+ let running = await probe(port, token, opts);
409
+ let portableAttempted = false;
410
+ const managedRunning = isServiceRunning({ ...opts, platform });
411
+ if (!running && managedRunning) running = await waitForNexusCrew(port, token, opts);
412
+
413
+ if (!running) {
414
+ if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
415
+ port = await findAvailablePort(port + 1, opts);
416
+ runInitImpl({ ...opts, port, log: quiet, platform, printUrl: false });
417
+ token = urlmod.readToken(tokenPath);
418
+ } else {
419
+ try {
420
+ const started = managedRunning
421
+ ? restartImpl({ ...opts, platform, log: quiet })
422
+ : startImpl({ ...opts, platform, log: quiet });
423
+ if (started && started.started === false) {
424
+ startPortable({ ...opts, platform }); portableAttempted = true;
425
+ }
426
+ } catch (_) {
427
+ startPortable({ ...opts, platform }); portableAttempted = true;
428
+ }
429
+ }
430
+ running = await waitForNexusCrew(port, token, opts);
431
+ if (!running && !portableAttempted) {
432
+ try { startPortable({ ...opts, platform }); portableAttempted = true; } catch (_) {}
433
+ running = await waitForNexusCrew(port, token, opts);
434
+ }
435
+ if (!running) throw new Error(`server did not become ready on 127.0.0.1:${port}`);
436
+ }
437
+
438
+ const shouldOpen = !!opts.forceOpen || !wizardComplete(configPath);
439
+ if (shouldOpen) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
440
+ return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
441
+ }
442
+
443
+ // url [--qr]: ristampa URL completo con #token — UNICO comando che mostra il token.
444
+ function url(opts = {}) {
445
+ const log = opts.log || console.log;
446
+ const { tokenPath } = urlmod.resolvePaths(opts);
447
+ const port = urlmod.loadPort(opts);
448
+ const token = urlmod.readToken(tokenPath);
449
+ const full = urlmod.buildUrl(port, token, { withToken: true });
450
+ log(full);
451
+ if (opts.qr) {
452
+ if (token) log(urlmod.renderQr(full, { qrcode: opts.qrcode }));
453
+ else log('url: nessun token (esegui init) — QR non generato');
454
+ }
455
+ return { url: full, port, hasToken: !!token };
456
+ }
457
+
458
+ // token rotate (§4b(3)): scrittura atomica nuovo token + invalidazione reale delle
459
+ // sessioni attive (restart se il service e' attivo). Il nuovo token NON si stampa.
460
+ function tokenRotate(opts = {}) {
461
+ const log = opts.log || console.log;
462
+ const platform = opts.platform || detectPlatform();
463
+ const { tokenPath } = urlmod.resolvePaths(opts);
464
+ const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
465
+ if (readonly) {
466
+ log('token rotate: READONLY, rotazione bloccata');
467
+ return { rotated: false, reason: 'readonly' };
468
+ }
469
+ rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
470
+ log(`token: nuovo segreto scritto (${tokenPath})`);
471
+ const running = isServiceRunning({ ...opts, platform });
472
+ if (running) {
473
+ restart({ ...opts, platform, log });
474
+ log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
475
+ } else {
476
+ // il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
477
+ // vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
478
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath());
479
+ if (meta && pidf.isAlive(meta)) {
480
+ log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
481
+ } else {
482
+ log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
483
+ }
484
+ }
485
+ log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
486
+ return { rotated: true, running };
487
+ }
488
+
489
+ // logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
490
+ function logs(opts = {}) {
491
+ const platform = opts.platform || detectPlatform();
492
+ const spawnImpl = opts.spawnImpl || spawn;
493
+ const log = opts.log || console.log;
494
+ const home = opts.home || require('node:os').homedir();
495
+ const follow = !!opts.follow;
496
+
497
+ let bin; let args;
498
+ if (platform === 'linux') {
499
+ bin = 'journalctl';
500
+ args = ['--user', '-u', 'nexuscrew', '--no-pager'];
501
+ if (follow) args.push('-f');
502
+ } else {
503
+ // mac (launchd StandardOutPath) / termux (nohup logfile): stesso path.
504
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
505
+ bin = 'tail';
506
+ args = follow ? ['-f', logPath] : ['-n', '200', logPath];
507
+ }
508
+ log(`logs: ${bin} ${args.join(' ')}`);
509
+ const child = spawnImpl(bin, args, { stdio: 'inherit' });
510
+ // passthrough: quando il figlio esce (o subito, se non-follow), esce anche la CLI.
511
+ if (child && typeof child.on === 'function') {
512
+ child.on('exit', (code) => process.exit(code || 0));
513
+ }
514
+ return { platform, bin, args, follow, keepAlive: true };
515
+ }
516
+
517
+ // update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
518
+ function update(opts = {}) {
519
+ const execImpl = opts.execImpl || execFileSync;
520
+ const log = opts.log || console.log;
521
+ const platform = opts.platform || detectPlatform();
522
+ const pkg = '@mmmbuto/nexuscrew@latest';
523
+ try {
524
+ execImpl('npm', ['i', '-g', pkg], { stdio: 'inherit' });
525
+ log(`update: ${pkg} installato`);
526
+ } catch (e) {
527
+ log(`update: npm install fallito — ${e && e.message ? e.message : e}`);
528
+ log('update: controlla npm/rete/permessi globali, poi riprova: npm i -g @mmmbuto/nexuscrew@latest');
529
+ return { updated: false, error: String(e && e.message ? e.message : e), code: 1 };
530
+ }
531
+ const running = isServiceRunning({ ...opts, platform });
532
+ if (running) {
533
+ restart({ ...opts, platform, log });
534
+ log('update: servizio riavviato sul nuovo codice');
535
+ } else {
536
+ log('update: servizio non attivo (nessun restart)');
537
+ }
538
+ return { updated: true, running, code: 0 };
539
+ }
540
+
166
541
  // B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
167
542
  // dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
168
543
  // viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
@@ -207,39 +582,123 @@ function dispatchFleetBoot(opts) {
207
582
  }
208
583
 
209
584
  function dispatch(argv, opts = {}) {
210
- const { flags, rest } = parseFlags(argv);
211
- const cmd = rest[0];
212
585
  const log = opts.log || console.log;
586
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
587
+ log(HELP);
588
+ return { code: 0 };
589
+ }
590
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
591
+ log(require('../../package.json').version);
592
+ return { code: 0 };
593
+ }
594
+ const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
595
+ const cmd = rest[0];
213
596
 
214
- if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
597
+ // help esplicito
598
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
215
599
  log(HELP);
216
600
  return { code: 0 };
217
601
  }
218
- if (cmd === 'init') {
219
- runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
602
+ // Public surface: normal start, show, doctor, help, version.
603
+ if (!cmd) {
604
+ if (Object.keys(flags).length) {
605
+ log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
606
+ return { code: 1 };
607
+ }
608
+ return smartUp({ ...opts, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
609
+ }
610
+ if (cmd === 'show') {
611
+ return smartUp({ ...opts, forceOpen: true, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
612
+ }
613
+ if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
614
+ log(require('../../package.json').version);
220
615
  return { code: 0 };
221
616
  }
617
+ // Internal runtime commands used by service managers and MCP clients. They
618
+ // are intentionally omitted from HELP and are not configuration surfaces.
222
619
  if (cmd === 'serve') {
223
620
  serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
224
621
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
225
622
  }
226
- if (cmd === 'start') {
227
- start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform: opts.platform, uid: opts.uid });
228
- return { code: 0 };
229
- }
230
- if (cmd === 'stop') {
231
- stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
232
- return { code: 0 };
623
+ if (cmd === 'doctor') {
624
+ const r = doctor({ ...opts, log });
625
+ return { code: r.code };
233
626
  }
234
- if (cmd === 'status') {
235
- status({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid, home: opts.home });
236
- return { code: 0 };
627
+ if (cmd === 'mcp') {
628
+ // Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
629
+ // NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
630
+ const startMcpImpl = opts.startMcpImpl || require('../mcp/server.js').startMcp;
631
+ startMcpImpl();
632
+ return { code: 0, keepAlive: true };
237
633
  }
238
634
  if (cmd === 'fleet-boot') {
239
635
  return dispatchFleetBoot({ ...opts, log });
240
636
  }
241
- log(`unknown command: ${cmd}\n\n${HELP}`);
637
+ log(`"${cmd}" is not a public CLI command. Open the PWA with: nexuscrew show\n\n${HELP}`);
638
+ return { code: 1 };
639
+ }
640
+
641
+ // nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
642
+ function dispatchNodes(rest, flags, opts) {
643
+ const log = opts.log;
644
+ const sub = rest[1];
645
+ const name = rest[2];
646
+ const common = { ...opts, name };
647
+ switch (sub) {
648
+ case 'add':
649
+ return { code: nodesCmds.nodesAdd({
650
+ ...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
651
+ key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
652
+ }).code };
653
+ case undefined:
654
+ case 'list':
655
+ return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
656
+ case 'remove':
657
+ return { code: nodesCmds.nodesRemove(common).code };
658
+ case 'up':
659
+ return { code: nodesCmds.nodesUp(common).code };
660
+ case 'down':
661
+ return { code: nodesCmds.nodesDown(common).code };
662
+ case 'restart':
663
+ return { code: nodesCmds.nodesRestart(common).code };
664
+ case 'set-token':
665
+ return { code: nodesCmds.nodesSetToken(common).code };
666
+ case 'test': {
667
+ // async: tieni vivo il processo finche' il probe non risolve, poi exit.
668
+ const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
669
+ nodesCmds.nodesTest(common)
670
+ .then((r) => exit(r.code))
671
+ .catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
672
+ return { code: 0, keepAlive: true };
673
+ }
674
+ default:
675
+ log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
676
+ return { code: 1 };
677
+ }
678
+ }
679
+
680
+ // node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
681
+ function dispatchNode(rest, flags, opts) {
682
+ const log = opts.log;
683
+ const sub = rest[1];
684
+ if (sub === 'on') {
685
+ return { code: nodesCmds.nodeOn({
686
+ ...opts, rendezvousSsh: flags.rendezvous,
687
+ publishedPort: flags['published-port'], key: flags.key,
688
+ }).code };
689
+ }
690
+ if (sub === 'off') {
691
+ return { code: nodesCmds.nodeOff({ ...opts }).code };
692
+ }
693
+ log('usage: nexuscrew node on|off');
242
694
  return { code: 1 };
243
695
  }
244
696
 
245
- module.exports = { dispatch, serve, start, stop, status, parseFlags, HELP, runFleetBoot, dispatchFleetBoot };
697
+ module.exports = {
698
+ dispatch, serve, start, stop, status, parseFlags, HELP,
699
+ smartUp, url, tokenRotate, logs, doctor, update, restart,
700
+ portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
701
+ servicePinsLegacyPort,
702
+ isServiceRunning, readRoles,
703
+ runFleetBoot, dispatchFleetBoot,
704
+ };