@mmmbuto/nexuscrew 0.8.12 → 0.8.14
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 +90 -36
- package/frontend/dist/assets/index-BR2Qfqi2.js +91 -0
- package/frontend/dist/assets/{index-PkKeNfT7.css → index-CopPQTMk.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +137 -0
- package/lib/cli/service.js +18 -6
- package/lib/config.js +14 -0
- package/lib/fleet/builtin.js +243 -38
- package/lib/fleet/cell-exec.js +81 -0
- package/lib/fleet/credentials.js +135 -0
- package/lib/fleet/env-key.js +12 -0
- package/lib/fleet/launch-broker.js +136 -0
- package/lib/fleet/managed.js +139 -19
- package/lib/fleet/routes.js +16 -1
- package/lib/mcp/server.js +131 -3
- package/lib/proxy/federation.js +47 -7
- package/lib/pty/attach.js +3 -2
- package/lib/runtime/env.js +50 -0
- package/lib/server.js +26 -2
- package/lib/settings/routes.js +4 -3
- package/lib/tmux/actions.js +96 -1
- package/lib/tmux/list.js +22 -3
- package/lib/update/core.js +35 -8
- package/lib/update/manager.js +6 -3
- package/lib/update/runner.js +7 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +14 -3
- package/frontend/dist/assets/index-DrroT7uq.js +0 -91
package/lib/proxy/federation.js
CHANGED
|
@@ -51,12 +51,14 @@ function parseRoute(raw) {
|
|
|
51
51
|
|
|
52
52
|
function knownResource(resource) {
|
|
53
53
|
return resource === '/sessions'
|
|
54
|
-
|| /^\/sessions\/[\w.@%:+-]{1,128}
|
|
54
|
+
|| /^\/sessions\/[\w.@%:+-]{1,128}(?:\/visibility)?$/.test(resource)
|
|
55
55
|
|| resource === '/config'
|
|
56
56
|
|| resource === '/fs/dirs'
|
|
57
57
|
|| resource === '/files'
|
|
58
58
|
|| resource === '/files/download'
|
|
59
59
|
|| resource === '/files/upload'
|
|
60
|
+
|| resource === '/cells'
|
|
61
|
+
|| resource === '/cells/send'
|
|
60
62
|
|| resource === '/decks'
|
|
61
63
|
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
62
64
|
|| resource === '/topology'
|
|
@@ -65,17 +67,20 @@ function knownResource(resource) {
|
|
|
65
67
|
// Hydra: the rest of /settings stays unreachable.
|
|
66
68
|
|| resource === '/settings/peering/invite'
|
|
67
69
|
|| resource === '/ws'
|
|
68
|
-
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
|
|
70
|
+
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
function allowedResource(resource, method = 'GET') {
|
|
72
74
|
if (resource === '/sessions') return method === 'GET' || method === 'POST';
|
|
73
75
|
if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
|
|
76
|
+
if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
|
|
74
77
|
if (resource === '/config') return method === 'GET';
|
|
75
78
|
if (resource === '/fs/dirs') return method === 'GET';
|
|
76
79
|
if (resource === '/files') return method === 'GET' || method === 'DELETE';
|
|
77
80
|
if (resource === '/files/download') return method === 'GET';
|
|
78
81
|
if (resource === '/files/upload') return method === 'POST';
|
|
82
|
+
if (resource === '/cells') return method === 'GET';
|
|
83
|
+
if (resource === '/cells/send') return method === 'POST';
|
|
79
84
|
if (resource === '/decks') return method === 'GET' || method === 'POST';
|
|
80
85
|
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
81
86
|
return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
@@ -83,8 +88,8 @@ function allowedResource(resource, method = 'GET') {
|
|
|
83
88
|
if (resource === '/topology') return method === 'GET';
|
|
84
89
|
if (resource === '/settings/peering/invite') return method === 'POST';
|
|
85
90
|
if (resource === '/ws') return method === 'GET';
|
|
86
|
-
if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
|
|
87
|
-
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
|
|
91
|
+
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
92
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
88
93
|
return false;
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -116,7 +121,12 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
116
121
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
|
117
122
|
if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
|
|
118
123
|
if (parsed.route.length === 0) {
|
|
119
|
-
return proxyHttp(req, res, {
|
|
124
|
+
return proxyHttp(req, res, {
|
|
125
|
+
port: typeof localPort === 'function' ? localPort() : localPort,
|
|
126
|
+
path: `/api${parsed.resource}${queryOf(req.url)}`,
|
|
127
|
+
credential: localCredential(),
|
|
128
|
+
visited,
|
|
129
|
+
});
|
|
120
130
|
}
|
|
121
131
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
122
132
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
@@ -196,9 +206,37 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
196
206
|
return out;
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
// A freshly restarted SSH supervisor returns before both forwards are
|
|
210
|
+
// necessarily accepting traffic. Share must therefore wait for the actual
|
|
211
|
+
// authenticated federation channel instead of racing a single immediate
|
|
212
|
+
// fetch. Auth/identity failures are terminal; transport startup is retried
|
|
213
|
+
// for a short bounded window.
|
|
214
|
+
async function waitForHealthyPeer(opts = {}) {
|
|
215
|
+
const attempts = Number.isInteger(opts.attempts) && opts.attempts > 0 ? Math.min(opts.attempts, 12) : 6;
|
|
216
|
+
const delayMs = Number.isInteger(opts.delayMs) && opts.delayMs >= 0 ? Math.min(opts.delayMs, 2000) : 200;
|
|
217
|
+
const delay = typeof opts.delay === 'function'
|
|
218
|
+
? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
const probeOpts = { ...opts };
|
|
220
|
+
delete probeOpts.attempts; delete probeOpts.delayMs; delete probeOpts.delay;
|
|
221
|
+
let last = null;
|
|
222
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
223
|
+
last = await probeHealth(probeOpts);
|
|
224
|
+
if (last.status === 'healthy') return last;
|
|
225
|
+
if (last.auth === 'failed' || /instanceId inatteso/.test(last.detail || '')) break;
|
|
226
|
+
if (attempt + 1 < attempts) await delay(delayMs);
|
|
227
|
+
}
|
|
228
|
+
return last || { status: 'down', detail: 'peer non raggiungibile' };
|
|
229
|
+
}
|
|
230
|
+
|
|
199
231
|
function controlledVisited(req, ingress, instanceId) {
|
|
200
232
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
201
233
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
234
|
+
// On a peer-facing route the last server-controlled hop must be the peer
|
|
235
|
+
// authenticated by its scoped federation token. Without this binding a
|
|
236
|
+
// token holder could forge the first visited ID and impersonate another
|
|
237
|
+
// cell-network sender at the destination.
|
|
238
|
+
if (ingress && (!store.NODE_ID_RE.test(String(ingress.nodeId || ''))
|
|
239
|
+
|| !seen.length || seen.at(-1) !== ingress.nodeId)) return null;
|
|
202
240
|
if (seen.some((id) => !store.NODE_ID_RE.test(id)) || seen.includes(instanceId) || seen.length > MAX_HOPS) return null;
|
|
203
241
|
return [...seen, instanceId];
|
|
204
242
|
}
|
|
@@ -320,11 +358,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
320
358
|
}
|
|
321
359
|
try {
|
|
322
360
|
if (body.shared) {
|
|
323
|
-
const health = await
|
|
361
|
+
const health = await waitForHealthyPeer({
|
|
324
362
|
port: req.peer.localPort,
|
|
325
363
|
token: req.peer.token,
|
|
326
364
|
expectedInstanceId: req.peer.nodeId || null,
|
|
327
365
|
fetchImpl: fetchImpl || fetch,
|
|
366
|
+
attempts: 6,
|
|
367
|
+
delayMs: 200,
|
|
328
368
|
});
|
|
329
369
|
if (health.status !== 'healthy') {
|
|
330
370
|
return res.status(409).json({
|
|
@@ -396,5 +436,5 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
396
436
|
module.exports = {
|
|
397
437
|
MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
398
438
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
399
|
-
probeHealth,
|
|
439
|
+
probeHealth, waitForHealthyPeer,
|
|
400
440
|
};
|
package/lib/pty/attach.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const { execFile } = require('node:child_process');
|
|
3
3
|
const os = require('node:os');
|
|
4
4
|
const { loadPty } = require('./provider.js');
|
|
5
|
+
const { withUtf8Locale } = require('../runtime/env.js');
|
|
5
6
|
|
|
6
7
|
// Opens `tmux attach` inside a real PTY, non-destructive for other clients.
|
|
7
8
|
//
|
|
@@ -31,7 +32,7 @@ function openAttach(session, opts = {}) {
|
|
|
31
32
|
name: 'xterm-256color',
|
|
32
33
|
cols, rows,
|
|
33
34
|
cwd: process.env.HOME || os.homedir(),
|
|
34
|
-
env: process.env,
|
|
35
|
+
env: withUtf8Locale(process.env),
|
|
35
36
|
});
|
|
36
37
|
// tty del client tmux (es. /dev/pts/N): identifica QUESTO client per la
|
|
37
38
|
// promozione/demozione runtime del size-owner via `refresh-client -t <tty>`.
|
|
@@ -52,4 +53,4 @@ function openAttach(session, opts = {}) {
|
|
|
52
53
|
demote: () => setFlags('ignore-size'),
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
|
-
module.exports = { openAttach };
|
|
56
|
+
module.exports = { openAttach, attachEnv: (env, platform) => withUtf8Locale(env, { platform }) };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
|
|
5
|
+
const MINIMAL_ENV_KEYS = Object.freeze([
|
|
6
|
+
'PATH', 'HOME', 'SHELL', 'TERM', 'COLORTERM', 'LANG', 'LANGUAGE',
|
|
7
|
+
'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME', 'TMUX', 'TMUX_TMPDIR',
|
|
8
|
+
'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
|
|
9
|
+
'DBUS_SESSION_BUS_ADDRESS',
|
|
10
|
+
// Termux/Android native clients need these to resolve their runtime, tmp and
|
|
11
|
+
// platform paths. They are location metadata, never credentials.
|
|
12
|
+
'PREFIX', 'TMPDIR', 'TERMUX_VERSION', 'ANDROID_DATA', 'ANDROID_ROOT',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const UTF8_RE = /utf-?8/i;
|
|
16
|
+
|
|
17
|
+
function localeDefaults(platform = process.platform, env = process.env) {
|
|
18
|
+
const termux = platform === 'android' || String(env.PREFIX || '').includes('com.termux');
|
|
19
|
+
if (platform === 'darwin') return { lang: 'en_US.UTF-8', ctype: 'UTF-8' };
|
|
20
|
+
if (termux) return { lang: 'en_US.UTF-8', ctype: 'en_US.UTF-8' };
|
|
21
|
+
return { lang: 'C.UTF-8', ctype: 'C.UTF-8' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function withUtf8Locale(source = process.env, { platform = process.platform } = {}) {
|
|
25
|
+
const env = { ...source };
|
|
26
|
+
const defaults = localeDefaults(platform, env);
|
|
27
|
+
const effective = env.LC_ALL || env.LC_CTYPE || env.LANG || '';
|
|
28
|
+
if (!UTF8_RE.test(effective)) {
|
|
29
|
+
if (env.LC_ALL) env.LC_ALL = defaults.lang;
|
|
30
|
+
env.LANG = defaults.lang;
|
|
31
|
+
env.LC_CTYPE = defaults.ctype;
|
|
32
|
+
} else {
|
|
33
|
+
if (!env.LANG) env.LANG = defaults.lang;
|
|
34
|
+
if (!env.LC_CTYPE) env.LC_CTYPE = env.LC_ALL || env.LANG || defaults.ctype;
|
|
35
|
+
}
|
|
36
|
+
if (!env.TERM) env.TERM = 'xterm-256color';
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function minimalRuntimeEnv(source = process.env, opts = {}) {
|
|
41
|
+
const selected = {};
|
|
42
|
+
for (const key of MINIMAL_ENV_KEYS) {
|
|
43
|
+
if (source[key] !== undefined && source[key] !== '') selected[key] = String(source[key]);
|
|
44
|
+
}
|
|
45
|
+
if (!selected.PATH) selected.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
46
|
+
if (!selected.HOME) selected.HOME = opts.home || os.homedir();
|
|
47
|
+
return withUtf8Locale(selected, opts);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale, minimalRuntimeEnv };
|
package/lib/server.js
CHANGED
|
@@ -8,8 +8,8 @@ const express = require('express');
|
|
|
8
8
|
const { WebSocketServer } = require('ws');
|
|
9
9
|
const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
|
|
10
10
|
const { writeConfigAtomic } = require('./cli/init.js');
|
|
11
|
-
const { listSessions, attachedClients } = require('./tmux/list.js');
|
|
12
|
-
const { runAction, pasteToSession } = require('./tmux/actions.js');
|
|
11
|
+
const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/list.js');
|
|
12
|
+
const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
|
|
13
13
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
14
14
|
const { createPreviewSampler } = require('./tmux/preview.js');
|
|
15
15
|
const { openAttach } = require('./pty/attach.js');
|
|
@@ -22,6 +22,7 @@ const VERSION = require('../package.json').version;
|
|
|
22
22
|
const { transcribe } = require('./voice/transcribe.js');
|
|
23
23
|
const { selectProvider } = require('./fleet/provider.js');
|
|
24
24
|
const { fleetRoutes } = require('./fleet/routes.js');
|
|
25
|
+
const { cellsRoutes } = require('./cells/routes.js');
|
|
25
26
|
const { fsRoutes } = require('./fs/routes.js');
|
|
26
27
|
const nodesStore = require('./nodes/store.js');
|
|
27
28
|
const nodesTunnel = require('./nodes/tunnel.js');
|
|
@@ -196,6 +197,17 @@ function createServer(opts = {}) {
|
|
|
196
197
|
res.json({ killed: true });
|
|
197
198
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
198
199
|
});
|
|
200
|
+
api.patch('/sessions/:name/visibility', express.json({ limit: '1kb' }), async (req, res) => {
|
|
201
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: classificazione sessione bloccata' });
|
|
202
|
+
const name = String(req.params.name || '');
|
|
203
|
+
try {
|
|
204
|
+
const fleet = await fleetP;
|
|
205
|
+
if (isProtectedSession(name, fleet.isCellSession)) {
|
|
206
|
+
return res.status(409).json({ error: 'sessione di cella: la visibilità tecnica vale solo per sessioni tmux non gestite' });
|
|
207
|
+
}
|
|
208
|
+
res.json(await setSessionVisibility(cfg.tmuxBin, name, req.body?.technical === true));
|
|
209
|
+
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
210
|
+
});
|
|
199
211
|
api.get('/config', (_req, res) => res.json({
|
|
200
212
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
201
213
|
bind: cfg.bind, port: cfg.port,
|
|
@@ -220,6 +232,14 @@ function createServer(opts = {}) {
|
|
|
220
232
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
221
233
|
}));
|
|
222
234
|
api.use('/fleet', fleetRoutes(fleetP, cfg));
|
|
235
|
+
api.use('/cells', cellsRoutes({
|
|
236
|
+
fleetP,
|
|
237
|
+
instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
238
|
+
submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
|
|
239
|
+
engine: meta && meta.engine,
|
|
240
|
+
})),
|
|
241
|
+
readonly: proxyReadonly,
|
|
242
|
+
}));
|
|
223
243
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
224
244
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
225
245
|
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
@@ -396,6 +416,10 @@ function createServer(opts = {}) {
|
|
|
396
416
|
}
|
|
397
417
|
});
|
|
398
418
|
|
|
419
|
+
server.on('close', () => {
|
|
420
|
+
fleetP.then((fleet) => (typeof fleet.close === 'function' ? fleet.close() : null)).catch(() => {});
|
|
421
|
+
});
|
|
422
|
+
|
|
399
423
|
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
|
|
400
424
|
}
|
|
401
425
|
|
package/lib/settings/routes.js
CHANGED
|
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
|
|
|
47
47
|
const nodesCmds = require('../nodes/commands.js');
|
|
48
48
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
49
49
|
const peering = require('../nodes/peering.js');
|
|
50
|
-
const { probeHealth } = require('../proxy/federation.js');
|
|
50
|
+
const { probeHealth, waitForHealthyPeer } = require('../proxy/federation.js');
|
|
51
51
|
const { rotateToken } = require('../auth/token.js');
|
|
52
52
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
53
53
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -822,9 +822,10 @@ function settingsRoutes(deps = {}) {
|
|
|
822
822
|
if (!started.started && started.reason !== 'already running') {
|
|
823
823
|
throw new Error(started.reason || 'avvio SSH fallito');
|
|
824
824
|
}
|
|
825
|
-
const ready = await
|
|
825
|
+
const ready = await waitForHealthyPeer({
|
|
826
826
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
|
|
827
|
-
fetchImpl,
|
|
827
|
+
fetchImpl, attempts: 8, delayMs: 200,
|
|
828
|
+
...(typeof seams.pairDelay === 'function' ? { delay: seams.pairDelay } : {}),
|
|
828
829
|
});
|
|
829
830
|
if (!ready || ready.status !== 'healthy') {
|
|
830
831
|
const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
|
package/lib/tmux/actions.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { execFile } = require('node:child_process');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { isValidSession } = require('../files/store.js');
|
|
3
8
|
|
|
4
9
|
// Window/pane navigation must NOT be emulated with client-side prefix keys
|
|
5
10
|
// (it depends on each host's bindings, which may be remapped or broken).
|
|
@@ -53,6 +58,7 @@ function runAction(tmuxBin, session, name) {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
const MAX_PASTE = 4096;
|
|
61
|
+
const MAX_SUBMIT = 8192;
|
|
56
62
|
|
|
57
63
|
// true se text contiene control char (codici 0x00-0x1f e 0x7f). Espresso via
|
|
58
64
|
// charCode invece di regex perche' il write-layer corrompe gli escape backslash
|
|
@@ -86,4 +92,93 @@ function pasteToSession(tmuxBin, session, text) {
|
|
|
86
92
|
});
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
function submitTextOk(text) {
|
|
96
|
+
if (typeof text !== 'string' || !text || text.length > MAX_SUBMIT) return false;
|
|
97
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
98
|
+
const c = text.charCodeAt(i);
|
|
99
|
+
if (c === 9 || c === 10 || c === 13) continue;
|
|
100
|
+
if (c < 32 || c === 127) return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function execTmux(execFileImpl, tmuxBin, args, timeout = 5000) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
try {
|
|
108
|
+
execFileImpl(tmuxBin, args, { timeout }, (err, stdout) => {
|
|
109
|
+
resolve({ ok: !err, stdout: String(stdout || '') });
|
|
110
|
+
});
|
|
111
|
+
} catch (_) { resolve({ ok: false, stdout: '' }); }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Consegna un messaggio a un pane esatto e lo sottopone al TUI. Il testo non
|
|
116
|
+
// passa mai in argv: file temporaneo 0600 -> tmux load-buffer -> paste-buffer -p
|
|
117
|
+
// (bracketed paste), poi Enter con una chiamata separata. Il pane id viene
|
|
118
|
+
// risolto prima del paste e verificato di nuovo prima dell'Enter, evitando
|
|
119
|
+
// prefix match e il riuso del solo nome sessione.
|
|
120
|
+
async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
121
|
+
if (!isValidSession(session) || !submitTextOk(text)) {
|
|
122
|
+
return { submitted: false, reason: 'input non valido' };
|
|
123
|
+
}
|
|
124
|
+
const execFileImpl = opts.execFileImpl || execFile;
|
|
125
|
+
const fsImpl = opts.fsImpl || fs;
|
|
126
|
+
const tmpRoot = opts.tmpdir || os.tmpdir();
|
|
127
|
+
const delay = typeof opts.delay === 'function'
|
|
128
|
+
? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
+
const codexComposer = /^(?:codex|codex-vl)(?:\.|$)/.test(String(opts.engine || ''));
|
|
130
|
+
const nonce = (opts.nonce || crypto.randomBytes(8).toString('hex')).replace(/[^a-f0-9]/g, '').slice(0, 32);
|
|
131
|
+
if (!nonce) return { submitted: false, reason: 'invio non disponibile' };
|
|
132
|
+
const buffer = `ncmsg-${nonce}`;
|
|
133
|
+
const tmp = path.join(tmpRoot, `.nexuscrew-message-${nonce}.txt`);
|
|
134
|
+
let loaded = false;
|
|
135
|
+
try {
|
|
136
|
+
fsImpl.writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
137
|
+
fsImpl.chmodSync(tmp, 0o600);
|
|
138
|
+
const pane = await execTmux(execFileImpl, tmuxBin,
|
|
139
|
+
['display-message', '-p', '-t', `=${session}:`, '#{pane_id}']);
|
|
140
|
+
const paneId = pane.stdout.trim();
|
|
141
|
+
if (!pane.ok || !/^%[0-9]+$/.test(paneId)) {
|
|
142
|
+
return { submitted: false, reason: 'sessione non raggiungibile' };
|
|
143
|
+
}
|
|
144
|
+
const load = await execTmux(execFileImpl, tmuxBin, ['load-buffer', '-b', buffer, tmp]);
|
|
145
|
+
if (!load.ok) return { submitted: false, reason: 'buffer non disponibile' };
|
|
146
|
+
loaded = true;
|
|
147
|
+
const paste = await execTmux(execFileImpl, tmuxBin,
|
|
148
|
+
['paste-buffer', '-p', '-t', paneId, '-b', buffer]);
|
|
149
|
+
if (!paste.ok) return { submitted: false, reason: 'consegna non riuscita' };
|
|
150
|
+
const paneAlive = async () => {
|
|
151
|
+
const verify = await execTmux(execFileImpl, tmuxBin,
|
|
152
|
+
['display-message', '-p', '-t', paneId, '#{session_name}\t#{pane_dead}\t#{pane_id}']);
|
|
153
|
+
const [verifiedSession, dead, verifiedPane] = verify.stdout.trim().split('\t');
|
|
154
|
+
return verify.ok && verifiedSession === session && dead === '0' && verifiedPane === paneId;
|
|
155
|
+
};
|
|
156
|
+
// Codex/Codex-VL have a paste-burst window: a too-early Enter can remain
|
|
157
|
+
// inside the composer. C-e is sent as its own tmux command after the paste,
|
|
158
|
+
// then the pane is revalidated before the separate Enter. Other clients
|
|
159
|
+
// still receive a short separation between paste and submit.
|
|
160
|
+
await delay(codexComposer ? 400 : 150);
|
|
161
|
+
if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
|
|
162
|
+
if (codexComposer) {
|
|
163
|
+
const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
|
|
164
|
+
if (!flush.ok) return { submitted: false, reason: 'testo consegnato ma flush composer non riuscito' };
|
|
165
|
+
await delay(300);
|
|
166
|
+
if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
|
|
167
|
+
}
|
|
168
|
+
const enter = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'Enter']);
|
|
169
|
+
if (!enter.ok) return { submitted: false, reason: 'testo consegnato ma invio non riuscito' };
|
|
170
|
+
return { submitted: true, reason: codexComposer
|
|
171
|
+
? 'bracketed paste + burst flush + Enter separati'
|
|
172
|
+
: 'bracketed paste + Enter separato' };
|
|
173
|
+
} catch (_) {
|
|
174
|
+
return { submitted: false, reason: 'consegna non riuscita' };
|
|
175
|
+
} finally {
|
|
176
|
+
if (loaded) await execTmux(execFileImpl, tmuxBin, ['delete-buffer', '-b', buffer]);
|
|
177
|
+
try { fsImpl.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
actionArgs, runAction, ACTIONS, pasteArgs, pasteToSession, scrollArgs,
|
|
183
|
+
submitTextOk, submitToSession, MAX_SUBMIT,
|
|
184
|
+
};
|
package/lib/tmux/list.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { execFile, execFileSync } = require('node:child_process');
|
|
3
3
|
|
|
4
|
-
const FMT = "#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}";
|
|
4
|
+
const FMT = "#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_created}\t#{session_activity}\t#{pane_current_command}\t#{@nexuscrew_visibility}";
|
|
5
5
|
|
|
6
6
|
function parseSessions(raw) {
|
|
7
7
|
return String(raw)
|
|
8
8
|
.split('\n')
|
|
9
9
|
.filter((l) => l.trim() !== '')
|
|
10
10
|
.map((line) => {
|
|
11
|
-
const [name, attached, windows, created, activity, cmd] = line.split('\t');
|
|
11
|
+
const [name, attached, windows, created, activity, cmd, visibility] = line.split('\t');
|
|
12
12
|
return {
|
|
13
13
|
name,
|
|
14
14
|
attached: attached === '1',
|
|
@@ -16,8 +16,27 @@ function parseSessions(raw) {
|
|
|
16
16
|
created: Number(created),
|
|
17
17
|
activity: Number(activity) || 0,
|
|
18
18
|
cmd: cmd || '',
|
|
19
|
+
technical: visibility === 'technical',
|
|
19
20
|
};
|
|
21
|
+
})
|
|
22
|
+
.filter((session) => session.windows > 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
|
|
28
|
+
const error = new Error('nome sessione non valido'); error.status = 400; reject(error); return;
|
|
29
|
+
}
|
|
30
|
+
const args = technical
|
|
31
|
+
? ['set-option', '-t', `=${name}`, '@nexuscrew_visibility', 'technical']
|
|
32
|
+
: ['set-option', '-u', '-t', `=${name}`, '@nexuscrew_visibility'];
|
|
33
|
+
execFile(tmuxBin, args, (err, _stdout, stderr) => {
|
|
34
|
+
if (!err) { resolve({ name, technical: !!technical }); return; }
|
|
35
|
+
const error = new Error(`tmux set-option failed: ${String(stderr || err.message).trim()}`);
|
|
36
|
+
error.status = /can't find session|no server running/i.test(stderr || '') ? 404 : 500;
|
|
37
|
+
reject(error);
|
|
20
38
|
});
|
|
39
|
+
});
|
|
21
40
|
}
|
|
22
41
|
|
|
23
42
|
function listSessions(tmuxBin = 'tmux') {
|
|
@@ -45,4 +64,4 @@ function attachedClients(tmuxBin = 'tmux', session) {
|
|
|
45
64
|
} catch (_) { return 0; }
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
module.exports = { parseSessions, listSessions, attachedClients, FMT };
|
|
67
|
+
module.exports = { parseSessions, listSessions, attachedClients, setSessionVisibility, FMT };
|
package/lib/update/core.js
CHANGED
|
@@ -48,15 +48,42 @@ function compareVersions(a, b) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function registryVersion(stdout) {
|
|
51
|
-
const raw = String(stdout || '')
|
|
52
|
-
|
|
51
|
+
const raw = String(stdout || '')
|
|
52
|
+
.replace(/^\uFEFF/, '')
|
|
53
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
54
|
+
.trim();
|
|
55
|
+
const candidates = [];
|
|
56
|
+
const add = (value) => {
|
|
57
|
+
if (typeof value !== 'string') return;
|
|
58
|
+
const parsed = parseVersion(value);
|
|
59
|
+
if (parsed) candidates.push(parsed.raw);
|
|
60
|
+
};
|
|
53
61
|
try {
|
|
54
62
|
const decoded = JSON.parse(raw);
|
|
55
|
-
if (typeof decoded === 'string')
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
if (typeof decoded === 'string') add(decoded);
|
|
64
|
+
else if (Array.isArray(decoded)) decoded.forEach(add);
|
|
65
|
+
else if (decoded && typeof decoded === 'object') add(decoded.version);
|
|
66
|
+
} catch (_) {
|
|
67
|
+
// npm versions differ in whether --json returns a JSON scalar or a plain
|
|
68
|
+
// line. Notices may also precede the value, so inspect complete lines but
|
|
69
|
+
// never extract a semver from the middle of arbitrary text.
|
|
70
|
+
for (const line of raw.split(/\r?\n/)) add(line.trim().replace(/^['"]|['"]$/g, ''));
|
|
71
|
+
}
|
|
72
|
+
const unique = [...new Set(candidates)];
|
|
73
|
+
if (unique.length !== 1) throw new Error('npm ha restituito una versione non valida');
|
|
74
|
+
return unique[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stableRuntimeDir(home) {
|
|
78
|
+
const dir = path.join(path.resolve(String(home || '')), '.nexuscrew');
|
|
79
|
+
try {
|
|
80
|
+
const st = fs.lstatSync(dir);
|
|
81
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('directory runtime NexusCrew non sicura');
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error.code !== 'ENOENT') throw error;
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
}
|
|
86
|
+
return dir;
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
function scrubError(error) {
|
|
@@ -153,5 +180,5 @@ function writeState(file, value) {
|
|
|
153
180
|
module.exports = {
|
|
154
181
|
PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
|
|
155
182
|
scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
|
|
156
|
-
readState, writeState,
|
|
183
|
+
readState, writeState, stableRuntimeDir,
|
|
157
184
|
};
|
package/lib/update/manager.js
CHANGED
|
@@ -6,7 +6,7 @@ const path = require('node:path');
|
|
|
6
6
|
const { execFile, spawn } = require('node:child_process');
|
|
7
7
|
const {
|
|
8
8
|
PACKAGE_NAME, compareVersions, parseVersion, registryVersion, scrubError, pidAlive, readLock,
|
|
9
|
-
acquireUpdateLock, releaseUpdateLock, readState, writeState,
|
|
9
|
+
acquireUpdateLock, releaseUpdateLock, readState, writeState, stableRuntimeDir,
|
|
10
10
|
} = require('./core.js');
|
|
11
11
|
|
|
12
12
|
const DEFAULT_INITIAL_DELAY_MS = 60 * 1000;
|
|
@@ -24,10 +24,11 @@ function isNewer(candidate, current) {
|
|
|
24
24
|
try { return compareVersions(candidate, current) > 0; } catch (_) { return false; }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000 } = {}) {
|
|
27
|
+
function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000, home = os.homedir(), cwd } = {}) {
|
|
28
28
|
return new Promise((resolve, reject) => {
|
|
29
29
|
execFileImpl('npm', ['view', `${PACKAGE_NAME}@latest`, 'version', '--json'], {
|
|
30
30
|
encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
|
|
31
|
+
cwd: cwd || stableRuntimeDir(home),
|
|
31
32
|
}, (error, stdout) => {
|
|
32
33
|
if (error) return reject(error);
|
|
33
34
|
try { resolve(registryVersion(stdout)); } catch (e) { reject(e); }
|
|
@@ -42,6 +43,7 @@ function createNpmUpdater(opts = {}) {
|
|
|
42
43
|
const logPath = opts.logPath || path.join(home, '.nexuscrew', 'npm-update.log');
|
|
43
44
|
const lockPath = opts.lockPath || path.join(home, '.nexuscrew', 'npm-update.lock');
|
|
44
45
|
const runnerPath = opts.runnerPath || path.join(__dirname, 'runner.js');
|
|
46
|
+
const workDir = opts.cwd || stableRuntimeDir(home);
|
|
45
47
|
const supported = opts.supported === undefined ? isGlobalInstall(opts.packageRoot) : !!opts.supported;
|
|
46
48
|
const readonly = opts.readonly === true;
|
|
47
49
|
const lookupLatest = opts.lookupLatest || (() => lookupLatestNpm(opts));
|
|
@@ -135,7 +137,8 @@ function createNpmUpdater(opts = {}) {
|
|
|
135
137
|
process.execPath, ...runnerArgs]
|
|
136
138
|
: runnerArgs;
|
|
137
139
|
child = spawnImpl(bin, argv, {
|
|
138
|
-
detached: true, stdio: ['ignore', fd, fd],
|
|
140
|
+
detached: true, stdio: ['ignore', fd, fd], cwd: workDir,
|
|
141
|
+
env: { ...process.env, NEXUSCREW_UPDATE_RUNNER: '1' },
|
|
139
142
|
});
|
|
140
143
|
} catch (e) {
|
|
141
144
|
releaseUpdateLock(lockPath, reservation.token);
|
package/lib/update/runner.js
CHANGED
|
@@ -7,6 +7,7 @@ const path = require('node:path');
|
|
|
7
7
|
const { execFileSync } = require('node:child_process');
|
|
8
8
|
const {
|
|
9
9
|
PACKAGE_NAME, parseVersion, scrubError, pidAlive, adoptUpdateLock, releaseUpdateLock, readState, writeState,
|
|
10
|
+
stableRuntimeDir,
|
|
10
11
|
} = require('./core.js');
|
|
11
12
|
|
|
12
13
|
function args(argv) {
|
|
@@ -69,6 +70,7 @@ async function runUpdate(opts = {}) {
|
|
|
69
70
|
if (!parseVersion(version)) throw new Error('versione update non valida');
|
|
70
71
|
const home = opts.home || os.homedir();
|
|
71
72
|
const statusPath = opts.statusPath || path.join(home, '.nexuscrew', 'npm-update.json');
|
|
73
|
+
const workDir = opts.cwd || stableRuntimeDir(home);
|
|
72
74
|
const execImpl = opts.execImpl || execFileSync;
|
|
73
75
|
const readInstalledVersion = opts.readInstalledVersion || (() => {
|
|
74
76
|
const p = path.resolve(__dirname, '..', '..', 'package.json');
|
|
@@ -76,7 +78,9 @@ async function runUpdate(opts = {}) {
|
|
|
76
78
|
});
|
|
77
79
|
const preflightImpl = opts.preflightImpl || (({ version: expectedVersion = version } = {}) => {
|
|
78
80
|
const bin = path.resolve(__dirname, '..', '..', 'bin', 'nexuscrew.js');
|
|
79
|
-
const output = execFileSync(process.execPath, [bin, 'version'], {
|
|
81
|
+
const output = execFileSync(process.execPath, [bin, 'version'], {
|
|
82
|
+
encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'], cwd: workDir,
|
|
83
|
+
});
|
|
80
84
|
if (String(output || '').trim() !== expectedVersion) throw new Error(`preflight CLI fallito: attesa ${expectedVersion}`);
|
|
81
85
|
return true;
|
|
82
86
|
});
|
|
@@ -96,7 +100,7 @@ async function runUpdate(opts = {}) {
|
|
|
96
100
|
try {
|
|
97
101
|
writeState(statusPath, { ...previous, phase: 'installing', targetVersion: version, updaterPid: process.pid, lastError: '' });
|
|
98
102
|
execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${version}`, '--no-audit', '--no-fund'], {
|
|
99
|
-
stdio: 'inherit', timeout: 5 * 60 * 1000,
|
|
103
|
+
stdio: 'inherit', timeout: 5 * 60 * 1000, cwd: workDir,
|
|
100
104
|
});
|
|
101
105
|
const installed = String(readInstalledVersion() || '');
|
|
102
106
|
if (installed !== version) throw new Error(`verifica installazione fallita: attesa ${version}, trovata ${installed || 'sconosciuta'}`);
|
|
@@ -114,7 +118,7 @@ async function runUpdate(opts = {}) {
|
|
|
114
118
|
if (installedNew && parseVersion(previousVersion) && previousVersion !== version) {
|
|
115
119
|
try {
|
|
116
120
|
execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${previousVersion}`, '--no-audit', '--no-fund'], {
|
|
117
|
-
stdio: 'inherit', timeout: 5 * 60 * 1000,
|
|
121
|
+
stdio: 'inherit', timeout: 5 * 60 * 1000, cwd: workDir,
|
|
118
122
|
});
|
|
119
123
|
if (String(readInstalledVersion() || '') !== previousVersion) throw new Error(`rollback verify: attesa ${previousVersion}`);
|
|
120
124
|
await preflightImpl({ version: previousVersion, home, rollback: true });
|