@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,358 @@
1
+ 'use strict';
2
+ // lib/nodes/store.js — nodes.json secret store (design §4, §4b(4)).
3
+ //
4
+ // nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
5
+ // remoti (iniettati dal proxy in B1). Percio' riusa lo stesso hardening di
6
+ // lib/fleet/definitions.js e del token file:
7
+ // - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
8
+ // - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
9
+ // - nessun token in output redatto (redactStore/redactNode)
10
+ //
11
+ // Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
12
+ // Tutto l'I/O vive in loadStore/atomicWriteStore/loadOrInitStore/migrateLegacyNodes.
13
+ // Principio fail-closed: qualunque dato malformato -> null (parse) o throw
14
+ // esplicito (mutazioni/write), mai un default silenzioso.
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const crypto = require('node:crypto');
19
+
20
+ const SCHEMA_VERSION = 1;
21
+ const MAX_NODES = 64;
22
+ const MAX_TOKEN_LEN = 4096;
23
+ const MAX_KEYPATH_LEN = 4096;
24
+ const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
25
+
26
+ // name: chiave strict usata anche come segmento path/route in B1 -> niente '.',
27
+ // '/', maiuscole. Allineato al contratto §4b(2) (^[a-z0-9-]{1,32}$).
28
+ const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
29
+ // nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
30
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
31
+ // user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
32
+ // interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
33
+ // va usato via Host alias in ~/.ssh/config, fuori scope B0).
34
+ const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
35
+ const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
36
+
37
+ const PORT_MIN = 1;
38
+ const PORT_MAX = 65535;
39
+
40
+ function isPort(n) {
41
+ return Number.isInteger(n) && n >= PORT_MIN && n <= PORT_MAX;
42
+ }
43
+
44
+ // user@host -> {user, host, value} | null. Strict: no whitespace/null, host non
45
+ // inizia con '-' (garanzia argv-safe, non diventa mai un flag ssh).
46
+ function parseSsh(s) {
47
+ if (typeof s !== 'string' || !s || s.length > MAX_SSH_LEN) return null;
48
+ if (s.includes('\0') || /\s/.test(s)) return null;
49
+ const at = s.indexOf('@');
50
+ if (at <= 0 || at !== s.lastIndexOf('@')) return null; // esattamente un '@'
51
+ const user = s.slice(0, at);
52
+ const host = s.slice(at + 1);
53
+ if (!SSH_USER_RE.test(user) || !SSH_HOST_RE.test(host)) return null;
54
+ return { user, host, value: `${user}@${host}` };
55
+ }
56
+
57
+ // keyPath: path assoluto, no null/newline (la chiave potrebbe non esistere ancora
58
+ // al momento dell'add: viene generata dopo). L'esistenza si verifica al lancio ssh.
59
+ function isAbsPath(p) {
60
+ return typeof p === 'string' && p.length > 0 && p.length <= MAX_KEYPATH_LEN
61
+ && !p.includes('\0') && !/[\n\r]/.test(p) && path.isAbsolute(p);
62
+ }
63
+
64
+ // token remoto: segreto opaco, single-line, cap. Vuoto -> assente (non salvato).
65
+ // Charset ristretto a header-safe (VCHAR + spazio/tab): il token viene iniettato
66
+ // in `Authorization: Bearer <t>` verso l'upstream; un char fuori range farebbe
67
+ // lanciare setHeader in modo sincrono (ERR_INVALID_CHAR). (hardening audit).
68
+ function validToken(t) {
69
+ return typeof t === 'string' && t.length > 0 && t.length <= MAX_TOKEN_LEN
70
+ && /^[\x20-\x7e\t]+$/.test(t);
71
+ }
72
+
73
+ // roles per-nodo (default {client:true, node:false}): STRICT, nessuna chiave extra.
74
+ function parseRoles(r) {
75
+ if (r === undefined) return { client: true, node: false };
76
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
77
+ for (const k of Object.keys(r)) { if (k !== 'client' && k !== 'node') return null; }
78
+ const client = r.client === undefined ? true : r.client;
79
+ const node = r.node === undefined ? false : r.node;
80
+ if (typeof client !== 'boolean' || typeof node !== 'boolean') return null;
81
+ return { client, node };
82
+ }
83
+
84
+ // parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
85
+ // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
86
+ const NODE_KEYS = new Set(['name', 'ssh', 'remotePort', 'localPort', 'keyPath', 'roles', 'token', 'nodeId']);
87
+ function parseNode(n) {
88
+ if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
89
+ for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
90
+ if (typeof n.name !== 'string' || !NODE_NAME_RE.test(n.name)) return null;
91
+ const ssh = parseSsh(n.ssh);
92
+ if (!ssh) return null;
93
+ if (!isPort(n.remotePort)) return null;
94
+ if (!isPort(n.localPort)) return null;
95
+ if (!isAbsPath(n.keyPath)) return null;
96
+ const roles = parseRoles(n.roles);
97
+ if (!roles) return null;
98
+
99
+ const out = {
100
+ name: n.name,
101
+ ssh: ssh.value,
102
+ remotePort: n.remotePort,
103
+ localPort: n.localPort,
104
+ keyPath: n.keyPath,
105
+ roles,
106
+ };
107
+ if (n.token !== undefined) {
108
+ if (!validToken(n.token)) return null;
109
+ out.token = n.token;
110
+ }
111
+ if (n.nodeId !== undefined) {
112
+ if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
113
+ out.nodeId = n.nodeId;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // rendezvous (ruolo node/reverse): dove questa installazione si pubblica.
119
+ const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
120
+ function parseRendezvous(r) {
121
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
122
+ for (const k of Object.keys(r)) { if (!RDV_KEYS.has(k)) return null; }
123
+ const ssh = parseSsh(r.ssh);
124
+ if (!ssh) return null;
125
+ if (!isPort(r.publishedPort)) return null;
126
+ if (!isPort(r.localPort)) return null;
127
+ if (!isAbsPath(r.keyPath)) return null;
128
+ return { ssh: ssh.value, publishedPort: r.publishedPort, localPort: r.localPort, keyPath: r.keyPath };
129
+ }
130
+
131
+ // parseStore(raw) -> store normalizzato | null. Accetta stringa JSON o oggetto.
132
+ function parseStore(raw) {
133
+ try {
134
+ let d;
135
+ if (typeof raw === 'string') {
136
+ try { d = JSON.parse(raw); } catch (_) { return null; }
137
+ } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
138
+ d = raw;
139
+ } else {
140
+ return null;
141
+ }
142
+
143
+ if (d.schemaVersion !== SCHEMA_VERSION) return null;
144
+ if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
145
+ if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
146
+
147
+ const names = new Set();
148
+ const ids = new Set();
149
+ const nodes = [];
150
+ for (const raw2 of d.nodes) {
151
+ const node = parseNode(raw2);
152
+ if (!node) return null;
153
+ if (names.has(node.name)) return null; // name univoco
154
+ names.add(node.name);
155
+ if (node.nodeId) {
156
+ if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
157
+ if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
158
+ ids.add(node.nodeId);
159
+ }
160
+ nodes.push(node);
161
+ }
162
+
163
+ const out = { schemaVersion: SCHEMA_VERSION, nodeId: d.nodeId, nodes };
164
+
165
+ if (d.rendezvous !== undefined && d.rendezvous !== null) {
166
+ const rdv = parseRendezvous(d.rendezvous);
167
+ if (!rdv) return null;
168
+ out.rendezvous = rdv;
169
+ }
170
+ return out;
171
+ } catch (_) {
172
+ return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
173
+ }
174
+ }
175
+
176
+ // --- I/O -------------------------------------------------------------------
177
+
178
+ function defaultNodesPath(home) {
179
+ return path.join(home || os.homedir(), '.nexuscrew', 'nodes.json');
180
+ }
181
+
182
+ // loadStore(p): legge rifiutando i symlink; parse strict. null se assente/invalido.
183
+ function loadStore(p) {
184
+ try {
185
+ let st;
186
+ try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
187
+ if (st.isSymbolicLink()) return null; // no symlink
188
+ if (!st.isFile()) return null;
189
+ return parseStore(fs.readFileSync(p, 'utf8'));
190
+ } catch (_) { return null; }
191
+ }
192
+
193
+ // atomicWriteStore(p, data): valida PRIMA di scrivere (fail-closed). Rifiuta di
194
+ // scrivere attraverso un symlink. tmp stessa dir -> chmod 0600 -> rename atomico.
195
+ function atomicWriteStore(p, data) {
196
+ try {
197
+ if (fs.lstatSync(p).isSymbolicLink()) {
198
+ throw new Error('refuse to write: nodes.json target e\' un symlink');
199
+ }
200
+ } catch (e) {
201
+ if (e.code === 'ENOENT') { /* nuovo file, ok */ }
202
+ else throw e; // inclusi i nostri 'refuse to write'
203
+ }
204
+
205
+ const parsed = parseStore(data);
206
+ if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
207
+
208
+ const dir = path.dirname(p);
209
+ fs.mkdirSync(dir, { recursive: true });
210
+ const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
211
+ try {
212
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
213
+ fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask
214
+ fs.renameSync(tmp, p); // atomico sullo stesso filesystem
215
+ } catch (e) {
216
+ try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
217
+ throw e;
218
+ }
219
+ return parsed;
220
+ }
221
+
222
+ function newNodeId() { return crypto.randomBytes(16).toString('hex'); }
223
+
224
+ function emptyStore(nodeId) {
225
+ return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
226
+ }
227
+
228
+ // loadOrInitStore(p): store esistente, oppure ne crea uno vuoto (nodeId fresco,
229
+ // STABILE una volta scritto). Se il file esiste ma e' invalido -> throw esplicito
230
+ // (mai sovrascrivere/mascherare corruzione o un file altrui).
231
+ function loadOrInitStore(p) {
232
+ let st;
233
+ try { st = fs.lstatSync(p); }
234
+ catch (e) {
235
+ if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
236
+ throw e;
237
+ }
238
+ if (st.isSymbolicLink()) throw new Error('nodes.json e\' un symlink (rifiutato)');
239
+ const parsed = loadStore(p);
240
+ if (!parsed) throw new Error('nodes.json presente ma invalido (schema strict): correggi o rimuovi il file');
241
+ return parsed;
242
+ }
243
+
244
+ // --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
245
+
246
+ function getNode(store, name) {
247
+ return store.nodes.find((n) => n.name === name) || null;
248
+ }
249
+
250
+ function addNode(store, entry) {
251
+ const node = parseNode(entry);
252
+ if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort/keyPath');
253
+ if (store.nodes.some((n) => n.name === node.name)) {
254
+ throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
255
+ }
256
+ if (node.nodeId) {
257
+ if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
258
+ if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
259
+ throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
260
+ }
261
+ }
262
+ return { ...store, nodes: store.nodes.concat([node]) };
263
+ }
264
+
265
+ function removeNode(store, name) {
266
+ const idx = store.nodes.findIndex((n) => n.name === name);
267
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
268
+ const nodes = store.nodes.slice();
269
+ nodes.splice(idx, 1);
270
+ return { ...store, nodes };
271
+ }
272
+
273
+ function setNodeToken(store, name, token) {
274
+ const idx = store.nodes.findIndex((n) => n.name === name);
275
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
276
+ if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
277
+ const nodes = store.nodes.slice();
278
+ nodes[idx] = { ...nodes[idx], token };
279
+ return { ...store, nodes };
280
+ }
281
+
282
+ function setRendezvous(store, rdv) {
283
+ const parsed = parseRendezvous(rdv);
284
+ if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
285
+ return { ...store, rendezvous: parsed };
286
+ }
287
+
288
+ function clearRendezvous(store) {
289
+ const out = { ...store };
290
+ delete out.rendezvous;
291
+ return out;
292
+ }
293
+
294
+ // --- Redazione (view sicura per status/list: MAI il token) ------------------
295
+
296
+ function redactNode(n) {
297
+ const out = {
298
+ name: n.name,
299
+ ssh: n.ssh,
300
+ remotePort: n.remotePort,
301
+ localPort: n.localPort,
302
+ keyPath: n.keyPath,
303
+ roles: n.roles,
304
+ hasToken: !!n.token, // presenza, non il valore
305
+ };
306
+ if (n.nodeId) out.nodeId = n.nodeId;
307
+ return out;
308
+ }
309
+
310
+ function redactStore(store) {
311
+ const out = {
312
+ schemaVersion: store.schemaVersion,
313
+ nodeId: store.nodeId,
314
+ nodes: store.nodes.map(redactNode),
315
+ };
316
+ // rendezvous non contiene token: sicuro da esporre integralmente.
317
+ if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
318
+ return out;
319
+ }
320
+
321
+ // --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
322
+ // Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
323
+ // lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
324
+ // nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
325
+ // -> throw esplicito (non importa silenziosamente spazzatura).
326
+ function migrateLegacyNodes(configPath, nodesPath) {
327
+ let cfg;
328
+ try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
329
+ catch (_) { return { migrated: false, count: 0, reason: 'config.json assente/illeggibile' }; }
330
+ if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
331
+ return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
332
+ }
333
+ const store = loadOrInitStore(nodesPath);
334
+ if (store.nodes.length > 0) {
335
+ return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
336
+ }
337
+ let next = store;
338
+ for (const legacy of cfg.nodes) {
339
+ next = addNode(next, legacy); // strict: garbage legacy -> throw esplicito
340
+ }
341
+ const written = atomicWriteStore(nodesPath, next);
342
+ return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
343
+ }
344
+
345
+ module.exports = {
346
+ // parse/validate
347
+ parseStore, parseNode, parseRendezvous, parseSsh, isPort, isAbsPath, validToken,
348
+ // I/O
349
+ defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
350
+ // mutazioni
351
+ getNode, addNode, removeNode, setNodeToken, setRendezvous, clearRendezvous,
352
+ // redazione
353
+ redactNode, redactStore,
354
+ // migrazione
355
+ migrateLegacyNodes,
356
+ // costanti
357
+ SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
358
+ };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // Detached supervisor for one SSH tunnel. The parent NexusCrew process can exit;
4
+ // this process keeps the tunnel alive and retries failures with bounded backoff.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const { backoffDelay } = require('./tunnel.js');
9
+
10
+ const sshBin = process.argv[2];
11
+ const sshArgs = process.argv.slice(3);
12
+ const statePath = process.env.NEXUSCREW_TUNNEL_STATE;
13
+ if (!sshBin || !statePath) process.exit(2);
14
+
15
+ let child = null;
16
+ let stopping = false;
17
+ let attempt = 0;
18
+ let retryTimer = null;
19
+ let upTimer = null;
20
+
21
+ function writeState(status, extra = {}) {
22
+ const tmp = `${statePath}.tmp.${process.pid}`;
23
+ const data = { status, supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
24
+ try {
25
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
26
+ fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
27
+ fs.chmodSync(tmp, 0o600);
28
+ fs.renameSync(tmp, statePath);
29
+ } catch (_) {
30
+ try { fs.unlinkSync(tmp); } catch (_e) {}
31
+ }
32
+ }
33
+
34
+ function scheduleRetry(detail) {
35
+ if (stopping) return finish();
36
+ const delayMs = backoffDelay(attempt, { baseMs: 1000, capMs: 60000 });
37
+ writeState('retrying', { delayMs, detail });
38
+ attempt += 1;
39
+ retryTimer = setTimeout(run, delayMs);
40
+ }
41
+
42
+ function run() {
43
+ if (stopping) return finish();
44
+ writeState('starting');
45
+ try {
46
+ child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
47
+ } catch (e) {
48
+ child = null;
49
+ return scheduleRetry(String(e && e.message || e));
50
+ }
51
+
52
+ let failureHandled = false;
53
+ const handleFailure = (detail) => {
54
+ if (failureHandled) return;
55
+ failureHandled = true;
56
+ clearTimeout(upTimer);
57
+ child = null;
58
+ scheduleRetry(detail);
59
+ };
60
+ child.once('spawn', () => {
61
+ // ExitOnForwardFailure makes bind/auth failures exit. Surviving this short
62
+ // grace window is the best portable readiness signal available for ssh -N.
63
+ upTimer = setTimeout(() => {
64
+ if (!stopping && child && child.exitCode == null) {
65
+ attempt = 0;
66
+ writeState('up', { sshPid: child.pid });
67
+ }
68
+ }, 750);
69
+ });
70
+ child.once('error', (e) => {
71
+ handleFailure(String(e && e.message || e));
72
+ });
73
+ child.once('exit', (code, signal) => {
74
+ if (stopping) return finish();
75
+ handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
76
+ });
77
+ }
78
+
79
+ function finish() {
80
+ clearTimeout(retryTimer);
81
+ clearTimeout(upTimer);
82
+ writeState('down');
83
+ try { fs.unlinkSync(statePath); } catch (_) {}
84
+ process.exit(0);
85
+ }
86
+
87
+ function stop() {
88
+ if (stopping) return;
89
+ stopping = true;
90
+ clearTimeout(retryTimer);
91
+ clearTimeout(upTimer);
92
+ if (child && child.exitCode == null) {
93
+ try { child.kill('SIGTERM'); } catch (_) {}
94
+ setTimeout(() => { try { if (child && child.exitCode == null) child.kill('SIGKILL'); } catch (_) {} finish(); }, 1500).unref();
95
+ } else {
96
+ finish();
97
+ }
98
+ }
99
+
100
+ process.on('SIGTERM', stop);
101
+ process.on('SIGINT', stop);
102
+ run();