@mmmbuto/nexuscrew 0.7.7 → 0.8.2
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 +153 -25
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -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 +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- 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 +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -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/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -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-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.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
|
+
};
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -74,6 +74,10 @@ function generateFleetMac(opts) {
|
|
|
74
74
|
const entryXml = escapeXml(opts.entryPath);
|
|
75
75
|
const repoXml = escapeXml(repoRoot);
|
|
76
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);
|
|
77
81
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
78
82
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
79
83
|
<plist version="1.0">
|
|
@@ -88,6 +92,11 @@ function generateFleetMac(opts) {
|
|
|
88
92
|
</array>
|
|
89
93
|
<key>WorkingDirectory</key>
|
|
90
94
|
<string>${repoXml}</string>
|
|
95
|
+
<key>EnvironmentVariables</key>
|
|
96
|
+
<dict>
|
|
97
|
+
<key>PATH</key>
|
|
98
|
+
<string>${launchPathXml}</string>
|
|
99
|
+
</dict>
|
|
91
100
|
<key>RunAtLoad</key>
|
|
92
101
|
<true/>
|
|
93
102
|
<key>StandardOutPath</key>
|
|
@@ -235,10 +244,11 @@ function fleetInstallCommands(platform, target, ctx) {
|
|
|
235
244
|
];
|
|
236
245
|
}
|
|
237
246
|
if (platform === 'mac') {
|
|
238
|
-
const
|
|
247
|
+
const domain = `gui/${ctx.uid || uid()}`;
|
|
248
|
+
const label = `${domain}/com.mmmbuto.nexuscrew-fleet`;
|
|
239
249
|
return [
|
|
240
250
|
['launchctl', ['bootout', label]], // idempotente: ignorato se non caricato
|
|
241
|
-
['launchctl', ['bootstrap',
|
|
251
|
+
['launchctl', ['bootstrap', domain, target]],
|
|
242
252
|
];
|
|
243
253
|
}
|
|
244
254
|
if (platform === 'termux') {
|
|
@@ -285,7 +295,10 @@ function installFleetService(platform, content, ctx, { dryRun = false, execImpl
|
|
|
285
295
|
const failures = [];
|
|
286
296
|
for (const [bin, args] of cmds) {
|
|
287
297
|
try { execImpl(bin, args, { stdio: 'ignore' }); }
|
|
288
|
-
catch (e) {
|
|
298
|
+
catch (e) {
|
|
299
|
+
if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
|
|
300
|
+
failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
|
|
301
|
+
}
|
|
289
302
|
}
|
|
290
303
|
|
|
291
304
|
return { target, mode, written: true, failures };
|
package/lib/cli/init.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
12
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
13
13
|
const { loadOrCreateToken } = require('../auth/token.js');
|
|
14
14
|
const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
|
|
@@ -17,16 +17,36 @@ const {
|
|
|
17
17
|
generateFleetService, installFleetService, migrationGate,
|
|
18
18
|
selectProviderModeSync, fleetFileMode,
|
|
19
19
|
} = require('./fleet-service.js');
|
|
20
|
+
const { atomicWrite: writeFleet } = require('../fleet/definitions.js');
|
|
21
|
+
const { defaultDefinitions } = require('../fleet/managed.js');
|
|
22
|
+
const { commandExists } = require('./path.js');
|
|
20
23
|
|
|
21
|
-
function haveTmux(tmuxBin) {
|
|
22
|
-
|
|
23
|
-
catch (_) { return false; }
|
|
24
|
+
function haveTmux(tmuxBin, env = process.env) {
|
|
25
|
+
return commandExists(tmuxBin, env);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
function nodeMajor() {
|
|
27
29
|
return parseInt(String(process.versions.node).split('.')[0], 10);
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function writeConfigAtomic(configPath, value) {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.lstatSync(configPath).isSymbolicLink()) throw new Error('refusing symlink config target');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code !== 'ENOENT') throw e;
|
|
37
|
+
}
|
|
38
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
39
|
+
const tmp = path.join(path.dirname(configPath), `.${path.basename(configPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
fs.chmodSync(tmp, 0o600);
|
|
43
|
+
fs.renameSync(tmp, configPath);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
// Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
|
|
31
51
|
function readExistingPort(platform, home, installPathOverride) {
|
|
32
52
|
const p = installPathOverride || svcInstallPath(platform, home);
|
|
@@ -77,16 +97,36 @@ function runInit(opts = {}) {
|
|
|
77
97
|
fs.mkdirSync(configDir, { recursive: true });
|
|
78
98
|
if (!fs.existsSync(configPath)) {
|
|
79
99
|
if (!port) port = 41820;
|
|
80
|
-
|
|
100
|
+
writeConfigAtomic(configPath, { port });
|
|
81
101
|
actions.push(`created config ${configPath} (port ${port})`);
|
|
82
102
|
} else {
|
|
103
|
+
let current;
|
|
83
104
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
current = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
106
|
+
} catch (_) { current = {}; }
|
|
107
|
+
if (opts.port) {
|
|
108
|
+
current.port = opts.port;
|
|
109
|
+
port = opts.port;
|
|
110
|
+
writeConfigAtomic(configPath, current);
|
|
111
|
+
actions.push(`updated config ${configPath} (port ${port})`);
|
|
112
|
+
} else if (current && current.port) port = current.port;
|
|
87
113
|
actions.push(`preserved config ${configPath} (port ${port})`);
|
|
88
114
|
}
|
|
89
115
|
}
|
|
116
|
+
|
|
117
|
+
// Fleet app defaults: soltanto i tre client nativi. Provider cloud/Z.AI sono
|
|
118
|
+
// disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
|
|
119
|
+
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
120
|
+
if (!dryRun && !fs.existsSync(fleetDefsPath)) {
|
|
121
|
+
try {
|
|
122
|
+
writeFleet(fleetDefsPath, defaultDefinitions());
|
|
123
|
+
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
actions.push(`WARN: fleet defaults non creati: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
} else if (!dryRun) {
|
|
128
|
+
actions.push(`preserved fleet definitions ${fleetDefsPath}`);
|
|
129
|
+
}
|
|
90
130
|
if (!port) port = 41820;
|
|
91
131
|
|
|
92
132
|
// token (preserva esistente) [M4]
|
|
@@ -104,6 +144,22 @@ function runInit(opts = {}) {
|
|
|
104
144
|
actions.push(`files root ${filesRoot}`);
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
// nodes.json (B0): nodeId STABILE per installazione (generato qui se manca) +
|
|
148
|
+
// migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
|
|
149
|
+
// saltano; ogni errore -> WARN, MAI far fallire l'init.
|
|
150
|
+
if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
|
|
151
|
+
try {
|
|
152
|
+
const nstore = require('../nodes/store.js');
|
|
153
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
154
|
+
const st = nstore.loadOrInitStore(nodesPath);
|
|
155
|
+
actions.push(`nodes.json ok (${nodesPath}, nodeId ${st.nodeId.slice(0, 8)}…)`);
|
|
156
|
+
const mig = nstore.migrateLegacyNodes(configPath, nodesPath);
|
|
157
|
+
if (mig.migrated) actions.push(`nodes: migrati ${mig.count} nodi legacy da config.json`);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
actions.push(`WARN: nodes.json init/migrazione fallita: ${e.message} (init prosegue)`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
107
163
|
// service generation
|
|
108
164
|
const svcCtx = {
|
|
109
165
|
repoRoot: repoRoot(),
|
|
@@ -153,7 +209,7 @@ function runInit(opts = {}) {
|
|
|
153
209
|
fleetBin: opts.fleetBin || path.join(home, '.local', 'bin', 'fleet'),
|
|
154
210
|
fleetProvider: opts.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER,
|
|
155
211
|
builtinEnabled: opts.builtinEnabled,
|
|
156
|
-
fleetDefsPath
|
|
212
|
+
fleetDefsPath,
|
|
157
213
|
};
|
|
158
214
|
const sel = selectProvider(fleetCfg) || {};
|
|
159
215
|
if (sel.mode !== 'builtin') {
|
|
@@ -206,11 +262,13 @@ function runInit(opts = {}) {
|
|
|
206
262
|
const url = `http://127.0.0.1:${port}/`;
|
|
207
263
|
const urlWithToken = token ? `${url}#token=${token}` : url;
|
|
208
264
|
actions.push(`platform: ${platform}`);
|
|
209
|
-
|
|
265
|
+
// printUrl:false -> non stampare l'URL col token (usato da smart-up, che presenta
|
|
266
|
+
// URL base + QR da se': cosi' l'output di smart-up non contiene il token in chiaro).
|
|
267
|
+
if (opts.printUrl !== false) actions.push(`URL: ${urlWithToken}`);
|
|
210
268
|
|
|
211
269
|
for (const a of actions) log(a);
|
|
212
270
|
|
|
213
271
|
return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
|
|
214
272
|
}
|
|
215
273
|
|
|
216
|
-
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor };
|
|
274
|
+
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic };
|
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 };
|
package/lib/cli/service.js
CHANGED
|
@@ -70,7 +70,6 @@ After=network-online.target
|
|
|
70
70
|
[Service]
|
|
71
71
|
Type=simple
|
|
72
72
|
WorkingDirectory=${repo}
|
|
73
|
-
Environment=NEXUSCREW_PORT=${ctx.port}
|
|
74
73
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
75
74
|
ExecStart=${node} ${repoBin} serve
|
|
76
75
|
Restart=on-failure
|
|
@@ -86,7 +85,10 @@ function generateMac(ctx) {
|
|
|
86
85
|
const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
87
86
|
const repoXml = escapeXml(ctx.repoRoot);
|
|
88
87
|
const homeXml = escapeXml(ctx.home);
|
|
89
|
-
const
|
|
88
|
+
const launchPath = [...new Set([
|
|
89
|
+
path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
90
|
+
])].join(':');
|
|
91
|
+
const launchPathXml = escapeXml(launchPath);
|
|
90
92
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
91
93
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
92
94
|
<plist version="1.0">
|
|
@@ -103,8 +105,8 @@ function generateMac(ctx) {
|
|
|
103
105
|
<string>${repoXml}</string>
|
|
104
106
|
<key>EnvironmentVariables</key>
|
|
105
107
|
<dict>
|
|
106
|
-
<key>
|
|
107
|
-
<string>${
|
|
108
|
+
<key>PATH</key>
|
|
109
|
+
<string>${launchPathXml}</string>
|
|
108
110
|
</dict>
|
|
109
111
|
<key>RunAtLoad</key>
|
|
110
112
|
<true/>
|
|
@@ -130,7 +132,6 @@ function generateTermux(ctx) {
|
|
|
130
132
|
# NexusCrew boot (Termux) - loopback, localhost del telefono
|
|
131
133
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
132
134
|
export HOME=/data/data/com.termux/files/home
|
|
133
|
-
export NEXUSCREW_PORT=${ctx.port}
|
|
134
135
|
cd -- ${repoQ}
|
|
135
136
|
termux-wake-lock 2>/dev/null || true
|
|
136
137
|
mkdir -p "$HOME/.nexuscrew"
|
|
@@ -193,7 +194,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
|
|
|
193
194
|
const failures = [];
|
|
194
195
|
for (const [bin, args] of cmds) {
|
|
195
196
|
try { execImpl(bin, args, { stdio: 'ignore' }); }
|
|
196
|
-
catch (e) {
|
|
197
|
+
catch (e) {
|
|
198
|
+
// bootout e' idempotente: un job non ancora caricato non e' un errore di installazione.
|
|
199
|
+
if (platform === 'mac' && bin === 'launchctl' && args[0] === 'bootout') continue;
|
|
200
|
+
failures.push({ cmd: `${bin} ${args.join(' ')}`, error: e.message });
|
|
201
|
+
}
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
return { target, mode, written: true, failures };
|
|
@@ -208,11 +213,12 @@ function installCommands(platform, target, ctx) {
|
|
|
208
213
|
];
|
|
209
214
|
}
|
|
210
215
|
if (platform === 'mac') {
|
|
211
|
-
const
|
|
216
|
+
const domain = `gui/${ctx.uid || uid()}`;
|
|
217
|
+
const label = `${domain}/com.mmmbuto.nexuscrew`;
|
|
212
218
|
// bootout (ignore se non esiste) poi bootstrap
|
|
213
219
|
return [
|
|
214
220
|
['launchctl', ['bootout', label]],
|
|
215
|
-
['launchctl', ['bootstrap',
|
|
221
|
+
['launchctl', ['bootstrap', domain, target]],
|
|
216
222
|
];
|
|
217
223
|
}
|
|
218
224
|
if (platform === 'termux') {
|
package/lib/cli/url.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// URL/token helpers per la CLI unificata (design §3). [A2]
|
|
3
|
+
// - resolvePaths: config/token path home-aware (seam per test con HOME temporanea).
|
|
4
|
+
// - readToken: legge il token esistente SENZA crearlo (no-follow symlink), null se assente.
|
|
5
|
+
// - loadPort: porta corrente (opts > env > config.json > default 41820).
|
|
6
|
+
// - buildUrl: URL loopback; withToken aggiunge #token=… (UNICO posto dove il token appare).
|
|
7
|
+
// - renderQr: QR ASCII generato in locale (qrcode-terminal, MIT) — NIENTE fetch a runtime.
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const { readTokenSafe } = require('../auth/token.js');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PORT = 41820;
|
|
14
|
+
|
|
15
|
+
function resolvePaths(opts = {}) {
|
|
16
|
+
const home = opts.home || os.homedir();
|
|
17
|
+
const configDir = opts.configDir || path.join(home, '.nexuscrew');
|
|
18
|
+
return {
|
|
19
|
+
home,
|
|
20
|
+
configDir,
|
|
21
|
+
configPath: opts.configPath || path.join(configDir, 'config.json'),
|
|
22
|
+
tokenPath: opts.tokenPath || path.join(configDir, 'token'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Legge il token esistente (no create). Symlink -> throw (readTokenSafe); assente -> null.
|
|
27
|
+
function readToken(tokenPath) {
|
|
28
|
+
try {
|
|
29
|
+
return readTokenSafe(tokenPath);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (e.code === 'ENOENT') return null; // lstat su path inesistente
|
|
32
|
+
throw e; // symlink / permessi -> propaga
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Porta corrente: opts.port > env NEXUSCREW_PORT > config.json > default.
|
|
37
|
+
function loadPort(opts = {}) {
|
|
38
|
+
if (opts.port) return Number(opts.port);
|
|
39
|
+
if (process.env.NEXUSCREW_PORT) return Number(process.env.NEXUSCREW_PORT);
|
|
40
|
+
const { configPath } = resolvePaths(opts);
|
|
41
|
+
try {
|
|
42
|
+
const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
43
|
+
if (c && c.port) return Number(c.port);
|
|
44
|
+
} catch (_) {}
|
|
45
|
+
return DEFAULT_PORT;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// URL loopback. withToken=true e token presente -> aggiunge #token=… (mai altrove).
|
|
49
|
+
function buildUrl(port, token, { withToken = false } = {}) {
|
|
50
|
+
const base = `http://127.0.0.1:${port}/`;
|
|
51
|
+
return withToken && token ? `${base}#token=${token}` : base;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// QR ASCII del testo. Generazione locale (qrcode-terminal), callback sincrona.
|
|
55
|
+
// small=true -> QR compatto (half-block chars) leggibile su terminale mobile/Termux.
|
|
56
|
+
function renderQr(text, opts = {}) {
|
|
57
|
+
const qrcode = opts.qrcode || require('qrcode-terminal');
|
|
58
|
+
let out = '';
|
|
59
|
+
qrcode.generate(String(text), { small: opts.small !== false }, (s) => { out = s; });
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { resolvePaths, readToken, loadPort, buildUrl, renderQr, DEFAULT_PORT };
|
package/lib/config.js
CHANGED
|
@@ -20,6 +20,8 @@ function baseDefaults() {
|
|
|
20
20
|
tokenPath: path.join(os.homedir(), '.nexuscrew', 'token'),
|
|
21
21
|
tmuxBin: 'tmux',
|
|
22
22
|
readonlyDefault: false,
|
|
23
|
+
// Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
|
|
24
|
+
replyLabel: 'human',
|
|
23
25
|
filesRoot: path.join(os.homedir(), 'NexusFiles'),
|
|
24
26
|
maxUpload: 100 * 1024 * 1024,
|
|
25
27
|
voiceUrl: null,
|
|
@@ -27,6 +29,7 @@ function baseDefaults() {
|
|
|
27
29
|
voiceTokenFile: null,
|
|
28
30
|
fleetEnabled: true,
|
|
29
31
|
fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
|
|
32
|
+
providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
|
|
30
33
|
sessionPresets: {},
|
|
31
34
|
};
|
|
32
35
|
}
|
|
@@ -51,6 +54,7 @@ function envOverrides() {
|
|
|
51
54
|
if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
|
|
52
55
|
if (process.env.NEXUSCREW_TMUX) e.tmuxBin = process.env.NEXUSCREW_TMUX;
|
|
53
56
|
if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
|
|
57
|
+
if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
|
|
54
58
|
if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
|
|
55
59
|
if (process.env.NEXUSCREW_MAX_UPLOAD_MB) e.maxUpload = Number(process.env.NEXUSCREW_MAX_UPLOAD_MB) * 1024 * 1024;
|
|
56
60
|
if (process.env.NEXUSCREW_VOICE_URL) e.voiceUrl = process.env.NEXUSCREW_VOICE_URL;
|
|
@@ -58,6 +62,7 @@ function envOverrides() {
|
|
|
58
62
|
if (process.env.NEXUSCREW_VOICE_TOKEN_FILE) e.voiceTokenFile = process.env.NEXUSCREW_VOICE_TOKEN_FILE;
|
|
59
63
|
if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
|
|
60
64
|
if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
|
|
65
|
+
if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
|
|
61
66
|
return e;
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const store = require('./store.js');
|
|
5
|
+
|
|
6
|
+
function httpError(status, message, extra) {
|
|
7
|
+
const e = new Error(message); e.status = status; e.extra = extra; return e;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function decksRoutes({ cfg = {}, decksPath }) {
|
|
11
|
+
const r = express.Router();
|
|
12
|
+
r.use(express.json({ limit: '64kb' }));
|
|
13
|
+
const readonly = () => cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1';
|
|
14
|
+
const mutate = (fn) => (req, res) => {
|
|
15
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: mutazione deck bloccata' });
|
|
16
|
+
try { return fn(req, res); }
|
|
17
|
+
catch (e) { return res.status(e.status || 500).json({ error: String(e.message || e), ...(e.extra || {}) }); }
|
|
18
|
+
};
|
|
19
|
+
const read = () => {
|
|
20
|
+
const found = store.loadStore(decksPath);
|
|
21
|
+
if (found) return found;
|
|
22
|
+
if (readonly() && !fs.existsSync(decksPath)) return store.emptyStore();
|
|
23
|
+
return store.loadOrCreate(decksPath);
|
|
24
|
+
};
|
|
25
|
+
const expected = (body) => {
|
|
26
|
+
const n = body && body.expectedRevision;
|
|
27
|
+
if (!Number.isSafeInteger(n) || n < 0) throw httpError(400, 'expectedRevision non valida');
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
30
|
+
const find = (st, name) => st.decks.find((d) => d.name === name) || null;
|
|
31
|
+
const checkRevision = (deck, rev) => {
|
|
32
|
+
if (deck.revision !== rev) throw httpError(409, 'deck modificato da un’altra finestra', { current: deck });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
r.get('/', (_req, res) => {
|
|
36
|
+
try { res.json(read()); } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
37
|
+
});
|
|
38
|
+
r.post('/', mutate((req, res) => {
|
|
39
|
+
const name = String(req.body && req.body.name || '');
|
|
40
|
+
if (!store.NAME_RE.test(name)) throw httpError(400, 'nome deck non valido');
|
|
41
|
+
const st = read();
|
|
42
|
+
if (find(st, name)) throw httpError(409, `deck già esistente: ${name}`);
|
|
43
|
+
if (st.decks.length >= store.MAX_DECKS) throw httpError(400, 'limite deck raggiunto');
|
|
44
|
+
const deck = { name, revision: 0, layout: { columns: [] } };
|
|
45
|
+
st.decks.push(deck); store.atomicWrite(decksPath, st);
|
|
46
|
+
res.status(201).json(deck);
|
|
47
|
+
}));
|
|
48
|
+
r.put('/:name', mutate((req, res) => {
|
|
49
|
+
const st = read(); const deck = find(st, String(req.params.name || ''));
|
|
50
|
+
if (!deck) throw httpError(404, 'deck inesistente');
|
|
51
|
+
checkRevision(deck, expected(req.body));
|
|
52
|
+
const layout = store.parseLayout(req.body && req.body.layout);
|
|
53
|
+
if (!layout) throw httpError(400, 'layout deck non valido');
|
|
54
|
+
deck.layout = layout; deck.revision += 1;
|
|
55
|
+
store.atomicWrite(decksPath, st); res.json(deck);
|
|
56
|
+
}));
|
|
57
|
+
r.patch('/:name', mutate((req, res) => {
|
|
58
|
+
const oldName = String(req.params.name || '');
|
|
59
|
+
if (oldName === 'main') throw httpError(400, 'main non rinominabile');
|
|
60
|
+
const newName = String(req.body && req.body.name || '');
|
|
61
|
+
if (!store.NAME_RE.test(newName)) throw httpError(400, 'nome deck non valido');
|
|
62
|
+
const st = read(); const deck = find(st, oldName);
|
|
63
|
+
if (!deck) throw httpError(404, 'deck inesistente');
|
|
64
|
+
checkRevision(deck, expected(req.body));
|
|
65
|
+
if (find(st, newName)) throw httpError(409, `deck già esistente: ${newName}`);
|
|
66
|
+
deck.name = newName; deck.revision += 1;
|
|
67
|
+
store.atomicWrite(decksPath, st); res.json(deck);
|
|
68
|
+
}));
|
|
69
|
+
r.delete('/:name', mutate((req, res) => {
|
|
70
|
+
const name = String(req.params.name || '');
|
|
71
|
+
if (name === 'main') throw httpError(400, 'main non eliminabile');
|
|
72
|
+
const st = read(); const deck = find(st, name);
|
|
73
|
+
if (!deck) throw httpError(404, 'deck inesistente');
|
|
74
|
+
checkRevision(deck, expected(req.body));
|
|
75
|
+
st.decks = st.decks.filter((d) => d.name !== name);
|
|
76
|
+
store.atomicWrite(decksPath, st); res.json({ removed: true, name });
|
|
77
|
+
}));
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { decksRoutes };
|