engine-kit 0.0.0-stage → 0.2.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.
- package/CONTRATO-MOTOR.md +194 -0
- package/LICENSE +21 -0
- package/README.md +305 -2
- package/consumers/mcp-gateway.mjs +192 -0
- package/consumers/neural-link.mjs +66 -0
- package/kit/app-release.mjs +94 -0
- package/kit/budget.mjs +112 -0
- package/kit/cli.mjs +48 -0
- package/kit/doctor.mjs +122 -0
- package/kit/ensure.mjs +82 -0
- package/kit/gen-key.mjs +52 -0
- package/kit/index.mjs +54 -0
- package/kit/keystore.mjs +29 -0
- package/kit/lifecycle.mjs +160 -0
- package/kit/provision.mjs +379 -0
- package/kit/publish-base.ps1 +163 -0
- package/kit/resolve.mjs +119 -0
- package/kit/sign.mjs +67 -0
- package/kit/signature.mjs +106 -0
- package/kit/trust.mjs +72 -0
- package/kit/update-manifest.mjs +109 -0
- package/kit/update.mjs +68 -0
- package/kit/verify-release.mjs +94 -0
- package/manifest.json +182 -0
- package/package.json +50 -4
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// engine-kit/provision.mjs — PROVISION: garante o motor instalado em <home>/<extractTo>.
|
|
2
|
+
// Generalização do provision.mjs do embed-house (medido em produção), agora parametrizado pelo
|
|
3
|
+
// descritor do registry. Mantém as garantias que já funcionavam:
|
|
4
|
+
// • SHA256 sidecar OBRIGATÓRIO, fail-closed (ausente/malformado/mismatch = ABORT, não instala);
|
|
5
|
+
// • lock atômico ('wx') para serializar consumidores concorrentes na mesma máquina;
|
|
6
|
+
// • reúso por semver same-major >= (dois consumidores com pins diferentes convergem no maior);
|
|
7
|
+
// • download -> .part -> rename atômico; extração em staging -> swap;
|
|
8
|
+
// • shutdown-first (Windows: DLL/EXE em uso impede sobrescrever o diretório).
|
|
9
|
+
// NUNCA lança: devolve { ok:false, reason }.
|
|
10
|
+
import { mkdirSync, existsSync, renameSync, rmSync, openSync, closeSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { verifyBlob, DEFAULT_ALGORITHM } from "./signature.mjs";
|
|
15
|
+
import { checkTrust, trustPath } from "./trust.mjs";
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
/** Lock mais velho que isto é considerado ÓRFÃO (processo morreu antes do finally). */
|
|
20
|
+
const LOCK_STALE_MS = 120000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extrai o .tgz. Usa o `tar` do sistema (presente por padrão no Windows 10 1803+, macOS e Linux).
|
|
24
|
+
* Se faltar, falha com razão EXPLÍCITA e acionável em vez de um erro cru de spawn — nunca
|
|
25
|
+
* instala pela metade.
|
|
26
|
+
*/
|
|
27
|
+
// No Windows, o tar NATIVO (bsdtar, System32) quando existir: um processo aberto a partir de um terminal
|
|
28
|
+
// Git Bash herda o GNU tar do Git na frente do PATH, e o GNU tar lê "C:\..." como host remoto ("Cannot
|
|
29
|
+
// connect to C: resolve failed") — a instalação de QUALQUER motor quebrava só por onde o host foi aberto.
|
|
30
|
+
export function tarBinary({ platform = process.platform, systemRoot = process.env.SystemRoot || process.env.windir, exists = existsSync } = {}) {
|
|
31
|
+
if (platform === "win32" && systemRoot) {
|
|
32
|
+
const native = join(systemRoot, "System32", "tar.exe");
|
|
33
|
+
if (exists(native)) return native;
|
|
34
|
+
}
|
|
35
|
+
return "tar";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractTgz(tgz, dest) {
|
|
39
|
+
const tar = tarBinary();
|
|
40
|
+
try {
|
|
41
|
+
execFileSync(tar, ["--version"], { stdio: "ignore" });
|
|
42
|
+
} catch {
|
|
43
|
+
return { ok: false, reason: "`tar` não encontrado no PATH — necessário para extrair o artefato (Windows 10 1803+, macOS e Linux já trazem). Instale-o e tente de novo." };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
execFileSync(tar, ["-xzf", tgz, "-C", dest], { stdio: "ignore" });
|
|
47
|
+
return { ok: true };
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { ok: false, reason: `extração (tar) falhou: ${e?.message || e}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tenta claim do lock de provisionamento. Grava o PID para diagnóstico e reclama locks
|
|
55
|
+
* órfãos (mais velhos que {@link LOCK_STALE_MS}) — sem isso, um processo morto entre o
|
|
56
|
+
* `openSync` e o `finally` travaria todos os consumidores para sempre.
|
|
57
|
+
*/
|
|
58
|
+
function claimLock(lock) {
|
|
59
|
+
try {
|
|
60
|
+
const fd = openSync(lock, "wx");
|
|
61
|
+
try { writeFileSync(lock, String(process.pid)); } catch { /* diagnóstico é best-effort */ }
|
|
62
|
+
return fd;
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
66
|
+
unlinkSync(lock);
|
|
67
|
+
const fd = openSync(lock, "wx");
|
|
68
|
+
try { writeFileSync(lock, String(process.pid)); } catch { /* ignore */ }
|
|
69
|
+
return fd;
|
|
70
|
+
}
|
|
71
|
+
} catch { /* outro consumidor ganhou a corrida */ }
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parseVer = (v) => String(v || "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
77
|
+
|
|
78
|
+
/** true se a versão instalada serve o pin: MESMO major e >= (protocolo é atado ao major). */
|
|
79
|
+
export function satisfiesPin(installed, pinned) {
|
|
80
|
+
const a = parseVer(installed), b = parseVer(pinned);
|
|
81
|
+
if (a[0] !== b[0]) { return false; }
|
|
82
|
+
for (let i = 0; i < 3; i++) {
|
|
83
|
+
const x = a[i] || 0, y = b[i] || 0;
|
|
84
|
+
if (x > y) { return true; }
|
|
85
|
+
if (x < y) { return false; }
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sha256File(p) {
|
|
91
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex").toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function fetchBuf(url, timeoutMs) {
|
|
95
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
96
|
+
if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; }
|
|
97
|
+
return Buffer.from(await res.arrayBuffer());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Lê a versão instalada a partir do package.json do artefato (se houver). */
|
|
101
|
+
export function installedVersion(binDir) {
|
|
102
|
+
try { return JSON.parse(readFileSync(join(binDir, "package.json"), "utf8")).version; } catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* AUTENTICIDADE do artefato (Ed25519 hash-then-sign) — o passo que o SHA256 não cobre.
|
|
107
|
+
*
|
|
108
|
+
* O sidecar `.sha256` só prova que o download não corrompeu: quem publica o artefato publica o
|
|
109
|
+
* hash junto, então um artefato TROCADO vem com o hash certo do artefato trocado. A assinatura é
|
|
110
|
+
* o que amarra o artefato a quem detém a chave privada (que nunca sai da máquina do dono).
|
|
111
|
+
*
|
|
112
|
+
* Política, deliberadamente fail-closed e sem meio-termo silencioso:
|
|
113
|
+
* • `publicKey` declarada no registry → assinatura é OBRIGATÓRIA (declarar = exigir);
|
|
114
|
+
* • `signatureRequired:true` sem `publicKey` → manifesto INCONSISTENTE, aborta (não "passa");
|
|
115
|
+
* • nenhum dos dois → motor ainda não migrado: instala, mas o log SINALIZA (nunca calado).
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<{ok:true, signed:boolean} | {ok:false, reason:string}>} nunca lança.
|
|
118
|
+
*/
|
|
119
|
+
async function verifyAuthenticity(engine, blob, { log, fetcher, trustedKeys }) {
|
|
120
|
+
const publicKey = engine.install?.publicKey;
|
|
121
|
+
const required = engine.install?.signatureRequired === true;
|
|
122
|
+
const algorithm = engine.install?.signatureAlgorithm ?? DEFAULT_ALGORITHM;
|
|
123
|
+
|
|
124
|
+
if (!publicKey) {
|
|
125
|
+
// Chave fixada pelo consumidor e registry sem chave: tirar a publicKey do manifest não pode
|
|
126
|
+
// ser o atalho para instalar sem assinatura.
|
|
127
|
+
if (trustedKeys?.[engine.name]) {
|
|
128
|
+
return { ok: false, reason: `${engine.name}: o consumidor fixou uma chave mas o registry não declara publicKey → ABORT (registry pode estar comprometido)` };
|
|
129
|
+
}
|
|
130
|
+
if (required) {
|
|
131
|
+
return { ok: false, reason: `${engine.name}: registry pede assinatura (signatureRequired) mas não declara publicKey → ABORT (manifesto inconsistente)` };
|
|
132
|
+
}
|
|
133
|
+
log(`[engine-kit] ${engine.name}: SEM assinatura no registry — integridade (sha256) verificada, AUTENTICIDADE não`);
|
|
134
|
+
return { ok: true, signed: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A chave veio do MESMO manifest que o kit baixou — sozinha, ela não prova nada contra um
|
|
138
|
+
// registry comprometido. Confronta com a raiz de confiança LOCAL antes de usá-la.
|
|
139
|
+
const t = checkTrust(engine.name, publicKey, { trustedKeys });
|
|
140
|
+
if (!t.ok) { return { ok: false, reason: t.reason }; }
|
|
141
|
+
if (t.tofu) {
|
|
142
|
+
const how = trustedKeys?.[engine.name] ? "chave FIXADA pelo consumidor conferiu e foi gravada" : "chave pública gravada na 1ª vez (TOFU)";
|
|
143
|
+
log(`[engine-kit] ${engine.name}: ${how} em ${trustPath()} — trocas futuras serão ABORT`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let sig;
|
|
147
|
+
try {
|
|
148
|
+
sig = await fetcher(engine.signatureUrl, 15000);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { ok: false, reason: `${engine.name}: sidecar .sig inacessível (${e.status || e.message}) mas o registry declara publicKey → ABORT (fail-closed)` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const v = verifyBlob(blob, sig, publicKey, { algorithm });
|
|
154
|
+
if (!v.ok) { return { ok: false, reason: `${engine.name}: ${v.reason}` }; }
|
|
155
|
+
|
|
156
|
+
log(`[engine-kit] ${engine.name}: assinatura Ed25519 CONFERE (${algorithm})`);
|
|
157
|
+
return { ok: true, signed: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Baixa e VERIFICA um artefato sem instalar — o caminho dos motores `self-managed`.
|
|
162
|
+
*
|
|
163
|
+
* Existe para fechar o ovo-e-galinha do primeiro install: um motor que traz o próprio
|
|
164
|
+
* instalador (o `vox-engine` é um app Python com `install.ps1`) não pode se instalar sozinho
|
|
165
|
+
* numa máquina onde ainda não está. Alguém tem de buscar o instalador — e esse alguém estava
|
|
166
|
+
* sendo CADA consumidor, cada um com sua cópia de resolução de release, SHA256 e Ed25519.
|
|
167
|
+
*
|
|
168
|
+
* Aqui o kit faz o que já sabe fazer (integridade + autenticidade fail-closed) e devolve o
|
|
169
|
+
* caminho do arquivo verificado. Quem sabe o que fazer com ele — rodar um instalador, extrair,
|
|
170
|
+
* conferir uma assinatura extra — é o consumidor. O kit não executa nada.
|
|
171
|
+
*
|
|
172
|
+
* @returns {Promise<{ok:true, path:string, version:string, signed:boolean} | {ok:false, reason:string}>} nunca lança.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchArtifact(engine, { log = () => {}, destDir, downloadTimeoutMs = 300000, fetcher = fetchBuf, trustedKeys } = {}) {
|
|
175
|
+
const dir = destDir ?? engine.homeDir;
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(dir, { recursive: true });
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { ok: false, reason: `${engine.name}: não consegui criar ${dir}: ${e?.message || e}` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let expected;
|
|
183
|
+
try {
|
|
184
|
+
const buf = await fetcher(engine.checksumUrl, 15000);
|
|
185
|
+
expected = String(buf).trim().split(/\s+/)[0].toLowerCase();
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return { ok: false, reason: `${engine.name}: sha256 sidecar inacessível (${e.status || e.message}) → ABORT (fail-closed)` };
|
|
188
|
+
}
|
|
189
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
190
|
+
return { ok: false, reason: `${engine.name}: sha256 sidecar malformado → ABORT` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
log(`[engine-kit] baixando ${engine.assetUrl} …`);
|
|
194
|
+
let buf;
|
|
195
|
+
try { buf = await fetcher(engine.assetUrl, downloadTimeoutMs); }
|
|
196
|
+
catch (e) { return { ok: false, reason: `${engine.name}: download falhou (${e.status || e.message})` }; }
|
|
197
|
+
|
|
198
|
+
const actual = createHash("sha256").update(buf).digest("hex").toLowerCase();
|
|
199
|
+
if (actual !== expected) {
|
|
200
|
+
return { ok: false, reason: `${engine.name}: SHA256 mismatch (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…) → ABORT` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const auth = await verifyAuthenticity(engine, buf, { log, fetcher, trustedKeys });
|
|
204
|
+
if (!auth.ok) { return { ok: false, reason: auth.reason }; }
|
|
205
|
+
|
|
206
|
+
// .part → rename: nunca entregar um caminho que aponta para download pela metade.
|
|
207
|
+
const part = join(dir, `${engine.assetName}.part`);
|
|
208
|
+
const out = join(dir, engine.assetName);
|
|
209
|
+
try {
|
|
210
|
+
writeFileSync(part, buf);
|
|
211
|
+
renameSync(part, out);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { ok: false, reason: `${engine.name}: não consegui gravar ${out}: ${e?.message || e}` };
|
|
214
|
+
}
|
|
215
|
+
log(`[engine-kit] ${engine.name} v${engine.version} verificado em ${out}`);
|
|
216
|
+
return { ok: true, path: out, version: engine.version, signed: auth.signed };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Garante o motor instalado. `onBeforeReplace` é o gancho de shutdown do MOTOR (o kit nunca
|
|
221
|
+
* adivinha como derrubar um daemon: quem sabe é o descritor/consumidor).
|
|
222
|
+
* @returns {Promise<{ok:true, entryPath:string, version:string, reused?:boolean, installed?:boolean} | {ok:false, reason:string}>}
|
|
223
|
+
*/
|
|
224
|
+
export async function provision(engine, { log = () => {}, allowNetwork = true, onBeforeReplace, downloadTimeoutMs = 120000, fetcher = fetchBuf, trustedKeys } = {}) {
|
|
225
|
+
const home = engine.homeDir;
|
|
226
|
+
const binDir = join(home, engine.install.extractTo ?? "bin");
|
|
227
|
+
const entryPath = join(home, engine.install.entry);
|
|
228
|
+
const lock = join(home, "provision.lock");
|
|
229
|
+
|
|
230
|
+
// Já instalado? Só reusa se a versão instalada for LEGÍVEL e satisfizer o pin.
|
|
231
|
+
// Versão ilegível (package.json ausente/corrompido = extração interrompida, disco cheio,
|
|
232
|
+
// arquivo truncado) NÃO é motivo para reusar: é estado desconhecido → reprovisiona.
|
|
233
|
+
// Reusar aqui seria fail-open silencioso — o oposto do resto do kit — e congelaria a máquina
|
|
234
|
+
// numa instalação quebrada para sempre, ignorando o pin do registry.
|
|
235
|
+
if (existsSync(entryPath)) {
|
|
236
|
+
const inst = installedVersion(binDir);
|
|
237
|
+
if (inst && satisfiesPin(inst, engine.version)) {
|
|
238
|
+
return { ok: true, entryPath, version: inst, reused: true };
|
|
239
|
+
}
|
|
240
|
+
if (!inst && engine.status !== "self-managed") {
|
|
241
|
+
log(`[engine-kit] ${engine.name}: instalação em estado desconhecido (versão ilegível) → reprovisionando`);
|
|
242
|
+
if (!allowNetwork) {
|
|
243
|
+
return { ok: false, reason: `${engine.name}: instalação em estado desconhecido (package.json ausente/corrompido em ${binDir}) e rede desabilitada — não dá para validar nem reparar` };
|
|
244
|
+
}
|
|
245
|
+
} else if (inst) {
|
|
246
|
+
log(`[engine-kit] ${engine.name}: instalada v${inst} não satisfaz o pin v${engine.version} → atualizando`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Antes de qualquer teste de rede/estado local: o que o kit NÃO faz com este motor vale
|
|
251
|
+
// independente de como ele está instalado.
|
|
252
|
+
// Motor que traz o PRÓPRIO instalador (ex.: um app com updater embutido). A entrada no registry
|
|
253
|
+
// existe como descritor de registro — release, chave, versão — mas quem instala é ele mesmo.
|
|
254
|
+
// Tentar provisionar aqui daria erro obscuro (o artefato é um instalador, não um tarball de
|
|
255
|
+
// `extractTo`); melhor recusar dizendo QUEM instala.
|
|
256
|
+
if (engine.status === "self-managed") {
|
|
257
|
+
return { ok: false, reason: `${engine.name} é self-managed: instala e atualiza pelo próprio updater, não pelo engine-kit (o registry guarda o descritor da release)` };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!allowNetwork) { return { ok: false, reason: `${engine.name} ausente/desatualizado e rede desabilitada` }; }
|
|
261
|
+
if (engine.status === "pending-release") {
|
|
262
|
+
return { ok: false, reason: `${engine.name} ainda não publicou release (status: pending-release)` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
mkdirSync(home, { recursive: true });
|
|
266
|
+
|
|
267
|
+
// LOCK atômico: só um consumidor provisiona; os demais aguardam o resultado dele.
|
|
268
|
+
// Um lock órfão (processo morto) é reclamado por idade — não trava a máquina para sempre.
|
|
269
|
+
const lockFd = claimLock(lock);
|
|
270
|
+
if (lockFd === null) {
|
|
271
|
+
for (let i = 0; i < 40; i++) {
|
|
272
|
+
if (existsSync(entryPath)) {
|
|
273
|
+
const inst = installedVersion(binDir);
|
|
274
|
+
// Se o vencedor terminou mas a versão continua ilegível, não invente: sinalize.
|
|
275
|
+
if (inst) { return { ok: true, entryPath, version: inst, reused: true }; }
|
|
276
|
+
}
|
|
277
|
+
await sleep(1000);
|
|
278
|
+
}
|
|
279
|
+
return { ok: false, reason: `${engine.name}: provision-locked-timeout (outro consumidor está instalando há >40s)` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
// 1) SHA256 sidecar OBRIGATÓRIO (fail-closed).
|
|
284
|
+
let expected;
|
|
285
|
+
try {
|
|
286
|
+
const buf = await fetcher(engine.checksumUrl, 15000);
|
|
287
|
+
expected = String(buf).trim().split(/\s+/)[0].toLowerCase();
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return { ok: false, reason: `${engine.name}: sha256 sidecar inacessível (${e.status || e.message}) → ABORT (fail-closed)` };
|
|
290
|
+
}
|
|
291
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
292
|
+
return { ok: false, reason: `${engine.name}: sha256 sidecar malformado → ABORT` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 2) Download → .part → rename atômico.
|
|
296
|
+
log(`[engine-kit] baixando ${engine.assetUrl} …`);
|
|
297
|
+
let buf;
|
|
298
|
+
try { buf = await fetcher(engine.assetUrl, downloadTimeoutMs); }
|
|
299
|
+
catch (e) { return { ok: false, reason: `${engine.name}: download falhou (${e.status || e.message})` }; }
|
|
300
|
+
const part = join(home, `${engine.assetName}.part`);
|
|
301
|
+
const tgz = join(home, engine.assetName);
|
|
302
|
+
writeFileSync(part, buf);
|
|
303
|
+
renameSync(part, tgz);
|
|
304
|
+
|
|
305
|
+
// 3) Integridade (mismatch = apaga + ABORT).
|
|
306
|
+
const actual = sha256File(tgz);
|
|
307
|
+
if (actual !== expected) {
|
|
308
|
+
try { unlinkSync(tgz); } catch { /* ignore */ }
|
|
309
|
+
return { ok: false, reason: `${engine.name}: SHA256 mismatch (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…) → ABORT` };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 3.5) AUTENTICIDADE — antes de extrair. Um artefato não confiável não chega a tocar o disco
|
|
313
|
+
// final: se a assinatura não confere, o .tgz é apagado e nada é instalado.
|
|
314
|
+
const auth = await verifyAuthenticity(engine, buf, { log, fetcher, trustedKeys });
|
|
315
|
+
if (!auth.ok) {
|
|
316
|
+
try { unlinkSync(tgz); } catch { /* ignore */ }
|
|
317
|
+
return { ok: false, reason: auth.reason };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 4) Extrai em staging e faz o swap (com shutdown-first se já existe).
|
|
321
|
+
const staging = join(home, `stage-${engine.version}`);
|
|
322
|
+
rmSync(staging, { recursive: true, force: true });
|
|
323
|
+
mkdirSync(staging, { recursive: true });
|
|
324
|
+
const ex = extractTgz(tgz, staging);
|
|
325
|
+
if (!ex.ok) { return { ok: false, reason: `${engine.name}: ${ex.reason}` }; }
|
|
326
|
+
|
|
327
|
+
// 4.5) VERSÃO — a assinatura prova QUEM publicou o artefato, não QUAL versão ele é. Uma release
|
|
328
|
+
// antiga, legitimamente assinada, servida no lugar da nova (replay/downgrade) passaria pelo
|
|
329
|
+
// sha256 e pela Ed25519. O package.json do artefato tem de dizer a versão que o registry pede.
|
|
330
|
+
const got = installedVersion(staging);
|
|
331
|
+
if (got !== engine.version) {
|
|
332
|
+
rmSync(staging, { recursive: true, force: true });
|
|
333
|
+
try { unlinkSync(tgz); } catch { /* ignore */ }
|
|
334
|
+
return { ok: false, reason: `${engine.name}: o artefato diz v${got ?? "? (package.json ausente/ilegível)"} e o registry pede v${engine.version} → ABORT` };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// A instalação atual sai do caminho por RENAME, não por rmSync: no Windows, com o motor em uso
|
|
338
|
+
// (sem shutdownPath, ou um host segurando o processo), o rmSync apagava metade da pasta antes do
|
|
339
|
+
// EPERM e deixava a máquina sem motor nenhum. O rename falha inteiro ou passa inteiro.
|
|
340
|
+
let aside = null;
|
|
341
|
+
if (existsSync(binDir)) {
|
|
342
|
+
if (onBeforeReplace) { try { await onBeforeReplace(); } catch { /* best-effort */ } }
|
|
343
|
+
aside = `${binDir}.old-${process.pid}`;
|
|
344
|
+
rmSync(aside, { recursive: true, force: true });
|
|
345
|
+
try {
|
|
346
|
+
renameSync(binDir, aside);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
rmSync(staging, { recursive: true, force: true });
|
|
349
|
+
try { unlinkSync(tgz); } catch { /* ignore */ }
|
|
350
|
+
return { ok: false, reason: `${engine.name}: não consegui trocar ${binDir} (${e.code || e.message}): arquivos em uso — feche quem usa o motor e rode de novo; a instalação atual ficou intacta` };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// `extractTo` pode ser ANINHADO (ex.: "runtimes/<motor>", quando o home guarda estado que
|
|
354
|
+
// precisa sobreviver ao swap — pesos, logs). O rename exige o diretório-pai existente: sem
|
|
355
|
+
// isto, funciona na máquina que já tem a pasta e falha justamente na máquina limpa.
|
|
356
|
+
mkdirSync(dirname(binDir), { recursive: true });
|
|
357
|
+
try {
|
|
358
|
+
renameSync(staging, binDir);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (aside) { try { renameSync(aside, binDir); } catch { /* o reason abaixo diz onde ficou */ } }
|
|
361
|
+
return { ok: false, reason: `${engine.name}: swap falhou (${e.code || e.message})${aside && existsSync(aside) ? `; a instalação anterior está em ${aside}` : ""}` };
|
|
362
|
+
}
|
|
363
|
+
if (aside) {
|
|
364
|
+
try { rmSync(aside, { recursive: true, force: true }); }
|
|
365
|
+
catch (e) { log(`[engine-kit] ${engine.name}: instalado, mas a versão anterior ficou em ${aside} (${e.code || e.message})`); }
|
|
366
|
+
}
|
|
367
|
+
try { unlinkSync(tgz); } catch { /* ignore */ }
|
|
368
|
+
|
|
369
|
+
if (!existsSync(entryPath)) {
|
|
370
|
+
return { ok: false, reason: `${engine.name}: artefato extraído incompleto (sem ${engine.install.entry})` };
|
|
371
|
+
}
|
|
372
|
+
log(`[engine-kit] ${engine.name} instalado em ${binDir} (v${engine.version})`);
|
|
373
|
+
return { ok: true, entryPath, version: engine.version, installed: true, signed: auth.signed };
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return { ok: false, reason: `${engine.name}: provision falhou: ${e?.message || e}` };
|
|
376
|
+
} finally {
|
|
377
|
+
try { closeSync(lockFd); unlinkSync(lock); } catch { /* ignore */ }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<#
|
|
2
|
+
publish-base.ps1 - Publica UM motor no engine-registry, assinado, com prova do caminho real.
|
|
3
|
+
|
|
4
|
+
Por que existe: o publish.ps1 do vox-engine ja resolvia, na marra, quatro armadilhas que
|
|
5
|
+
nenhum README impede alguem de esquecer. Este script generaliza esses passos para QUALQUER
|
|
6
|
+
motor, para que um motor novo nao repita a licao a partir do zero:
|
|
7
|
+
|
|
8
|
+
1. o app Copilot injeta o token de uma conta de TRABALHO em todo processo filho, e o gh
|
|
9
|
+
publica pela conta ERRADA (a release aparece no lugar errado, ou some);
|
|
10
|
+
2. assinar so tem valor se a assinatura for CONFERIDA antes de sair do computador;
|
|
11
|
+
3. "publiquei" nao e prova: asset faltando, .sig esquecido ou tag errada so aparecem
|
|
12
|
+
quando um consumidor tenta instalar -- em producao;
|
|
13
|
+
4. editar o manifest.json com regex corrompe o arquivo em silencio.
|
|
14
|
+
|
|
15
|
+
Este script NAO builda o motor. Cada motor tem seu build (wheel Python, bundle esbuild, tgz
|
|
16
|
+
multi-plataforma) -- parametrizar isso seria reescrever cada build dentro daqui. O motor
|
|
17
|
+
builda e chama este script com o(s) artefato(s) prontos.
|
|
18
|
+
|
|
19
|
+
PUBLICACAO DUPLA: o engine-registry (publico) e o CANONICO de consumo -- e o unico que o
|
|
20
|
+
manifest aponta e o unico cuja falha aborta. O espelho no repo privado do motor (-SourceRepo)
|
|
21
|
+
e historico/rollback: se falhar, AVISA alto mas nao invalida a publicacao. Dois canonicos
|
|
22
|
+
seriam duas verdades, e a divergencia apareceria como "atualizei e voltou a versao velha".
|
|
23
|
+
|
|
24
|
+
PONTE DE MIGRACAO (-MarketplaceMirror): enquanto existir consumidor com a URL ANTIGA gravada
|
|
25
|
+
no codigo (SDK vendorizado, provision copiado), a release precisa continuar aparecendo la
|
|
26
|
+
tambem -- senao a migracao quebra quem ainda nao foi repontado. Tambem e o rollback: para
|
|
27
|
+
voltar atras, o consumidor so reaponta a URL, sem rebuild. Como o -SourceRepo, a falha aqui
|
|
28
|
+
AVISA e segue: o canonico ja esta de pe. Desligue quando um grep nos consumidores pela URL
|
|
29
|
+
antiga daquele motor voltar VAZIO.
|
|
30
|
+
|
|
31
|
+
Uso:
|
|
32
|
+
./publish-base.ps1 -Engine mcp-gateway -Version 0.2.0 -Asset dist/mcp-gateway.tgz
|
|
33
|
+
./publish-base.ps1 -Engine vox-engine -Version 0.23.0 -Asset a.tgz,b.tgz -SourceRepo AllanSantos-DV/vox-engine
|
|
34
|
+
./publish-base.ps1 -Engine embed-house -Version 1.0.5 -Asset x.tgz -MarketplaceMirror AllanSantos-DV/copilot-marketplace
|
|
35
|
+
./publish-base.ps1 -Engine mcp-gateway -Version 0.2.0 -Asset dist/x.tgz -DryRun
|
|
36
|
+
#>
|
|
37
|
+
param(
|
|
38
|
+
[Parameter(Mandatory = $true)][string]$Engine,
|
|
39
|
+
[Parameter(Mandatory = $true)][string]$Version,
|
|
40
|
+
[Parameter(Mandatory = $true)][string[]]$Asset,
|
|
41
|
+
[string]$Registry = "AllanSantos-DV/engine-registry",
|
|
42
|
+
[string]$SourceRepo = "",
|
|
43
|
+
[string]$MarketplaceMirror = "",
|
|
44
|
+
[string]$RegistryPath = "",
|
|
45
|
+
[string]$Notes = "",
|
|
46
|
+
[switch]$SkipManifest,
|
|
47
|
+
[switch]$DryRun
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
$ErrorActionPreference = "Stop"
|
|
51
|
+
$kit = $PSScriptRoot
|
|
52
|
+
if (-not $RegistryPath) { $RegistryPath = Split-Path -Parent $kit }
|
|
53
|
+
$manifest = Join-Path $RegistryPath "manifest.json"
|
|
54
|
+
|
|
55
|
+
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
56
|
+
function Warn($m) { Write-Host "AVISO: $m" -ForegroundColor Yellow }
|
|
57
|
+
function Fail($m) { Write-Host "FALHOU: $m" -ForegroundColor Red; exit 1 }
|
|
58
|
+
|
|
59
|
+
if ($Version -notmatch '^\d+\.\d+\.\d+$') { Fail "versao '$Version' invalida (esperado x.y.z)" }
|
|
60
|
+
if ($Engine -notmatch '^[a-z0-9][a-z0-9-]*$') { Fail "motor '$Engine' invalido (minusculas, hifens)" }
|
|
61
|
+
|
|
62
|
+
# --- 1. A armadilha da conta de TRABALHO --------------------------------------------------
|
|
63
|
+
# O token injetado pelo app vence o keyring pessoal. Sem zerar, o gh autentica como a conta
|
|
64
|
+
# errada e a release vai parar em outro lugar (ou falha com 404 num repo que existe).
|
|
65
|
+
$env:GH_TOKEN = ''
|
|
66
|
+
$env:GITHUB_TOKEN = ''
|
|
67
|
+
$env:GIT_CONFIG_PARAMETERS = ''
|
|
68
|
+
|
|
69
|
+
Step "Conferindo identidade do gh"
|
|
70
|
+
$who = (gh api user --jq '.login' 2>$null)
|
|
71
|
+
if ($LASTEXITCODE -ne 0 -or -not $who) { Fail "gh nao autenticado - rode 'gh auth login' com a conta dona dos repos" }
|
|
72
|
+
$owner = $Registry.Split('/')[0]
|
|
73
|
+
if ($who.Trim() -ne $owner) { Fail "gh autenticado como '$($who.Trim())' mas o registry e de '$owner' (token da conta errada)" }
|
|
74
|
+
Write-Host " gh=$($who.Trim()) registry=$Registry"
|
|
75
|
+
|
|
76
|
+
Step "Conferindo os artefatos"
|
|
77
|
+
$assets = @()
|
|
78
|
+
foreach ($a in $Asset) {
|
|
79
|
+
if (-not (Test-Path $a)) { Fail "artefato nao encontrado: $a" }
|
|
80
|
+
$full = (Resolve-Path $a).Path
|
|
81
|
+
if ((Get-Item $full).Length -eq 0) { Fail "artefato vazio: $full" }
|
|
82
|
+
$assets += $full
|
|
83
|
+
Write-Host " $([IO.Path]::GetFileName($full)) ($((Get-Item $full).Length) bytes)"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$keyPath = Join-Path $env:USERPROFILE ".engine-signing\${Engine}_ed25519_private.key"
|
|
87
|
+
$envVar = "ENGINE_SIGNING_KEY_" + ($Engine -replace '-', '_').ToUpper()
|
|
88
|
+
if (Test-Path "env:$envVar") { $keyPath = (Get-Item "env:$envVar").Value }
|
|
89
|
+
if (-not (Test-Path $keyPath)) {
|
|
90
|
+
Fail "chave privada ausente: $keyPath`n gere com: node `"$kit\gen-key.mjs`" $Engine`n ou aponte a existente com: `$env:$envVar = '<caminho>'"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# --- 2. Assinar e PROVAR a assinatura antes de publicar -----------------------------------
|
|
94
|
+
Step "Assinando (Ed25519 hash-then-sign; a chave privada nunca entra no CI)"
|
|
95
|
+
$upload = @()
|
|
96
|
+
foreach ($a in $assets) {
|
|
97
|
+
node (Join-Path $kit "sign.mjs") $a --engine $Engine
|
|
98
|
+
if ($LASTEXITCODE -ne 0) { Fail "assinatura de $a" }
|
|
99
|
+
if (-not (Test-Path "$a.sig")) { Fail ".sig nao foi gerado para $a" }
|
|
100
|
+
if (-not (Test-Path "$a.sha256")) { Fail ".sha256 nao foi gerado para $a" }
|
|
101
|
+
$upload += @($a, "$a.sig", "$a.sha256")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$pubKey = node -e "import('file:///' + process.argv[1].replace(/\\/g,'/')).then(async s => { const fs = await import('node:fs'); console.log(s.publicKeyHexFromPrivateHex(fs.readFileSync(process.argv[2],'utf8').trim())); })" (Join-Path $kit "signature.mjs") $keyPath
|
|
105
|
+
if ($LASTEXITCODE -ne 0 -or -not $pubKey) { Fail "nao foi possivel derivar a chave publica de $keyPath" }
|
|
106
|
+
$pubKey = $pubKey.Trim()
|
|
107
|
+
Write-Host " chave publica: $pubKey"
|
|
108
|
+
|
|
109
|
+
$tag = "$Engine-v$Version"
|
|
110
|
+
if ($DryRun) {
|
|
111
|
+
Step "DRY-RUN: publicaria a tag '$tag' em $Registry com:"
|
|
112
|
+
$upload | ForEach-Object { Write-Host " $([IO.Path]::GetFileName($_))" }
|
|
113
|
+
if ($SourceRepo) { Write-Host " + espelho no repo privado $SourceRepo (nao-fatal)" }
|
|
114
|
+
if ($MarketplaceMirror) { Write-Host " + ponte no canal antigo $MarketplaceMirror (nao-fatal)" }
|
|
115
|
+
Write-Host " e atualizaria $manifest (publicKey=$pubKey, signatureRequired=true, repo=$Registry)"
|
|
116
|
+
exit 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# --- 3. Publicar no CANONICO (falha aqui aborta) ------------------------------------------
|
|
120
|
+
Step "Publicando a release no registry ($Registry, tag $tag)"
|
|
121
|
+
if (-not $Notes) {
|
|
122
|
+
$Notes = "$Engine $Version. Artefato assinado com Ed25519 (ed25519-sha256-raw): o engine-kit e fail-closed e recusa artefato sem assinatura valida."
|
|
123
|
+
}
|
|
124
|
+
gh release view $tag --repo $Registry 2>$null | Out-Null
|
|
125
|
+
if ($LASTEXITCODE -eq 0) { Fail "a release '$tag' ja existe em $Registry - suba a versao ou apague a release antes (nao sobrescreva um artefato ja assinado)" }
|
|
126
|
+
|
|
127
|
+
gh release create $tag --repo $Registry --title "$Engine $Version" --notes $Notes @upload
|
|
128
|
+
if ($LASTEXITCODE -ne 0) { Fail "gh release create em $Registry" }
|
|
129
|
+
|
|
130
|
+
# --- 4. Espelho no repo privado: historico/rollback, NAO canonico --------------------------
|
|
131
|
+
if ($SourceRepo) {
|
|
132
|
+
Step "Espelhando no repo do codigo ($SourceRepo) - historico, nao canonico"
|
|
133
|
+
gh release create $tag --repo $SourceRepo --title "$Engine $Version" --notes "Espelho do release canonico: https://github.com/$Registry/releases/tag/$tag" @upload
|
|
134
|
+
if ($LASTEXITCODE -ne 0) {
|
|
135
|
+
Warn "o espelho em $SourceRepo falhou. A publicacao CANONICA esta de pe (consumidores nao sao afetados); refaca o espelho a mao se quiser o historico la."
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# --- 4b. Ponte no canal ANTIGO: para quem ainda tem a URL velha gravada no codigo ----------
|
|
140
|
+
if ($MarketplaceMirror) {
|
|
141
|
+
Step "Publicando a ponte no canal antigo ($MarketplaceMirror) - consumidores nao repontados"
|
|
142
|
+
$bridgeNotes = "PONTE DE MIGRACAO. O canonico deste motor agora e https://github.com/$Registry/releases/tag/$tag - esta copia existe so para os consumidores que ainda apontam para ca e sera removida quando nao restar nenhum."
|
|
143
|
+
gh release create $tag --repo $MarketplaceMirror --title "$Engine $Version (ponte)" --notes $bridgeNotes @upload
|
|
144
|
+
if ($LASTEXITCODE -ne 0) {
|
|
145
|
+
Warn "a ponte em $MarketplaceMirror falhou. O canonico esta de pe, mas quem AINDA aponta para o canal antigo nao vera esta versao - publique a ponte a mao ou reponte esses consumidores."
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# --- 5. Manifest: parseado, nao editado com regex -----------------------------------------
|
|
150
|
+
if (-not $SkipManifest) {
|
|
151
|
+
Step "Atualizando o manifest do registry"
|
|
152
|
+
node (Join-Path $kit "update-manifest.mjs") $Engine --version $Version --public-key $pubKey --repo $Registry --manifest $manifest
|
|
153
|
+
if ($LASTEXITCODE -ne 0) { Fail "update-manifest (a release esta publicada; corrija o manifest e commite)" }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# --- 6. A prova: baixar do release como um consumidor faria -------------------------------
|
|
157
|
+
Step "Provando o caminho REAL (baixa do release publicado e confere sha256 + assinatura)"
|
|
158
|
+
node (Join-Path $kit "verify-release.mjs") $Engine --manifest $manifest
|
|
159
|
+
if ($LASTEXITCODE -ne 0) { Fail "a release publicada NAO passa pelo caminho do consumidor - conserte antes de anunciar" }
|
|
160
|
+
|
|
161
|
+
Write-Host ""
|
|
162
|
+
Write-Host "OK: $Engine v$Version publicado e VERIFICADO em $Registry." -ForegroundColor Green
|
|
163
|
+
Write-Host "Falta: commitar e enviar o manifest.json ($manifest) para o registry publico."
|
package/kit/resolve.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// engine-kit/resolve.mjs — RESOLVE: descobre o descritor de um motor no registry.
|
|
2
|
+
// Ordem: cache local fresco -> registry remoto -> cache obsoleto (degradação sinalizada).
|
|
3
|
+
// NUNCA lança: devolve { ok:false, reason } para o consumidor decidir (fail-loud no caller).
|
|
4
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_REGISTRY_URL =
|
|
9
|
+
"https://raw.githubusercontent.com/AllanSantos-DV/engine-registry/main/manifest.json";
|
|
10
|
+
|
|
11
|
+
const CACHE_DIR = join(homedir(), ".engine-kit");
|
|
12
|
+
const CACHE = join(CACHE_DIR, "manifest.json");
|
|
13
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
14
|
+
|
|
15
|
+
/** Expande `~` e placeholders de plataforma. */
|
|
16
|
+
export function expandHome(p) {
|
|
17
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1).replace(/^[/\\]/, "")) : p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function target() {
|
|
21
|
+
return `${process.platform}-${process.arch}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Substitui {version}, {platform}, {arch}, {asset} num template. */
|
|
25
|
+
export function fill(tpl, vars) {
|
|
26
|
+
return String(tpl).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCache() {
|
|
30
|
+
try {
|
|
31
|
+
const age = Date.now() - statSync(CACHE).mtimeMs;
|
|
32
|
+
return { manifest: JSON.parse(readFileSync(CACHE, "utf8")), stale: age > CACHE_TTL_MS };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchManifest(url, timeoutMs) {
|
|
39
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
40
|
+
if (!res.ok) { throw new Error(`HTTP ${res.status}`); }
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Devolve o descritor do motor `name`, já com caminhos expandidos e URLs montadas.
|
|
46
|
+
* @param opts.manifest manifest JÁ carregado (pula rede e cache) — usado pelo publish para
|
|
47
|
+
* conferir a release contra o manifest LOCAL que ele acabou de escrever, e por testes.
|
|
48
|
+
* @returns {Promise<{ok:true, engine:object} | {ok:false, reason:string}>}
|
|
49
|
+
*/
|
|
50
|
+
export async function resolve(name, { registryUrl = DEFAULT_REGISTRY_URL, allowNetwork = true, timeoutMs = 10000, version, manifest: given, refresh = false } = {}) {
|
|
51
|
+
let manifest = null;
|
|
52
|
+
const cached = given ? null : readCache();
|
|
53
|
+
// Sinalizações marcadas ONDE acontecem (não deduzidas depois):
|
|
54
|
+
// • degraded → tentou a rede, falhou, e caiu num cache VENCIDO (descritor pode estar velho);
|
|
55
|
+
// • offline → não se falou com o registry nesta resolução (rede desabilitada ou falha).
|
|
56
|
+
let degraded = false;
|
|
57
|
+
let offline = false;
|
|
58
|
+
|
|
59
|
+
// `refresh` ignora o cache fresco de propósito. Sem isso, quem já resolveu uma vez fica com o
|
|
60
|
+
// descritor congelado pelo TTL inteiro: publiquei três versões seguidas do mcp-gateway e o
|
|
61
|
+
// consumidor continuou instalando a primeira, porque o cache tinha 6 minutos de vida. Cache
|
|
62
|
+
// acelera o caminho quente (reusar o que já está instalado); INSTALAR ou DIAGNOSTICAR quer a
|
|
63
|
+
// verdade do registry, e essas operações são raras — pagar uma requisição não pesa.
|
|
64
|
+
const useCache = cached && !cached.stale && !refresh;
|
|
65
|
+
|
|
66
|
+
if (given) {
|
|
67
|
+
manifest = given;
|
|
68
|
+
offline = true; // manifest entregue na mão: não se falou com o registry
|
|
69
|
+
} else if (useCache) {
|
|
70
|
+
manifest = cached.manifest;
|
|
71
|
+
offline = true; // cache fresco: não precisou da rede
|
|
72
|
+
} else if (allowNetwork) {
|
|
73
|
+
try {
|
|
74
|
+
manifest = await fetchManifest(registryUrl, timeoutMs);
|
|
75
|
+
try { mkdirSync(CACHE_DIR, { recursive: true }); writeFileSync(CACHE, JSON.stringify(manifest, null, 2)); } catch { /* cache é best-effort */ }
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (!cached) { return { ok: false, reason: `registry inacessível (${e.message}) e sem cache` }; }
|
|
78
|
+
manifest = cached.manifest;
|
|
79
|
+
offline = true;
|
|
80
|
+
degraded = cached.stale; // AQUI é a degradação real: rede falhou e o cache está vencido
|
|
81
|
+
}
|
|
82
|
+
} else if (cached) {
|
|
83
|
+
manifest = cached.manifest;
|
|
84
|
+
offline = true;
|
|
85
|
+
degraded = cached.stale;
|
|
86
|
+
} else {
|
|
87
|
+
return { ok: false, reason: "sem cache do registry e rede desabilitada" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const found = (manifest.engines ?? []).find((e) => e.name === name);
|
|
91
|
+
if (!found) { return { ok: false, reason: `motor "${name}" não existe no registry` }; }
|
|
92
|
+
|
|
93
|
+
const v = version ?? found.version;
|
|
94
|
+
const vars = { version: v, platform: process.platform, arch: process.arch };
|
|
95
|
+
const asset = fill(found.install.asset, vars);
|
|
96
|
+
const tag = fill(found.install.tag, vars);
|
|
97
|
+
const base = `https://github.com/${found.install.repo}/releases/download/${tag}`;
|
|
98
|
+
// Sidecar de assinatura: template opcional, default `<asset>.sig`. A URL é sempre montada —
|
|
99
|
+
// quem decide se ela é OBRIGATÓRIA é o provision, olhando publicKey/signatureRequired.
|
|
100
|
+
const sigName = fill(found.install.signature ?? "{asset}.sig", { ...vars, asset });
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
engine: {
|
|
105
|
+
...found,
|
|
106
|
+
version: v,
|
|
107
|
+
homeDir: expandHome(found.home),
|
|
108
|
+
assetName: asset,
|
|
109
|
+
assetUrl: `${base}/${asset}`,
|
|
110
|
+
checksumUrl: `${base}/${fill(found.install.checksum, { ...vars, asset })}`,
|
|
111
|
+
signatureName: sigName,
|
|
112
|
+
signatureUrl: `${base}/${sigName}`,
|
|
113
|
+
/** true = o descritor veio de um cache VENCIDO porque o registry não respondeu. */
|
|
114
|
+
staleCache: degraded,
|
|
115
|
+
/** true = esta resolução não falou com o registry (cache fresco, sem rede, ou falha). */
|
|
116
|
+
offline,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|