@mmmbuto/nexuscrew 0.8.21 → 0.8.23

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DaFQbMHq.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-DQbVJLji.css">
14
+ <script type="module" crossorigin src="/assets/index-C0QL3gEp.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-77r8nzQf.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.21"}
1
+ {"version":"0.8.23"}
@@ -17,8 +17,8 @@ function publicCells(status, instanceId, now = Date.now()) {
17
17
  if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
18
18
  || !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
19
19
  seen.add(raw.cell);
20
- // Provider external legacy may not expose `tmux`; an explicit false still
21
- // wins, while the historical `active:true` contract remains compatible.
20
+ // Un `tmux:false` esplicito prevale su active:true: la directory globale non
21
+ // deve dichiarare ricevibile una cella senza sessione viva.
22
22
  const active = raw.active === true && raw.tmux !== false;
23
23
  cells.push({
24
24
  id: `${instanceId}:${raw.cell}`,
@@ -11,12 +11,14 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
11
11
  const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
12
12
  const { fleetInstallPath } = require('./fleet-service.js');
13
13
  const pidf = require('./pidfile.js');
14
- const { runInit } = require('./init.js');
14
+ const { runInit, ensureFleetDefaults } = 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
18
  const nodesStore = require('../nodes/store.js');
19
19
  const nodesTunnel = require('../nodes/tunnel.js');
20
+ const nodesCmds = require('../nodes/commands.js');
21
+ const peering = require('../nodes/peering.js');
20
22
  // Service/portable runtime ownership + start/stop lifecycle (Phase 3 extraction).
21
23
  const runtimeLifecycle = require('./runtime-lifecycle.js');
22
24
  const {
@@ -36,12 +38,50 @@ Usage:
36
38
  nexuscrew stop stop the background service
37
39
  nexuscrew restart restart the background service
38
40
  nexuscrew doctor run local diagnostics
41
+ nexuscrew nodes list and manage connected peers (use: nodes help)
39
42
  nexuscrew help show this help
40
43
  nexuscrew version show the installed version
41
44
 
42
- Configuration, nodes, engines, providers, models and lifecycle live in the PWA.
45
+ Configuration, engines, models and advanced lifecycle also live in the PWA.
43
46
  NexusCrew binds only to 127.0.0.1 and automatically selects a free port.`;
44
47
 
48
+ const NODES_HELP = `NexusCrew nodes — headless peer management.
49
+
50
+ Usage:
51
+ nexuscrew nodes list [--direct|--network] [--json]
52
+ nexuscrew nodes inspect|show <name|nodeId> [--json]
53
+ nexuscrew nodes doctor [name|nodeId] [--json]
54
+ nexuscrew nodes edit <name|nodeId> [--label TEXT] [--ssh TARGET]
55
+ [--ssh-port PORT] [--autostart on|off]
56
+ [--visibility network|relay-only|selected]
57
+ [--selected NODE_ID,...]
58
+ nexuscrew nodes remove <name|nodeId> --yes
59
+ nexuscrew nodes test|up|down|connect|disconnect|restart|reconnect <name|nodeId>
60
+ [--persist]
61
+ nexuscrew nodes rename <name|nodeId> --label TEXT
62
+ nexuscrew nodes visibility <name|nodeId> network|relay-only|selected
63
+ [--selected NODE_ID,...]
64
+ nexuscrew nodes share <name|nodeId> on|off [--json]
65
+ nexuscrew nodes invite --ssh TARGET [--ssh-port PORT] [--name SLUG]
66
+ [--label TEXT] [--json]
67
+ printf '%s' "$PAIRING_URL" | nexuscrew nodes pair|join
68
+ [--ssh TARGET] [--name SLUG] [--label TEXT]
69
+ nexuscrew nodes identity [--json]
70
+
71
+ Pairing links are read from stdin, never from argv. Mutations honor
72
+ NEXUSCREW_READONLY=1. Routed peers are inspect-only.
73
+
74
+ Lifecycle semantics:
75
+ up connect now and enable autostart
76
+ down disconnect and disable autostart
77
+ disconnect disconnect now without changing autostart
78
+ restart reconnect now without changing autostart`;
79
+
80
+ const CLI_VALUE_FLAGS = new Set([
81
+ 'label', 'ssh', 'ssh-port', 'autostart', 'visibility', 'selected',
82
+ 'name', 'local-label', 'identity-file',
83
+ ]);
84
+
45
85
  // valueFlags (Set|array opzionale): flag che consumano il token successivo nella
46
86
  // forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
47
87
  function parseFlags(argv, valueFlags) {
@@ -70,6 +110,10 @@ function parseFlags(argv, valueFlags) {
70
110
 
71
111
  function serve(opts = {}) {
72
112
  const serverStart = opts.serverStart || require('../server.js').start;
113
+ // Service manager e Termux:Boot entrano direttamente da `serve`, senza
114
+ // passare per smartUp. Ripara solo fleet.json MANCANTE; un file invalido
115
+ // resta intatto e il provider continua a fallire chiuso.
116
+ (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
73
117
  if (opts.pidfile) {
74
118
  const pidPath = pidf.defaultPidfilePath(opts.home);
75
119
  // already-running check
@@ -410,6 +454,11 @@ async function smartUp(opts = {}) {
410
454
  initialized = true;
411
455
  }
412
456
 
457
+ // Config+token da soli non garantiscono un'installazione completa. Questo
458
+ // era il buco delle installazioni/migrazioni Termux che lasciava il provider
459
+ // disabilitato e l'editor Fleet irraggiungibile.
460
+ const fleetBootstrap = (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
461
+
413
462
  // 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
414
463
  // config.json forever. Regenerate once so config.json becomes authoritative.
415
464
  const home = opts.home || require('node:os').homedir();
@@ -423,6 +472,19 @@ async function smartUp(opts = {}) {
423
472
  let running = await probe(port, token, opts);
424
473
  let portableAttempted = false;
425
474
  let runtime = resolveRuntimeOwner({ ...opts, platform });
475
+
476
+ // selectProvider() viene risolto una volta allo startup. Se abbiamo creato
477
+ // fleet.json mentre un vecchio processo era gia' vivo, serve un restart
478
+ // verificato per fargli acquisire il provider builtin.
479
+ if (running && fleetBootstrap.created) {
480
+ const restarted = restartImpl({ ...opts, platform, log: quiet });
481
+ if (!restarted || restarted.restarted !== true) {
482
+ throw new Error(`fleet bootstrap completato ma restart fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
483
+ }
484
+ running = await waitForNexusCrew(port, token, opts);
485
+ if (!running) throw new Error(`server non pronto dopo il bootstrap Fleet su 127.0.0.1:${port}`);
486
+ runtime = resolveRuntimeOwner({ ...opts, platform });
487
+ }
426
488
  if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
427
489
 
428
490
  if (!running) {
@@ -594,10 +656,6 @@ async function runFleetBoot(opts = {}) {
594
656
  const cfg = opts.cfg || loadConfig();
595
657
 
596
658
  const sel = await selectProvider(cfg);
597
- if (sel.mode === 'external') {
598
- log('fleet-boot: boot gestito dal fleet esterno (nessuna azione del companion)');
599
- return { code: 0, mode: 'external' };
600
- }
601
659
  if (sel.mode !== 'builtin' || !sel.fleet || !sel.fleet.available) {
602
660
  // disabled (o builtin unavailable): niente da avviare, non e' un errore.
603
661
  log(`fleet-boot: nessun boot (provider ${sel.mode}${sel.reason ? ' — ' + sel.reason : ''})`);
@@ -623,6 +681,193 @@ function dispatchFleetBoot(opts) {
623
681
  return { code: 0, keepAlive: true }; // il processo resta vivo finche' runFleetBoot non chiama exit()
624
682
  }
625
683
 
684
+ function boolFlag(value, name) {
685
+ if (value === true || value === 'on' || value === 'true' || value === '1') return true;
686
+ if (value === 'off' || value === 'false' || value === '0') return false;
687
+ throw new Error(`${name}: atteso on|off`);
688
+ }
689
+
690
+ function readPairingStdin(opts = {}) {
691
+ const clean = (value) => String(value || '').trim().replace(/[\r\n]/g, '');
692
+ if (typeof opts.stdin === 'function') return clean(opts.stdin());
693
+ if (typeof opts.stdin === 'string') return clean(opts.stdin);
694
+ if (process.stdin.isTTY) return '';
695
+ try { return clean(fs.readFileSync(0, 'utf8')); } catch (_) { return ''; }
696
+ }
697
+
698
+ async function localSettingsRequest(pathname, body, opts = {}) {
699
+ const method = opts.method || 'POST';
700
+ if (typeof opts.localApiImpl === 'function') return opts.localApiImpl(pathname, body, { method });
701
+ const up = await (opts.smartUpImpl || smartUp)({
702
+ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}),
703
+ });
704
+ const { tokenPath } = urlmod.resolvePaths(opts);
705
+ const token = urlmod.readToken(tokenPath);
706
+ if (!token) throw new Error('token locale assente: inizializza NexusCrew');
707
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
708
+ if (typeof fetchImpl !== 'function') throw new Error('fetch non disponibile');
709
+ const response = await fetchImpl(`http://127.0.0.1:${up.port || urlmod.loadPort(opts)}${pathname}`, {
710
+ method,
711
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
712
+ body: JSON.stringify(body || {}),
713
+ });
714
+ const value = await response.json().catch(() => ({}));
715
+ if (!response.ok) {
716
+ const error = new Error(value.error || `HTTP ${response.status}`);
717
+ error.status = response.status;
718
+ error.data = value;
719
+ throw error;
720
+ }
721
+ return value;
722
+ }
723
+
724
+ async function dispatchNodes(rest, flags, opts = {}) {
725
+ const log = opts.log || console.log;
726
+ const sub = rest[1] || 'list';
727
+ const ref = rest[2];
728
+ if (sub === 'help' || sub === '--help' || sub === '-h') { log(NODES_HELP); return { code: 0 }; }
729
+ if (sub === 'list') return { code: nodesCmds.nodesList({
730
+ ...opts, log, json: flags.json === true, direct: flags.direct === true,
731
+ }).code };
732
+ if (sub === 'inspect' || sub === 'show') return { code: nodesCmds.nodesInspect({ ...opts, log, ref, json: flags.json === true }).code };
733
+ if (sub === 'doctor') {
734
+ const view = nodesCmds.loadPeerInventory(opts);
735
+ let peers = view.nodes;
736
+ if (ref) {
737
+ const inspected = nodesCmds.nodesInspect({ ...opts, log: () => {}, ref });
738
+ if (inspected.code !== 0) { log(`nodes doctor: nodo sconosciuto "${ref}"`); return { code: 1 }; }
739
+ peers = [inspected.peer];
740
+ }
741
+ const checks = [];
742
+ for (const peer of peers) {
743
+ if (peer.kind === 'transitive') {
744
+ checks.push({ name: peer.name, nodeId: peer.nodeId, ok: true, result: 'inspect-only', route: peer.route });
745
+ continue;
746
+ }
747
+ const lines = [];
748
+ const result = await nodesCmds.nodesTest({ ...opts, log: (line) => lines.push(String(line)), ref: peer.nodeId || peer.name });
749
+ checks.push({
750
+ name: peer.name, nodeId: peer.nodeId || null, ok: result.code === 0,
751
+ result: result.result || (result.code === 0 ? 'ok' : 'failed'),
752
+ detail: lines[lines.length - 1] || '',
753
+ });
754
+ }
755
+ if (flags.json === true) log(JSON.stringify({ nodeId: view.nodeId, checks }, null, 2));
756
+ else if (checks.length === 0) log('nodes doctor: nessun peer diretto');
757
+ else for (const check of checks) log(`${check.name}: ${check.ok ? 'OK' : 'KO'} — ${check.result}${check.detail ? ` · ${check.detail}` : ''}`);
758
+ return { code: checks.some((check) => !check.ok) ? 1 : 0 };
759
+ }
760
+ if (sub === 'identity') {
761
+ const view = nodesCmds.loadPeerInventory(opts);
762
+ const value = { nodeId: view.nodeId };
763
+ if (flags.json === true) log(JSON.stringify(value, null, 2));
764
+ else log(`nodeId: ${value.nodeId || '(store non inizializzato)'}`);
765
+ return { code: value.nodeId ? 0 : 1 };
766
+ }
767
+ if (sub === 'edit') {
768
+ const patch = {};
769
+ if (flags.label !== undefined) patch.label = flags.label;
770
+ if (flags.ssh !== undefined) patch.ssh = flags.ssh;
771
+ if (flags['ssh-port'] !== undefined) patch.sshPort = Number(flags['ssh-port']);
772
+ if (flags.autostart !== undefined) {
773
+ try { patch.autostart = boolFlag(flags.autostart, '--autostart'); }
774
+ catch (e) { log(e.message); return { code: 1 }; }
775
+ }
776
+ if (flags.visibility !== undefined) patch.visibility = flags.visibility;
777
+ if (flags.selected !== undefined) patch.selected = String(flags.selected).split(',').map((x) => x.trim()).filter(Boolean);
778
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch }).code };
779
+ }
780
+ if (sub === 'rename') {
781
+ if (flags.label === undefined) { log('nodes rename: --label TEXT obbligatorio'); return { code: 1 }; }
782
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { label: flags.label } }).code };
783
+ }
784
+ if (sub === 'visibility') {
785
+ const visibility = rest[3] || flags.visibility;
786
+ const selected = flags.selected === undefined ? []
787
+ : String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
788
+ return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
789
+ }
790
+ if (sub === 'remove') {
791
+ if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
792
+ return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
793
+ }
794
+ if (sub === 'test') return { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code };
795
+ if (['up', 'down', 'connect', 'disconnect', 'restart', 'reconnect'].includes(sub)) {
796
+ const fn = sub === 'up' || sub === 'connect' ? nodesCmds.nodesUp
797
+ : sub === 'restart' || sub === 'reconnect' ? nodesCmds.nodesRestart : nodesCmds.nodesDown;
798
+ const persistAutostart = sub === 'up' || sub === 'down' || flags.persist === true;
799
+ return { code: fn({ ...opts, log, ref, persistAutostart }).code };
800
+ }
801
+ if (sub === 'share') {
802
+ const inspected = nodesCmds.nodesInspect({ ...opts, log: () => {}, ref });
803
+ if (inspected.code !== 0 || inspected.peer.kind !== 'direct') {
804
+ log(`nodes share: peer diretto sconosciuto "${ref || ''}"`); return { code: 1 };
805
+ }
806
+ let shared;
807
+ try { shared = boolFlag(rest[3], 'nodes share'); }
808
+ catch (error) { log(error.message); return { code: 1 }; }
809
+ try {
810
+ const result = await localSettingsRequest(
811
+ `/api/settings/nodes/${encodeURIComponent(inspected.peer.name)}/share`,
812
+ { shared }, { ...opts, method: 'PATCH' },
813
+ );
814
+ if (flags.json === true) log(JSON.stringify(result, null, 2));
815
+ else log(`nodes share [${inspected.peer.name}]: ${shared ? 'on' : 'off'}`);
816
+ return { code: 0 };
817
+ } catch (error) { log(`nodes share: ${error.message}`); return { code: 1 }; }
818
+ }
819
+ if (sub === 'invite') {
820
+ const body = {
821
+ ...(flags.ssh !== undefined ? { ssh: flags.ssh } : {}),
822
+ ...(flags['ssh-port'] !== undefined ? { sshPort: Number(flags['ssh-port']) } : {}),
823
+ ...(flags.name !== undefined ? { name: flags.name } : {}),
824
+ ...(flags.label !== undefined ? { label: flags.label } : {}),
825
+ };
826
+ if (!body.ssh) { log('nodes invite: --ssh TARGET obbligatorio'); return { code: 1 }; }
827
+ try {
828
+ const result = await localSettingsRequest('/api/settings/peering/invite', body, opts);
829
+ if (flags.json === true) log(JSON.stringify(result, null, 2));
830
+ else { log(result.pairingUrl); log(`scade: ${new Date(result.expiresAt).toISOString()}`); }
831
+ return { code: 0 };
832
+ } catch (e) { log(`nodes invite: ${e.message}`); return { code: 1 }; }
833
+ }
834
+ if (sub === 'pair' || sub === 'join') {
835
+ const pairingUrl = readPairingStdin(opts);
836
+ if (!pairingUrl) {
837
+ log("nodes pair: passa il link via stdin (es. printf '%s' \"$PAIRING_URL\" | nexuscrew nodes pair)");
838
+ return { code: 1 };
839
+ }
840
+ const decoded = peering.parsePairingUrl(pairingUrl);
841
+ if (!decoded) { log('nodes pair: link non valido o corrotto'); return { code: 1 }; }
842
+ const body = {
843
+ pairingUrl,
844
+ name: flags.name || decoded.name,
845
+ ssh: flags.ssh || decoded.ssh,
846
+ ...(flags.label !== undefined || decoded.label ? { label: flags.label || decoded.label } : {}),
847
+ ...(flags['ssh-port'] !== undefined || decoded.sshPort ? { sshPort: Number(flags['ssh-port'] || decoded.sshPort) } : {}),
848
+ ...(flags['local-label'] !== undefined ? { localLabel: flags['local-label'] } : {}),
849
+ ...(flags['identity-file'] !== undefined ? { identityFile: flags['identity-file'] } : {}),
850
+ };
851
+ if (!body.name || !body.ssh) {
852
+ log('nodes pair: il link non contiene name/ssh; fornisci --name e --ssh');
853
+ return { code: 1 };
854
+ }
855
+ try {
856
+ const result = await localSettingsRequest('/api/settings/nodes/pair', body, opts);
857
+ if (flags.json === true) log(JSON.stringify(result, null, 2));
858
+ else log(`nodes pair: collegato "${result.name}" (${result.instanceId})`);
859
+ return { code: 0 };
860
+ } catch (e) {
861
+ const stage = e.data && e.data.stage ? ` [${e.data.stage}]` : '';
862
+ const hint = e.data && e.data.hint ? ` · ${e.data.hint}` : '';
863
+ log(`nodes pair${stage}: ${e.message}${hint}`);
864
+ return { code: 1 };
865
+ }
866
+ }
867
+ log(`comando nodes sconosciuto: ${sub}\n\n${NODES_HELP}`);
868
+ return { code: 1 };
869
+ }
870
+
626
871
  function dispatch(argv, opts = {}) {
627
872
  const log = opts.log || console.log;
628
873
  if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -633,7 +878,7 @@ function dispatch(argv, opts = {}) {
633
878
  log(require('../../package.json').version);
634
879
  return { code: 0 };
635
880
  }
636
- const { flags, rest } = parseFlags(argv);
881
+ const { flags, rest } = parseFlags(argv, CLI_VALUE_FLAGS);
637
882
  const cmd = rest[0];
638
883
 
639
884
  // help esplicito
@@ -673,9 +918,10 @@ function dispatch(argv, opts = {}) {
673
918
  return { code: 0 };
674
919
  }
675
920
  if (cmd === 'status') {
676
- status({ ...opts, log });
921
+ status({ ...opts, log, json: flags.json === true });
677
922
  return { code: 0 };
678
923
  }
924
+ if (cmd === 'nodes' || cmd === 'peers') return dispatchNodes(rest, flags, opts);
679
925
  if (cmd === 'stop') {
680
926
  const result = stop({ ...opts, log });
681
927
  return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
@@ -687,10 +933,11 @@ function dispatch(argv, opts = {}) {
687
933
  // Internal runtime commands used by service managers and MCP clients. They
688
934
  // are intentionally omitted from HELP and are not configuration surfaces.
689
935
  if (cmd === 'serve') {
690
- serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
936
+ serve({ ...opts, pidfile: flags.pidfile, serverStart: opts.serverStart });
691
937
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
692
938
  }
693
939
  if (cmd === 'doctor') {
940
+ if (flags.peers === true) return dispatchNodes(['nodes', 'doctor'], flags, opts);
694
941
  const r = doctor({ ...opts, log });
695
942
  return { code: r.code };
696
943
  }
@@ -709,7 +956,7 @@ function dispatch(argv, opts = {}) {
709
956
  }
710
957
 
711
958
  module.exports = {
712
- dispatch, serve, start, stop, status, parseFlags, HELP,
959
+ dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
713
960
  smartUp, url, tokenRotate, logs, doctor, update, restart,
714
961
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
715
962
  servicePinsLegacyPort,
package/lib/cli/doctor.js CHANGED
@@ -13,6 +13,7 @@ const { detectPlatform, uid } = require('./platform.js');
13
13
  const { installPath } = require('./service.js');
14
14
  const { resolvePaths } = require('./url.js');
15
15
  const { commandExists } = require('./path.js');
16
+ const { loadDefinitions } = require('../fleet/definitions.js');
16
17
 
17
18
  function nodeMajor() {
18
19
  return parseInt(String(process.versions.node).split('.')[0], 10);
@@ -111,18 +112,38 @@ function checkTmuxSurvival(platform, execImpl) {
111
112
  if (platform !== 'linux') {
112
113
  return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
113
114
  }
114
- try {
115
- const value = String(execImpl('systemctl', [
116
- '--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
117
- ], { encoding: 'utf8' }) || '').trim();
118
- const ok = value === 'process';
119
- return {
120
- name: 'tmux survival on service restart', ok,
121
- detail: ok ? 'KillMode=process' : `KillMode=${value || 'sconosciuto'} (restart NexusCrew puo terminare tmux)`,
122
- };
123
- } catch (error) {
124
- return { name: 'tmux survival on service restart', ok: false, detail: `KillMode non verificabile: ${error.message || error}` };
115
+ const units = ['nexuscrew.service', 'nexuscrew-fleet.service'];
116
+ const results = [];
117
+ for (const unit of units) {
118
+ try {
119
+ const loadState = String(execImpl('systemctl', [
120
+ '--user', 'show', unit, '--property=LoadState', '--value',
121
+ ], { encoding: 'utf8' }) || '').trim();
122
+ if (loadState === 'not-found') {
123
+ results.push({ unit, skipped: true, detail: 'non installata' });
124
+ continue;
125
+ }
126
+ const value = String(execImpl('systemctl', [
127
+ '--user', 'show', unit, '--property=KillMode', '--value',
128
+ ], { encoding: 'utf8' }) || '').trim();
129
+ results.push({ unit, ok: value === 'process', value: value || 'sconosciuto' });
130
+ } catch (error) {
131
+ if (/not[ -]?found|could not be found|not loaded/i.test(String(error && error.message || error))) {
132
+ results.push({ unit, skipped: true, detail: 'non installata' });
133
+ } else {
134
+ results.push({ unit, ok: false, value: `non verificabile: ${error.message || error}` });
135
+ }
136
+ }
125
137
  }
138
+ const checked = results.filter((result) => !result.skipped);
139
+ const ok = checked.length > 0 && checked.every((result) => result.ok);
140
+ const detail = results.map((result) => result.skipped
141
+ ? `${result.unit}: ${result.detail}`
142
+ : `${result.unit}: KillMode=${result.value}`).join(' · ');
143
+ return {
144
+ name: 'tmux survival on service restart', ok,
145
+ detail: ok ? detail : `${detail} (restart/oneshot puo terminare tmux)`,
146
+ };
126
147
  }
127
148
 
128
149
  // ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
@@ -165,6 +186,34 @@ function checkTokenPerms(tokenPath) {
165
186
  }
166
187
  }
167
188
 
189
+ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
190
+ const target = fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
191
+ if (!enabled) {
192
+ return { name: 'Fleet builtin definitions', ok: true, warn: true, detail: 'disabilitata intenzionalmente' };
193
+ }
194
+ let st;
195
+ try {
196
+ st = fs.lstatSync(target);
197
+ } catch (e) {
198
+ return {
199
+ name: 'Fleet builtin definitions', ok: false,
200
+ detail: e.code === 'ENOENT' ? `fleet.json assente (${target}); esegui nexuscrew per riparare` : e.message,
201
+ };
202
+ }
203
+ if (!st.isFile() || st.isSymbolicLink()) {
204
+ return { name: 'Fleet builtin definitions', ok: false, detail: `target non sicuro o non regolare (${target})` };
205
+ }
206
+ const defs = loadDefinitions(target);
207
+ if (!defs) {
208
+ return { name: 'Fleet builtin definitions', ok: false, detail: `fleet.json invalido (${target}); preservato, non sovrascritto` };
209
+ }
210
+ const mode = st.mode & 0o777;
211
+ return {
212
+ name: 'Fleet builtin definitions', ok: true, warn: mode !== 0o600,
213
+ detail: `${defs.engines.length} engine · ${defs.cells.length} celle · mode 0${mode.toString(8)}`,
214
+ };
215
+ }
216
+
168
217
  // Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
169
218
  function doctor(opts = {}) {
170
219
  const platform = opts.platform || detectPlatform();
@@ -175,6 +224,9 @@ function doctor(opts = {}) {
175
224
  const ptyLoad = opts.ptyLoad || (() => require('../pty/provider.js').loadPty());
176
225
  const existsImpl = opts.commandExists || commandExists;
177
226
  const { tokenPath } = resolvePaths(opts);
227
+ const fleetEnabled = opts.fleetEnabled !== false
228
+ && opts.builtinEnabled !== false
229
+ && process.env.NEXUSCREW_FLEET !== '0';
178
230
 
179
231
  const checks = [
180
232
  checkNode(),
@@ -185,6 +237,7 @@ function doctor(opts = {}) {
185
237
  checkUserLinger(platform, execImpl, uidVal),
186
238
  checkTmuxSurvival(platform, execImpl),
187
239
  checkTokenPerms(tokenPath),
240
+ checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
188
241
  checkSshClient(existsImpl),
189
242
  checkAutossh(existsImpl),
190
243
  checkSshPermitlisten(opts.sshVersion),
@@ -202,6 +255,7 @@ function doctor(opts = {}) {
202
255
  module.exports = {
203
256
  doctor, nodeMajor,
204
257
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
258
+ checkFleetDefinitions,
205
259
  checkTmuxSurvival, checkUserLinger,
206
260
  checkSshClient, checkAutossh, checkSshPermitlisten,
207
261
  };
@@ -58,6 +58,9 @@ After=network-online.target
58
58
 
59
59
  [Service]
60
60
  Type=oneshot
61
+ # fleet-boot may be the process that creates the shared tmux server. Keep
62
+ # systemd from reaping that server and its cells when this oneshot exits.
63
+ KillMode=process
61
64
  WorkingDirectory=${repo}
62
65
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
63
66
  ExecStart=${node} ${entry} fleet-boot
@@ -164,63 +167,29 @@ function migrationGate(opts = {}) {
164
167
  return {
165
168
  blocked: true,
166
169
  units,
167
- remediation: 'disabilita le unit legacy cloud-cell@*.service (systemctl --user disable "cloud-cell@*") o usa il fleet esterno; il companion nexuscrew-fleet.service non va installato per evitare doppio boot',
170
+ remediation: 'esegui il backup, poi disabilita le unit legacy cloud-cell@*.service prima di abilitare nexuscrew-fleet.service; non devono esistere due proprietari del boot',
168
171
  };
169
172
  }
170
173
  return { blocked: false, units: [], reason: 'nessuna unit cloud-cell@*.service abilitata' };
171
174
  }
172
175
 
173
176
  // --- selectProviderModeSync — resolver SINCRONO del mode del provider ---
174
- // Speculare alla logica di selectProvider (lib/fleet/provider.js) ma SENZA spawn/await.
175
- // Necessario: runInit e' sync (bin/nexuscrew.js chiama process.exit subito dopo dispatch
176
- // per 'init'; selectProvider e' async e non sopravvive al process.exit). E' il default
177
- // del seam opts.selectProvider del companion (iniettabile per i test).
178
- //
179
- // Divergenza documentata vs selectProvider: la responsivita' del fleet esterno
180
- // (status --json) richiederebbe spawn async; qui un fleetBin FIDATO (binTrusted, sync)
181
- // -> external. Scelta CONSERVATIVA sicura (no-doppio-boot, §9b): se c'e' un fleet
182
- // esterno fidato configurato, NON installiamo il companion builtin.
183
- // Ritorna {mode, reason} con mode in 'external' | 'builtin' | 'disabled'.
177
+ // Speculare alla logica builtin-only di lib/fleet/provider.js ma sincrono per
178
+ // runInit. Il binario legacy `fleet` non viene scoperto eseguito.
184
179
  function selectProviderModeSync(cfg = {}) {
185
180
  if (cfg.fleetEnabled === false) {
186
181
  return { mode: 'disabled', reason: 'fleet disabilitato (fleetEnabled=false)' };
187
182
  }
188
- const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
189
-
190
- // external: binario fidato (regular file, exec, non world-writable, no symlink).
191
- // lazy-require: niente costo/accoppiamento all'import del modulo (usato solo dal companion).
192
- const { binTrusted, externalFleetCandidates } = require('../fleet/index.js'); // sync (audit F3)
193
- // Deve usare lo stesso insieme di path del resolver runtime asincrono. In
194
- // particolare Termux installa normalmente `fleet` in $PREFIX/bin: se init
195
- // guardasse soltanto cfg.fleetBin potrebbe installare il companion builtin e
196
- // il runtime scegliere poi l'external, creando due proprietari del boot.
197
- const trustedExternal = externalFleetCandidates(cfg).find((candidate) => binTrusted(candidate)) || null;
198
- const extTrusted = !!trustedExternal;
183
+ if (cfg.builtinEnabled === false) return { mode: 'disabled', reason: 'fleet builtin disabilitata' };
199
184
 
200
185
  // builtin: fleet.json valido (loadDefinitions e' sync; garbage -> null = fail-closed).
201
186
  const { loadDefinitions } = require('../fleet/definitions.js');
202
187
  const home = cfg.home || os.homedir();
203
188
  const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
204
- const builtinAvail = cfg.builtinEnabled !== false ? !!loadDefinitions(defsPath) : false;
205
-
206
- // forced: fail-closed se il richiesto non e' disponibile (§9g: niente auto-fallback).
207
- if (forced === 'external') {
208
- return extTrusted
209
- ? { mode: 'external', reason: 'forced external (binario fidato)' }
210
- : { mode: 'disabled', reason: 'fail-closed: forced external non fidato (§9g)' };
211
- }
212
- if (forced === 'builtin') {
213
- return builtinAvail
214
- ? { mode: 'builtin', reason: 'forced builtin (fleet.json valido)' }
215
- : { mode: 'disabled', reason: 'fail-closed: forced builtin ma fleet.json invalido/mancante (§9g)' };
216
- }
217
- if (forced === 'disabled') return { mode: 'disabled', reason: 'forced disabled' };
218
- if (forced) return { mode: 'disabled', reason: `fail-closed: provider forzato "${forced}" non riconosciuto` };
189
+ const builtinAvail = !!loadDefinitions(defsPath);
219
190
 
220
- // auto: external fidato vince (no-doppio-boot), poi builtin, poi disabled.
221
- if (extTrusted) return { mode: 'external', reason: `auto: external fidato configurato (${trustedExternal}; skip companion)` };
222
- if (builtinAvail) return { mode: 'builtin', reason: 'auto: builtin (fleet.json valido)' };
223
- return { mode: 'disabled', reason: 'auto: nessun provider disponibile (no external fidato, no fleet.json)' };
191
+ if (builtinAvail) return { mode: 'builtin', reason: 'NexusCrew builtin (fleet.json valido)' };
192
+ return { mode: 'disabled', reason: 'fleet.json invalido o mancante (fail-closed)' };
224
193
  }
225
194
 
226
195
  // --- Install (speculare a installService di lib/cli/service.js) ---
package/lib/cli/init.js CHANGED
@@ -47,6 +47,30 @@ function writeConfigAtomic(configPath, value) {
47
47
  }
48
48
  }
49
49
 
50
+ // Bootstrap/migration idempotente degli artefatti Fleet. Un file presente non
51
+ // viene MAI riscritto qui: se e' invalido, il provider e doctor devono restare
52
+ // fail-closed e mostrarne la causa. Questo helper copre anche installazioni
53
+ // parziali in cui config+token esistono ma fleet.json non fu mai creato.
54
+ function ensureFleetDefaults(opts = {}) {
55
+ const home = opts.home || os.homedir();
56
+ const configDir = opts.configDir || path.join(home, '.nexuscrew');
57
+ const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
58
+ const enabled = opts.fleetEnabled !== false
59
+ && opts.builtinEnabled !== false
60
+ && process.env.NEXUSCREW_FLEET !== '0';
61
+ if (!enabled) return { path: fleetDefsPath, created: false, enabled: false };
62
+
63
+ try {
64
+ fs.lstatSync(fleetDefsPath);
65
+ return { path: fleetDefsPath, created: false, enabled: true };
66
+ } catch (e) {
67
+ if (e.code !== 'ENOENT') throw e;
68
+ }
69
+
70
+ writeFleet(fleetDefsPath, defaultDefinitions());
71
+ return { path: fleetDefsPath, created: true, enabled: true };
72
+ }
73
+
50
74
  // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
51
75
  function readExistingPort(platform, home, installPathOverride) {
52
76
  const p = installPathOverride || svcInstallPath(platform, home);
@@ -118,15 +142,15 @@ function runInit(opts = {}) {
118
142
  // Fleet app defaults: soltanto i quattro client nativi. Provider cloud/Z.AI sono
119
143
  // disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
120
144
  const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
121
- if (!dryRun && !fs.existsSync(fleetDefsPath)) {
145
+ if (!dryRun) {
122
146
  try {
123
- writeFleet(fleetDefsPath, defaultDefinitions());
124
- actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
147
+ const fleetBootstrap = ensureFleetDefaults({ ...opts, home, configDir, fleetDefsPath });
148
+ if (!fleetBootstrap.enabled) actions.push('fleet defaults: disabilitati intenzionalmente');
149
+ else if (fleetBootstrap.created) actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
150
+ else actions.push(`preserved fleet definitions ${fleetDefsPath}`);
125
151
  } catch (e) {
126
152
  actions.push(`WARN: fleet defaults non creati: ${e.message}`);
127
153
  }
128
- } else if (!dryRun) {
129
- actions.push(`preserved fleet definitions ${fleetDefsPath}`);
130
154
  }
131
155
  if (!port) port = 41820;
132
156
 
@@ -219,8 +243,6 @@ function runInit(opts = {}) {
219
243
  const fleetCfg = opts.fleetCfg || {
220
244
  home,
221
245
  fleetEnabled: opts.fleetEnabled !== undefined ? opts.fleetEnabled : true,
222
- fleetBin: opts.fleetBin || path.join(home, '.local', 'bin', 'fleet'),
223
- fleetProvider: opts.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER,
224
246
  builtinEnabled: opts.builtinEnabled,
225
247
  fleetDefsPath,
226
248
  };
@@ -284,4 +306,4 @@ function runInit(opts = {}) {
284
306
  return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
285
307
  }
286
308
 
287
- module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic };
309
+ module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };