@mmmbuto/nexuscrew 0.8.0 → 0.8.3

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.
@@ -6,8 +6,10 @@
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');
10
11
  const { installPath: serviceInstallPath } = require('./service.js');
12
+ const { fleetInstallPath } = require('./fleet-service.js');
11
13
  const pidf = require('./pidfile.js');
12
14
  const { runInit } = require('./init.js');
13
15
  const { rotateToken } = require('../auth/token.js');
@@ -20,35 +22,22 @@ const nodesTunnel = require('../nodes/tunnel.js');
20
22
  // Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
21
23
  // Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
22
24
  const VALUE_FLAGS = new Set([
23
- 'ssh', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
25
+ 'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
24
26
  ]);
25
27
 
26
- const HELP = `NexusCrew (portable) browser tmux client.
28
+ const HELP = `NexusCrew — PWA for local and remote AI workers.
27
29
 
28
30
  Usage:
29
- nexuscrew smart-up: init se serve -> start -> URL + QR
30
- nexuscrew init [--dry-run] [--port N] setup: detect + config + token + service + URL
31
- nexuscrew serve [--pidfile] HTTP server foreground (dev / ExecStart)
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)
48
-
49
- Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
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.`;
31
+ nexuscrew start in background; show status and quick guide
32
+ nexuscrew show start when needed and open the authenticated PWA
33
+ nexuscrew show token print the clickable authenticated URL
34
+ nexuscrew boot enable startup at boot (use: boot off|status)
35
+ nexuscrew doctor run local diagnostics
36
+ nexuscrew help show this help
37
+ nexuscrew version show the installed version
38
+
39
+ Configuration, nodes, engines, providers, models and lifecycle live in the PWA.
40
+ NexusCrew binds only to 127.0.0.1 and automatically selects a free port.`;
52
41
 
53
42
  // valueFlags (Set|array opzionale): flag che consumano il token successivo nella
54
43
  // forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
@@ -79,7 +68,7 @@ function parseFlags(argv, valueFlags) {
79
68
  function serve(opts = {}) {
80
69
  const serverStart = opts.serverStart || require('../server.js').start;
81
70
  if (opts.pidfile) {
82
- const pidPath = pidf.defaultPidfilePath();
71
+ const pidPath = pidf.defaultPidfilePath(opts.home);
83
72
  // already-running check
84
73
  const meta = pidf.readPidfile(pidPath);
85
74
  if (meta && pidf.isAlive(meta)) {
@@ -129,8 +118,9 @@ function start(opts = {}) {
129
118
  return { platform, started: true };
130
119
  }
131
120
  if (platform === 'termux') {
121
+ const home = opts.home || require('node:os').homedir();
132
122
  // already-running?
133
- const pidPath = pidf.defaultPidfilePath();
123
+ const pidPath = pidf.defaultPidfilePath(opts.home);
134
124
  const meta = pidf.readPidfile(pidPath);
135
125
  if (meta && pidf.isAlive(meta)) {
136
126
  log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
@@ -138,12 +128,24 @@ function start(opts = {}) {
138
128
  }
139
129
  pidf.cleanStale(pidPath);
140
130
  const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
141
- const logPath = path.join(require('node:os').homedir(), '.nexuscrew', 'nexuscrew.log');
131
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
142
132
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
143
133
  const logFd = fs.openSync(logPath, 'a');
144
- const child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
145
- detached: true, stdio: ['ignore', logFd, logFd],
146
- });
134
+ const resolved = urlmod.resolvePaths(opts);
135
+ const childEnv = {
136
+ ...process.env,
137
+ HOME: home,
138
+ NEXUSCREW_CONFIG_FILE: resolved.configPath,
139
+ NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
140
+ };
141
+ // config.json is authoritative after automatic port fallback.
142
+ delete childEnv.NEXUSCREW_PORT;
143
+ let child;
144
+ try {
145
+ child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
146
+ detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
147
+ });
148
+ } finally { try { fs.closeSync(logFd); } catch (_) {} }
147
149
  if (child && typeof child.unref === 'function') child.unref();
148
150
  log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
149
151
  return { platform, started: true, pid: child && child.pid };
@@ -176,7 +178,7 @@ function stop(opts = {}) {
176
178
  }
177
179
  if (platform === 'termux') {
178
180
  // kill via pidfile verificato (no broad match) + wake-lock-release
179
- const pidPath = pidf.defaultPidfilePath();
181
+ const pidPath = pidf.defaultPidfilePath(opts.home);
180
182
  const r = pidf.killPidfile(pidPath);
181
183
  try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
182
184
  log(`stop: ${r.killed ? `killed pid ${r.pid}` : r.reason}`);
@@ -198,12 +200,93 @@ function isServiceRunning(opts = {}) {
198
200
  catch (_) { return false; }
199
201
  }
200
202
  if (platform === 'termux') {
201
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
203
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
202
204
  return !!(meta && pidf.isAlive(meta));
203
205
  }
204
206
  return false;
205
207
  }
206
208
 
209
+ function bootState(opts = {}) {
210
+ const platform = opts.platform || detectPlatform();
211
+ const execImpl = opts.execImpl || execFileSync;
212
+ const home = opts.home || require('node:os').homedir();
213
+ if (platform === 'linux') {
214
+ try { return { platform, enabled: execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'enabled' }; }
215
+ catch (_) { return { platform, enabled: false }; }
216
+ }
217
+ try { return { platform, enabled: fs.lstatSync(serviceInstallPath(platform, home)).isFile() }; }
218
+ catch (_) { return { platform, enabled: false }; }
219
+ }
220
+
221
+ function unlinkRegular(target) {
222
+ try {
223
+ const st = fs.lstatSync(target);
224
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe boot target: ${target}`);
225
+ fs.unlinkSync(target); return true;
226
+ } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
227
+ }
228
+
229
+ async function bootOn(opts = {}) {
230
+ const platform = opts.platform || detectPlatform();
231
+ const quiet = opts.lifecycleLog || (() => {});
232
+ const { configPath, tokenPath } = urlmod.resolvePaths(opts);
233
+ if (!fs.existsSync(configPath) || !urlmod.readToken(tokenPath)) {
234
+ const selected = await findAvailablePort(opts.port || urlmod.loadPort(opts), opts);
235
+ (opts.runInitImpl || runInit)({ ...opts, platform, port: selected, installBoot: false, printUrl: false, log: quiet });
236
+ }
237
+ try { pidf.killPidfile(pidf.defaultPidfilePath(opts.home)); } catch (_) {}
238
+ (opts.runInitImpl || runInit)({ ...opts, platform, installBoot: true, printUrl: false, log: quiet });
239
+ const result = await smartUp({ ...opts, platform });
240
+ return { ...result, boot: bootState({ ...opts, platform }).enabled };
241
+ }
242
+
243
+ async function bootOff(opts = {}) {
244
+ const platform = opts.platform || detectPlatform();
245
+ const execImpl = opts.execImpl || execFileSync;
246
+ const home = opts.home || require('node:os').homedir();
247
+ if (platform === 'linux') {
248
+ try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew'], { stdio: 'ignore' }); } catch (_) {}
249
+ try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew-fleet.service'], { stdio: 'ignore' }); } catch (_) {}
250
+ } else if (platform === 'mac') {
251
+ const domain = `gui/${opts.uid || uid()}`;
252
+ try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); } catch (_) {}
253
+ try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew-fleet`], { stdio: 'ignore' }); } catch (_) {}
254
+ unlinkRegular(serviceInstallPath(platform, home));
255
+ try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
256
+ const { tokenPath } = urlmod.resolvePaths(opts); const port = urlmod.loadPort(opts); const token = urlmod.readToken(tokenPath);
257
+ if (!(await probeNexusCrew(port, token, opts))) {
258
+ startPortable({ ...opts, platform });
259
+ await waitForNexusCrew(port, token, opts);
260
+ }
261
+ } else if (platform === 'termux') {
262
+ unlinkRegular(serviceInstallPath(platform, home));
263
+ try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
264
+ }
265
+ return { platform, enabled: false, running: isServiceRunning({ ...opts, platform }) || !!pidf.readPidfile(pidf.defaultPidfilePath(home)) };
266
+ }
267
+
268
+ function quickSummary(result, opts = {}) {
269
+ const log = opts.log || console.log;
270
+ const platform = result.platform || opts.platform || detectPlatform();
271
+ const home = opts.home || require('node:os').homedir();
272
+ const { configDir } = urlmod.resolvePaths(opts);
273
+ const st = nodesStore.loadStore(opts.nodesPath || path.join(configDir, 'nodes.json'));
274
+ let cached = [];
275
+ try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
276
+ const direct = (st && st.nodes) || [];
277
+ const online = direct.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
278
+ const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
279
+ log(`NexusCrew ${require('../../package.json').version}`);
280
+ log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
281
+ log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
282
+ log(`nodes ${known} known · ${online}/${direct.length} direct tunnels up`);
283
+ log('');
284
+ log('open nexuscrew show');
285
+ log('link nexuscrew show token');
286
+ log('boot nexuscrew boot');
287
+ log('check nexuscrew doctor');
288
+ }
289
+
207
290
  // Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
208
291
  function readRoles(configPath) {
209
292
  try {
@@ -238,7 +321,7 @@ function status(opts = {}) {
238
321
  // boot-script installed vs server running (pidfile vivo)
239
322
  const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
240
323
  out.bootScriptInstalled = fs.existsSync(bootScript);
241
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
324
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
242
325
  out.running = !!(meta && pidf.isAlive(meta));
243
326
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
244
327
  }
@@ -304,43 +387,169 @@ function restart(opts = {}) {
304
387
  throw new Error(`restart: platform ${platform} non supportata`);
305
388
  }
306
389
 
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;
390
+ function wizardComplete(configPath) {
391
+ try {
392
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
393
+ return cfg && cfg.wizardDone === true;
394
+ } catch (_) { return false; }
395
+ }
396
+
397
+ function servicePinsLegacyPort(platform, home, installPath) {
398
+ let target;
399
+ try { target = installPath || serviceInstallPath(platform, home); } catch (_) { return false; }
400
+ try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
401
+ }
402
+
403
+ function portAvailable(port, host = '127.0.0.1') {
404
+ return new Promise((resolve) => {
405
+ const s = net.createServer();
406
+ s.unref();
407
+ s.once('error', () => resolve(false));
408
+ s.listen(port, host, () => s.close(() => resolve(true)));
409
+ });
410
+ }
411
+
412
+ async function findAvailablePort(startPort, opts = {}) {
413
+ const available = opts.portAvailableImpl || portAvailable;
414
+ const first = Number(startPort);
415
+ if (!Number.isInteger(first) || first < 1 || first > 65535) throw new Error(`invalid port: ${startPort}`);
416
+ for (let port = first; port <= Math.min(65535, first + 200); port += 1) {
417
+ if (await available(port, '127.0.0.1')) return port;
418
+ }
419
+ throw new Error(`no free loopback port found from ${first}`);
420
+ }
421
+
422
+ async function probeNexusCrew(port, token, opts = {}) {
423
+ if (!token) return false;
424
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
425
+ if (typeof fetchImpl !== 'function') return false;
426
+ try {
427
+ const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
428
+ headers: { authorization: `Bearer ${token}` },
429
+ signal: AbortSignal.timeout?.(700),
430
+ });
431
+ return r.status === 200;
432
+ } catch (_) { return false; }
433
+ }
434
+
435
+ async function waitForNexusCrew(port, token, opts = {}) {
436
+ const probe = opts.probeImpl || probeNexusCrew;
437
+ const attempts = opts.waitAttempts === undefined ? 30 : opts.waitAttempts;
438
+ const delay = opts.waitDelayMs === undefined ? 100 : opts.waitDelayMs;
439
+ for (let i = 0; i < attempts; i += 1) {
440
+ if (await probe(port, token, opts)) return true;
441
+ if (i + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, delay));
442
+ }
443
+ return false;
444
+ }
445
+
446
+ function startPortable(opts = {}) {
447
+ const spawnImpl = opts.spawnImpl || spawn;
448
+ const home = opts.home || require('node:os').homedir();
449
+ const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
450
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
451
+ const logFd = fs.openSync(logPath, 'a');
452
+ const resolved = urlmod.resolvePaths(opts);
453
+ const childEnv = {
454
+ ...process.env,
455
+ HOME: home,
456
+ NEXUSCREW_CONFIG_FILE: resolved.configPath,
457
+ NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
458
+ ...(opts.filesRoot ? { NEXUSCREW_FILES_ROOT: opts.filesRoot } : {}),
459
+ };
460
+ // config.json is authoritative after automatic port fallback.
461
+ delete childEnv.NEXUSCREW_PORT;
462
+ let child;
463
+ try {
464
+ child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
465
+ detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
466
+ });
467
+ } finally { try { fs.closeSync(logFd); } catch (_) {} }
468
+ if (child && typeof child.unref === 'function') child.unref();
469
+ return { started: true, pid: child && child.pid, portable: true };
470
+ }
471
+
472
+ function openPwa(fullUrl, opts = {}) {
473
+ if (opts.openImpl) return opts.openImpl(fullUrl);
474
+ const platform = opts.platform || detectPlatform();
475
+ const commandExists = opts.commandExists || require('./path.js').commandExists;
476
+ const candidates = platform === 'termux'
477
+ ? ['termux-open-url']
478
+ : platform === 'mac' ? ['open'] : ['xdg-open', 'gio'];
479
+ const bin = candidates.find((candidate) => commandExists(candidate, opts.env || process.env));
480
+ if (!bin) throw new Error('no URL opener found; install termux-tools or xdg-utils');
481
+ const args = bin === 'gio' ? ['open', fullUrl] : [fullUrl];
482
+ const child = (opts.spawnImpl || spawn)(bin, args, { detached: true, stdio: 'ignore' });
483
+ if (child && typeof child.on === 'function') child.on('error', () => {});
484
+ if (child && typeof child.unref === 'function') child.unref();
485
+ return true;
486
+ }
487
+
488
+ // Normal entry point. It is deliberately quiet and returns after starting the
489
+ // background service. Only first-run opens the wizard; `show` always opens it.
490
+ async function smartUp(opts = {}) {
491
+ const quiet = opts.lifecycleLog || (() => {});
312
492
  const platform = opts.platform || detectPlatform();
313
- const { configPath, tokenPath } = urlmod.resolvePaths(opts);
314
493
  const runInitImpl = opts.runInitImpl || runInit;
315
494
  const startImpl = opts.startImpl || start;
495
+ const restartImpl = opts.restartImpl || restart;
496
+ const portableStart = opts.startPortableImpl || startPortable;
497
+ const probe = opts.probeImpl || probeNexusCrew;
498
+ const { configPath, tokenPath } = urlmod.resolvePaths(opts);
499
+ let initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
500
+ let port;
316
501
 
317
- const initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
318
502
  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 });
503
+ const requested = opts.port || urlmod.loadPort(opts);
504
+ const selected = await findAvailablePort(requested, opts);
505
+ runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
506
+ port = selected;
507
+ initialized = true;
322
508
  }
323
509
 
324
- const running = isServiceRunning({ ...opts, platform });
510
+ // 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
511
+ // config.json forever. Regenerate once so config.json becomes authoritative.
512
+ const home = opts.home || require('node:os').homedir();
513
+ const persistent = bootState({ ...opts, platform }).enabled;
514
+ if (persistent && servicePinsLegacyPort(platform, home, opts.installPath)) {
515
+ runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
516
+ }
517
+
518
+ if (!port) port = urlmod.loadPort(opts);
519
+ let token = urlmod.readToken(tokenPath);
520
+ let running = await probe(port, token, opts);
521
+ let portableAttempted = false;
522
+ const managedRunning = isServiceRunning({ ...opts, platform });
523
+ if (!running && managedRunning) running = await waitForNexusCrew(port, token, opts);
524
+
325
525
  if (!running) {
326
- startImpl({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
327
- } else {
328
- log('smart-up: servizio gia\' attivo');
526
+ if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
527
+ port = await findAvailablePort(port + 1, opts);
528
+ runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
529
+ token = urlmod.readToken(tokenPath);
530
+ } else {
531
+ try {
532
+ const started = persistent
533
+ ? (managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
534
+ : portableStart({ ...opts, platform });
535
+ if (started && started.started === false) {
536
+ portableStart({ ...opts, platform }); portableAttempted = true;
537
+ }
538
+ } catch (_) {
539
+ portableStart({ ...opts, platform }); portableAttempted = true;
540
+ }
541
+ }
542
+ running = await waitForNexusCrew(port, token, opts);
543
+ if (!running && !portableAttempted) {
544
+ try { portableStart({ ...opts, platform }); portableAttempted = true; } catch (_) {}
545
+ running = await waitForNexusCrew(port, token, opts);
546
+ }
547
+ if (!running) throw new Error(`server did not become ready on 127.0.0.1:${port}`);
329
548
  }
330
549
 
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 };
550
+ const shouldOpen = !opts.noOpen && (!!opts.forceOpen || !wizardComplete(configPath));
551
+ if (shouldOpen) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
552
+ return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
344
553
  }
345
554
 
346
555
  // url [--qr]: ristampa URL completo con #token — UNICO comando che mostra il token.
@@ -378,7 +587,7 @@ function tokenRotate(opts = {}) {
378
587
  } else {
379
588
  // il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
380
589
  // vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
381
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
590
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
382
591
  if (meta && pidf.isAlive(meta)) {
383
592
  log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
384
593
  } else {
@@ -485,66 +694,64 @@ function dispatchFleetBoot(opts) {
485
694
  }
486
695
 
487
696
  function dispatch(argv, opts = {}) {
697
+ const log = opts.log || console.log;
698
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
699
+ log(HELP);
700
+ return { code: 0 };
701
+ }
702
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
703
+ log(require('../../package.json').version);
704
+ return { code: 0 };
705
+ }
488
706
  const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
489
707
  const cmd = rest[0];
490
- const log = opts.log || console.log;
491
708
 
492
709
  // help esplicito
493
710
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
494
711
  log(HELP);
495
712
  return { code: 0 };
496
713
  }
497
- // nexuscrew nudo = smart-up (design §3.2)
714
+ // Public surface: normal start, show, boot, doctor, help, version.
498
715
  if (!cmd) {
499
- smartUp({ ...opts, log });
500
- return { code: 0 };
716
+ if (Object.keys(flags).length) {
717
+ log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
718
+ return { code: 1 };
719
+ }
720
+ return smartUp({ ...opts, log: opts.lifecycleLog || (() => {}) }).then((r) => { quickSummary(r, { ...opts, log }); return { code: 0 }; });
721
+ }
722
+ if (cmd === 'show') {
723
+ if (rest[1] === 'token') {
724
+ return smartUp({ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}) }).then((r) => {
725
+ const { tokenPath } = urlmod.resolvePaths(opts); const token = urlmod.readToken(tokenPath);
726
+ log(urlmod.buildUrl(r.port, token, { withToken: true })); return { code: 0 };
727
+ });
728
+ }
729
+ if (rest[1]) { log('usage: nexuscrew show [token]'); return { code: 1 }; }
730
+ return smartUp({ ...opts, forceOpen: true, log: opts.lifecycleLog || (() => {}) }).then(() => ({ code: 0 }));
731
+ }
732
+ if (cmd === 'boot') {
733
+ const sub = rest[1] || 'on';
734
+ if (sub === 'status') {
735
+ const b = bootState(opts); log(`boot: ${b.enabled ? 'on' : 'off'} · ${b.platform}`); return { code: 0 };
736
+ }
737
+ if (sub === 'off') return bootOff(opts).then((r) => { log(`boot: off · ${r.platform}`); return { code: 0 }; });
738
+ if (sub === 'on') return bootOn(opts).then((r) => { quickSummary(r, { ...opts, log }); return { code: r.boot ? 0 : 1 }; });
739
+ log('usage: nexuscrew boot [on|off|status]'); return { code: 1 };
501
740
  }
502
- if (cmd === 'init') {
503
- runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
741
+ if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
742
+ log(require('../../package.json').version);
504
743
  return { code: 0 };
505
744
  }
745
+ // Internal runtime commands used by service managers and MCP clients. They
746
+ // are intentionally omitted from HELP and are not configuration surfaces.
506
747
  if (cmd === 'serve') {
507
748
  serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
508
749
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
509
750
  }
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 };
514
- }
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 };
518
- }
519
- if (cmd === 'status') {
520
- status({ ...opts, json: !!flags.json, log });
521
- return { code: 0 };
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
751
  if (cmd === 'doctor') {
541
752
  const r = doctor({ ...opts, log });
542
753
  return { code: r.code };
543
754
  }
544
- if (cmd === 'update') {
545
- const r = update({ ...opts, log });
546
- return { code: r.code || 0 };
547
- }
548
755
  if (cmd === 'mcp') {
549
756
  // Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
550
757
  // NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
@@ -555,13 +762,7 @@ function dispatch(argv, opts = {}) {
555
762
  if (cmd === 'fleet-boot') {
556
763
  return dispatchFleetBoot({ ...opts, log });
557
764
  }
558
- if (cmd === 'nodes') {
559
- return dispatchNodes(rest, flags, { ...opts, log });
560
- }
561
- if (cmd === 'node') {
562
- return dispatchNode(rest, flags, { ...opts, log });
563
- }
564
- log(`unknown command: ${cmd}\n\n${HELP}`);
765
+ log(`"${cmd}" is not a public CLI command. Open the PWA with: nexuscrew show\n\n${HELP}`);
565
766
  return { code: 1 };
566
767
  }
567
768
 
@@ -574,7 +775,7 @@ function dispatchNodes(rest, flags, opts) {
574
775
  switch (sub) {
575
776
  case 'add':
576
777
  return { code: nodesCmds.nodesAdd({
577
- ...common, ssh: flags.ssh, remotePort: flags['remote-port'],
778
+ ...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
578
779
  key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
579
780
  }).code };
580
781
  case undefined:
@@ -624,6 +825,9 @@ function dispatchNode(rest, flags, opts) {
624
825
  module.exports = {
625
826
  dispatch, serve, start, stop, status, parseFlags, HELP,
626
827
  smartUp, url, tokenRotate, logs, doctor, update, restart,
828
+ portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
829
+ servicePinsLegacyPort,
627
830
  isServiceRunning, readRoles,
831
+ bootState, bootOn, bootOff, quickSummary,
628
832
  runFleetBoot, dispatchFleetBoot,
629
833
  };
package/lib/cli/doctor.js CHANGED
@@ -51,7 +51,7 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
51
51
  active = true;
52
52
  } else if (platform === 'termux') {
53
53
  const pidf = require('./pidfile.js');
54
- const meta = pidf.readPidfile(pidf.defaultPidfilePath());
54
+ const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
55
55
  active = !!(meta && pidf.isAlive(meta));
56
56
  }
57
57
  } catch (_) { active = false; }