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.
@@ -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."
@@ -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
+ }