@mmmbuto/nexuscrew 0.8.9 → 0.8.10

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.
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // CLI dispatcher: init / serve / start / stop / status. [M6][R1]
2
+ // Minimal public CLI plus internal service/MCP entry points. [M6][R1]
3
3
  // serve = foreground HTTP (+ --pidfile lifecycle su Termux/manuale).
4
4
  // start/stop/status = per-platform: linux (systemctl --user), mac (launchctl),
5
5
  // termux (nohup serve --pidfile + pidfile verificato; status boot-script vs running).
@@ -15,16 +15,9 @@ const { runInit } = require('./init.js');
15
15
  const { rotateToken } = require('../auth/token.js');
16
16
  const urlmod = require('./url.js');
17
17
  const { doctor } = require('./doctor.js');
18
- const nodesCmds = require('../nodes/commands.js');
19
18
  const nodesStore = require('../nodes/store.js');
20
19
  const nodesTunnel = require('../nodes/tunnel.js');
21
20
 
22
- // Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
23
- // Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
24
- const VALUE_FLAGS = new Set([
25
- 'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
26
- ]);
27
-
28
21
  const HELP = `NexusCrew — PWA for local and remote AI workers.
29
22
 
30
23
  Usage:
@@ -32,6 +25,9 @@ Usage:
32
25
  nexuscrew show start when needed and open the authenticated PWA
33
26
  nexuscrew show token print the clickable authenticated URL
34
27
  nexuscrew boot enable startup at boot (use: boot off|status)
28
+ nexuscrew status show service, port, roles and node status
29
+ nexuscrew stop stop the background service
30
+ nexuscrew restart restart the background service
35
31
  nexuscrew doctor run local diagnostics
36
32
  nexuscrew help show this help
37
33
  nexuscrew version show the installed version
@@ -153,57 +149,166 @@ function start(opts = {}) {
153
149
  throw new Error(`start: platform ${platform} non supportata`);
154
150
  }
155
151
 
156
- function stop(opts = {}) {
152
+ function stopManagedTunnels(opts = {}) {
153
+ const home = opts.home || require('node:os').homedir();
154
+ const { configDir } = urlmod.resolvePaths(opts);
155
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
156
+ const st = nodesStore.loadStore(nodesPath);
157
+ const stopped = [];
158
+ for (const node of (st && st.nodes) || []) {
159
+ if (node.direction === 'inbound') continue;
160
+ try { nodesTunnel.stopTunnel({ home, name: node.name }); stopped.push(node.name); } catch (_) {}
161
+ }
162
+ if (st && st.rendezvous) {
163
+ try { nodesTunnel.stopTunnel({ home, name: nodesTunnel.REVERSE_NAME }); stopped.push(nodesTunnel.REVERSE_NAME); } catch (_) {}
164
+ }
165
+ return stopped;
166
+ }
167
+
168
+ function refusePairedPortRelocation(opts, from, to) {
169
+ if (Number(from) === Number(to)) return;
170
+ const { configDir } = urlmod.resolvePaths(opts);
171
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
172
+ if (nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath))) {
173
+ throw new Error(`port ${from} is busy and paired peers exist; refusing automatic move to ${to}. Free the configured port or reconnect peers intentionally.`);
174
+ }
175
+ }
176
+
177
+ // Runtime ownership is independent from boot persistence. With boot disabled,
178
+ // smartUp intentionally launches `serve --pidfile` on Linux/macOS too; lifecycle
179
+ // commands must therefore inspect both the service manager and that portable
180
+ // pidfile instead of assuming the platform selects exactly one owner.
181
+ function managedRuntimeState(opts = {}) {
157
182
  const platform = opts.platform || detectPlatform();
158
183
  const execImpl = opts.execImpl || execFileSync;
159
- const log = opts.log || console.log;
160
-
161
184
  if (platform === 'linux') {
162
- execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
163
- log('stop: systemctl --user stop nexuscrew');
164
- return { platform, stopped: true };
185
+ try {
186
+ const service = String(execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }) || '').trim();
187
+ return { supported: true, running: service === 'active', service: service || 'inactive' };
188
+ } catch (_) { return { supported: true, running: false, service: 'inactive' }; }
165
189
  }
166
190
  if (platform === 'mac') {
167
- const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
191
+ const service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
168
192
  try {
169
- // bootout scarica il job: KeepAlive non puo' rianimare uno stop volontario.
170
- execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
171
- log(`stop: launchctl bootout ${label}`);
172
- return { platform, stopped: true };
173
- } catch (e) {
174
- const reason = String(e.message || e);
175
- log(`stop: launchctl bootout fallito: ${reason}`);
176
- return { platform, stopped: false, reason };
177
- }
193
+ execImpl('launchctl', ['print', service], { stdio: 'ignore' });
194
+ return { supported: true, running: true, service };
195
+ } catch (_) { return { supported: true, running: false, service }; }
178
196
  }
179
- if (platform === 'termux') {
180
- // kill via pidfile verificato (no broad match) + wake-lock-release
181
- const pidPath = pidf.defaultPidfilePath(opts.home);
182
- const r = pidf.killPidfile(pidPath);
183
- try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
184
- log(`stop: ${r.killed ? `killed pid ${r.pid}` : r.reason}`);
185
- return { platform, stopped: r.killed, reason: r.reason };
197
+ return { supported: false, running: false, service: 'portable-only' };
198
+ }
199
+
200
+ function portableRuntimeState(opts = {}) {
201
+ const home = opts.home || require('node:os').homedir();
202
+ const pidPath = pidf.defaultPidfilePath(home);
203
+ const meta = pidf.readPidfile(pidPath);
204
+ if (!meta) return { running: false, pidPath, reason: 'no pidfile' };
205
+ if (pidf.isAlive(meta)) return { running: true, pidPath, pid: meta.pid, meta };
206
+ pidf.cleanStale(pidPath);
207
+ return { running: false, pidPath, reason: 'stale pidfile' };
208
+ }
209
+
210
+ function resolveRuntimeOwner(opts = {}) {
211
+ const platform = opts.platform || detectPlatform();
212
+ const managed = managedRuntimeState({ ...opts, platform });
213
+ const portable = portableRuntimeState(opts);
214
+ let owner = 'stopped';
215
+ if (managed.running && portable.running) owner = 'conflict';
216
+ else if (managed.running) owner = 'managed';
217
+ else if (portable.running) owner = 'portable';
218
+ return {
219
+ platform, owner,
220
+ managedRunning: managed.running,
221
+ portableRunning: portable.running,
222
+ service: managed.service,
223
+ portablePid: portable.pid,
224
+ pidPath: portable.pidPath,
225
+ stalePidfile: portable.reason === 'stale pidfile',
226
+ portableReason: portable.reason,
227
+ };
228
+ }
229
+
230
+ function waitForPidExit(pid, timeoutMs = 2000) {
231
+ const deadline = Date.now() + timeoutMs;
232
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
233
+ const exited = () => {
234
+ if (!pidf.pidExists(pid)) return true;
235
+ // A test-owned child can remain as a zombie until this synchronous caller
236
+ // returns to the event loop. It no longer owns sockets and is exited for
237
+ // lifecycle purposes. Linux/Termux expose the state in /proc; macOS has no
238
+ // equivalent cheap path and normally removes a non-child immediately.
239
+ try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
240
+ catch (_) { return false; }
241
+ };
242
+ while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
243
+ return exited();
244
+ }
245
+
246
+ function stopPortableRuntime(opts = {}) {
247
+ const home = opts.home || require('node:os').homedir();
248
+ const pidPath = pidf.defaultPidfilePath(home);
249
+ const result = pidf.killPidfile(pidPath);
250
+ if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
251
+ return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
186
252
  }
187
- throw new Error(`stop: platform ${platform} non supportata`);
253
+ return result;
188
254
  }
189
255
 
190
- // running-check per-platform (no log) — riusato da smart-up / token rotate / update.
191
- function isServiceRunning(opts = {}) {
256
+ function stop(opts = {}) {
192
257
  const platform = opts.platform || detectPlatform();
193
258
  const execImpl = opts.execImpl || execFileSync;
194
- if (platform === 'linux') {
195
- try { return execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'active'; }
196
- catch (_) { return false; }
259
+ const log = opts.log || console.log;
260
+ if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`stop: platform ${platform} non supportata`);
261
+
262
+ const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
263
+ const stoppedOwners = [];
264
+ const errors = [];
265
+
266
+ if (before.managedRunning) {
267
+ try {
268
+ if (platform === 'linux') {
269
+ execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
270
+ log('stop: systemctl --user stop nexuscrew');
271
+ } else {
272
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
273
+ // bootout unloads KeepAlive, so a voluntary stop stays stopped.
274
+ execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
275
+ log(`stop: launchctl bootout ${label}`);
276
+ }
277
+ stoppedOwners.push('managed');
278
+ } catch (error) { errors.push(String(error.message || error)); }
197
279
  }
198
- if (platform === 'mac') {
199
- try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
200
- catch (_) { return false; }
280
+
281
+ if (before.portableRunning) {
282
+ const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
283
+ if (portable.killed) {
284
+ stoppedOwners.push('portable');
285
+ log(`stop: killed portable pid ${portable.pid}`);
286
+ } else errors.push(portable.reason || 'portable stop failed');
201
287
  }
288
+
289
+ // Detached tunnel supervisors are outside both server owners. A real stop
290
+ // closes them once; the next start restores only autostart:true links.
291
+ (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
202
292
  if (platform === 'termux') {
203
- const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
204
- return !!(meta && pidf.isAlive(meta));
293
+ try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
205
294
  }
206
- return false;
295
+
296
+ if (errors.length) {
297
+ const reason = errors.join('; ');
298
+ log(`stop: incompleto — ${reason}`);
299
+ return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
300
+ }
301
+ const alreadyStopped = before.owner === 'stopped';
302
+ if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
303
+ return {
304
+ platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
305
+ reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
306
+ };
307
+ }
308
+
309
+ // running-check per-platform (no log) — riusato da smart-up / token rotate / update.
310
+ function isServiceRunning(opts = {}) {
311
+ return resolveRuntimeOwner(opts).owner !== 'stopped';
207
312
  }
208
313
 
209
314
  function bootState(opts = {}) {
@@ -274,12 +379,13 @@ function quickSummary(result, opts = {}) {
274
379
  let cached = [];
275
380
  try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
276
381
  const direct = (st && st.nodes) || [];
277
- const online = direct.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
382
+ const managed = direct.filter((n) => n.direction !== 'inbound');
383
+ const online = managed.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
278
384
  const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
279
385
  log(`NexusCrew ${require('../../package.json').version}`);
280
386
  log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
281
387
  log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
282
- log(`nodes ${known} known · ${online}/${direct.length} direct tunnels up`);
388
+ log(`nodes ${known} known · ${online}/${managed.length} hub connections up`);
283
389
  log('');
284
390
  log('open nexuscrew show');
285
391
  log('link nexuscrew show token');
@@ -304,25 +410,18 @@ function status(opts = {}) {
304
410
  const { configPath, configDir } = urlmod.resolvePaths(opts);
305
411
  const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
306
412
 
307
- const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
413
+ const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
414
+ const out = {
415
+ platform, service: runtime.service, running: runtime.owner !== 'stopped',
416
+ runtimeOwner: runtime.owner, managedRunning: runtime.managedRunning,
417
+ portableRunning: runtime.portableRunning, portablePid: runtime.portablePid,
418
+ port: null, url: null, roles: null, nodes: [],
419
+ };
308
420
 
309
- if (platform === 'linux') {
310
- try {
311
- const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim();
312
- out.service = s; out.running = s === 'active';
313
- } catch (_) { out.service = 'inactive'; out.running = false; }
314
- } else if (platform === 'mac') {
315
- out.service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
316
- try {
317
- execImpl('launchctl', ['print', out.service], { stdio: 'ignore' });
318
- out.running = true;
319
- } catch (_) { out.running = false; }
320
- } else if (platform === 'termux') {
421
+ if (platform === 'termux') {
321
422
  // boot-script installed vs server running (pidfile vivo)
322
423
  const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
323
424
  out.bootScriptInstalled = fs.existsSync(bootScript);
324
- const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
325
- out.running = !!(meta && pidf.isAlive(meta));
326
425
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
327
426
  }
328
427
 
@@ -341,8 +440,10 @@ function status(opts = {}) {
341
440
  return {
342
441
  name: red.name, roles: red.roles,
343
442
  remotePort: red.remotePort, localPort: red.localPort,
344
- hasToken: red.hasToken,
345
- tunnel: nodesTunnel.readTunnelState(home, n.name),
443
+ direction: red.direction, shared: red.shared, hasToken: red.hasToken,
444
+ tunnel: n.direction === 'inbound'
445
+ ? { status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false }
446
+ : nodesTunnel.readTunnelState(home, n.name),
346
447
  };
347
448
  });
348
449
  }
@@ -354,6 +455,7 @@ function status(opts = {}) {
354
455
  log(`platform: ${out.platform}`);
355
456
  log(`service: ${out.service}`);
356
457
  log(`running: ${out.running}`);
458
+ log(`runtime: ${out.runtimeOwner}${out.portablePid ? ` (pid ${out.portablePid})` : ''}`);
357
459
  log(`port: ${out.port}`);
358
460
  log(`url: ${out.url}`);
359
461
  log(`roles: client=${out.roles.client} node=${out.roles.node}`);
@@ -368,23 +470,50 @@ function restart(opts = {}) {
368
470
  const platform = opts.platform || detectPlatform();
369
471
  const execImpl = opts.execImpl || execFileSync;
370
472
  const log = opts.log || console.log;
371
- if (platform === 'linux') {
372
- execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
373
- log('restart: systemctl --user restart nexuscrew');
374
- return { platform, restarted: true };
375
- }
376
- if (platform === 'mac') {
377
- const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
378
- execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
379
- log(`restart: launchctl kickstart -k ${label}`);
380
- return { platform, restarted: true };
381
- }
382
- if (platform === 'termux') {
383
- stop({ execImpl, log, platform, uid: opts.uid });
384
- start({ execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
385
- return { platform, restarted: true };
473
+ if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`restart: platform ${platform} non supportata`);
474
+ const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
475
+
476
+ // A managed owner can use the service manager's atomic restart. In a
477
+ // conflict, first remove only the stray portable owner, then keep managed as
478
+ // the single authority.
479
+ if (before.managedRunning) {
480
+ if (before.portableRunning) {
481
+ const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
482
+ if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
483
+ }
484
+ try {
485
+ if (platform === 'linux') {
486
+ execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
487
+ log('restart: systemctl --user restart nexuscrew');
488
+ } else {
489
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
490
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
491
+ log(`restart: launchctl kickstart -k ${label}`);
492
+ }
493
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: true };
494
+ } catch (error) {
495
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
496
+ }
386
497
  }
387
- throw new Error(`restart: platform ${platform} non supportata`);
498
+
499
+ if (before.portableRunning) {
500
+ const stopped = stop({ ...opts, platform, execImpl, log });
501
+ if (!stopped.stopped) return { platform, owner: before.owner, restarted: false, reason: stopped.reason };
502
+ const started = (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
503
+ const ok = !!(started && started.started !== false);
504
+ if (ok) log('restart: portable runtime started');
505
+ return { platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
506
+ }
507
+
508
+ // `restart` on a stopped runtime follows the explicit boot owner; with boot
509
+ // disabled it starts a portable background runtime.
510
+ const useManaged = platform !== 'termux' && bootState({ ...opts, platform, execImpl }).enabled;
511
+ const started = useManaged
512
+ ? start({ ...opts, platform, execImpl, spawnImpl: opts.spawnImpl, log })
513
+ : (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
514
+ const ok = !!(started && started.started !== false);
515
+ if (ok) log(`restart: ${useManaged ? 'managed' : 'portable'} runtime started`);
516
+ return { platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
388
517
  }
389
518
 
390
519
  function wizardComplete(configPath) {
@@ -446,6 +575,8 @@ async function waitForNexusCrew(port, token, opts = {}) {
446
575
  function startPortable(opts = {}) {
447
576
  const spawnImpl = opts.spawnImpl || spawn;
448
577
  const home = opts.home || require('node:os').homedir();
578
+ const existing = portableRuntimeState({ home });
579
+ if (existing.running) return { started: false, reason: 'already running', pid: existing.pid, portable: true };
449
580
  const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
450
581
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
451
582
  const logFd = fs.openSync(logPath, 'a');
@@ -502,6 +633,7 @@ async function smartUp(opts = {}) {
502
633
  if (!initialized) {
503
634
  const requested = opts.port || urlmod.loadPort(opts);
504
635
  const selected = await findAvailablePort(requested, opts);
636
+ refusePairedPortRelocation(opts, requested, selected);
505
637
  runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
506
638
  port = selected;
507
639
  initialized = true;
@@ -519,19 +651,24 @@ async function smartUp(opts = {}) {
519
651
  let token = urlmod.readToken(tokenPath);
520
652
  let running = await probe(port, token, opts);
521
653
  let portableAttempted = false;
522
- const managedRunning = isServiceRunning({ ...opts, platform });
523
- if (!running && managedRunning) running = await waitForNexusCrew(port, token, opts);
654
+ let runtime = resolveRuntimeOwner({ ...opts, platform });
655
+ if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
524
656
 
525
657
  if (!running) {
526
658
  if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
659
+ const previousPort = port;
527
660
  port = await findAvailablePort(port + 1, opts);
661
+ refusePairedPortRelocation(opts, previousPort, port);
528
662
  runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
529
663
  token = urlmod.readToken(tokenPath);
530
664
  } else {
531
665
  try {
532
- const started = persistent
533
- ? (managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
534
- : portableStart({ ...opts, platform });
666
+ runtime = resolveRuntimeOwner({ ...opts, platform });
667
+ const started = runtime.owner === 'conflict'
668
+ ? restartImpl({ ...opts, platform, log: quiet })
669
+ : persistent
670
+ ? (runtime.managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
671
+ : (runtime.portableRunning ? restartImpl({ ...opts, platform, log: quiet }) : portableStart({ ...opts, platform }));
535
672
  if (started && started.started === false) {
536
673
  portableStart({ ...opts, platform }); portableAttempted = true;
537
674
  }
@@ -594,7 +731,7 @@ function tokenRotate(opts = {}) {
594
731
  log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
595
732
  }
596
733
  }
597
- log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
734
+ log('usa `nexuscrew show token` per il nuovo URL (il token non si stampa qui)');
598
735
  return { rotated: true, running };
599
736
  }
600
737
 
@@ -703,7 +840,7 @@ function dispatch(argv, opts = {}) {
703
840
  log(require('../../package.json').version);
704
841
  return { code: 0 };
705
842
  }
706
- const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
843
+ const { flags, rest } = parseFlags(argv);
707
844
  const cmd = rest[0];
708
845
 
709
846
  // help esplicito
@@ -742,6 +879,18 @@ function dispatch(argv, opts = {}) {
742
879
  log(require('../../package.json').version);
743
880
  return { code: 0 };
744
881
  }
882
+ if (cmd === 'status') {
883
+ status({ ...opts, log });
884
+ return { code: 0 };
885
+ }
886
+ if (cmd === 'stop') {
887
+ const result = stop({ ...opts, log });
888
+ return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
889
+ }
890
+ if (cmd === 'restart') {
891
+ const result = restart({ ...opts, log });
892
+ return { code: result.restarted ? 0 : 1 };
893
+ }
745
894
  // Internal runtime commands used by service managers and MCP clients. They
746
895
  // are intentionally omitted from HELP and are not configuration surfaces.
747
896
  if (cmd === 'serve') {
@@ -766,62 +915,6 @@ function dispatch(argv, opts = {}) {
766
915
  return { code: 1 };
767
916
  }
768
917
 
769
- // nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
770
- function dispatchNodes(rest, flags, opts) {
771
- const log = opts.log;
772
- const sub = rest[1];
773
- const name = rest[2];
774
- const common = { ...opts, name };
775
- switch (sub) {
776
- case 'add':
777
- return { code: nodesCmds.nodesAdd({
778
- ...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
779
- key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
780
- }).code };
781
- case undefined:
782
- case 'list':
783
- return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
784
- case 'remove':
785
- return { code: nodesCmds.nodesRemove(common).code };
786
- case 'up':
787
- return { code: nodesCmds.nodesUp(common).code };
788
- case 'down':
789
- return { code: nodesCmds.nodesDown(common).code };
790
- case 'restart':
791
- return { code: nodesCmds.nodesRestart(common).code };
792
- case 'set-token':
793
- return { code: nodesCmds.nodesSetToken(common).code };
794
- case 'test': {
795
- // async: tieni vivo il processo finche' il probe non risolve, poi exit.
796
- const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
797
- nodesCmds.nodesTest(common)
798
- .then((r) => exit(r.code))
799
- .catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
800
- return { code: 0, keepAlive: true };
801
- }
802
- default:
803
- log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
804
- return { code: 1 };
805
- }
806
- }
807
-
808
- // node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
809
- function dispatchNode(rest, flags, opts) {
810
- const log = opts.log;
811
- const sub = rest[1];
812
- if (sub === 'on') {
813
- return { code: nodesCmds.nodeOn({
814
- ...opts, rendezvousSsh: flags.rendezvous,
815
- publishedPort: flags['published-port'], key: flags.key,
816
- }).code };
817
- }
818
- if (sub === 'off') {
819
- return { code: nodesCmds.nodeOff({ ...opts }).code };
820
- }
821
- log('usage: nexuscrew node on|off');
822
- return { code: 1 };
823
- }
824
-
825
918
  module.exports = {
826
919
  dispatch, serve, start, stop, status, parseFlags, HELP,
827
920
  smartUp, url, tokenRotate, logs, doctor, update, restart,
@@ -829,5 +922,7 @@ module.exports = {
829
922
  servicePinsLegacyPort,
830
923
  isServiceRunning, readRoles,
831
924
  bootState, bootOn, bootOff, quickSummary,
925
+ stopManagedTunnels,
926
+ managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
832
927
  runFleetBoot, dispatchFleetBoot,
833
928
  };
package/lib/cli/doctor.js CHANGED
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
  // nexuscrew doctor: auto-diagnosi (design §3, §7). [A2]
3
3
  // Struttura ESTENSIBILE: una lista di check-fn, ognuna ritorna
4
- // { name, ok, warn?, detail? }. B0 aggiungerà i check tunnel/ssh (permitlisten,
5
- // OpenSSH ≥7.8 sul rendezvous) appendendo alla lista, senza toccare il resto.
4
+ // { name, ok, warn?, detail? }. SSH è un requisito locale; la policy del
5
+ // server remoto si prova soltanto tentando il forwarding reale.
6
6
  // Exit code: 0 se tutti ok (i warn non falliscono), 1 se almeno un check è FAIL.
7
7
  // Nessun segreto nell'output (mai il token, solo il path + i permessi).
8
8
  const fs = require('node:fs');
@@ -87,22 +87,24 @@ function checkBoot(platform, home, execImpl) {
87
87
  // ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
88
88
  function checkSshClient(existsImpl) {
89
89
  return existsImpl('ssh')
90
- ? { name: 'ssh client presente', ok: true }
91
- : { name: 'ssh client presente', ok: false, detail: 'ssh non trovato su PATH (necessario per i tunnel multi-node)' };
90
+ ? { name: 'OpenSSH transport', ok: true, detail: 'ssh presente · USATO dal supervisor NexusCrew' }
91
+ : { name: 'OpenSSH transport', ok: false, detail: 'ssh non trovato su PATH; autossh da solo non funziona senza OpenSSH' };
92
92
  }
93
93
 
94
- // OpenSSH >=7.8 per permitlisten (vincola i -R lato rendezvous; design §7 (a)).
95
- // Determinabile e <7.8 -> FAIL (il ruolo node non e' abilitabile). Non
96
- // determinabile -> WARN (verifica manuale), non blocca la diagnosi generale.
94
+ function checkAutossh(existsImpl) {
95
+ return existsImpl('autossh')
96
+ ? { name: 'autossh', ok: true, detail: 'presente · NON usato (retry gia gestito dal supervisor NexusCrew)' }
97
+ : { name: 'autossh', ok: true, warn: true, detail: 'assente · opzionale, non necessario con SSH supervisionato' };
98
+ }
99
+
100
+ // Versione locale informativa. `permitlisten` e' una policy del server sshd e
101
+ // NON puo' essere certificata guardando `ssh -V` sul client: Share la verifica
102
+ // con un vero -R + health autenticato.
97
103
  function checkSshPermitlisten(sshVersionImpl) {
98
104
  const tun = require('../nodes/tunnel.js');
99
105
  const v = tun.readSshVersion(sshVersionImpl);
100
- const supp = tun.sshSupportsPermitlisten(v);
101
- if (supp === null) {
102
- return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, warn: true, detail: 'versione ssh non determinabile — verifica prima di abilitare il ruolo node' };
103
- }
104
- if (supp) return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, detail: v.raw };
105
- return { name: 'OpenSSH permitlisten (>=7.8)', ok: false, detail: `OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato (ruolo node non abilitabile)` };
106
+ if (!v) return { name: 'OpenSSH version', ok: true, warn: true, detail: 'versione non determinabile; Share verra verificato a runtime sul server' };
107
+ return { name: 'OpenSSH version', ok: true, detail: `${v.raw} · policy reverse verificata a runtime` };
106
108
  }
107
109
 
108
110
  function checkTokenPerms(tokenPath) {
@@ -141,6 +143,7 @@ function doctor(opts = {}) {
141
143
  checkBoot(platform, home, execImpl),
142
144
  checkTokenPerms(tokenPath),
143
145
  checkSshClient(existsImpl),
146
+ checkAutossh(existsImpl),
144
147
  checkSshPermitlisten(opts.sshVersion),
145
148
  ];
146
149
 
@@ -156,5 +159,5 @@ function doctor(opts = {}) {
156
159
  module.exports = {
157
160
  doctor, nodeMajor,
158
161
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
159
- checkSshClient, checkSshPermitlisten,
162
+ checkSshClient, checkAutossh, checkSshPermitlisten,
160
163
  };
package/lib/cli/init.js CHANGED
@@ -115,13 +115,13 @@ function runInit(opts = {}) {
115
115
  }
116
116
  }
117
117
 
118
- // Fleet app defaults: soltanto i tre client nativi. Provider cloud/Z.AI sono
118
+ // Fleet app defaults: soltanto i quattro client nativi. Provider cloud/Z.AI sono
119
119
  // disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
120
120
  const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
121
121
  if (!dryRun && !fs.existsSync(fleetDefsPath)) {
122
122
  try {
123
123
  writeFleet(fleetDefsPath, defaultDefinitions());
124
- actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
124
+ actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
125
125
  } catch (e) {
126
126
  actions.push(`WARN: fleet defaults non creati: ${e.message}`);
127
127
  }
@@ -19,9 +19,10 @@ function readPidfile(p) {
19
19
  }
20
20
 
21
21
  // Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
22
- function writePidfile(p, pid, cmd) {
22
+ function writePidfile(p, pid, cmd, extra = {}) {
23
23
  fs.mkdirSync(path.dirname(p), { recursive: true });
24
- const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now() });
24
+ const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
25
+ const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now(), ...safeExtra });
25
26
  fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
26
27
  }
27
28
 
@@ -9,6 +9,7 @@ const MAX_DECKS = 24;
9
9
  const MAX_TILES = 9;
10
10
  const NAME_RE = /^[a-z0-9-]{1,32}$/;
11
11
  const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
12
+ const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
12
13
 
13
14
  function validNodeRoute(node) {
14
15
  if (!NODE_RE.test(node)) return false;
@@ -48,15 +49,17 @@ function parseLayout(raw) {
48
49
  for (const t of c.tiles) {
49
50
  if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
50
51
  if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
52
+ if (t.ownerId !== undefined && (typeof t.ownerId !== 'string' || !OWNER_ID_RE.test(t.ownerId))) return null;
51
53
  const height = Number(t.height);
52
54
  const fontSize = Number(t.fontSize);
53
55
  if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
54
56
  if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
55
- const key = t.node ? `${t.node}:${t.session}` : t.session;
57
+ const key = t.ownerId ? `${t.ownerId}:${t.session}` : t.node ? `${t.node}:${t.session}` : t.session;
56
58
  if (seen.has(key) || ++count > MAX_TILES) return null;
57
59
  seen.add(key);
58
60
  const tile = { session: t.session, height, fontSize };
59
61
  if (t.node) tile.node = t.node;
62
+ if (t.ownerId) tile.ownerId = t.ownerId;
60
63
  tiles.push(tile);
61
64
  }
62
65
  columns.push({ width, tiles });
@@ -118,6 +121,6 @@ function loadOrCreate(p) {
118
121
  }
119
122
 
120
123
  module.exports = {
121
- SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
124
+ SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE, OWNER_ID_RE,
122
125
  defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
123
126
  };