@mmmbuto/nexuscrew 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +156 -20
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +415 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +16 -3
  12. package/lib/cli/init.js +39 -6
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +55 -1
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/builtin.js +141 -24
  22. package/lib/fleet/definitions.js +26 -0
  23. package/lib/fleet/managed.js +211 -0
  24. package/lib/fleet/routes.js +5 -4
  25. package/lib/mcp/server.js +362 -0
  26. package/lib/nodes/commands.js +396 -0
  27. package/lib/nodes/store.js +358 -0
  28. package/lib/nodes/tunnel-supervisor.js +102 -0
  29. package/lib/nodes/tunnel.js +300 -0
  30. package/lib/notify/asks.js +150 -0
  31. package/lib/notify/events.js +59 -0
  32. package/lib/notify/notifier.js +37 -0
  33. package/lib/notify/persist.js +73 -0
  34. package/lib/notify/push.js +289 -0
  35. package/lib/notify/routes.js +260 -0
  36. package/lib/proxy/node-proxy.js +292 -0
  37. package/lib/pty/attach.js +34 -9
  38. package/lib/pty/provider.js +21 -6
  39. package/lib/server.js +206 -12
  40. package/lib/settings/routes.js +473 -0
  41. package/lib/ws/bridge.js +10 -1
  42. package/package.json +7 -1
  43. package/skills/nexuscrew-agent/SKILL.md +83 -0
  44. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  45. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  46. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  47. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+ // lib/proxy/node-proxy.js — reverse-proxy single-origin /node/<name> (design §4, §4b(2)).
3
+ //
4
+ // La superficie PIU' security-critical del progetto. Contratti duri (§4b(2)):
5
+ // 1. Il token LOCALE (Bearer) si verifica PRIMA di risolvere <name>. Il router
6
+ // HTTP e' montato DIETRO requireToken; l'upgrade WS verifica il token in testa
7
+ // a handleNodeUpgrade, prima di qualunque parsing/resolve.
8
+ // 2. <name> = chiave strict di nodes.json (^[a-z0-9-]{1,32}$), MAI usata per
9
+ // costruire path/URL filesystem; nome non in config -> 404 secco.
10
+ // 3. Il token del nodo remoto lo inietta SOLO il proxy (da nodes.json via B0); il
11
+ // browser non lo vede MAI. Header hop-by-hop e Authorization/cookie/host/
12
+ // x-forwarded/proxy-* client-supplied STRIPPATI, mai inoltrati upstream.
13
+ // 4. Upstream consentito: ESCLUSIVAMENTE 127.0.0.1:<localPort> del tunnel da
14
+ // config — mai derivato da input utente/header.
15
+ // 5. Upgrade WS: STESSI check dell'HTTP (auth locale -> name strict -> inject
16
+ // token remoto). Parita' HTTP/WS.
17
+ // 7. NIENTE proxy transitivo: /node/<a>/node/<b> -> 404 (espone SOLO la root
18
+ // locale del nodo remoto, mai il suo /node/*).
19
+ // 8. Nodo irraggiungibile -> 502 JSON {error}, mai hang: timeout esplicito.
20
+ //
21
+ // Niente shell, niente http-proxy: proxy manuale con http.request (HTTP) e piping
22
+ // raw socket TCP (upgrade WS). resolveNode/httpRequest/connect iniettabili (test).
23
+ const http = require('node:http');
24
+ const net = require('node:net');
25
+ const { NODE_NAME_RE } = require('../nodes/store.js');
26
+ const { bearerFrom } = require('../auth/middleware.js');
27
+
28
+ // Timeout upstream: irraggiungibile/lento -> 502, mai spinner infinito (§8).
29
+ const PROXY_TIMEOUT_MS = 30000;
30
+ const CONNECT_TIMEOUT_MS = 10000;
31
+
32
+ // Metodi che mutano stato sul nodo remoto: bloccati sotto NEXUSCREW_READONLY locale.
33
+ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
34
+
35
+ // Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
36
+ const HOP_BY_HOP = new Set([
37
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
38
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
39
+ ]);
40
+
41
+ // Header della RICHIESTA client da NON inoltrare upstream: hop-by-hop + i
42
+ // client-supplied che potrebbero (a) impersonare/derivare l'upstream o (b)
43
+ // confondere l'auth remota. Authorization viene RI-iniettato col token del nodo.
44
+ function isStrippedRequestHeader(lk) {
45
+ if (HOP_BY_HOP.has(lk)) return true;
46
+ if (lk === 'authorization' || lk === 'cookie' || lk === 'host') return true;
47
+ if (lk.startsWith('proxy-')) return true;
48
+ if (lk === 'forwarded' || lk.startsWith('x-forwarded-')) return true;
49
+ return false;
50
+ }
51
+
52
+ // Divide "/vps/api/x?y=1" -> { name:'vps', rest:'/api/x', search:'?y=1' }.
53
+ // name RAW (non decodificato): validato poi contro l'allowlist strict, cosi'
54
+ // '..', '%2e', nomi lunghi falliscono il regex -> 404. rest/search preservano
55
+ // l'encoding originale e vengono inoltrati verbatim.
56
+ function splitNodePath(afterNode) {
57
+ const qIdx = afterNode.indexOf('?');
58
+ const rawPath = qIdx >= 0 ? afterNode.slice(0, qIdx) : afterNode;
59
+ const search = qIdx >= 0 ? afterNode.slice(qIdx) : '';
60
+ const trimmed = rawPath.replace(/^\/+/, '');
61
+ if (!trimmed) return null; // '/node' o '/node/' senza name -> 404
62
+ const slash = trimmed.indexOf('/');
63
+ const name = slash >= 0 ? trimmed.slice(0, slash) : trimmed;
64
+ const rest = slash >= 0 ? trimmed.slice(slash) : '/';
65
+ return { name, rest, search };
66
+ }
67
+
68
+ // Proxy transitivo: il path inoltrato NON deve a sua volta essere un /node/*.
69
+ // Controlla sia raw sia decodificato (il remoto normalizza il percent-encoding
70
+ // in fase di routing, quindi '/%6eode/b' vale '/node/b').
71
+ function isTransitiveRest(rest) {
72
+ const hit = (p) => p === '/node' || p.startsWith('/node/');
73
+ if (hit(rest)) return true;
74
+ try { if (hit(decodeURIComponent(rest))) return true; } catch (_) { /* malformed: raw basta */ }
75
+ return false;
76
+ }
77
+
78
+ // Headers per l'HTTP upstream: strip client-supplied pericolosi, inietta il token
79
+ // del nodo. host lo mette node http.request da host/port (loopback da config).
80
+ function sanitizeRequestHeaders(headers, remoteToken) {
81
+ const out = {};
82
+ for (const [k, v] of Object.entries(headers || {})) {
83
+ if (isStrippedRequestHeader(k.toLowerCase())) continue;
84
+ out[k] = v;
85
+ }
86
+ if (remoteToken) out.authorization = `Bearer ${remoteToken}`;
87
+ return out;
88
+ }
89
+
90
+ // Headers della RISPOSTA verso il browser: strip hop-by-hop (igiene proxy). Il
91
+ // token remoto non viene MAI messo qui dal proxy; il nodo remoto non lo riflette.
92
+ function sanitizeResponseHeaders(headers) {
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(headers || {})) {
95
+ if (HOP_BY_HOP.has(k.toLowerCase())) continue;
96
+ out[k] = v;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function notFound(res) { res.status(404).json({ error: 'not found' }); }
102
+
103
+ // --- HTTP proxy middleware --------------------------------------------------
104
+ // Montare come: app.use('/node', requireToken(token), createNodeProxy({...}))
105
+ // -> req.url e' gia' il path DOPO /node (es. '/vps/api/x'); l'auth locale e' gia'
106
+ // passata (requireToken davanti). Ordine interno: name strict -> no-transitive ->
107
+ // resolve -> readonly -> proxy.
108
+ function createNodeProxy(deps) {
109
+ const { resolveNode, readonly = () => false, httpRequest = http.request } = deps;
110
+ return function nodeProxy(req, res) {
111
+ const parsed = splitNodePath(req.url);
112
+ if (!parsed) return notFound(res); // §4b(2)#2 no name
113
+ if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // §4b(2)#2 strict/traversal
114
+ if (isTransitiveRest(parsed.rest)) return notFound(res); // §4b(2)#7 no transitive
115
+ const node = resolveNode(parsed.name);
116
+ if (!node) return notFound(res); // nome non in config -> 404 secco
117
+ if (readonly() && MUTATING.has(req.method)) {
118
+ return res.status(403).json({ error: 'READONLY: mutazione verso nodo bloccata' });
119
+ }
120
+ proxyHttp(req, res, node, parsed.rest, parsed.search, httpRequest);
121
+ };
122
+ }
123
+
124
+ function proxyHttp(req, res, node, rest, search, httpRequest) {
125
+ const options = {
126
+ host: '127.0.0.1', // §4b(2)#4 upstream SOLO loopback da config
127
+ port: node.localPort,
128
+ method: req.method,
129
+ // parita' col path WS: il token LOCALE eventualmente in query (?token=) non
130
+ // deve MAI arrivare al nodo remoto — l'auth upstream e' l'Authorization col
131
+ // token remoto iniettato da sanitizeRequestHeaders.
132
+ path: `${rest}${stripLocalTokenQuery(search)}`,
133
+ headers: sanitizeRequestHeaders(req.headers, node.token),
134
+ };
135
+ // http.request puo' lanciare in modo SINCRONO (es. header value con char
136
+ // invalido dal token del nodo) -> senza try/catch diventa un throw non gestito
137
+ // e Express emette una pagina 500 con lo stack (path interni). Chiudiamo in
138
+ // 502 JSON come gli altri errori upstream, senza esporre nulla. (fix audit).
139
+ let upstream;
140
+ try {
141
+ upstream = httpRequest(options, (up) => {
142
+ res.writeHead(up.statusCode, sanitizeResponseHeaders(up.headers));
143
+ up.pipe(res);
144
+ });
145
+ } catch (_) {
146
+ if (!res.headersSent) res.status(502).json({ error: 'node non raggiungibile' });
147
+ return;
148
+ }
149
+ upstream.setTimeout(PROXY_TIMEOUT_MS, () => upstream.destroy(new Error('upstream timeout')));
150
+ upstream.on('error', () => {
151
+ if (!res.headersSent) res.status(502).json({ error: 'node non raggiungibile' });
152
+ else res.destroy();
153
+ });
154
+ req.on('aborted', () => upstream.destroy());
155
+ req.pipe(upstream);
156
+ }
157
+
158
+ // --- WebSocket upgrade proxy (raw socket) -----------------------------------
159
+ // Stessi check dell'HTTP, ma l'auth locale va fatta in TESTA (browser non puo'
160
+ // settare Authorization sul WS -> token accettato anche via ?token=<local>).
161
+ // Proxy trasparente a livello TCP: si inoltra la stessa upgrade request (header
162
+ // sanificati + token remoto iniettato) all'upstream loopback e si fa piping raw
163
+ // dei due socket. Nessun doppio handshake: l'Accept lo calcola l'upstream dalla
164
+ // Sec-WebSocket-Key del client, che inoltriamo verbatim.
165
+ function bearerFromUpgrade(req, url) {
166
+ const h = bearerFrom(req);
167
+ if (h) return h;
168
+ try { return url.searchParams.get('token') || ''; } catch (_) { return ''; }
169
+ }
170
+
171
+ function abortUpgrade(socket, code) {
172
+ const msg = http.STATUS_CODES[code] || 'Error';
173
+ try {
174
+ socket.write(
175
+ `HTTP/1.1 ${code} ${msg}\r\n` +
176
+ 'Connection: close\r\n' +
177
+ 'Content-Type: text/plain\r\n' +
178
+ `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`,
179
+ );
180
+ } catch (_) { /* socket gia' morto */ }
181
+ try { socket.destroy(); } catch (_) {}
182
+ }
183
+
184
+ // Costruisce il blocco header raw dell'upgrade da inoltrare upstream. Preserva i
185
+ // Sec-WebSocket-* e imposta Connection/Upgrade in modo controllato; strippa i
186
+ // client-supplied pericolosi; inietta il token remoto. Rimuove ?token= locale.
187
+ function buildUpgradeRequest(method, rest, search, headers, remoteToken, localPort) {
188
+ const lines = [];
189
+ const cleanSearch = stripLocalTokenQuery(search);
190
+ lines.push(`${method} ${rest}${cleanSearch} HTTP/1.1`);
191
+ lines.push(`Host: 127.0.0.1:${localPort}`);
192
+ for (const [k, v] of Object.entries(headers || {})) {
193
+ const lk = k.toLowerCase();
194
+ if (lk === 'host') continue;
195
+ if (lk === 'connection' || lk === 'upgrade') continue; // ri-aggiunti controllati
196
+ if (isStrippedRequestHeader(lk)) continue; // authorization/cookie/proxy-*/x-forwarded-*/hop-by-hop
197
+ const val = Array.isArray(v) ? v.join(', ') : v;
198
+ lines.push(`${k}: ${val}`);
199
+ }
200
+ lines.push('Connection: Upgrade');
201
+ lines.push('Upgrade: websocket');
202
+ if (remoteToken) lines.push(`Authorization: Bearer ${remoteToken}`);
203
+ return `${lines.join('\r\n')}\r\n\r\n`;
204
+ }
205
+
206
+ function stripLocalTokenQuery(search) {
207
+ if (!search || search === '?') return '';
208
+ const sp = new URLSearchParams(search.slice(1));
209
+ sp.delete('token');
210
+ const s = sp.toString();
211
+ return s ? `?${s}` : '';
212
+ }
213
+
214
+ // handleNodeUpgrade — chiamato dal server.on('upgrade') per i path /node/*.
215
+ // connect(port) -> duplex verso 127.0.0.1:port (default net.connect), seam test.
216
+ function handleNodeUpgrade(ctx) {
217
+ const {
218
+ req, socket, head, resolveNode, verifyToken,
219
+ readonly = () => false, connect = defaultConnect, activeSockets = null,
220
+ } = ctx;
221
+ let url;
222
+ try { url = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return abortUpgrade(socket, 400); }
223
+
224
+ // (1) AUTH LOCALE PRIMA DI TUTTO
225
+ if (!verifyToken(bearerFromUpgrade(req, url))) return abortUpgrade(socket, 401);
226
+
227
+ // (2) name strict + (7) no transitive
228
+ const afterNode = req.url.replace(/^\/node(?=\/|\?|$)/, '');
229
+ const parsed = splitNodePath(afterNode);
230
+ if (!parsed || !NODE_NAME_RE.test(parsed.name)) return abortUpgrade(socket, 404);
231
+ if (isTransitiveRest(parsed.rest)) return abortUpgrade(socket, 404);
232
+ const node = resolveNode(parsed.name);
233
+ if (!node) return abortUpgrade(socket, 404);
234
+
235
+ // §9d: in READONLY locale il WS proxy si nega in toto — il piping raw non puo'
236
+ // applicare un readonly frame-level e un attach WS e' un canale di scrittura
237
+ // (PTY remoto). Il nodo remoto applica il PROPRIO READONLY ai suoi client;
238
+ // qui vale quello locale, come per i metodi HTTP mutanti.
239
+ if (readonly()) return abortUpgrade(socket, 403);
240
+
241
+ // (3)(4) inject token remoto, upstream SOLO loopback da config
242
+ let upstream;
243
+ let settled = false;
244
+ let fail = (code) => { if (settled) return; settled = true; try { upstream && upstream.destroy(); } catch (_) {} abortUpgrade(socket, code); };
245
+ try {
246
+ upstream = connect(node.localPort); // net.connect puo' lanciare sync su opzioni invalide (port NaN da config)
247
+ } catch (_) { return fail(502); } // (parita' error-handling con proxyHttp: 502 JSON-equivalent, no stack)
248
+
249
+ const timer = setTimeout(() => fail(502), CONNECT_TIMEOUT_MS);
250
+ upstream.on('connect', () => {
251
+ clearTimeout(timer);
252
+ if (settled) { try { upstream.destroy(); } catch (_) {} return; }
253
+ // buildUpgradeRequest/write possono lanciare (header invalido). Settle DOPO il
254
+ // successo dei write: cosi' il catch -> fail(502) e' OPERATIVO (distrugge entrambi
255
+ // i socket e invia 502) invece di no-op. Prima dell'audit settled veniva messo a
256
+ // true PRIMA dei write: un throw li' rendeva fail() un no-op e lasciava entrambi i
257
+ // socket vivi (leak) senza alcun 502 al client (audit F5).
258
+ try {
259
+ upstream.write(buildUpgradeRequest(req.method, parsed.rest, parsed.search, req.headers, node.token, node.localPort));
260
+ if (head && head.length) upstream.write(head);
261
+ } catch (e) { return fail(502); }
262
+ settled = true;
263
+ if (activeSockets && typeof activeSockets.add === 'function') {
264
+ activeSockets.add(socket);
265
+ activeSockets.add(upstream);
266
+ const remove = () => {
267
+ try { activeSockets.delete(socket); } catch (_) {}
268
+ try { activeSockets.delete(upstream); } catch (_) {}
269
+ };
270
+ if (typeof socket.once === 'function') socket.once('close', remove);
271
+ if (typeof upstream.once === 'function') upstream.once('close', remove);
272
+ }
273
+ socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
274
+ upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
275
+ socket.pipe(upstream);
276
+ upstream.pipe(socket);
277
+ });
278
+ upstream.on('error', () => { clearTimeout(timer); fail(502); });
279
+ }
280
+
281
+ function defaultConnect(port) {
282
+ return net.connect({ host: '127.0.0.1', port });
283
+ }
284
+
285
+ module.exports = {
286
+ createNodeProxy,
287
+ handleNodeUpgrade,
288
+ // esposti per i test
289
+ splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
290
+ buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, bearerFromUpgrade,
291
+ MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
292
+ };
package/lib/pty/attach.js CHANGED
@@ -1,30 +1,55 @@
1
1
  'use strict';
2
+ const { execFile } = require('node:child_process');
3
+ const os = require('node:os');
2
4
  const { loadPty } = require('./provider.js');
3
5
 
4
6
  // Opens `tmux attach` inside a real PTY, non-destructive for other clients.
5
- // Size model (decisione DAG 2026-07-09, "segue il focus"): la sessione usa
6
- // `window-size latest` (impostato dal bridge) e i client web NON readonly
7
- // PARTECIPANO alla geometria chi è attivo per ultimo comanda. Tornare sul
8
- // desktop e digitare riporta la sessione grande; il telefono la restringe
9
- // solo mentre lo usi. I viewer readonly restano ignore-size (mai guidare).
10
- // readonly `-f read-only,ignore-size` (read-only limita anche copy-mode/KeyBar)
7
+ //
8
+ // Size model (emendamento post-audit §5b deck multi-finestra): la sessione usa
9
+ // `window-size latest` (impostato dal bridge). Un client "possiede" la geometria
10
+ // SOLO se NON è `ignore-size`. I tile grid/deck attaccano `ignore-size` (non
11
+ // contendono la geometria da N finestre/N ResizeObserver); l'owner è il tile col
12
+ // FOCUS, promosso a runtime via `refresh-client -f '!ignore-size'` (demozione col
13
+ // flag `ignore-size`). La single-view / gli attach diretti restano owner di
14
+ // default (takeSize non specificato → drive).
15
+ // - takeSize esplicito vince sempre (true = owner, false = ignore-size)
16
+ // - readonly ⇒ SEMPRE ignore-size (un viewer non guida mai)
17
+ // - readonly → `-f read-only,ignore-size` (read-only limita anche copy-mode/KeyBar)
11
18
  function openAttach(session, opts = {}) {
12
- const { readonly = false, takeSize = false, cols = 80, rows = 24, tmuxBin = 'tmux' } = opts;
19
+ const { readonly = false, cols = 80, rows = 24, tmuxBin = 'tmux' } = opts;
20
+ const drives = opts.takeSize !== undefined
21
+ ? !!opts.takeSize
22
+ : (opts.ignoreSize !== undefined ? !opts.ignoreSize : true);
23
+ const ignoreSize = readonly || !drives;
13
24
  const pty = loadPty();
14
25
  const args = ['attach-session', '-t', session];
15
- if (readonly) args.push('-f', 'read-only,ignore-size');
26
+ const flags = [];
27
+ if (readonly) flags.push('read-only');
28
+ if (ignoreSize) flags.push('ignore-size');
29
+ if (flags.length) args.push('-f', flags.join(','));
16
30
  const term = pty.spawn(tmuxBin, args, {
17
31
  name: 'xterm-256color',
18
32
  cols, rows,
19
- cwd: process.env.HOME,
33
+ cwd: process.env.HOME || os.homedir(),
20
34
  env: process.env,
21
35
  });
36
+ // tty del client tmux (es. /dev/pts/N): identifica QUESTO client per la
37
+ // promozione/demozione runtime del size-owner via `refresh-client -t <tty>`.
38
+ const tty = term.ptsName || term._pty || null;
39
+ const setFlags = (spec) => {
40
+ if (!tty) return;
41
+ try { execFile(tmuxBin, ['refresh-client', '-t', tty, '-f', spec], () => {}); } catch (_) {}
42
+ };
22
43
  return {
44
+ tty,
23
45
  write: (data) => term.write(data),
24
46
  resize: (c, r) => { try { term.resize(c, r); } catch (_) {} },
25
47
  onData: (cb) => term.onData(cb),
26
48
  onExit: (cb) => term.onExit(cb),
27
49
  kill: () => { try { term.kill(); } catch (_) {} },
50
+ // Promozione a size-owner (focus). I readonly non guidano MAI la geometria.
51
+ promote: () => { if (!readonly) setFlags('!ignore-size'); },
52
+ demote: () => setFlags('ignore-size'),
28
53
  };
29
54
  }
30
55
  module.exports = { openAttach };
@@ -4,19 +4,34 @@
4
4
  // `tmux attach` ESIGE un vero tty: il fallback child_process NON è accettabile qui.
5
5
  let _cached = null;
6
6
 
7
+ function providerCandidates({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
8
+ const isTermux = platform === 'android'
9
+ || (env.PREFIX || '').includes('com.termux');
10
+ if (isTermux) return ['@mmmbuto/node-pty-android-arm64', 'node-pty'];
11
+ if (platform === 'darwin') {
12
+ return arch === 'arm64'
13
+ ? ['@lydell/node-pty-darwin-arm64', 'node-pty']
14
+ : ['@lydell/node-pty-darwin-x64', 'node-pty'];
15
+ }
16
+ if (platform === 'linux' && arch === 'arm64') {
17
+ return ['@lydell/node-pty-linux-arm64', 'node-pty'];
18
+ }
19
+ return ['@lydell/node-pty-linux-x64', 'node-pty'];
20
+ }
21
+
7
22
  function loadPty() {
8
23
  if (_cached) return _cached;
9
24
  const isTermux = process.platform === 'android'
10
25
  || (process.env.PREFIX || '').includes('com.termux');
11
- const candidates = isTermux
12
- ? ['@mmmbuto/node-pty-android-arm64', 'node-pty']
13
- : ['node-pty', '@lydell/node-pty-linux-x64', '@mmmbuto/node-pty-android-arm64'];
26
+ const candidates = providerCandidates();
14
27
  for (const mod of candidates) {
15
28
  try {
16
29
  const pty = require(mod);
17
30
  // Accept ONLY a provider with a complete PTY API; never child_process.
18
31
  if (pty && typeof pty.spawn === 'function') {
19
- const t = pty.spawn(process.env.SHELL || '/bin/sh', ['-c', 'true'], { cols: 80, rows: 24 });
32
+ const fallbackShell = isTermux && process.env.PREFIX
33
+ ? require('node:path').join(process.env.PREFIX, 'bin', 'sh') : '/bin/sh';
34
+ const t = pty.spawn(process.env.SHELL || fallbackShell, ['-c', 'true'], { cols: 80, rows: 24 });
20
35
  const ok = t && typeof t.write === 'function' && typeof t.onData === 'function'
21
36
  && typeof t.resize === 'function' && typeof t.kill === 'function';
22
37
  try { t.kill(); } catch (_) {}
@@ -28,6 +43,6 @@ function loadPty() {
28
43
  }
29
44
  } catch (_) { /* prova il prossimo */ }
30
45
  }
31
- throw new Error('no real PTY provider available (need node-pty or @mmmbuto/node-pty-android-arm64)');
46
+ throw new Error('no real PTY provider available (install a platform prebuilt or node-pty build prerequisites)');
32
47
  }
33
- module.exports = { loadPty };
48
+ module.exports = { loadPty, providerCandidates };