@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.
Files changed (59) hide show
  1. package/README.md +173 -18
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +462 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +319 -0
  12. package/lib/cli/init.js +107 -7
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +59 -2
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/boot.js +62 -0
  22. package/lib/fleet/builtin.js +594 -0
  23. package/lib/fleet/definitions.js +410 -0
  24. package/lib/fleet/index.js +49 -10
  25. package/lib/fleet/managed.js +211 -0
  26. package/lib/fleet/provider.js +102 -0
  27. package/lib/fleet/routes.js +78 -8
  28. package/lib/fs/routes.js +56 -0
  29. package/lib/mcp/server.js +362 -0
  30. package/lib/nodes/commands.js +396 -0
  31. package/lib/nodes/store.js +358 -0
  32. package/lib/nodes/tunnel-supervisor.js +102 -0
  33. package/lib/nodes/tunnel.js +300 -0
  34. package/lib/notify/asks.js +150 -0
  35. package/lib/notify/events.js +59 -0
  36. package/lib/notify/notifier.js +37 -0
  37. package/lib/notify/persist.js +73 -0
  38. package/lib/notify/push.js +289 -0
  39. package/lib/notify/routes.js +260 -0
  40. package/lib/proxy/node-proxy.js +292 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +214 -15
  44. package/lib/settings/routes.js +473 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
  51. package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
  52. package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
  53. package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
  54. package/frontend/dist/assets/index-DE67kR0M.js +0 -81
  55. package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
  56. package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
  57. package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
  58. package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
  59. 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
- // print URL #token. Termux:boot detection best-effort.
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
- function haveTmux(tmuxBin) {
16
- try { execFileSync('command', ['-v', tmuxBin], { stdio: 'ignore', shell: true }); return true; }
17
- catch (_) { return false; }
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
- actions.push(`URL: ${urlWithToken}`);
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
 
@@ -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 };