@mmmbuto/nexuscrew 0.7.6 → 0.8.0
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 +173 -18
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// nexuscrew doctor: auto-diagnosi (design §3, §7). [A2]
|
|
3
|
+
// Struttura ESTENSIBILE: una lista di check-fn, ognuna ritorna
|
|
4
|
+
// { name, ok, warn?, detail? }. B0 aggiungerà i check tunnel/ssh (permitlisten,
|
|
5
|
+
// OpenSSH ≥7.8 sul rendezvous) appendendo alla lista, senza toccare il resto.
|
|
6
|
+
// Exit code: 0 se tutti ok (i warn non falliscono), 1 se almeno un check è FAIL.
|
|
7
|
+
// Nessun segreto nell'output (mai il token, solo il path + i permessi).
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { execFileSync } = require('node:child_process');
|
|
12
|
+
const { detectPlatform, uid } = require('./platform.js');
|
|
13
|
+
const { installPath } = require('./service.js');
|
|
14
|
+
const { resolvePaths } = require('./url.js');
|
|
15
|
+
const { commandExists } = require('./path.js');
|
|
16
|
+
|
|
17
|
+
function nodeMajor() {
|
|
18
|
+
return parseInt(String(process.versions.node).split('.')[0], 10);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function checkNode() {
|
|
22
|
+
const maj = nodeMajor();
|
|
23
|
+
return { name: 'node >= 18', ok: maj >= 18, detail: `v${process.versions.node}` };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function checkTmux(existsImpl, tmuxBin) {
|
|
27
|
+
return existsImpl(tmuxBin || 'tmux')
|
|
28
|
+
? { name: 'tmux presente', ok: true }
|
|
29
|
+
: { name: 'tmux presente', ok: false, detail: 'non trovato su PATH (installa tmux)' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checkPty(ptyLoad) {
|
|
33
|
+
try {
|
|
34
|
+
ptyLoad();
|
|
35
|
+
return { name: 'PTY prebuilt caricabile', ok: true };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { name: 'PTY prebuilt caricabile', ok: false, detail: e && e.message ? e.message : 'load fallito' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checkService(platform, home, execImpl, uidVal, installPathOverride) {
|
|
42
|
+
const target = installPathOverride || installPath(platform, home);
|
|
43
|
+
const installed = fs.existsSync(target);
|
|
44
|
+
let active = false;
|
|
45
|
+
try {
|
|
46
|
+
if (platform === 'linux') {
|
|
47
|
+
const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' });
|
|
48
|
+
active = String(s).trim() === 'active';
|
|
49
|
+
} else if (platform === 'mac') {
|
|
50
|
+
execImpl('launchctl', ['print', `gui/${uidVal}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' });
|
|
51
|
+
active = true;
|
|
52
|
+
} else if (platform === 'termux') {
|
|
53
|
+
const pidf = require('./pidfile.js');
|
|
54
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
55
|
+
active = !!(meta && pidf.isAlive(meta));
|
|
56
|
+
}
|
|
57
|
+
} catch (_) { active = false; }
|
|
58
|
+
return {
|
|
59
|
+
name: 'service installato/attivo',
|
|
60
|
+
ok: installed,
|
|
61
|
+
warn: installed && !active, // installato ma non attivo = warning, non fail
|
|
62
|
+
detail: installed ? (active ? 'attivo' : 'installato ma non attivo') : `non installato (${target})`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkBoot(platform, home, execImpl) {
|
|
67
|
+
if (platform === 'termux') {
|
|
68
|
+
const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
69
|
+
const ok = fs.existsSync(p);
|
|
70
|
+
return { name: 'boot script', ok, warn: !ok, detail: ok ? p : 'nessun Termux:boot script' };
|
|
71
|
+
}
|
|
72
|
+
if (platform === 'linux') {
|
|
73
|
+
try {
|
|
74
|
+
const s = execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' });
|
|
75
|
+
const enabled = String(s).trim() === 'enabled';
|
|
76
|
+
return { name: 'boot (systemd enabled)', ok: true, warn: !enabled, detail: enabled ? 'enabled' : 'non enabled (non parte al boot)' };
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return { name: 'boot (systemd enabled)', ok: true, warn: true, detail: 'non enabled' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// mac: RunAtLoad nel plist installato
|
|
82
|
+
const target = installPath('mac', home);
|
|
83
|
+
const ok = fs.existsSync(target);
|
|
84
|
+
return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
|
|
88
|
+
function checkSshClient(existsImpl) {
|
|
89
|
+
return existsImpl('ssh')
|
|
90
|
+
? { name: 'ssh client presente', ok: true }
|
|
91
|
+
: { name: 'ssh client presente', ok: false, detail: 'ssh non trovato su PATH (necessario per i tunnel multi-node)' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// OpenSSH >=7.8 per permitlisten (vincola i -R lato rendezvous; design §7 (a)).
|
|
95
|
+
// Determinabile e <7.8 -> FAIL (il ruolo node non e' abilitabile). Non
|
|
96
|
+
// determinabile -> WARN (verifica manuale), non blocca la diagnosi generale.
|
|
97
|
+
function checkSshPermitlisten(sshVersionImpl) {
|
|
98
|
+
const tun = require('../nodes/tunnel.js');
|
|
99
|
+
const v = tun.readSshVersion(sshVersionImpl);
|
|
100
|
+
const supp = tun.sshSupportsPermitlisten(v);
|
|
101
|
+
if (supp === null) {
|
|
102
|
+
return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, warn: true, detail: 'versione ssh non determinabile — verifica prima di abilitare il ruolo node' };
|
|
103
|
+
}
|
|
104
|
+
if (supp) return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, detail: v.raw };
|
|
105
|
+
return { name: 'OpenSSH permitlisten (>=7.8)', ok: false, detail: `OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato (ruolo node non abilitabile)` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkTokenPerms(tokenPath) {
|
|
109
|
+
try {
|
|
110
|
+
const st = fs.lstatSync(tokenPath);
|
|
111
|
+
if (st.isSymbolicLink()) {
|
|
112
|
+
return { name: 'token file permessi', ok: false, detail: 'è un symlink (rifiutato)' };
|
|
113
|
+
}
|
|
114
|
+
const mode = st.mode & 0o777;
|
|
115
|
+
const ok = mode === 0o600;
|
|
116
|
+
return { name: 'token file permessi', ok, detail: `mode 0${mode.toString(8)}${ok ? '' : ' (atteso 0600)'}` };
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (e.code === 'ENOENT') {
|
|
119
|
+
return { name: 'token file permessi', ok: false, detail: 'token assente (esegui init)' };
|
|
120
|
+
}
|
|
121
|
+
return { name: 'token file permessi', ok: false, detail: e.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
|
|
126
|
+
function doctor(opts = {}) {
|
|
127
|
+
const platform = opts.platform || detectPlatform();
|
|
128
|
+
const home = opts.home || os.homedir();
|
|
129
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
130
|
+
const uidVal = opts.uid || uid();
|
|
131
|
+
const log = opts.log || console.log;
|
|
132
|
+
const ptyLoad = opts.ptyLoad || (() => require('../pty/provider.js').loadPty());
|
|
133
|
+
const existsImpl = opts.commandExists || commandExists;
|
|
134
|
+
const { tokenPath } = resolvePaths(opts);
|
|
135
|
+
|
|
136
|
+
const checks = [
|
|
137
|
+
checkNode(),
|
|
138
|
+
checkTmux(existsImpl, opts.tmuxBin),
|
|
139
|
+
checkPty(ptyLoad),
|
|
140
|
+
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
141
|
+
checkBoot(platform, home, execImpl),
|
|
142
|
+
checkTokenPerms(tokenPath),
|
|
143
|
+
checkSshClient(existsImpl),
|
|
144
|
+
checkSshPermitlisten(opts.sshVersion),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
for (const c of checks) {
|
|
148
|
+
const tag = c.ok ? (c.warn ? 'WARN' : 'OK ') : 'FAIL';
|
|
149
|
+
log(`${tag} ${c.name}${c.detail ? ' — ' + c.detail : ''}`);
|
|
150
|
+
}
|
|
151
|
+
const ok = checks.every((c) => c.ok); // i warn non fanno fallire
|
|
152
|
+
log(ok ? 'doctor: tutto ok' : 'doctor: problemi rilevati (vedi FAIL sopra)');
|
|
153
|
+
return { platform, checks, ok, code: ok ? 0 : 1 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
doctor, nodeMajor,
|
|
158
|
+
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
159
|
+
checkSshClient, checkSshPermitlisten,
|
|
160
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// B4.3 — Service companion (boot) generation + migration gate. Design §4c / §9b.
|
|
3
|
+
//
|
|
4
|
+
// generateFleetService({platform, nodeBin, entryPath}) genera il contenuto del
|
|
5
|
+
// service UNICO di boot (NON una unit per cella) che al boot esegue
|
|
6
|
+
// <node> <entry> fleet-boot
|
|
7
|
+
// seguendo ESATTAMENTE i pattern/escaping di lib/cli/service.js:
|
|
8
|
+
// linux -> systemd --user 'nexuscrew-fleet.service' (Type=oneshot)
|
|
9
|
+
// mac -> launchd plist 'com.mmmbuto.nexuscrew-fleet.plist' (RunAtLoad)
|
|
10
|
+
// termux -> script '~/.termux/boot/nexuscrew-fleet.sh'
|
|
11
|
+
//
|
|
12
|
+
// migrationGate({exec, platform}) rileva unit legacy 'cloud-cell@*.service'
|
|
13
|
+
// abilitate (systemctl --user list-unit-files --state=enabled, via execFile —
|
|
14
|
+
// MAI shell string). Se presenti -> {blocked:true, units, remediation}: il
|
|
15
|
+
// companion NON va installato (design 9b: mai doppio boot silenzioso). Su
|
|
16
|
+
// piattaforme senza systemd il gate passa. `exec` e' iniettabile per testabilita'.
|
|
17
|
+
//
|
|
18
|
+
// Nessun side-effect all'import e NESSUNA installazione reale qui (solo generazione
|
|
19
|
+
// contenuto + gate). L'installazione e' demandata al flusso init (separato).
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { execFileSync } = require('node:child_process');
|
|
25
|
+
const {
|
|
26
|
+
escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote, assertSystemdSafe,
|
|
27
|
+
} = require('./service.js');
|
|
28
|
+
const { uid } = require('./platform.js');
|
|
29
|
+
|
|
30
|
+
// entryPath e' tipicamente <repoRoot>/bin/nexuscrew.js -> repoRoot = dirname x2.
|
|
31
|
+
function deriveRepoRoot(entryPath) {
|
|
32
|
+
return path.dirname(path.dirname(entryPath));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generateFleetService(opts) {
|
|
36
|
+
const platform = opts && opts.platform;
|
|
37
|
+
if (platform === 'linux') return generateFleetLinux(opts);
|
|
38
|
+
if (platform === 'mac') return generateFleetMac(opts);
|
|
39
|
+
if (platform === 'termux') return generateFleetTermux(opts);
|
|
40
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function generateFleetLinux(opts) {
|
|
44
|
+
const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
|
|
45
|
+
const { nodeBin, entryPath } = opts;
|
|
46
|
+
// Parita' di hardening con service.js (M3): reject char non gestibili in systemd.
|
|
47
|
+
assertSystemdSafe('repoRoot', repoRoot);
|
|
48
|
+
assertSystemdSafe('nodeBin', nodeBin);
|
|
49
|
+
assertSystemdSafe('entryPath', entryPath);
|
|
50
|
+
const repo = escapeSystemdPath(repoRoot);
|
|
51
|
+
const node = escapeSystemdExec(nodeBin);
|
|
52
|
+
const entry = escapeSystemdExec(entryPath);
|
|
53
|
+
const nodeDir = escapeSystemdPath(path.dirname(nodeBin));
|
|
54
|
+
return `# NexusCrew fleet boot companion (systemd --user) - avvia le celle boot:true
|
|
55
|
+
[Unit]
|
|
56
|
+
Description=NexusCrew fleet boot companion (avvia le celle boot:true)
|
|
57
|
+
After=network-online.target
|
|
58
|
+
|
|
59
|
+
[Service]
|
|
60
|
+
Type=oneshot
|
|
61
|
+
WorkingDirectory=${repo}
|
|
62
|
+
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
63
|
+
ExecStart=${node} ${entry} fleet-boot
|
|
64
|
+
|
|
65
|
+
[Install]
|
|
66
|
+
WantedBy=default.target
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function generateFleetMac(opts) {
|
|
71
|
+
const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
|
|
72
|
+
const home = opts.home || os.homedir();
|
|
73
|
+
const nodeXml = escapeXml(opts.nodeBin);
|
|
74
|
+
const entryXml = escapeXml(opts.entryPath);
|
|
75
|
+
const repoXml = escapeXml(repoRoot);
|
|
76
|
+
const homeXml = escapeXml(home);
|
|
77
|
+
const launchPath = [...new Set([
|
|
78
|
+
path.dirname(opts.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
79
|
+
])].join(':');
|
|
80
|
+
const launchPathXml = escapeXml(launchPath);
|
|
81
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
83
|
+
<plist version="1.0">
|
|
84
|
+
<dict>
|
|
85
|
+
<key>Label</key>
|
|
86
|
+
<string>com.mmmbuto.nexuscrew-fleet</string>
|
|
87
|
+
<key>ProgramArguments</key>
|
|
88
|
+
<array>
|
|
89
|
+
<string>${nodeXml}</string>
|
|
90
|
+
<string>${entryXml}</string>
|
|
91
|
+
<string>fleet-boot</string>
|
|
92
|
+
</array>
|
|
93
|
+
<key>WorkingDirectory</key>
|
|
94
|
+
<string>${repoXml}</string>
|
|
95
|
+
<key>EnvironmentVariables</key>
|
|
96
|
+
<dict>
|
|
97
|
+
<key>PATH</key>
|
|
98
|
+
<string>${launchPathXml}</string>
|
|
99
|
+
</dict>
|
|
100
|
+
<key>RunAtLoad</key>
|
|
101
|
+
<true/>
|
|
102
|
+
<key>StandardOutPath</key>
|
|
103
|
+
<string>${homeXml}/.nexuscrew/fleet-boot.log</string>
|
|
104
|
+
<key>StandardErrorPath</key>
|
|
105
|
+
<string>${homeXml}/.nexuscrew/fleet-boot.log</string>
|
|
106
|
+
</dict>
|
|
107
|
+
</plist>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function generateFleetTermux(opts) {
|
|
112
|
+
const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
|
|
113
|
+
const nodeQ = shellQuote(opts.nodeBin);
|
|
114
|
+
const entryQ = shellQuote(opts.entryPath);
|
|
115
|
+
const repoQ = shellQuote(repoRoot);
|
|
116
|
+
return `#!/data/data/com.termux/files/usr/bin/sh
|
|
117
|
+
# NexusCrew fleet boot companion (Termux) - avvia le celle boot:true
|
|
118
|
+
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
119
|
+
export HOME=/data/data/com.termux/files/home
|
|
120
|
+
cd -- ${repoQ}
|
|
121
|
+
exec ${nodeQ} ${entryQ} fleet-boot >> "$HOME/.nexuscrew/fleet-boot.log" 2>&1
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default exec per il gate: execFileSync (argv diretto, MAI shell string).
|
|
126
|
+
function defaultListEnabled(bin, args, opts) {
|
|
127
|
+
return execFileSync(bin, args, opts);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Migration gate (design 9b): blocca l'installazione del companion se ci sono
|
|
131
|
+
// unit legacy cloud-cell@*.service abilitate (mai doppio boot silenzioso).
|
|
132
|
+
// opts.exec — funzione iniettabile con firma execFileSync(bin, args, opts)
|
|
133
|
+
// opts.platform — se !== 'linux' il gate passa (no systemd -> no rischio)
|
|
134
|
+
// Ritorna {blocked, units[], remediation?|reason?}.
|
|
135
|
+
function migrationGate(opts = {}) {
|
|
136
|
+
const { exec, platform } = opts;
|
|
137
|
+
if (platform && platform !== 'linux') {
|
|
138
|
+
return { blocked: false, units: [], reason: `platform ${platform}: no systemd, gate skipped` };
|
|
139
|
+
}
|
|
140
|
+
const execImpl = typeof exec === 'function' ? exec : defaultListEnabled;
|
|
141
|
+
|
|
142
|
+
let stdout;
|
|
143
|
+
try {
|
|
144
|
+
stdout = execImpl('systemctl',
|
|
145
|
+
['--user', 'list-unit-files', '--state=enabled', 'cloud-cell@*'],
|
|
146
|
+
{ encoding: 'utf8' });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// systemctl assente / ambiente non systemd-user: nessuna evidenza di unit
|
|
149
|
+
// legacy -> il gate passa (non blocca su assenza di informazioni).
|
|
150
|
+
return { blocked: false, units: [], reason: `gate skipped: ${e && e.message ? e.message : 'command error'}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const units = [];
|
|
154
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
155
|
+
const tok = line.trim().split(/\s+/)[0];
|
|
156
|
+
if (tok && /^cloud-cell@.+\.service$/.test(tok)) units.push(tok);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (units.length) {
|
|
160
|
+
return {
|
|
161
|
+
blocked: true,
|
|
162
|
+
units,
|
|
163
|
+
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',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { blocked: false, units: [], reason: 'nessuna unit cloud-cell@*.service abilitata' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- selectProviderModeSync — resolver SINCRONO del mode del provider ---
|
|
170
|
+
// Speculare alla logica di selectProvider (lib/fleet/provider.js) ma SENZA spawn/await.
|
|
171
|
+
// Necessario: runInit e' sync (bin/nexuscrew.js chiama process.exit subito dopo dispatch
|
|
172
|
+
// per 'init'; selectProvider e' async e non sopravvive al process.exit). E' il default
|
|
173
|
+
// del seam opts.selectProvider del companion (iniettabile per i test).
|
|
174
|
+
//
|
|
175
|
+
// Divergenza documentata vs selectProvider: la responsivita' del fleet esterno
|
|
176
|
+
// (status --json) richiederebbe spawn async; qui un fleetBin FIDATO (binTrusted, sync)
|
|
177
|
+
// -> external. Scelta CONSERVATIVA sicura (no-doppio-boot, §9b): se c'e' un fleet
|
|
178
|
+
// esterno fidato configurato, NON installiamo il companion builtin.
|
|
179
|
+
// Ritorna {mode, reason} con mode in 'external' | 'builtin' | 'disabled'.
|
|
180
|
+
function selectProviderModeSync(cfg = {}) {
|
|
181
|
+
if (cfg.fleetEnabled === false) {
|
|
182
|
+
return { mode: 'disabled', reason: 'fleet disabilitato (fleetEnabled=false)' };
|
|
183
|
+
}
|
|
184
|
+
const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
|
|
185
|
+
|
|
186
|
+
// external: binario fidato (regular file, exec, non world-writable, no symlink).
|
|
187
|
+
// lazy-require: niente costo/accoppiamento all'import del modulo (usato solo dal companion).
|
|
188
|
+
const { binTrusted } = require('../fleet/index.js'); // sync (audit F3)
|
|
189
|
+
const extTrusted = !!(cfg.fleetBin && binTrusted(cfg.fleetBin));
|
|
190
|
+
|
|
191
|
+
// builtin: fleet.json valido (loadDefinitions e' sync; garbage -> null = fail-closed).
|
|
192
|
+
const { loadDefinitions } = require('../fleet/definitions.js');
|
|
193
|
+
const home = cfg.home || os.homedir();
|
|
194
|
+
const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
195
|
+
const builtinAvail = cfg.builtinEnabled !== false ? !!loadDefinitions(defsPath) : false;
|
|
196
|
+
|
|
197
|
+
// forced: fail-closed se il richiesto non e' disponibile (§9g: niente auto-fallback).
|
|
198
|
+
if (forced === 'external') {
|
|
199
|
+
return extTrusted
|
|
200
|
+
? { mode: 'external', reason: 'forced external (binario fidato)' }
|
|
201
|
+
: { mode: 'disabled', reason: 'fail-closed: forced external non fidato (§9g)' };
|
|
202
|
+
}
|
|
203
|
+
if (forced === 'builtin') {
|
|
204
|
+
return builtinAvail
|
|
205
|
+
? { mode: 'builtin', reason: 'forced builtin (fleet.json valido)' }
|
|
206
|
+
: { mode: 'disabled', reason: 'fail-closed: forced builtin ma fleet.json invalido/mancante (§9g)' };
|
|
207
|
+
}
|
|
208
|
+
if (forced === 'disabled') return { mode: 'disabled', reason: 'forced disabled' };
|
|
209
|
+
if (forced) return { mode: 'disabled', reason: `fail-closed: provider forzato "${forced}" non riconosciuto` };
|
|
210
|
+
|
|
211
|
+
// auto: external fidato vince (no-doppio-boot), poi builtin, poi disabled.
|
|
212
|
+
if (extTrusted) return { mode: 'external', reason: 'auto: external fidato configurato (skip companion)' };
|
|
213
|
+
if (builtinAvail) return { mode: 'builtin', reason: 'auto: builtin (fleet.json valido)' };
|
|
214
|
+
return { mode: 'disabled', reason: 'auto: nessun provider disponibile (no external fidato, no fleet.json)' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Install (speculare a installService di lib/cli/service.js) ---
|
|
218
|
+
// Stesso hardening di service.js: no-symlink atomic (lstat + tmp+rename), failure
|
|
219
|
+
// collection (NON ingoiare activation — M1), execImpl iniettabile (test). Differenze
|
|
220
|
+
// companion:
|
|
221
|
+
// - nomi 'nexuscrew-fleet.*' + mode termux 0o755 (boot script eseguibile)
|
|
222
|
+
// - linux: daemon-reload + enable, NESSUN restart (Type=oneshot -> parte al boot)
|
|
223
|
+
// - mac: bootout + bootstrap (idempotente come service.js)
|
|
224
|
+
// Nessuna installazione reale all'import: il companion e' installato dal flusso init.
|
|
225
|
+
|
|
226
|
+
function fleetInstallPath(platform, home) {
|
|
227
|
+
if (platform === 'linux') return path.join(home, '.config', 'systemd', 'user', 'nexuscrew-fleet.service');
|
|
228
|
+
if (platform === 'mac') return path.join(home, 'Library', 'LaunchAgents', 'com.mmmbuto.nexuscrew-fleet.plist');
|
|
229
|
+
if (platform === 'termux') return path.join(home, '.termux', 'boot', 'nexuscrew-fleet.sh');
|
|
230
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function fleetFileMode(platform) {
|
|
234
|
+
if (platform === 'termux') return 0o755; // boot script eseguibile (design §4c: chmod 755)
|
|
235
|
+
return 0o644; // systemd unit + launchd plist (come service.js)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fleetInstallCommands(platform, target, ctx) {
|
|
239
|
+
if (platform === 'linux') {
|
|
240
|
+
return [
|
|
241
|
+
['systemctl', ['--user', 'daemon-reload']],
|
|
242
|
+
['systemctl', ['--user', 'enable', 'nexuscrew-fleet.service']],
|
|
243
|
+
// NESSUN restart: Type=oneshot — il companion avvia le celle boot:true al boot.
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
if (platform === 'mac') {
|
|
247
|
+
const domain = `gui/${ctx.uid || uid()}`;
|
|
248
|
+
const label = `${domain}/com.mmmbuto.nexuscrew-fleet`;
|
|
249
|
+
return [
|
|
250
|
+
['launchctl', ['bootout', label]], // idempotente: ignorato se non caricato
|
|
251
|
+
['launchctl', ['bootstrap', domain, target]],
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
if (platform === 'termux') {
|
|
255
|
+
return []; // nessun service manager; boot script + app Termux:Boot (start manuale)
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Install no-symlink + atomic rename (come service.js). execImpl iniettabile per test;
|
|
261
|
+
// default execFileSync (argv diretto, MAI shell string). Su activation fallita il file
|
|
262
|
+
// e' PRESERVATO e le failure raccolte per diagnosi (M1: non si ingoia, non si rollback).
|
|
263
|
+
function installFleetService(platform, content, ctx, { dryRun = false, execImpl = execFileSync } = {}) {
|
|
264
|
+
const home = ctx.home || os.homedir();
|
|
265
|
+
const target = ctx.installPath || fleetInstallPath(platform, home);
|
|
266
|
+
const mode = fleetFileMode(platform);
|
|
267
|
+
|
|
268
|
+
// lstat: reject symlink preesistente (no-symlink atomic, parita' service.js [M3])
|
|
269
|
+
try {
|
|
270
|
+
const st = fs.lstatSync(target);
|
|
271
|
+
if (st.isSymbolicLink()) {
|
|
272
|
+
throw new Error(`refusing symlink install target: ${target}`);
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e.code !== 'ENOENT') throw e;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (dryRun) {
|
|
279
|
+
return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
283
|
+
const tmp = target + '.tmp.' + process.pid; // stessa dir -> atomic rename su stesso FS
|
|
284
|
+
try {
|
|
285
|
+
fs.writeFileSync(tmp, content, { mode });
|
|
286
|
+
fs.chmodSync(tmp, mode);
|
|
287
|
+
fs.renameSync(tmp, target); // atomic
|
|
288
|
+
} catch (e) {
|
|
289
|
+
try { fs.unlinkSync(tmp); } catch (_) {} // cleanup temp su failure [m1]
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// exec service manager — raccogli failure per diagnosi (NON ingoiare, M1)
|
|
294
|
+
const cmds = fleetInstallCommands(platform, target, ctx);
|
|
295
|
+
const failures = [];
|
|
296
|
+
for (const [bin, args] of cmds) {
|
|
297
|
+
try { execImpl(bin, args, { stdio: 'ignore' }); }
|
|
298
|
+
catch (e) {
|
|
299
|
+
if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
|
|
300
|
+
failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { target, mode, written: true, failures };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
generateFleetService,
|
|
309
|
+
generateFleetLinux,
|
|
310
|
+
generateFleetMac,
|
|
311
|
+
generateFleetTermux,
|
|
312
|
+
migrationGate,
|
|
313
|
+
deriveRepoRoot,
|
|
314
|
+
installFleetService,
|
|
315
|
+
fleetInstallPath,
|
|
316
|
+
fleetFileMode,
|
|
317
|
+
fleetInstallCommands,
|
|
318
|
+
selectProviderModeSync,
|
|
319
|
+
};
|
package/lib/cli/init.js
CHANGED
|
@@ -3,18 +3,25 @@
|
|
|
3
3
|
// detectPlatform -> prereq (Node>=18 abort, tmux abort-before-service) ->
|
|
4
4
|
// migration rule (porta da service esistente) -> config.json (preserva) ->
|
|
5
5
|
// token (preserva) -> NexusFiles -> generateService -> installService (skip dry-run) ->
|
|
6
|
-
//
|
|
6
|
+
// [B4.3] fleet companion (solo se provider builtin + gate ok; READONLY/dry-run skip;
|
|
7
|
+
// mai fa fallire l'init principale) -> print URL #token. Termux:boot best-effort.
|
|
7
8
|
const fs = require('node:fs');
|
|
8
9
|
const os = require('node:os');
|
|
9
10
|
const path = require('node:path');
|
|
10
|
-
const { execFileSync } = require('node:child_process');
|
|
11
11
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
12
12
|
const { loadOrCreateToken } = require('../auth/token.js');
|
|
13
13
|
const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// B4.3 — companion di boot del fleet (design §4c/§9b/§9d). Seam iniettabili per test.
|
|
15
|
+
const {
|
|
16
|
+
generateFleetService, installFleetService, migrationGate,
|
|
17
|
+
selectProviderModeSync, fleetFileMode,
|
|
18
|
+
} = require('./fleet-service.js');
|
|
19
|
+
const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
|
|
20
|
+
const { defaultDefinitions } = require('../fleet/managed.js');
|
|
21
|
+
const { commandExists } = require('./path.js');
|
|
22
|
+
|
|
23
|
+
function haveTmux(tmuxBin, env = process.env) {
|
|
24
|
+
return commandExists(tmuxBin, env);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function nodeMajor() {
|
|
@@ -81,6 +88,20 @@ function runInit(opts = {}) {
|
|
|
81
88
|
actions.push(`preserved config ${configPath} (port ${port})`);
|
|
82
89
|
}
|
|
83
90
|
}
|
|
91
|
+
|
|
92
|
+
// Fleet app defaults: soltanto i due client nativi. Provider cloud/Z.AI sono
|
|
93
|
+
// disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
|
|
94
|
+
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
95
|
+
if (!dryRun && !fs.existsSync(fleetDefsPath)) {
|
|
96
|
+
try {
|
|
97
|
+
writeFleet(fleetDefsPath, defaultDefinitions());
|
|
98
|
+
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex-vl.native)`);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
actions.push(`WARN: fleet defaults non creati: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
} else if (!dryRun) {
|
|
103
|
+
actions.push(`preserved fleet definitions ${fleetDefsPath}`);
|
|
104
|
+
}
|
|
84
105
|
if (!port) port = 41820;
|
|
85
106
|
|
|
86
107
|
// token (preserva esistente) [M4]
|
|
@@ -98,6 +119,22 @@ function runInit(opts = {}) {
|
|
|
98
119
|
actions.push(`files root ${filesRoot}`);
|
|
99
120
|
}
|
|
100
121
|
|
|
122
|
+
// nodes.json (B0): nodeId STABILE per installazione (generato qui se manca) +
|
|
123
|
+
// migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
|
|
124
|
+
// saltano; ogni errore -> WARN, MAI far fallire l'init.
|
|
125
|
+
if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
|
|
126
|
+
try {
|
|
127
|
+
const nstore = require('../nodes/store.js');
|
|
128
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
129
|
+
const st = nstore.loadOrInitStore(nodesPath);
|
|
130
|
+
actions.push(`nodes.json ok (${nodesPath}, nodeId ${st.nodeId.slice(0, 8)}…)`);
|
|
131
|
+
const mig = nstore.migrateLegacyNodes(configPath, nodesPath);
|
|
132
|
+
if (mig.migrated) actions.push(`nodes: migrati ${mig.count} nodi legacy da config.json`);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
actions.push(`WARN: nodes.json init/migrazione fallita: ${e.message} (init prosegue)`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
101
138
|
// service generation
|
|
102
139
|
const svcCtx = {
|
|
103
140
|
repoRoot: repoRoot(),
|
|
@@ -127,6 +164,67 @@ function runInit(opts = {}) {
|
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
|
|
167
|
+
// --- B4.3 companion: service di boot del fleet (design §4c/§9b/§9d) ---
|
|
168
|
+
// Il companion si installa SOLO se il provider e' builtin (§9b) e il migration
|
|
169
|
+
// gate non rileva unit legacy cloud-cell@*.service abilitate (no doppio boot).
|
|
170
|
+
// READONLY blocca ogni azione del companion (§9d). MAI far fallire l'init
|
|
171
|
+
// principale: ogni errore del companion -> WARN action + return normale.
|
|
172
|
+
try {
|
|
173
|
+
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
174
|
+
if (readonly) {
|
|
175
|
+
actions.push('fleet companion: READONLY, non installato');
|
|
176
|
+
} else {
|
|
177
|
+
// provider mode: companion SOLO se builtin (§9b). selectProviderModeSync e'
|
|
178
|
+
// il default SINCRONO (runInit e' sync: bin chiama process.exit dopo dispatch,
|
|
179
|
+
// selectProvider async non sopravvive). Seam opts.selectProvider per i test.
|
|
180
|
+
const selectProvider = opts.selectProvider || selectProviderModeSync;
|
|
181
|
+
const fleetCfg = opts.fleetCfg || {
|
|
182
|
+
home,
|
|
183
|
+
fleetEnabled: opts.fleetEnabled !== undefined ? opts.fleetEnabled : true,
|
|
184
|
+
fleetBin: opts.fleetBin || path.join(home, '.local', 'bin', 'fleet'),
|
|
185
|
+
fleetProvider: opts.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER,
|
|
186
|
+
builtinEnabled: opts.builtinEnabled,
|
|
187
|
+
fleetDefsPath,
|
|
188
|
+
};
|
|
189
|
+
const sel = selectProvider(fleetCfg) || {};
|
|
190
|
+
if (sel.mode !== 'builtin') {
|
|
191
|
+
actions.push(`fleet companion: non installato (provider ${sel.mode})`);
|
|
192
|
+
} else {
|
|
193
|
+
// migration gate (§9b): seam opts.migrationGate iniettabile; default quello
|
|
194
|
+
// di fleet-service.js (exec iniettabile via opts.execImpl). blocked -> NON
|
|
195
|
+
// installare (mai doppio boot silenzioso).
|
|
196
|
+
const gate = (opts.migrationGate || migrationGate)({ exec: opts.execImpl, platform });
|
|
197
|
+
if (gate.blocked) {
|
|
198
|
+
actions.push(`WARN: fleet companion NON installato — migration gate bloccato: ${(gate.units || []).join(', ')} (${gate.remediation || 'disabilita le unit legacy cloud-cell@*.service'})`);
|
|
199
|
+
} else if (dryRun) {
|
|
200
|
+
actions.push('DRY-RUN fleet companion generato, NON installato');
|
|
201
|
+
} else {
|
|
202
|
+
const fleetContent = generateFleetService({
|
|
203
|
+
platform,
|
|
204
|
+
nodeBin: svcCtx.nodeBin,
|
|
205
|
+
entryPath: path.join(svcCtx.repoRoot, 'bin', 'nexuscrew.js'),
|
|
206
|
+
repoRoot: svcCtx.repoRoot,
|
|
207
|
+
home,
|
|
208
|
+
});
|
|
209
|
+
const fr = installFleetService(
|
|
210
|
+
platform, fleetContent,
|
|
211
|
+
{ home, uid: uid(), installPath: opts.fleetInstallPath },
|
|
212
|
+
{ execImpl: opts.execImpl },
|
|
213
|
+
);
|
|
214
|
+
if (fr.failures && fr.failures.length) {
|
|
215
|
+
// file installato MA activation (systemctl/launchctl) fallita [M1]
|
|
216
|
+
actions.push(`WARN: fleet companion file installato ${fr.target} MA activation fallita: ${fr.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
217
|
+
} else {
|
|
218
|
+
actions.push(`fleet companion installed ${fr.target} (mode 0${fleetFileMode(platform).toString(8)})`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
// qualunque errore del companion: WARN + init principale prosegue intatto.
|
|
225
|
+
actions.push(`WARN: fleet companion fallito: ${e.message} (init principale prosegue)`);
|
|
226
|
+
}
|
|
227
|
+
|
|
130
228
|
// Termux:boot best-effort detection (R4)
|
|
131
229
|
if (platform === 'termux') {
|
|
132
230
|
const bootDir = path.join(home, '.termux', 'boot');
|
|
@@ -139,7 +237,9 @@ function runInit(opts = {}) {
|
|
|
139
237
|
const url = `http://127.0.0.1:${port}/`;
|
|
140
238
|
const urlWithToken = token ? `${url}#token=${token}` : url;
|
|
141
239
|
actions.push(`platform: ${platform}`);
|
|
142
|
-
|
|
240
|
+
// printUrl:false -> non stampare l'URL col token (usato da smart-up, che presenta
|
|
241
|
+
// URL base + QR da se': cosi' l'output di smart-up non contiene il token in chiaro).
|
|
242
|
+
if (opts.printUrl !== false) actions.push(`URL: ${urlWithToken}`);
|
|
143
243
|
|
|
144
244
|
for (const a of actions) log(a);
|
|
145
245
|
|
package/lib/cli/path.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Risoluzione eseguibili senza shell: nessun `command -v`, nessuna espansione
|
|
3
|
+
// o concatenazione di input. Supporta path assoluti/espliciti e scan di PATH.
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function executable(p) {
|
|
8
|
+
try {
|
|
9
|
+
const st = fs.statSync(p);
|
|
10
|
+
if (!st.isFile()) return false;
|
|
11
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (_) { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandExists(bin, env = process.env) {
|
|
17
|
+
if (typeof bin !== 'string' || !bin || bin.includes('\0')) return false;
|
|
18
|
+
if (path.isAbsolute(bin) || bin.includes('/') || bin.includes('\\')) return executable(bin);
|
|
19
|
+
return String((env && env.PATH) || '').split(path.delimiter)
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.some((dir) => executable(path.join(dir, bin)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { commandExists };
|