@mmmbuto/nexuscrew 0.8.9 → 0.8.11

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).
@@ -8,23 +8,16 @@ const fs = require('node:fs');
8
8
  const path = require('node:path');
9
9
  const net = require('node:net');
10
10
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
11
- const { installPath: serviceInstallPath } = require('./service.js');
11
+ const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
12
12
  const { fleetInstallPath } = require('./fleet-service.js');
13
13
  const pidf = require('./pidfile.js');
14
14
  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,180 @@ 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
+ // The shared tmux server can live in the NexusCrew systemd cgroup. If the
267
+ // effective KillMode cannot be proven safe, stop must not mutate *any*
268
+ // runtime owner or detached tunnel: callers can then fix the unit and retry
269
+ // without ending up in a partially stopped state.
270
+ if (before.managedRunning && platform === 'linux') {
271
+ try {
272
+ (opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
273
+ } catch (error) {
274
+ const reason = String(error.message || error);
275
+ log(`stop: annullato — protezione tmux non verificata: ${reason}`);
276
+ return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
277
+ }
197
278
  }
198
- if (platform === 'mac') {
199
- try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
200
- catch (_) { return false; }
279
+
280
+ if (before.managedRunning) {
281
+ try {
282
+ if (platform === 'linux') {
283
+ execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
284
+ log('stop: systemctl --user stop nexuscrew');
285
+ } else {
286
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
287
+ // bootout unloads KeepAlive, so a voluntary stop stays stopped.
288
+ execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
289
+ log(`stop: launchctl bootout ${label}`);
290
+ }
291
+ stoppedOwners.push('managed');
292
+ } catch (error) { errors.push(String(error.message || error)); }
201
293
  }
294
+
295
+ if (before.portableRunning) {
296
+ const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
297
+ if (portable.killed) {
298
+ stoppedOwners.push('portable');
299
+ log(`stop: killed portable pid ${portable.pid}`);
300
+ } else errors.push(portable.reason || 'portable stop failed');
301
+ }
302
+
303
+ // Detached tunnel supervisors are outside both server owners. A real stop
304
+ // closes them once; the next start restores only autostart:true links.
305
+ (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
202
306
  if (platform === 'termux') {
203
- const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
204
- return !!(meta && pidf.isAlive(meta));
307
+ try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
205
308
  }
206
- return false;
309
+
310
+ if (errors.length) {
311
+ const reason = errors.join('; ');
312
+ log(`stop: incompleto — ${reason}`);
313
+ return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
314
+ }
315
+ const alreadyStopped = before.owner === 'stopped';
316
+ if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
317
+ return {
318
+ platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
319
+ reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
320
+ };
321
+ }
322
+
323
+ // running-check per-platform (no log) — riusato da smart-up / token rotate / update.
324
+ function isServiceRunning(opts = {}) {
325
+ return resolveRuntimeOwner(opts).owner !== 'stopped';
207
326
  }
208
327
 
209
328
  function bootState(opts = {}) {
@@ -274,12 +393,13 @@ function quickSummary(result, opts = {}) {
274
393
  let cached = [];
275
394
  try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
276
395
  const direct = (st && st.nodes) || [];
277
- const online = direct.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
396
+ const managed = direct.filter((n) => n.direction !== 'inbound');
397
+ const online = managed.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
278
398
  const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
279
399
  log(`NexusCrew ${require('../../package.json').version}`);
280
400
  log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
281
401
  log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
282
- log(`nodes ${known} known · ${online}/${direct.length} direct tunnels up`);
402
+ log(`nodes ${known} known · ${online}/${managed.length} hub connections up`);
283
403
  log('');
284
404
  log('open nexuscrew show');
285
405
  log('link nexuscrew show token');
@@ -304,25 +424,18 @@ function status(opts = {}) {
304
424
  const { configPath, configDir } = urlmod.resolvePaths(opts);
305
425
  const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
306
426
 
307
- const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
427
+ const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
428
+ const out = {
429
+ platform, service: runtime.service, running: runtime.owner !== 'stopped',
430
+ runtimeOwner: runtime.owner, managedRunning: runtime.managedRunning,
431
+ portableRunning: runtime.portableRunning, portablePid: runtime.portablePid,
432
+ port: null, url: null, roles: null, nodes: [],
433
+ };
308
434
 
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') {
435
+ if (platform === 'termux') {
321
436
  // boot-script installed vs server running (pidfile vivo)
322
437
  const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
323
438
  out.bootScriptInstalled = fs.existsSync(bootScript);
324
- const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
325
- out.running = !!(meta && pidf.isAlive(meta));
326
439
  out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
327
440
  }
328
441
 
@@ -341,8 +454,10 @@ function status(opts = {}) {
341
454
  return {
342
455
  name: red.name, roles: red.roles,
343
456
  remotePort: red.remotePort, localPort: red.localPort,
344
- hasToken: red.hasToken,
345
- tunnel: nodesTunnel.readTunnelState(home, n.name),
457
+ direction: red.direction, shared: red.shared, hasToken: red.hasToken,
458
+ tunnel: n.direction === 'inbound'
459
+ ? { status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false }
460
+ : nodesTunnel.readTunnelState(home, n.name),
346
461
  };
347
462
  });
348
463
  }
@@ -354,6 +469,7 @@ function status(opts = {}) {
354
469
  log(`platform: ${out.platform}`);
355
470
  log(`service: ${out.service}`);
356
471
  log(`running: ${out.running}`);
472
+ log(`runtime: ${out.runtimeOwner}${out.portablePid ? ` (pid ${out.portablePid})` : ''}`);
357
473
  log(`port: ${out.port}`);
358
474
  log(`url: ${out.url}`);
359
475
  log(`roles: client=${out.roles.client} node=${out.roles.node}`);
@@ -368,23 +484,59 @@ function restart(opts = {}) {
368
484
  const platform = opts.platform || detectPlatform();
369
485
  const execImpl = opts.execImpl || execFileSync;
370
486
  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 };
487
+ if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`restart: platform ${platform} non supportata`);
488
+ const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
489
+
490
+ // A managed owner can use the service manager's atomic restart. In a
491
+ // conflict, first remove only the stray portable owner, then keep managed as
492
+ // the single authority.
493
+ if (before.managedRunning) {
494
+ if (platform === 'linux') {
495
+ try {
496
+ (opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
497
+ } catch (error) {
498
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
499
+ }
500
+ }
501
+ if (before.portableRunning) {
502
+ const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
503
+ if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
504
+ }
505
+ try {
506
+ if (platform === 'linux') {
507
+ (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
508
+ execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
509
+ log('restart: systemctl --user restart nexuscrew');
510
+ } else {
511
+ (opts.stopTunnelsImpl || stopManagedTunnels)(opts);
512
+ const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
513
+ execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
514
+ log(`restart: launchctl kickstart -k ${label}`);
515
+ }
516
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: true };
517
+ } catch (error) {
518
+ return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
519
+ }
386
520
  }
387
- throw new Error(`restart: platform ${platform} non supportata`);
521
+
522
+ if (before.portableRunning) {
523
+ const stopped = stop({ ...opts, platform, execImpl, log });
524
+ if (!stopped.stopped) return { platform, owner: before.owner, restarted: false, reason: stopped.reason };
525
+ const started = (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
526
+ const ok = !!(started && started.started !== false);
527
+ if (ok) log('restart: portable runtime started');
528
+ return { platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
529
+ }
530
+
531
+ // `restart` on a stopped runtime follows the explicit boot owner; with boot
532
+ // disabled it starts a portable background runtime.
533
+ const useManaged = platform !== 'termux' && bootState({ ...opts, platform, execImpl }).enabled;
534
+ const started = useManaged
535
+ ? start({ ...opts, platform, execImpl, spawnImpl: opts.spawnImpl, log })
536
+ : (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
537
+ const ok = !!(started && started.started !== false);
538
+ if (ok) log(`restart: ${useManaged ? 'managed' : 'portable'} runtime started`);
539
+ return { platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
388
540
  }
389
541
 
390
542
  function wizardComplete(configPath) {
@@ -446,6 +598,8 @@ async function waitForNexusCrew(port, token, opts = {}) {
446
598
  function startPortable(opts = {}) {
447
599
  const spawnImpl = opts.spawnImpl || spawn;
448
600
  const home = opts.home || require('node:os').homedir();
601
+ const existing = portableRuntimeState({ home });
602
+ if (existing.running) return { started: false, reason: 'already running', pid: existing.pid, portable: true };
449
603
  const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
450
604
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
451
605
  const logFd = fs.openSync(logPath, 'a');
@@ -502,6 +656,7 @@ async function smartUp(opts = {}) {
502
656
  if (!initialized) {
503
657
  const requested = opts.port || urlmod.loadPort(opts);
504
658
  const selected = await findAvailablePort(requested, opts);
659
+ refusePairedPortRelocation(opts, requested, selected);
505
660
  runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
506
661
  port = selected;
507
662
  initialized = true;
@@ -519,19 +674,24 @@ async function smartUp(opts = {}) {
519
674
  let token = urlmod.readToken(tokenPath);
520
675
  let running = await probe(port, token, opts);
521
676
  let portableAttempted = false;
522
- const managedRunning = isServiceRunning({ ...opts, platform });
523
- if (!running && managedRunning) running = await waitForNexusCrew(port, token, opts);
677
+ let runtime = resolveRuntimeOwner({ ...opts, platform });
678
+ if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
524
679
 
525
680
  if (!running) {
526
681
  if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
682
+ const previousPort = port;
527
683
  port = await findAvailablePort(port + 1, opts);
684
+ refusePairedPortRelocation(opts, previousPort, port);
528
685
  runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
529
686
  token = urlmod.readToken(tokenPath);
530
687
  } else {
531
688
  try {
532
- const started = persistent
533
- ? (managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
534
- : portableStart({ ...opts, platform });
689
+ runtime = resolveRuntimeOwner({ ...opts, platform });
690
+ const started = runtime.owner === 'conflict'
691
+ ? restartImpl({ ...opts, platform, log: quiet })
692
+ : persistent
693
+ ? (runtime.managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
694
+ : (runtime.portableRunning ? restartImpl({ ...opts, platform, log: quiet }) : portableStart({ ...opts, platform }));
535
695
  if (started && started.started === false) {
536
696
  portableStart({ ...opts, platform }); portableAttempted = true;
537
697
  }
@@ -572,17 +732,34 @@ function url(opts = {}) {
572
732
  function tokenRotate(opts = {}) {
573
733
  const log = opts.log || console.log;
574
734
  const platform = opts.platform || detectPlatform();
735
+ const execImpl = opts.execImpl || execFileSync;
575
736
  const { tokenPath } = urlmod.resolvePaths(opts);
576
737
  const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
577
738
  if (readonly) {
578
739
  log('token rotate: READONLY, rotazione bloccata');
579
740
  return { rotated: false, reason: 'readonly' };
580
741
  }
742
+ const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
743
+ const running = runtime.owner !== 'stopped';
744
+ if (runtime.managedRunning && platform === 'linux') {
745
+ try {
746
+ (opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
747
+ } catch (error) {
748
+ const reason = String(error.message || error);
749
+ log(`token rotate: annullata — protezione tmux non verificata: ${reason}`);
750
+ return { rotated: false, tokenWritten: false, restarted: false, running, reason };
751
+ }
752
+ }
581
753
  rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
582
754
  log(`token: nuovo segreto scritto (${tokenPath})`);
583
- const running = isServiceRunning({ ...opts, platform });
584
755
  if (running) {
585
- restart({ ...opts, platform, log });
756
+ const restarted = (opts.restartImpl || restart)({ ...opts, platform, execImpl, log });
757
+ if (!restarted || restarted.restarted !== true) {
758
+ const reason = (restarted && restarted.reason) || 'esito restart non verificato';
759
+ log(`token rotate: INCOMPLETA — nuovo token scritto ma restart fallito: ${reason}`);
760
+ log('token rotate: non e\' possibile confermare l\'invalidazione del vecchio token; correggi il servizio e ripeti il restart');
761
+ return { rotated: false, tokenWritten: true, restarted: false, running, reason };
762
+ }
586
763
  log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
587
764
  } else {
588
765
  // il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
@@ -594,8 +771,8 @@ function tokenRotate(opts = {}) {
594
771
  log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
595
772
  }
596
773
  }
597
- log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
598
- return { rotated: true, running };
774
+ log('usa `nexuscrew show token` per il nuovo URL (il token non si stampa qui)');
775
+ return { rotated: true, tokenWritten: true, restarted: running, running };
599
776
  }
600
777
 
601
778
  // logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
@@ -642,12 +819,17 @@ function update(opts = {}) {
642
819
  }
643
820
  const running = isServiceRunning({ ...opts, platform });
644
821
  if (running) {
645
- restart({ ...opts, platform, log });
822
+ const restarted = (opts.restartImpl || restart)({ ...opts, platform, log });
823
+ if (!restarted || restarted.restarted !== true) {
824
+ const reason = (restarted && restarted.reason) || 'esito restart non verificato';
825
+ log(`update: pacchetto installato ma restart fallito — ${reason}`);
826
+ return { updated: true, running, restarted: false, reason, code: 1 };
827
+ }
646
828
  log('update: servizio riavviato sul nuovo codice');
647
829
  } else {
648
830
  log('update: servizio non attivo (nessun restart)');
649
831
  }
650
- return { updated: true, running, code: 0 };
832
+ return { updated: true, running, restarted: running, code: 0 };
651
833
  }
652
834
 
653
835
  // B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
@@ -703,7 +885,7 @@ function dispatch(argv, opts = {}) {
703
885
  log(require('../../package.json').version);
704
886
  return { code: 0 };
705
887
  }
706
- const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
888
+ const { flags, rest } = parseFlags(argv);
707
889
  const cmd = rest[0];
708
890
 
709
891
  // help esplicito
@@ -742,6 +924,18 @@ function dispatch(argv, opts = {}) {
742
924
  log(require('../../package.json').version);
743
925
  return { code: 0 };
744
926
  }
927
+ if (cmd === 'status') {
928
+ status({ ...opts, log });
929
+ return { code: 0 };
930
+ }
931
+ if (cmd === 'stop') {
932
+ const result = stop({ ...opts, log });
933
+ return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
934
+ }
935
+ if (cmd === 'restart') {
936
+ const result = restart({ ...opts, log });
937
+ return { code: result.restarted ? 0 : 1 };
938
+ }
745
939
  // Internal runtime commands used by service managers and MCP clients. They
746
940
  // are intentionally omitted from HELP and are not configuration surfaces.
747
941
  if (cmd === 'serve') {
@@ -766,62 +960,6 @@ function dispatch(argv, opts = {}) {
766
960
  return { code: 1 };
767
961
  }
768
962
 
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
963
  module.exports = {
826
964
  dispatch, serve, start, stop, status, parseFlags, HELP,
827
965
  smartUp, url, tokenRotate, logs, doctor, update, restart,
@@ -829,5 +967,7 @@ module.exports = {
829
967
  servicePinsLegacyPort,
830
968
  isServiceRunning, readRoles,
831
969
  bootState, bootOn, bootOff, quickSummary,
970
+ stopManagedTunnels,
971
+ managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
832
972
  runFleetBoot, dispatchFleetBoot,
833
973
  };