@mmmbuto/nexuscrew 0.8.7 → 0.8.10
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 +77 -20
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/config.js +6 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +69 -5
- package/lib/fleet/routes.js +13 -2
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +33 -14
- package/lib/nodes/peering.js +75 -12
- package/lib/nodes/store.js +30 -20
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +81 -9
- package/lib/server.js +47 -32
- package/lib/settings/routes.js +247 -92
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.css +0 -32
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = require('../../package.json').name;
|
|
8
|
+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
9
|
+
|
|
10
|
+
function parseVersion(value) {
|
|
11
|
+
const m = VERSION_RE.exec(String(value || '').trim());
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return {
|
|
14
|
+
raw: m[0],
|
|
15
|
+
major: Number(m[1]),
|
|
16
|
+
minor: Number(m[2]),
|
|
17
|
+
patch: Number(m[3]),
|
|
18
|
+
prerelease: m[4] ? m[4].split('.') : [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function compareIdentifier(a, b) {
|
|
23
|
+
const an = /^[0-9]+$/.test(a);
|
|
24
|
+
const bn = /^[0-9]+$/.test(b);
|
|
25
|
+
if (an && bn) return Number(a) === Number(b) ? 0 : Number(a) > Number(b) ? 1 : -1;
|
|
26
|
+
if (an !== bn) return an ? -1 : 1;
|
|
27
|
+
return a === b ? 0 : a > b ? 1 : -1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compareVersions(a, b) {
|
|
31
|
+
const av = parseVersion(a);
|
|
32
|
+
const bv = parseVersion(b);
|
|
33
|
+
if (!av || !bv) throw new Error('versione semver non valida');
|
|
34
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
35
|
+
if (av[key] !== bv[key]) return av[key] > bv[key] ? 1 : -1;
|
|
36
|
+
}
|
|
37
|
+
if (!av.prerelease.length && !bv.prerelease.length) return 0;
|
|
38
|
+
if (!av.prerelease.length) return 1;
|
|
39
|
+
if (!bv.prerelease.length) return -1;
|
|
40
|
+
const n = Math.max(av.prerelease.length, bv.prerelease.length);
|
|
41
|
+
for (let i = 0; i < n; i += 1) {
|
|
42
|
+
if (av.prerelease[i] === undefined) return -1;
|
|
43
|
+
if (bv.prerelease[i] === undefined) return 1;
|
|
44
|
+
const c = compareIdentifier(av.prerelease[i], bv.prerelease[i]);
|
|
45
|
+
if (c) return c;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function registryVersion(stdout) {
|
|
51
|
+
const raw = String(stdout || '').trim();
|
|
52
|
+
let value = raw;
|
|
53
|
+
try {
|
|
54
|
+
const decoded = JSON.parse(raw);
|
|
55
|
+
if (typeof decoded === 'string') value = decoded;
|
|
56
|
+
} catch (_) { /* npm senza --json o output gia' plain */ }
|
|
57
|
+
const parsed = parseVersion(value);
|
|
58
|
+
if (!parsed) throw new Error('npm ha restituito una versione non valida');
|
|
59
|
+
return parsed.raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrubError(error) {
|
|
63
|
+
return String((error && error.message) || error || 'errore sconosciuto')
|
|
64
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
65
|
+
.replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/gi, 'https://***@')
|
|
66
|
+
.replace(/\/(?:home|Users|data\/data\/com\.termux\/files\/home)\/[^\s:]+/g, '<local-path>')
|
|
67
|
+
.replace(/(?:Bearer\s+)?[A-Za-z0-9_-]{40,}/gi, '***')
|
|
68
|
+
.slice(0, 300);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pidAlive(pid) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
73
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readLock(file) {
|
|
77
|
+
try {
|
|
78
|
+
const st = fs.lstatSync(file);
|
|
79
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
80
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
return value && Number.isInteger(value.pid) && typeof value.token === 'string' ? value : null;
|
|
82
|
+
} catch (_) { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeLock(file, value, flags = 'wx') {
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
87
|
+
fs.writeFileSync(file, `${JSON.stringify(value)}\n`, { flag: flags, mode: 0o600 });
|
|
88
|
+
fs.chmodSync(file, 0o600);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function acquireUpdateLock(file, pid = process.pid, token = crypto.randomBytes(16).toString('hex')) {
|
|
92
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
93
|
+
try { writeLock(file, { pid, token, createdAt: Date.now() }); return { ok: true, token }; }
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e.code !== 'EEXIST') throw e;
|
|
96
|
+
const current = readLock(file);
|
|
97
|
+
if (current && pidAlive(current.pid)) return { ok: false, current };
|
|
98
|
+
try { fs.unlinkSync(file); } catch (unlinkError) { if (unlinkError.code !== 'ENOENT') throw unlinkError; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: false, current: readLock(file) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function adoptUpdateLock(file, token, pid = process.pid) {
|
|
105
|
+
const current = readLock(file);
|
|
106
|
+
if (!current || current.token !== token) return false;
|
|
107
|
+
const tmp = `${file}.${pid}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
writeLock(tmp, { ...current, pid, adoptedAt: Date.now() });
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releaseUpdateLock(file, token) {
|
|
119
|
+
const current = readLock(file);
|
|
120
|
+
if (!current || current.token !== token) return false;
|
|
121
|
+
try { fs.unlinkSync(file); return true; } catch (_) { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readState(file) {
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.lstatSync(file);
|
|
127
|
+
if (!st.isFile() || st.isSymbolicLink()) return {};
|
|
128
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
129
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
130
|
+
} catch (_) { return {}; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeState(file, value) {
|
|
134
|
+
try {
|
|
135
|
+
const st = fs.lstatSync(file);
|
|
136
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error('update state target non sicuro');
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e.code !== 'ENOENT') throw e;
|
|
139
|
+
}
|
|
140
|
+
const dir = path.dirname(file);
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
142
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
145
|
+
fs.chmodSync(tmp, 0o600);
|
|
146
|
+
fs.renameSync(tmp, file);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
|
|
155
|
+
scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
|
|
156
|
+
readState, writeState,
|
|
157
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { execFile, spawn } = require('node:child_process');
|
|
7
|
+
const {
|
|
8
|
+
PACKAGE_NAME, compareVersions, parseVersion, registryVersion, scrubError, pidAlive, readLock,
|
|
9
|
+
acquireUpdateLock, releaseUpdateLock, readState, writeState,
|
|
10
|
+
} = require('./core.js');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_INITIAL_DELAY_MS = 60 * 1000;
|
|
13
|
+
const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
14
|
+
const DEFAULT_MAX_LOG_BYTES = 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
function packageRoot() { return path.resolve(__dirname, '..', '..'); }
|
|
17
|
+
|
|
18
|
+
function isGlobalInstall(root = packageRoot()) {
|
|
19
|
+
const normalized = path.resolve(root).split(path.sep).join('/');
|
|
20
|
+
return normalized.includes('/node_modules/@mmmbuto/nexuscrew');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isNewer(candidate, current) {
|
|
24
|
+
try { return compareVersions(candidate, current) > 0; } catch (_) { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000 } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
execFileImpl('npm', ['view', `${PACKAGE_NAME}@latest`, 'version', '--json'], {
|
|
30
|
+
encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
|
|
31
|
+
}, (error, stdout) => {
|
|
32
|
+
if (error) return reject(error);
|
|
33
|
+
try { resolve(registryVersion(stdout)); } catch (e) { reject(e); }
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createNpmUpdater(opts = {}) {
|
|
39
|
+
const currentVersion = String(opts.currentVersion || require('../../package.json').version);
|
|
40
|
+
const home = opts.home || os.homedir();
|
|
41
|
+
const statusPath = opts.statusPath || path.join(home, '.nexuscrew', 'npm-update.json');
|
|
42
|
+
const logPath = opts.logPath || path.join(home, '.nexuscrew', 'npm-update.log');
|
|
43
|
+
const lockPath = opts.lockPath || path.join(home, '.nexuscrew', 'npm-update.lock');
|
|
44
|
+
const runnerPath = opts.runnerPath || path.join(__dirname, 'runner.js');
|
|
45
|
+
const supported = opts.supported === undefined ? isGlobalInstall(opts.packageRoot) : !!opts.supported;
|
|
46
|
+
const readonly = opts.readonly === true;
|
|
47
|
+
const lookupLatest = opts.lookupLatest || (() => lookupLatestNpm(opts));
|
|
48
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
49
|
+
const useSystemdRun = opts.useSystemdRun === undefined
|
|
50
|
+
? (process.platform === 'linux' && !!process.env.INVOCATION_ID)
|
|
51
|
+
: opts.useSystemdRun === true;
|
|
52
|
+
const initialDelayMs = opts.initialDelayMs === undefined ? DEFAULT_INITIAL_DELAY_MS : opts.initialDelayMs;
|
|
53
|
+
const intervalMs = opts.intervalMs === undefined ? DEFAULT_INTERVAL_MS : opts.intervalMs;
|
|
54
|
+
const maxLogBytes = opts.maxLogBytes === undefined ? DEFAULT_MAX_LOG_BYTES : opts.maxLogBytes;
|
|
55
|
+
let enabled = opts.enabled !== false && !readonly;
|
|
56
|
+
let checking = null;
|
|
57
|
+
let timer = null;
|
|
58
|
+
let state = readState(statusPath);
|
|
59
|
+
|
|
60
|
+
// Only stale temp files are reaped. A fresh temp may belong to another live
|
|
61
|
+
// NexusCrew process writing the same shared state.
|
|
62
|
+
try {
|
|
63
|
+
const dir = path.dirname(statusPath); const prefix = `${path.basename(statusPath)}.`;
|
|
64
|
+
for (const name of fs.readdirSync(dir)) {
|
|
65
|
+
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
|
|
66
|
+
const file = path.join(dir, name);
|
|
67
|
+
if (Date.now() - fs.statSync(file).mtimeMs > 24 * 60 * 60 * 1000) fs.unlinkSync(file);
|
|
68
|
+
}
|
|
69
|
+
} catch (_) {}
|
|
70
|
+
|
|
71
|
+
if (state.phase === 'installed' && state.targetVersion === currentVersion) {
|
|
72
|
+
state = { ...state, phase: 'idle', available: false, latest: currentVersion, lastError: '' };
|
|
73
|
+
writeState(statusPath, state);
|
|
74
|
+
}
|
|
75
|
+
const activeLock = () => { const lock = readLock(lockPath); return !!(lock && pidAlive(lock.pid)); };
|
|
76
|
+
if ((state.phase === 'installing' || state.phase === 'restarting') && !pidAlive(state.updaterPid) && !activeLock()) {
|
|
77
|
+
state = { ...state, phase: 'error', lastError: 'aggiornamento precedente interrotto' };
|
|
78
|
+
writeState(statusPath, state);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const persist = (patch) => {
|
|
82
|
+
state = { ...state, ...patch };
|
|
83
|
+
writeState(statusPath, state);
|
|
84
|
+
return status();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const status = () => {
|
|
88
|
+
state = { ...state, ...readState(statusPath) };
|
|
89
|
+
return {
|
|
90
|
+
supported, enabled, current: currentVersion,
|
|
91
|
+
phase: state.phase || 'idle', latest: state.latest || '',
|
|
92
|
+
available: state.available === true && isNewer(state.latest, currentVersion),
|
|
93
|
+
lastCheckedAt: state.lastCheckedAt || '', lastUpdatedAt: state.lastUpdatedAt || '',
|
|
94
|
+
lastError: state.lastError || '', blockedVersion: state.blockedVersion || '',
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const launch = (version) => {
|
|
99
|
+
if (!supported) throw new Error('auto-update disponibile solo nell’installazione npm globale');
|
|
100
|
+
if (readonly) throw new Error('READONLY: auto-update bloccato');
|
|
101
|
+
if (!isNewer(version, currentVersion)) return status();
|
|
102
|
+
status();
|
|
103
|
+
if (state.phase === 'installing' || state.phase === 'restarting' || activeLock()) {
|
|
104
|
+
const error = new Error('aggiornamento già in corso'); error.status = 409; error.code = 'update-busy'; throw error;
|
|
105
|
+
}
|
|
106
|
+
if (state.blockedVersion === version) {
|
|
107
|
+
const error = new Error(`versione ${version} bloccata dopo un rollback fallito/necessario`); error.status = 409; error.code = 'update-blocked'; throw error;
|
|
108
|
+
}
|
|
109
|
+
const reservation = acquireUpdateLock(lockPath);
|
|
110
|
+
if (!reservation.ok) {
|
|
111
|
+
const error = new Error('aggiornamento già in corso'); error.status = 409; error.code = 'update-busy'; throw error;
|
|
112
|
+
}
|
|
113
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 });
|
|
114
|
+
try {
|
|
115
|
+
const logStat = fs.lstatSync(logPath);
|
|
116
|
+
if (!logStat.isFile() || logStat.isSymbolicLink()) throw new Error('update log target non sicuro');
|
|
117
|
+
if (logStat.size > maxLogBytes) fs.truncateSync(logPath, 0);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code !== 'ENOENT') {
|
|
120
|
+
releaseUpdateLock(lockPath, reservation.token);
|
|
121
|
+
persist({ phase: 'error', available: true, lastError: scrubError(error) });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fd = fs.openSync(logPath, 'a', 0o600);
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
persist({ phase: 'installing', targetVersion: version, latest: version, available: true, lastError: '' });
|
|
129
|
+
const runnerArgs = [runnerPath, '--version', version, '--status', statusPath, '--home', home,
|
|
130
|
+
'--lock', lockPath, '--lock-token', reservation.token];
|
|
131
|
+
const bin = useSystemdRun ? 'systemd-run' : process.execPath;
|
|
132
|
+
const argv = useSystemdRun
|
|
133
|
+
? ['--user', '--quiet', '--collect', `--unit=nexuscrew-update-${process.pid}-${Date.now()}`,
|
|
134
|
+
`--property=StandardOutput=append:${logPath}`, `--property=StandardError=append:${logPath}`,
|
|
135
|
+
process.execPath, ...runnerArgs]
|
|
136
|
+
: runnerArgs;
|
|
137
|
+
child = spawnImpl(bin, argv, {
|
|
138
|
+
detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, NEXUSCREW_UPDATE_RUNNER: '1' },
|
|
139
|
+
});
|
|
140
|
+
} catch (e) {
|
|
141
|
+
releaseUpdateLock(lockPath, reservation.token);
|
|
142
|
+
persist({ phase: 'error', available: true, lastError: scrubError(e) });
|
|
143
|
+
throw e;
|
|
144
|
+
} finally {
|
|
145
|
+
try { fs.closeSync(fd); } catch (_) {}
|
|
146
|
+
}
|
|
147
|
+
if (child && typeof child.once === 'function') {
|
|
148
|
+
child.once('error', (error) => {
|
|
149
|
+
if (releaseUpdateLock(lockPath, reservation.token)) {
|
|
150
|
+
persist({ phase: 'error', available: true, lastError: scrubError(error) });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
if (useSystemdRun) child.once('exit', (code) => {
|
|
154
|
+
if (code && releaseUpdateLock(lockPath, reservation.token)) {
|
|
155
|
+
persist({ phase: 'error', available: true, lastError: `systemd-run terminato con codice ${code}` });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// systemd-run returns after scheduling the transient unit. The runner
|
|
159
|
+
// must adopt the reservation promptly; otherwise release a dead lock.
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
const lock = readLock(lockPath);
|
|
162
|
+
if (lock?.token === reservation.token && lock.pid === process.pid
|
|
163
|
+
&& releaseUpdateLock(lockPath, reservation.token)) {
|
|
164
|
+
persist({ phase: 'error', available: true, lastError: 'runner aggiornamento non avviato' });
|
|
165
|
+
}
|
|
166
|
+
}, 15_000);
|
|
167
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!child || !Number.isInteger(child.pid)) {
|
|
171
|
+
releaseUpdateLock(lockPath, reservation.token);
|
|
172
|
+
const error = new Error('impossibile avviare il processo di aggiornamento');
|
|
173
|
+
persist({ phase: 'error', available: true, lastError: error.message });
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
persist({ updaterPid: child.pid });
|
|
177
|
+
if (typeof child.unref === 'function') child.unref();
|
|
178
|
+
return status();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const check = async ({ autoApply = false } = {}) => {
|
|
182
|
+
if (!supported) return status();
|
|
183
|
+
if (checking) return checking;
|
|
184
|
+
checking = (async () => {
|
|
185
|
+
status();
|
|
186
|
+
if (state.phase === 'installing' || state.phase === 'restarting' || activeLock()) return status();
|
|
187
|
+
persist({ phase: 'checking', lastError: '' });
|
|
188
|
+
try {
|
|
189
|
+
const latest = await lookupLatest();
|
|
190
|
+
if (parseVersion(latest)?.prerelease.length) throw new Error('npm latest punta a una prerelease: aggiornamento rifiutato');
|
|
191
|
+
const available = isNewer(latest, currentVersion);
|
|
192
|
+
const blocked = state.blockedVersion === latest;
|
|
193
|
+
persist({
|
|
194
|
+
phase: blocked ? 'error' : available ? 'available' : 'idle', latest, available,
|
|
195
|
+
lastCheckedAt: new Date().toISOString(),
|
|
196
|
+
lastError: blocked ? (state.lastError || `versione ${latest} bloccata dopo rollback`) : '',
|
|
197
|
+
});
|
|
198
|
+
if (available && !blocked && autoApply && enabled) return launch(latest);
|
|
199
|
+
return status();
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return persist({ phase: 'error', lastCheckedAt: new Date().toISOString(), lastError: scrubError(e) });
|
|
202
|
+
} finally { checking = null; }
|
|
203
|
+
})();
|
|
204
|
+
return checking;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const apply = async () => {
|
|
208
|
+
if (!supported) throw new Error('auto-update disponibile solo nell’installazione npm globale');
|
|
209
|
+
if (readonly) throw new Error('READONLY: auto-update bloccato');
|
|
210
|
+
status();
|
|
211
|
+
if (state.phase === 'installing' || state.phase === 'restarting' || activeLock()) {
|
|
212
|
+
const error = new Error('aggiornamento già in corso'); error.status = 409; error.code = 'update-busy'; throw error;
|
|
213
|
+
}
|
|
214
|
+
const latest = state.latest && isNewer(state.latest, currentVersion)
|
|
215
|
+
? state.latest : (await check()).latest;
|
|
216
|
+
if (!latest || !isNewer(latest, currentVersion)) return status();
|
|
217
|
+
return launch(latest);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const schedule = (delay) => {
|
|
221
|
+
if (!enabled || !supported || readonly || timer) return;
|
|
222
|
+
timer = setTimeout(async () => {
|
|
223
|
+
timer = null;
|
|
224
|
+
await check({ autoApply: true });
|
|
225
|
+
schedule(intervalMs);
|
|
226
|
+
}, Math.max(0, delay));
|
|
227
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const start = () => schedule(initialDelayMs);
|
|
231
|
+
const close = () => { if (timer) clearTimeout(timer); timer = null; };
|
|
232
|
+
const setEnabled = (value) => {
|
|
233
|
+
enabled = value === true && !readonly;
|
|
234
|
+
close();
|
|
235
|
+
if (enabled) schedule(250);
|
|
236
|
+
return status();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return { status, check, apply, start, close, setEnabled, supported };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
DEFAULT_INITIAL_DELAY_MS, DEFAULT_INTERVAL_MS, DEFAULT_MAX_LOG_BYTES, packageRoot, isGlobalInstall,
|
|
244
|
+
isNewer, pidAlive, lookupLatestNpm, createNpmUpdater,
|
|
245
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
const {
|
|
9
|
+
PACKAGE_NAME, parseVersion, scrubError, pidAlive, adoptUpdateLock, releaseUpdateLock, readState, writeState,
|
|
10
|
+
} = require('./core.js');
|
|
11
|
+
|
|
12
|
+
function args(argv) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
15
|
+
const key = argv[i];
|
|
16
|
+
if (!key || !key.startsWith('--') || argv[i + 1] === undefined) return null;
|
|
17
|
+
out[key.slice(2)] = argv[i + 1];
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function restartRuntime(opts = {}) {
|
|
23
|
+
const home = opts.home || os.homedir();
|
|
24
|
+
const commands = opts.commands || require('../cli/commands.js');
|
|
25
|
+
const pidf = opts.pidfile || require('../cli/pidfile.js');
|
|
26
|
+
const platform = opts.platform || require('../cli/platform.js').detectPlatform();
|
|
27
|
+
const url = opts.url || require('../cli/url.js');
|
|
28
|
+
const port = opts.port || url.loadPort({ home });
|
|
29
|
+
const token = opts.token || url.readToken(url.resolvePaths({ home }).tokenPath);
|
|
30
|
+
let mode = 'inactive';
|
|
31
|
+
if (commands.isServiceRunning({ platform, home })) {
|
|
32
|
+
commands.restart({ platform, home, log: () => {} });
|
|
33
|
+
mode = 'service';
|
|
34
|
+
} else {
|
|
35
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
36
|
+
const meta = pidf.readPidfile(pidPath);
|
|
37
|
+
if (meta && pidf.isAlive(meta)) {
|
|
38
|
+
const stopped = pidf.killPidfile(pidPath);
|
|
39
|
+
if (!stopped.killed) throw new Error(`restart portatile fallito: ${stopped.reason || 'processo non arrestato'}`);
|
|
40
|
+
const wait = opts.waitImpl || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
41
|
+
let released = false;
|
|
42
|
+
for (let i = 0; i < 60; i += 1) {
|
|
43
|
+
const dead = !(opts.pidAliveImpl || pidAlive)(stopped.pid);
|
|
44
|
+
const free = await (opts.portAvailableImpl || commands.portAvailable)(port);
|
|
45
|
+
if (dead && free) { released = true; break; }
|
|
46
|
+
await wait(100);
|
|
47
|
+
}
|
|
48
|
+
if (!released) throw new Error(`restart portatile fallito: porta ${port} non liberata`);
|
|
49
|
+
commands.startPortable({ platform, home });
|
|
50
|
+
mode = 'portable';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (mode !== 'inactive') {
|
|
54
|
+
const waitFor = opts.waitForRuntimeImpl || commands.waitForNexusCrew;
|
|
55
|
+
const healthy = await waitFor(port, token, {
|
|
56
|
+
waitAttempts: opts.healthAttempts || 60, waitDelayMs: opts.healthDelayMs || 250,
|
|
57
|
+
...(opts.healthProbeImpl ? { probeImpl: opts.healthProbeImpl } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (!healthy) throw new Error(`NexusCrew ${mode} non healthy su 127.0.0.1:${port} dopo il restart`);
|
|
60
|
+
}
|
|
61
|
+
return mode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runUpdate(opts = {}) {
|
|
65
|
+
const version = String(opts.version || '');
|
|
66
|
+
if (!parseVersion(version)) throw new Error('versione update non valida');
|
|
67
|
+
const home = opts.home || os.homedir();
|
|
68
|
+
const statusPath = opts.statusPath || path.join(home, '.nexuscrew', 'npm-update.json');
|
|
69
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
70
|
+
const readInstalledVersion = opts.readInstalledVersion || (() => {
|
|
71
|
+
const p = path.resolve(__dirname, '..', '..', 'package.json');
|
|
72
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')).version;
|
|
73
|
+
});
|
|
74
|
+
const preflightImpl = opts.preflightImpl || (({ version: expectedVersion = version } = {}) => {
|
|
75
|
+
const bin = path.resolve(__dirname, '..', '..', 'bin', 'nexuscrew.js');
|
|
76
|
+
const output = execFileSync(process.execPath, [bin, 'version'], { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
77
|
+
if (String(output || '').trim() !== expectedVersion) throw new Error(`preflight CLI fallito: attesa ${expectedVersion}`);
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
const restartImpl = opts.restartImpl || restartRuntime;
|
|
81
|
+
const lockPath = opts.lockPath || '';
|
|
82
|
+
const lockToken = opts.lockToken || '';
|
|
83
|
+
let ownsLock = false;
|
|
84
|
+
if (lockPath || lockToken) {
|
|
85
|
+
if (!lockPath || !lockToken || !adoptUpdateLock(lockPath, lockToken, process.pid)) {
|
|
86
|
+
const error = new Error('lock aggiornamento non posseduto'); error.status = 409; throw error;
|
|
87
|
+
}
|
|
88
|
+
ownsLock = true;
|
|
89
|
+
}
|
|
90
|
+
const previous = readState(statusPath);
|
|
91
|
+
const previousVersion = String(readInstalledVersion() || '');
|
|
92
|
+
let installedNew = false;
|
|
93
|
+
try {
|
|
94
|
+
writeState(statusPath, { ...previous, phase: 'installing', targetVersion: version, updaterPid: process.pid, lastError: '' });
|
|
95
|
+
execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${version}`, '--no-audit', '--no-fund'], {
|
|
96
|
+
stdio: 'inherit', timeout: 5 * 60 * 1000,
|
|
97
|
+
});
|
|
98
|
+
const installed = String(readInstalledVersion() || '');
|
|
99
|
+
if (installed !== version) throw new Error(`verifica installazione fallita: attesa ${version}, trovata ${installed || 'sconosciuta'}`);
|
|
100
|
+
installedNew = true;
|
|
101
|
+
await preflightImpl({ version, home });
|
|
102
|
+
writeState(statusPath, { ...readState(statusPath), phase: 'restarting', updaterPid: process.pid, lastError: '' });
|
|
103
|
+
const restartMode = await restartImpl({ home, ...(opts.runtimeSeams || {}) });
|
|
104
|
+
writeState(statusPath, {
|
|
105
|
+
...readState(statusPath), phase: 'installed', current: version, latest: version,
|
|
106
|
+
available: false, blockedVersion: '', lastUpdatedAt: new Date().toISOString(), lastError: '',
|
|
107
|
+
});
|
|
108
|
+
return { updated: true, version, restartMode };
|
|
109
|
+
} catch (e) {
|
|
110
|
+
let rollbackError = null; let rolledBack = false;
|
|
111
|
+
if (installedNew && parseVersion(previousVersion) && previousVersion !== version) {
|
|
112
|
+
try {
|
|
113
|
+
execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${previousVersion}`, '--no-audit', '--no-fund'], {
|
|
114
|
+
stdio: 'inherit', timeout: 5 * 60 * 1000,
|
|
115
|
+
});
|
|
116
|
+
if (String(readInstalledVersion() || '') !== previousVersion) throw new Error(`rollback verify: attesa ${previousVersion}`);
|
|
117
|
+
await preflightImpl({ version: previousVersion, home, rollback: true });
|
|
118
|
+
await restartImpl({ home, ...(opts.runtimeSeams || {}) });
|
|
119
|
+
rolledBack = true;
|
|
120
|
+
} catch (rollbackFailure) { rollbackError = rollbackFailure; }
|
|
121
|
+
}
|
|
122
|
+
const detail = rollbackError
|
|
123
|
+
? `${scrubError(e)}; rollback ${previousVersion || '?'} fallito: ${scrubError(rollbackError)}`
|
|
124
|
+
: rolledBack ? `${scrubError(e)}; rollback a ${previousVersion} completato` : scrubError(e);
|
|
125
|
+
writeState(statusPath, {
|
|
126
|
+
...readState(statusPath), phase: 'error', current: rolledBack ? previousVersion : readState(statusPath).current,
|
|
127
|
+
available: true, blockedVersion: installedNew ? version : '', rolledBackTo: rolledBack ? previousVersion : '',
|
|
128
|
+
lastError: detail,
|
|
129
|
+
});
|
|
130
|
+
throw e;
|
|
131
|
+
} finally {
|
|
132
|
+
if (ownsLock) releaseUpdateLock(lockPath, lockToken);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (require.main === module) {
|
|
137
|
+
const parsed = args(process.argv.slice(2));
|
|
138
|
+
if (!parsed || !parsed.version || !parsed.status) process.exitCode = 2;
|
|
139
|
+
else runUpdate({ version: parsed.version, statusPath: parsed.status, home: parsed.home,
|
|
140
|
+
lockPath: parsed.lock, lockToken: parsed['lock-token'] })
|
|
141
|
+
.catch(() => { process.exitCode = 1; });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { args, restartRuntime, runUpdate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.10",
|
|
4
4
|
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "node bin/nexuscrew.js serve",
|
|
21
21
|
"dev": "node bin/nexuscrew.js serve",
|
|
22
22
|
"build": "cd frontend && npm install && npm run build && cd ..",
|
|
23
|
-
"test": "node
|
|
23
|
+
"test": "node tests/run-isolated.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"express": "^4.21.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status or its own deck membership, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_deck, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -16,6 +16,7 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
|
16
16
|
| Notify the human about a result, blocker, or milestone | `nc_notify` |
|
|
17
17
|
| Ask for a decision without blocking the agent | `nc_ask` |
|
|
18
18
|
| Inspect live tmux sessions and fleet cells | `nc_status` |
|
|
19
|
+
| Read the caller's deck name(s) and member cells/tmux sessions | `nc_deck` |
|
|
19
20
|
| List files received for the current session | `nc_inbox` |
|
|
20
21
|
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
21
22
|
|
|
@@ -24,7 +25,9 @@ Apply these rules:
|
|
|
24
25
|
- Use `nc_notify` for meaningful asynchronous updates, failures requiring attention, and completion. Do not notify for every command or duplicate routine chat commentary.
|
|
25
26
|
- Never include access tokens, credentials, private keys, push subscriptions, or other secrets in a notification, ask, file caption, or tool result.
|
|
26
27
|
- Treat `nc_ask` as non-blocking: it returns an ask ID immediately. Continue safe independent work or wait normally; the human response arrives in the originating tmux session with a `[human reply · ask#<id>]` prefix by default.
|
|
27
|
-
- Use `nc_status` instead of scraping NexusCrew state files. Use `
|
|
28
|
+
- Use `nc_status` instead of scraping NexusCrew state files. Use `nc_deck` instead of reading `decks.json`: it returns every local or authorized shared-owner deck containing the caller, preserves visual member order, identifies each deck and member by stable owner ID, includes viewer-valid Hydra routes, and reports `cell: null` when no managed Fleet match is available.
|
|
29
|
+
- Treat `nc_deck` as discovery, not authorization to contact every member. Use the verified tmux-messaging flow below for actual delivery and confirm submission visually.
|
|
30
|
+
- Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
28
31
|
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
29
32
|
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
30
33
|
|
|
@@ -68,6 +71,7 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
|
68
71
|
| Notify the human | `nc_notify` |
|
|
69
72
|
| Ask the human without blocking | `nc_ask` |
|
|
70
73
|
| Inspect NexusCrew runtime state | `nc_status` |
|
|
74
|
+
| Discover this session's deck neighbours | `nc_deck` |
|
|
71
75
|
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
72
76
|
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
73
77
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|