@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.
- package/README.md +27 -22
- package/frontend/dist/assets/{index-DQbVJLji.css → index-77r8nzQf.css} +1 -1
- package/frontend/dist/assets/index-C0QL3gEp.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +2 -2
- package/lib/cli/commands.js +257 -10
- package/lib/cli/doctor.js +65 -11
- package/lib/cli/fleet-service.js +10 -41
- package/lib/cli/init.js +30 -8
- package/lib/config.js +7 -2
- package/lib/fleet/builtin.js +17 -6
- package/lib/fleet/cell-exec.js +165 -11
- package/lib/fleet/definitions.js +1 -1
- package/lib/fleet/provider.js +10 -90
- package/lib/fleet/routes.js +8 -14
- package/lib/fleet/runtime.js +44 -22
- package/lib/nodes/commands.js +160 -19
- package/lib/nodes/inventory.js +81 -0
- package/lib/server.js +40 -6
- package/lib/settings/routes.js +43 -23
- package/lib/tmux/lifecycle.js +4 -2
- package/lib/tmux/shared-server.js +60 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-DaFQbMHq.js +0 -93
- package/lib/fleet/exec.js +0 -32
- package/lib/fleet/index.js +0 -180
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
1
|
+
{"version":"0.8.23"}
|
package/lib/cells/routes.js
CHANGED
|
@@ -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
|
-
//
|
|
21
|
-
//
|
|
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}`,
|
package/lib/cli/commands.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
};
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -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
|
|
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
|
|
175
|
-
//
|
|
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 né 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
|
-
|
|
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 =
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
145
|
+
if (!dryRun) {
|
|
122
146
|
try {
|
|
123
|
-
|
|
124
|
-
actions.push(
|
|
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 };
|