@mmmbuto/nexuscrew 0.9.6 → 0.9.8
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/CHANGELOG.md +42 -0
- package/frontend/dist/assets/index-C2A08iwn.js +93 -0
- package/frontend/dist/assets/index-CM1TECdp.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +32 -3
- package/lib/cli/fleet-service.js +44 -2
- package/lib/cli/service.js +49 -2
- package/lib/cli/stable-alias.js +49 -0
- package/lib/notify/routes.js +15 -1
- package/lib/update/manager.js +6 -0
- package/lib/update/runner.js +244 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-MdL-ZvnH.js +0 -93
- package/frontend/dist/assets/index-forYfgKZ.css +0 -32
package/lib/update/runner.js
CHANGED
|
@@ -10,6 +10,219 @@ const {
|
|
|
10
10
|
stableRuntimeDir,
|
|
11
11
|
} = require('./core.js');
|
|
12
12
|
|
|
13
|
+
// R28 — rigenerazione delle definizioni di boot dopo l'installazione.
|
|
14
|
+
//
|
|
15
|
+
// L'aggiornamento del pacchetto sposta i path versionati che le unit/plist
|
|
16
|
+
// scrivono LETTERALMENTE (node del Cellar su Homebrew, pacchetto in
|
|
17
|
+
// lib/node_modules): senza questo passo il servizio riparte sulle definizioni
|
|
18
|
+
// vecchie e la companion di boot — che nessun altro percorso sana, se non
|
|
19
|
+
// `nexuscrew init` — resta puntata a un binario che non esiste più, con exit
|
|
20
|
+
// 78 EX_CONFIG e le celle boot:true mute (misurato sul campo 2026-08-18).
|
|
21
|
+
//
|
|
22
|
+
// Il criterio R23 resta intatto («alias stabile che realpath allo stesso
|
|
23
|
+
// file», verificato alla SCRITTURA, mai a ogni avvio) con una estensione
|
|
24
|
+
// dichiarata per il caso del runner: il processo dell'update può girare con
|
|
25
|
+
// un node già unlinkato su disco, e in quel caso si sceglie il primo percorso
|
|
26
|
+
// stabile VIVO (resolveLiveBootPaths).
|
|
27
|
+
//
|
|
28
|
+
// Contratto: sana SOLO le definizioni che ESISTONO già (il boot è opt-in:
|
|
29
|
+
// l'update non installa un companion mai chiesto); attivazione differita
|
|
30
|
+
// (activate:false) perché il companion RunAtLoad/oneshot non va eseguito a
|
|
31
|
+
// metà update — R28-rimedio: l'attivazione la APPLICA healBootDefinitions
|
|
32
|
+
// nel percorso di update (daemon-reload / bootout+bootstrap), non un restart
|
|
33
|
+
// che non carica le definizioni; un fallimento NON blocca l'aggiornamento
|
|
34
|
+
// (il comportamento senza questo passo era proprio quello) ma viene LOGGATO
|
|
35
|
+
// e riportato: il silenzio era il difetto, non il fallimento.
|
|
36
|
+
function regenBootDefinitions(opts = {}) {
|
|
37
|
+
const readFileImpl = opts.readFileImpl || fs.readFileSync;
|
|
38
|
+
const serviceMod = opts.serviceMod || require('../cli/service.js');
|
|
39
|
+
const fleetMod = opts.fleetMod || require('../cli/fleet-service.js');
|
|
40
|
+
const aliasMod = opts.aliasMod || require('../cli/stable-alias.js');
|
|
41
|
+
const platformMod = opts.platformMod || require('../cli/platform.js');
|
|
42
|
+
const home = opts.home || os.homedir();
|
|
43
|
+
const platform = opts.platform || platformMod.detectPlatform();
|
|
44
|
+
const log = opts.log || console.log;
|
|
45
|
+
const out = { regenerated: [], skipped: [], warnings: [], errors: [] };
|
|
46
|
+
|
|
47
|
+
const revive = aliasMod.resolveLiveBootPaths({
|
|
48
|
+
nodeBin: platformMod.nodeBin(),
|
|
49
|
+
entryPath: path.resolve(__dirname, '..', '..', 'bin', 'nexuscrew.js'),
|
|
50
|
+
...(opts.realpath ? { realpath: opts.realpath } : {}),
|
|
51
|
+
...(opts.exists ? { exists: opts.exists } : {}),
|
|
52
|
+
});
|
|
53
|
+
out.warnings.push(...revive.warnings);
|
|
54
|
+
|
|
55
|
+
const targets = [
|
|
56
|
+
{
|
|
57
|
+
component: 'service',
|
|
58
|
+
path: () => serviceMod.installPath(platform, home),
|
|
59
|
+
isOurs: (content) => serviceMod.isOurService(platform, content),
|
|
60
|
+
exists: () => {
|
|
61
|
+
try { return fs.lstatSync(serviceMod.installPath(platform, home)).isFile(); } catch (_) { return false; }
|
|
62
|
+
},
|
|
63
|
+
install: () => {
|
|
64
|
+
const content = serviceMod.generateService(platform, {
|
|
65
|
+
repoRoot: platformMod.repoRoot(),
|
|
66
|
+
nodeBin: revive.nodeBin,
|
|
67
|
+
entryPath: revive.entryPath,
|
|
68
|
+
port: opts.port,
|
|
69
|
+
home,
|
|
70
|
+
uid: platformMod.uid(),
|
|
71
|
+
});
|
|
72
|
+
return serviceMod.installService(platform, content, {
|
|
73
|
+
repoRoot: platformMod.repoRoot(),
|
|
74
|
+
nodeBin: revive.nodeBin,
|
|
75
|
+
entryPath: revive.entryPath,
|
|
76
|
+
port: opts.port,
|
|
77
|
+
home,
|
|
78
|
+
uid: platformMod.uid(),
|
|
79
|
+
}, { activate: false });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
component: 'fleet-companion',
|
|
84
|
+
path: () => fleetMod.fleetInstallPath(platform, home),
|
|
85
|
+
isOurs: (content) => fleetMod.isOurFleetService(platform, content),
|
|
86
|
+
exists: () => {
|
|
87
|
+
try { return fs.lstatSync(fleetMod.fleetInstallPath(platform, home)).isFile(); } catch (_) { return false; }
|
|
88
|
+
},
|
|
89
|
+
install: () => fleetMod.installFleetService(platform, fleetMod.generateFleetService({
|
|
90
|
+
platform,
|
|
91
|
+
nodeBin: revive.nodeBin,
|
|
92
|
+
entryPath: revive.entryPath,
|
|
93
|
+
repoRoot: platformMod.repoRoot(),
|
|
94
|
+
home,
|
|
95
|
+
}), { home, uid: platformMod.uid() }, { activate: false }),
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const target of targets) {
|
|
100
|
+
let present;
|
|
101
|
+
try { present = target.exists(); } catch (e) { present = false; }
|
|
102
|
+
if (!present) {
|
|
103
|
+
out.skipped.push(`${target.component}: nessuna definizione installata, nulla da sanare`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// R28-rimedio, difetto 3 (audit): symlink e directory al posto del target
|
|
107
|
+
// erano già saltati, ma un FILE REGOLARE DI TERZI veniva sovrascritto. Le
|
|
108
|
+
// definizioni che generiamo portano tutte il nome del progetto nell'header
|
|
109
|
+
// (systemd: «# NexusCrew service», plist: Label com.mmmbuto.nexuscrew):
|
|
110
|
+
// se il contenuto non lo contiene, il file non è nostro e non lo tocchiamo
|
|
111
|
+
// — lo skip è dichiarato, non silenzioso. Un file illeggibile non blocca
|
|
112
|
+
// la sanazione (si presume nostro: il caso terzi è il contenuto leggibile
|
|
113
|
+
// che dichiara altro).
|
|
114
|
+
// R28 (audit rev2): la proprieta' si riconosce da un'ancora STRUTTURALE del
|
|
115
|
+
// nostro generatore (isOurService/isOurFleetService), non dalla parola
|
|
116
|
+
// «nexuscrew» presente nel file: un unit di terzi che ci nomina in un
|
|
117
|
+
// commento veniva sovrascritto. E cio' che NON si riesce a leggere non si
|
|
118
|
+
// presume nostro: si salta e si dichiara — l'unica direzione sicura, perche'
|
|
119
|
+
// l'errore costa una sanazione mancata invece del file di qualcun altro.
|
|
120
|
+
let content = null;
|
|
121
|
+
let why = null;
|
|
122
|
+
try { content = readFileImpl(target.path(), 'utf8'); }
|
|
123
|
+
catch (e) {
|
|
124
|
+
why = `${target.component}: definizione esistente non leggibile (${target.path()}, ${(e && e.code) || 'errore'}) — non identificabile, non la tocco; rilancia nexuscrew init`;
|
|
125
|
+
}
|
|
126
|
+
if (!why && !target.isOurs(content)) {
|
|
127
|
+
why = `${target.component}: file esistente non nostro al target (${target.path()}) — nessuna ancora delle nostre definizioni, possibile file di terzi, non lo tocco`;
|
|
128
|
+
}
|
|
129
|
+
if (why) {
|
|
130
|
+
out.skipped.push(why);
|
|
131
|
+
log(`WARN boot definitions: ${why}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const result = target.install();
|
|
136
|
+
out.regenerated.push(target.component);
|
|
137
|
+
out.warnings.push(...(Array.isArray(result.skippedActivation)
|
|
138
|
+
? result.skippedActivation.map((cmd) => `${target.component}: attivazione differita (${cmd}) — il passo di attivazione dell'update la applica`)
|
|
139
|
+
: []));
|
|
140
|
+
log(`boot definitions: ${target.component} rigenerata su ${result.target}`);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
out.errors.push(`${target.component}: ${String((e && e.message) || e)}`);
|
|
143
|
+
log(`WARN boot definitions: rigenerazione di ${target.component} fallita: ${(e && e.message) || e} (aggiornamento proseguito; rilancia nexuscrew init)`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const w of out.warnings) log(`WARN boot definitions: ${w}`);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// R28-rimedio, difetto 2 (audit): l'attivazione differita dichiarata dalla
|
|
151
|
+
// regen era una promessa FALSA in silenzio. `restart` fa `systemctl --user
|
|
152
|
+
// restart` SENZA daemon-reload (systemd riavvia con la definizione VECCHIA
|
|
153
|
+
// in memoria) e `launchctl kickstart` riusa la definizione già caricata; col
|
|
154
|
+
// runtime spento non c'è restart affatto. L'attivazione va APPLICATA qui,
|
|
155
|
+
// nel percorso di update, subito dopo la regen. È UN SOLO CERVELLO per
|
|
156
|
+
// «reinstalla ⇒ sana le definizioni ⇒ rendile note al service manager»:
|
|
157
|
+
// la usa il runner dell'auto-update e la usa `update()` manuale (difetto 1:
|
|
158
|
+
// la via manuale bypassava la sanazione).
|
|
159
|
+
//
|
|
160
|
+
// Cosa applica, per piattaforma, dopo la regen:
|
|
161
|
+
// - linux: `systemctl --user daemon-reload` — rende note le unit riscritte
|
|
162
|
+
// SENZA avviare nulla (il companion oneshot resta fermo; il restart
|
|
163
|
+
// dell'update carica davvero la definizione nuova).
|
|
164
|
+
// - mac, runtime attivo: bootout+bootstrap del principale (RunAtLoad lo
|
|
165
|
+
// riparte subito sul nuovo codice: coincide col restart che l'update farebbe
|
|
166
|
+
// comunque) e POI della companion (le celle boot:true partono col servizio
|
|
167
|
+
// già nuovo). Il kickstart del restart successivo è ridondante e innocuo.
|
|
168
|
+
// - mac, runtime SPENTO: NESSUN bootstrap — non si accende ciò che l'utente
|
|
169
|
+
// ha spento, e start/restart non caricano definizioni (kickstart). Limite
|
|
170
|
+
// DICHIARATO in activation.skipped: launchd prende le definizioni scritte
|
|
171
|
+
// al prossimo boot della macchina o con `nexuscrew init`.
|
|
172
|
+
// - termux: nessun service manager — skipped dichiarato.
|
|
173
|
+
//
|
|
174
|
+
// Best-effort come la regen: un fallimento non blocca l'aggiornamento, viene
|
|
175
|
+
// loggato e riportato in activation.errors.
|
|
176
|
+
function healBootDefinitions(opts = {}) {
|
|
177
|
+
const platformMod = opts.platformMod || require('../cli/platform.js');
|
|
178
|
+
const platform = opts.platform || platformMod.detectPlatform();
|
|
179
|
+
const home = opts.home || os.homedir();
|
|
180
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
181
|
+
const log = opts.log || console.log;
|
|
182
|
+
const regen = (opts.regenImpl || regenBootDefinitions)({ ...opts });
|
|
183
|
+
const out = { ...regen, activation: { applied: [], skipped: [], errors: [] } };
|
|
184
|
+
if (!Array.isArray(regen.regenerated) || regen.regenerated.length === 0) {
|
|
185
|
+
out.activation.skipped.push('nessuna definizione rigenerata: nulla da attivare');
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
const run = (bin, args) => {
|
|
189
|
+
const cmd = `${bin} ${args.join(' ')}`;
|
|
190
|
+
try { execImpl(bin, args, { stdio: 'ignore' }); out.activation.applied.push(cmd); }
|
|
191
|
+
catch (e) { out.activation.errors.push(`${cmd}: ${(e && e.message) || e}`); }
|
|
192
|
+
};
|
|
193
|
+
if (platform === 'linux') {
|
|
194
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
195
|
+
} else if (platform === 'mac') {
|
|
196
|
+
if (opts.running !== true) {
|
|
197
|
+
const limit = 'runtime spento: definizioni scritte e NON attivate — launchd le carica al prossimo boot della macchina o con nexuscrew init (start/restart usano kickstart sulla definizione già in memoria)';
|
|
198
|
+
out.activation.skipped.push(limit);
|
|
199
|
+
out.warnings.push(`attivazione non applicata: ${limit}`);
|
|
200
|
+
} else {
|
|
201
|
+
const uid = opts.uid !== undefined ? opts.uid : platformMod.uid();
|
|
202
|
+
const domain = `gui/${uid}`;
|
|
203
|
+
const serviceMod = opts.serviceMod || require('../cli/service.js');
|
|
204
|
+
const fleetMod = opts.fleetMod || require('../cli/fleet-service.js');
|
|
205
|
+
const boot = (label, target) => {
|
|
206
|
+
// bootout best-effort: non caricato è l'esito atteso del primo giro
|
|
207
|
+
try { execImpl('launchctl', ['bootout', `${domain}/${label}`], { stdio: 'ignore' }); } catch (_) {}
|
|
208
|
+
run('launchctl', ['bootstrap', domain, target]);
|
|
209
|
+
};
|
|
210
|
+
if (regen.regenerated.includes('service')) {
|
|
211
|
+
boot('com.mmmbuto.nexuscrew', serviceMod.installPath(platform, home));
|
|
212
|
+
}
|
|
213
|
+
if (regen.regenerated.includes('fleet-companion')) {
|
|
214
|
+
boot('com.mmmbuto.nexuscrew-fleet', fleetMod.fleetInstallPath(platform, home));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
out.activation.skipped.push(`platform ${platform}: nessun service manager, attivazione non applicabile`);
|
|
219
|
+
}
|
|
220
|
+
for (const a of out.activation.applied) log(`boot definitions: attivazione applicata (${a})`);
|
|
221
|
+
for (const s of out.activation.skipped) log(`WARN boot definitions: ${s}`);
|
|
222
|
+
for (const e of out.activation.errors) log(`WARN boot definitions: attivazione fallita: ${e}`);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
13
226
|
function args(argv) {
|
|
14
227
|
const out = {};
|
|
15
228
|
for (let i = 0; i < argv.length; i += 2) {
|
|
@@ -132,6 +345,16 @@ async function runUpdate(opts = {}) {
|
|
|
132
345
|
const previous = readState(statusPath);
|
|
133
346
|
const previousVersion = String(readInstalledVersion() || '');
|
|
134
347
|
let installedNew = false;
|
|
348
|
+
// wasRunning serve solo a mac (decide se bootout+bootstrap è lecito: col
|
|
349
|
+
// runtime spento non si accende nulla): su linux daemon-reload non esegue
|
|
350
|
+
// servizi e non serve saperlo. Calcolato PRIMA del try perché vale per il
|
|
351
|
+
// percorso di aggiornamento e per quello di rollback.
|
|
352
|
+
const regenPlatform = opts.regenSeams && opts.regenSeams.platformMod
|
|
353
|
+
? opts.regenSeams.platformMod.detectPlatform()
|
|
354
|
+
: require('../cli/platform.js').detectPlatform();
|
|
355
|
+
const wasRunning = regenPlatform === 'mac'
|
|
356
|
+
? ((opts.isRunningImpl || ((o) => require('../cli/commands.js').isServiceRunning(o)))({ home }))
|
|
357
|
+
: undefined;
|
|
135
358
|
try {
|
|
136
359
|
writeState(statusPath, { ...previous, phase: 'installing', targetVersion: version, updaterPid: process.pid, lastError: '' });
|
|
137
360
|
execImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${version}`, '--no-audit', '--no-fund'], {
|
|
@@ -142,12 +365,23 @@ async function runUpdate(opts = {}) {
|
|
|
142
365
|
installedNew = true;
|
|
143
366
|
await preflightImpl({ version, home });
|
|
144
367
|
writeState(statusPath, { ...readState(statusPath), phase: 'restarting', updaterPid: process.pid, lastError: '' });
|
|
368
|
+
// R28: sana le definizioni di boot PRIMA del riavvio — è il passo che
|
|
369
|
+
// manca al percorso di upgrade e che lasciava la companion puntata a un
|
|
370
|
+
// binario morto (exit 78, silenzio). R28-rimedio: la sanazione ora
|
|
371
|
+
// APPLICA anche l'attivazione differita (healBootDefinitions) — il
|
|
372
|
+
// restart da solo non caricava le definizioni riscritte. Best-effort
|
|
373
|
+
// DICHIARATO: un errore qui non blocca l'aggiornamento, viene loggato e
|
|
374
|
+
// riportato nel risultato.
|
|
375
|
+
const bootDefinitions = (opts.healBootImpl || healBootDefinitions)({
|
|
376
|
+
home, running: wasRunning, execImpl, ...(opts.regenSeams || {}),
|
|
377
|
+
...(opts.regenBootImpl ? { regenImpl: opts.regenBootImpl } : {}),
|
|
378
|
+
});
|
|
145
379
|
const restartMode = await restartImpl({ home, ...(opts.runtimeSeams || {}) });
|
|
146
380
|
writeState(statusPath, {
|
|
147
381
|
...readState(statusPath), phase: 'installed', current: version, latest: version,
|
|
148
382
|
available: false, blockedVersion: '', lastUpdatedAt: new Date().toISOString(), lastError: '',
|
|
149
383
|
});
|
|
150
|
-
return { updated: true, version, restartMode };
|
|
384
|
+
return { updated: true, version, restartMode, bootDefinitions };
|
|
151
385
|
} catch (e) {
|
|
152
386
|
let rollbackError = null; let rolledBack = false;
|
|
153
387
|
if (installedNew && parseVersion(previousVersion) && previousVersion !== version) {
|
|
@@ -157,6 +391,14 @@ async function runUpdate(opts = {}) {
|
|
|
157
391
|
});
|
|
158
392
|
if (String(readInstalledVersion() || '') !== previousVersion) throw new Error(`rollback verify: attesa ${previousVersion}`);
|
|
159
393
|
await preflightImpl({ version: previousVersion, home, rollback: true });
|
|
394
|
+
// R28: il rollback riparte dalle stesse definizioni sane — il
|
|
395
|
+
// downgrade reinstalla il pacchetto nel prefix attuale e i path
|
|
396
|
+
// vivi valgono anche per la versione precedente. R28-rimedio:
|
|
397
|
+
// anche qui sanazione+attivazione (heal), non la sola regen.
|
|
398
|
+
(opts.healBootImpl || healBootDefinitions)({
|
|
399
|
+
home, running: wasRunning, execImpl, ...(opts.regenSeams || {}),
|
|
400
|
+
...(opts.regenBootImpl ? { regenImpl: opts.regenBootImpl } : {}),
|
|
401
|
+
});
|
|
160
402
|
await restartImpl({ home, ...(opts.runtimeSeams || {}) });
|
|
161
403
|
rolledBack = true;
|
|
162
404
|
} catch (rollbackFailure) { rollbackError = rollbackFailure; }
|
|
@@ -183,4 +425,4 @@ if (require.main === module) {
|
|
|
183
425
|
.catch(() => { process.exitCode = 1; });
|
|
184
426
|
}
|
|
185
427
|
|
|
186
|
-
module.exports = { args, restartRuntime, runUpdate };
|
|
428
|
+
module.exports = { args, restartRuntime, runUpdate, regenBootDefinitions, healBootDefinitions };
|