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,192 @@
|
|
|
1
|
+
// consumers/mcp-gateway.mjs — adaptador pronto do motor `mcp-gateway`.
|
|
2
|
+
//
|
|
3
|
+
// Qualquer extensão que queira falar com o agregador MCP importa isto e chama `ensureGateway()`.
|
|
4
|
+
// O adaptador sabe o que é específico DESTE motor (handshake do /health, onde mora o bearer
|
|
5
|
+
// token, como pedir shutdown antes de uma atualização) — o engine-kit cuida do resto
|
|
6
|
+
// (resolver no registry, baixar com SHA256, instalar, subir, esperar o anúncio).
|
|
7
|
+
//
|
|
8
|
+
// import { ensureGateway } from "engine-registry/consumers/mcp-gateway.mjs";
|
|
9
|
+
//
|
|
10
|
+
// const gw = await ensureGateway({ log });
|
|
11
|
+
// if (!gw.available) { log(`gateway indisponível: ${gw.reason}`); return; } // degrade SINALIZADO
|
|
12
|
+
// // gw.url -> http://127.0.0.1:<porta>/mcp
|
|
13
|
+
// // gw.token -> bearer para o header Authorization (cofre DPAPI no HOME padrão; ver readToken)
|
|
14
|
+
//
|
|
15
|
+
// Contrato: nunca lança.
|
|
16
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { join, dirname, resolve as resolvePath, sep } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { ensureEngine } from "../kit/index.mjs";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HOME = join(homedir(), ".mcp-gateway");
|
|
24
|
+
const DEFAULT_PORT = 7337;
|
|
25
|
+
const ENGINE_NAME = "mcp-gateway";
|
|
26
|
+
// manifest.json do próprio engine-registry — mesmo arquivo que declara `install.agentInstructions`
|
|
27
|
+
// e `install.extractTo` para este motor (fonte única de verdade; ver schema.json).
|
|
28
|
+
const MANIFEST_PATH = join(dirname(dirname(fileURLToPath(import.meta.url))), "manifest.json");
|
|
29
|
+
|
|
30
|
+
/** HOME do motor, resolvido a cada chamada (respeita override de teste/dev via env). */
|
|
31
|
+
function resolveHome() {
|
|
32
|
+
return process.env.MCP_GATEWAY_DATA_DIR || DEFAULT_HOME;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HOME = resolveHome();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Descritor do motor `mcp-gateway` lido do manifest.json do registry. Nunca lança: manifest
|
|
39
|
+
* ilegível ou motor ausente do registro → null (quem chama sinaliza, não inventa um default).
|
|
40
|
+
*/
|
|
41
|
+
function loadEngineDescriptor() {
|
|
42
|
+
try {
|
|
43
|
+
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
44
|
+
return (manifest.engines ?? []).find((e) => e?.name === ENGINE_NAME) ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Lê a porta configurada pelo usuário (config.json), caindo no default do motor. */
|
|
51
|
+
function configuredPort() {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(join(HOME, "config.json"), "utf8")).port ?? DEFAULT_PORT;
|
|
54
|
+
} catch {
|
|
55
|
+
return DEFAULT_PORT;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lê o bearer token pela MESMA regra do daemon (mcp-gateway src/daemon/auth.ts): no HOME de
|
|
61
|
+
* produção (~/.mcp-gateway) no Windows o token mora no cofre DPAPI (segredo MCP_GATEWAY_TOKEN em
|
|
62
|
+
* %LOCALAPPDATA%\Vault, entropia `vault.ps1/v1`) — ordem cofre → env MCP_GATEWAY_TOKEN → arquivo
|
|
63
|
+
* `auth-token` legado. Em qualquer outro HOME (teste/dev, não-Windows) só vale o arquivo
|
|
64
|
+
* `auth-token`. Nunca lança nem loga o valor: ausência → null; falha ao abrir um cofre que
|
|
65
|
+
* existe → null com o motivo em `log` (não finge que o token não existe).
|
|
66
|
+
* @param {{home?:string, defaultHome?:string, env?:object, platform?:string, log?:(m:string)=>void}} [o]
|
|
67
|
+
*/
|
|
68
|
+
export function readToken({ home = resolveHome(), defaultHome = DEFAULT_HOME, env = process.env, platform = process.platform, log = () => {} } = {}) {
|
|
69
|
+
const legacy = () => {
|
|
70
|
+
try { return readFileSync(join(home, "auth-token"), "utf8").trim() || null; } catch { return null; }
|
|
71
|
+
};
|
|
72
|
+
const isProd = platform === "win32" && resolvePath(home).toLowerCase() === resolvePath(defaultHome).toLowerCase();
|
|
73
|
+
if (!isProd) { return legacy(); }
|
|
74
|
+
|
|
75
|
+
const vaultDir = env.VAULT_DIR?.trim() || join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Vault");
|
|
76
|
+
const blob = join(vaultDir, "MCP_GATEWAY_TOKEN.dpapi");
|
|
77
|
+
if (existsSync(blob)) {
|
|
78
|
+
const script = "$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Security; " +
|
|
79
|
+
"$b=[IO.File]::ReadAllBytes($env:MCPGW_BLOB); [Console]::Out.Write([Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($b,[Text.Encoding]::UTF8.GetBytes('vault.ps1/v1'),'CurrentUser')))";
|
|
80
|
+
try {
|
|
81
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
82
|
+
windowsHide: true, encoding: "utf8", timeout: 20000, stdio: ["ignore", "pipe", "pipe"], env: { ...env, MCPGW_BLOB: blob },
|
|
83
|
+
}).trim();
|
|
84
|
+
if (out) { return out; }
|
|
85
|
+
} catch (e) {
|
|
86
|
+
log(`mcp-gateway: cofre do token ilegível: ${String(e.message).split("\n")[0]}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return env.MCP_GATEWAY_TOKEN?.trim() || legacy();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handshake do gateway: confirma que quem atende na porta é MESMO este motor e já está
|
|
95
|
+
* servindo. `degraded` é ACEITO — significa que o gateway está de pé e atendendo, com um
|
|
96
|
+
* ou mais backends em reconexão; `starting` não serve (ainda conectando pela primeira vez).
|
|
97
|
+
*/
|
|
98
|
+
async function healthCheck() {
|
|
99
|
+
const port = configuredPort();
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) });
|
|
102
|
+
if (!res.ok) { return null; }
|
|
103
|
+
const h = await res.json();
|
|
104
|
+
const serving = h?.status === "ok" || h?.status === "degraded";
|
|
105
|
+
return h?.name === "mcp-gateway" && serving ? { ...h, port } : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derruba o daemon antes de sobrescrever os arquivos (Windows trava arquivo em uso). */
|
|
112
|
+
async function onBeforeReplace() {
|
|
113
|
+
try {
|
|
114
|
+
const rt = JSON.parse(readFileSync(join(HOME, "runtime.json"), "utf8"));
|
|
115
|
+
if (rt?.pid) { process.kill(rt.pid, "SIGTERM"); }
|
|
116
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
117
|
+
} catch { /* já parado / sem runtime */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Caminho do pacote de instruções para agente, dentro do HOME do motor. Composto a partir do
|
|
122
|
+
* MESMO `install.agentInstructions` declarado no manifest.json (fonte única de verdade — mudar
|
|
123
|
+
* o manifest muda o consumer, nunca o contrário) e de `install.extractTo` (a pasta onde o
|
|
124
|
+
* `provision` REALMENTE desempacota o artefato — ver kit/provision.mjs: `binDir = join(home,
|
|
125
|
+
* install.extractTo ?? "bin")`). Sem isso o caminho apontaria para <home>/docs/... enquanto o
|
|
126
|
+
* artefato desempacotado mora em <home>/<extractTo>/docs/... — dois lugares diferentes.
|
|
127
|
+
* Função pura: só resolve o caminho, não toca rede (o manifest.json lido é o arquivo estático
|
|
128
|
+
* empacotado junto com este módulo, não uma chamada ao registry remoto).
|
|
129
|
+
* @returns {string|null} null quando o manifest não declara `agentInstructions` para o motor.
|
|
130
|
+
*/
|
|
131
|
+
export function instructionsPath() {
|
|
132
|
+
const engine = loadEngineDescriptor();
|
|
133
|
+
const rel = engine?.install?.agentInstructions;
|
|
134
|
+
if (!rel) { return null; }
|
|
135
|
+
const extractTo = engine.install.extractTo ?? "bin";
|
|
136
|
+
if (rel.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(rel) || rel.split(/[\\/]+/).includes("..")) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const home = resolvePath(resolveHome());
|
|
140
|
+
const candidate = resolvePath(home, extractTo, rel);
|
|
141
|
+
if (candidate !== home && !candidate.startsWith(`${home}${sep}`)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Carrega o pacote de instruções do disco (sem rede, sem healthcheck). Host-agnóstico:
|
|
149
|
+
* devolve o conteúdo cru para qualquer cliente MCP decidir o que fazer com ele.
|
|
150
|
+
* @returns {{available:true, path:string, content:string} | {available:false, reason:string}}
|
|
151
|
+
*/
|
|
152
|
+
export function readInstructions() {
|
|
153
|
+
const path = instructionsPath();
|
|
154
|
+
if (!path) {
|
|
155
|
+
return { available: false, reason: "manifest.json não declara um caminho seguro para as instruções do mcp-gateway" };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return { available: true, path, content: readFileSync(path, "utf8") };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { available: false, reason: e.message };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Garante o agregador MCP instalado e vivo.
|
|
166
|
+
* @returns {Promise<{available:true, url:string, token:string|null, port:number, backends:string[], health:object}
|
|
167
|
+
* | {available:false, reason:string}>}
|
|
168
|
+
*/
|
|
169
|
+
export async function ensureGateway({ log = () => {}, version, allowNetwork = true, bootTimeoutMs = 45000, registryUrl, fetcher } = {}) {
|
|
170
|
+
const r = await ensureEngine("mcp-gateway", {
|
|
171
|
+
healthCheck,
|
|
172
|
+
onBeforeReplace,
|
|
173
|
+
log,
|
|
174
|
+
version,
|
|
175
|
+
allowNetwork,
|
|
176
|
+
bootTimeoutMs,
|
|
177
|
+
registryUrl,
|
|
178
|
+
fetcher,
|
|
179
|
+
env: { MCP_GATEWAY_DATA_DIR: HOME },
|
|
180
|
+
});
|
|
181
|
+
if (!r.available) { return r; }
|
|
182
|
+
|
|
183
|
+
const port = r.health.port ?? configuredPort();
|
|
184
|
+
return {
|
|
185
|
+
available: true,
|
|
186
|
+
url: `http://127.0.0.1:${port}/mcp`,
|
|
187
|
+
token: readToken({ log }),
|
|
188
|
+
port,
|
|
189
|
+
backends: r.health.backends ?? [],
|
|
190
|
+
health: r.health,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// consumers/neural-link.mjs — adaptador pronto do motor `neural-link` (dispatcher de hooks).
|
|
2
|
+
//
|
|
3
|
+
// Qualquer extensão que traga hook (`.js` + companion `<hook>.neural-link.json`) importa isto e
|
|
4
|
+
// chama `ensureNeuralLink()` na ativação, DEPOIS de copiar seus arquivos para a pasta de hooks.
|
|
5
|
+
// O adaptador garante o dispatcher instalado/atualizado (achar-ou-baixar-ou-atualizar — o kit
|
|
6
|
+
// cuida disso) e roda o `install()` do próprio motor para que os companions recém-entregues
|
|
7
|
+
// virem registro REAL no `neural-link.config.json` — sem isso, o hook fica instalado mas
|
|
8
|
+
// nunca é chamado por nenhum evento.
|
|
9
|
+
//
|
|
10
|
+
// import { ensureNeuralLink } from "engine-kit/consumers/neural-link.mjs";
|
|
11
|
+
//
|
|
12
|
+
// const n = await ensureNeuralLink({ log });
|
|
13
|
+
// if (!n.available) { log(`hooks desativados: ${n.reason}`); /* degrade SINALIZADO */ }
|
|
14
|
+
// // n.entryPath -> caminho do dispatcher instalado (kind=cli, sem processo persistente)
|
|
15
|
+
//
|
|
16
|
+
// Contrato: nunca lança.
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { ensureEngine } from "../kit/index.mjs";
|
|
20
|
+
|
|
21
|
+
/** Pin default: última versão do neural-link confirmada publicada no manifest deste kit. */
|
|
22
|
+
const DEFAULT_PIN = "0.7.15";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Garante o dispatcher de hooks `neural-link` instalado/atualizado, e roda o `install()` dele
|
|
26
|
+
* para que os companions (`<hook>.neural-link.json`) recém-entregues pela extensão virem
|
|
27
|
+
* registro real no config global — o `install()` é quem lê os companions; `provision()` só
|
|
28
|
+
* garante o binário no disco, ele não sabe nada sobre config de hooks.
|
|
29
|
+
*
|
|
30
|
+
* Sem `healthCheck`: `kind=cli` não sobe processo persistente — o kit devolve o caminho do
|
|
31
|
+
* binário e sai (ver `kit/lifecycle.mjs`).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string=} opts.version pin de versão mínima (default: {@link DEFAULT_PIN}).
|
|
35
|
+
* @param {(msg:string)=>void=} opts.log
|
|
36
|
+
* @param {boolean=} opts.allowNetwork permite baixar/atualizar (default true). Com `false` e um
|
|
37
|
+
* neural-link já instalado que satisfaça o pin, reusa sem tocar rede (comportamento do
|
|
38
|
+
* próprio `provision()` — não há necessidade de um caminho manual separado: o kit já
|
|
39
|
+
* reusa QUALQUER instalação existente que satisfaça o pin antes de olhar para a rede).
|
|
40
|
+
* @returns {Promise<{available:true, entryPath:string, version:string, registered:object|null}
|
|
41
|
+
* | {available:false, reason:string}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureNeuralLink({ version = DEFAULT_PIN, log = () => {}, allowNetwork = true } = {}) {
|
|
44
|
+
const r = await ensureEngine("neural-link", { version, allowNetwork, log });
|
|
45
|
+
if (!r.available) { return r; }
|
|
46
|
+
|
|
47
|
+
const entryPath = r.bin;
|
|
48
|
+
let registered = null;
|
|
49
|
+
try {
|
|
50
|
+
const installMod = await import(pathToFileURL(join(dirname(entryPath), "install.js")).href);
|
|
51
|
+
registered = installMod.install({});
|
|
52
|
+
log(`[engine-kit] neural-link: install() rodou — ${registered.written?.length ?? 0} declarado(s), ${registered.companions?.selados?.length ?? 0} companion(s) selado(s)`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// O dispatcher está presente e é invocável por evento mesmo se o `install()` falhar aqui
|
|
55
|
+
// (ex.: permissão de escrita no config, layout inesperado do binário) — não travamos a
|
|
56
|
+
// extensão por isso; só sinalizamos que o registro pode não ter acontecido.
|
|
57
|
+
log(`[engine-kit] neural-link: install() falhou (${e?.message || e}) — companions podem não estar registrados`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
available: true,
|
|
62
|
+
entryPath,
|
|
63
|
+
version: r.engine?.installedVersion ?? version,
|
|
64
|
+
registered,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// engine-kit/app-release.mjs — a release de um APLICATIVO (manifest.apps), não de um motor.
|
|
2
|
+
//
|
|
3
|
+
// Um app não é provisionado pelo kit: não há home, entry nem lifecycle. O que o registry garante
|
|
4
|
+
// é o download — cada asset publicado, com .sha256 e (se o app tiver chave) .sig conferindo.
|
|
5
|
+
// Este módulo monta as URLs a partir da entrada e faz essa prova; o CLI é
|
|
6
|
+
// `node kit/verify-release.mjs --app <nome>`.
|
|
7
|
+
//
|
|
8
|
+
// NUNCA lança: devolve { ok:false, reason } com o asset que falhou.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { fill } from "./resolve.mjs";
|
|
11
|
+
import { verifyBlob, DEFAULT_ALGORITHM } from "./signature.mjs";
|
|
12
|
+
|
|
13
|
+
/** Acha a entrada `name` em manifest.apps. */
|
|
14
|
+
export function findApp(manifest, name) {
|
|
15
|
+
const app = (manifest?.apps ?? []).find((a) => a?.name === name);
|
|
16
|
+
if (!app) {
|
|
17
|
+
const known = (manifest?.apps ?? []).map((a) => a?.name).join(", ") || "(nenhum)";
|
|
18
|
+
return { ok: false, reason: `app "${name}" não existe em manifest.apps (registrados: ${known})` };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, app };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Expande os templates da entrada: nome com {version}; checksum e signature com {asset} e
|
|
25
|
+
* {version}. Nome literal (sem placeholder) passa intacto.
|
|
26
|
+
*/
|
|
27
|
+
export function appAssetUrls(app) {
|
|
28
|
+
const vars = { version: app.version };
|
|
29
|
+
const tag = fill(app.release.tag, vars);
|
|
30
|
+
const base = `https://github.com/${app.release.repo}/releases/download/${tag}`;
|
|
31
|
+
return {
|
|
32
|
+
tag,
|
|
33
|
+
assets: app.release.assets.map((a) => {
|
|
34
|
+
const name = fill(a.name, vars);
|
|
35
|
+
const checksum = fill(a.checksum, { ...vars, asset: name });
|
|
36
|
+
const signature = fill(a.signature ?? "{asset}.sig", { ...vars, asset: name });
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
platform: a.platform,
|
|
40
|
+
url: `${base}/${encodeURIComponent(name)}`,
|
|
41
|
+
checksumUrl: `${base}/${encodeURIComponent(checksum)}`,
|
|
42
|
+
signatureUrl: `${base}/${encodeURIComponent(signature)}`,
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function anonymousGet(url) {
|
|
49
|
+
// Sem Authorization de propósito: a promessa do registry é download SEM login.
|
|
50
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(300000) });
|
|
51
|
+
if (!res.ok) { throw new Error(`HTTP ${res.status} em ${url}`); }
|
|
52
|
+
return Buffer.from(await res.arrayBuffer());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Baixa TODOS os assets da entrada e confere sha256 e, havendo publicKey, a assinatura — mais a
|
|
57
|
+
* metade negativa (adulterado é recusado).
|
|
58
|
+
* @param opts.get fetcher injetável (testes); padrão = GET anônimo.
|
|
59
|
+
* @returns {Promise<{ok:true, assets:object[]} | {ok:false, reason:string}>}
|
|
60
|
+
*/
|
|
61
|
+
export async function verifyAppRelease(app, { get = anonymousGet, log = () => {} } = {}) {
|
|
62
|
+
const { publicKey, signatureRequired } = app.release;
|
|
63
|
+
if (signatureRequired && !publicKey) {
|
|
64
|
+
return { ok: false, reason: "signatureRequired sem publicKey na entrada do app — corrija antes de anunciar" };
|
|
65
|
+
}
|
|
66
|
+
const algorithm = app.release.signatureAlgorithm ?? DEFAULT_ALGORITHM;
|
|
67
|
+
const { assets } = appAssetUrls(app);
|
|
68
|
+
const results = [];
|
|
69
|
+
|
|
70
|
+
for (const a of assets) {
|
|
71
|
+
let blob, sha;
|
|
72
|
+
try { blob = await get(a.url); } catch (e) { return { ok: false, reason: `${a.name}: asset não baixou — ${e.message}` }; }
|
|
73
|
+
try { sha = String(await get(a.checksumUrl)).trim().split(/\s+/)[0].toLowerCase(); }
|
|
74
|
+
catch (e) { return { ok: false, reason: `${a.name}: sidecar .sha256 não baixou — ${e.message}` }; }
|
|
75
|
+
|
|
76
|
+
const actual = createHash("sha256").update(blob).digest("hex");
|
|
77
|
+
if (actual !== sha) { return { ok: false, reason: `${a.name}: SHA256 divergente (release=${sha.slice(0, 12)}… baixado=${actual.slice(0, 12)}…)` }; }
|
|
78
|
+
|
|
79
|
+
let signed = false;
|
|
80
|
+
if (publicKey) {
|
|
81
|
+
let sig;
|
|
82
|
+
try { sig = await get(a.signatureUrl); } catch (e) { return { ok: false, reason: `${a.name}: sidecar .sig não baixou — ${e.message}` }; }
|
|
83
|
+
const v = verifyBlob(blob, sig, publicKey, { algorithm });
|
|
84
|
+
if (!v.ok) { return { ok: false, reason: `${a.name}: ${v.reason}` }; }
|
|
85
|
+
if (verifyBlob(Buffer.concat([blob, Buffer.from([0])]), sig, publicKey, { algorithm }).ok) {
|
|
86
|
+
return { ok: false, reason: `${a.name}: a verificação ACEITOU um artefato adulterado — contrato quebrado` };
|
|
87
|
+
}
|
|
88
|
+
signed = true;
|
|
89
|
+
}
|
|
90
|
+
log(`${a.name}: sha256 ${actual} OK${signed ? `, assinatura ${algorithm} OK` : ""}`);
|
|
91
|
+
results.push({ name: a.name, platform: a.platform, sha256: actual, signed });
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, assets: results };
|
|
94
|
+
}
|
package/kit/budget.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// engine-kit/budget.mjs — o KILL-SWITCH de tamanho, auto-aplicado.
|
|
2
|
+
//
|
|
3
|
+
// node kit/budget.mjs # mede e falha (exit 1) se estourar
|
|
4
|
+
//
|
|
5
|
+
// Por que existe: o budget vivia como um NÚMERO NUM PLANO, e número em plano apodrece —
|
|
6
|
+
// o plano dizia "393 linhas" quando o real já era 1087. Um kill-switch que ninguém mede
|
|
7
|
+
// não é kill-switch, é decoração. Aqui ele se mede sozinho e falha loud.
|
|
8
|
+
//
|
|
9
|
+
// O que ele mede (a distinção que faltava): o budget existe para impedir que **as extensões**
|
|
10
|
+
// carreguem um monstro. Então o que conta é o CAMINHO DO CONSUMIDOR — o fecho transitivo dos
|
|
11
|
+
// imports a partir de `index.mjs`, que é o que um `import "engine-kit"` realmente traz. O
|
|
12
|
+
// ferramental de AUTOR (assinar, gerar chave, publicar, diagnosticar) nunca é importado por
|
|
13
|
+
// consumidor: roda como comando na mão de quem publica. Misturar os dois numa soma só compara
|
|
14
|
+
// alhos com bugalhos e faz o kill-switch disparar pelo motivo errado.
|
|
15
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const KIT = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const ROOT = resolvePath(KIT, "..");
|
|
21
|
+
|
|
22
|
+
// Budgets. A métrica ENFORÇADA é **bytes de código** (linhas em branco e comentários fora), não
|
|
23
|
+
// linhas: uma review corroborada apontou, com razão, que LOC é proxy ruim — o que custa ao
|
|
24
|
+
// consumidor é o que ele carrega e parseia, e este kit é comentado em pt-BR (comentário engorda
|
|
25
|
+
// LOC sem custar comportamento). LOC fica como informação, não como portão.
|
|
26
|
+
//
|
|
27
|
+
// Contra o "mover a trave": cada faixa registra `baseline` — o valor MEDIDO quando a métrica foi
|
|
28
|
+
// adotada (2026-07-29, já com a raiz de confiança). O relatório imprime o crescimento desde essa
|
|
29
|
+
// marca, então subir o teto depois deixa de ser invisível: vira um número na tela e um diff.
|
|
30
|
+
// Calibrar uma vez, na adoção da métrica, é linha de base; recalibrar toda vez que dispara é
|
|
31
|
+
// contornar o guarda-corpo — e é isso que o baseline expõe.
|
|
32
|
+
//
|
|
33
|
+
// Recalibrações com delta justificado (o baseline NÃO muda; só o teto):
|
|
34
|
+
// 2026-10-11 tooling 18000 → 24000: catálogo de APLICATIVOS (manifest.apps) — app-release.mjs e
|
|
35
|
+
// os modos --app de update-manifest/verify-release, +6.4 KB. É escopo novo (segundo tipo de
|
|
36
|
+
// entrada no registry), não inchaço; e é ferramental de autor: o caminho do consumidor não mudou.
|
|
37
|
+
// 2026-10-23 adapter 4000 → 5000: o token do mcp-gateway passou ao cofre DPAPI (0.13.8) e o
|
|
38
|
+
// adaptador precisa lê-lo (cofre → env → arquivo, a regra do daemon), +1.4 KB. Não é gordura:
|
|
39
|
+
// é o contrato de autenticação do motor, que só o adaptador do motor pode conhecer.
|
|
40
|
+
// 2026-10-27 tooling 24000 → 31000 (loc 800 → 950): canal npm aprovado pelo dono — cli.mjs, ensure.mjs
|
|
41
|
+
// e update.mjs (`npx engine-kit ensure <motor>` com autoatualização exigida pelo gate de release), +6.3 KB.
|
|
42
|
+
// Escopo novo (comando de instalação), rodado pela mão de quem instala, nunca importado por consumidor:
|
|
43
|
+
// o caminho do `import "engine-kit"` não mudou.
|
|
44
|
+
const BUDGETS = {
|
|
45
|
+
runtime: { codeBytes: 27000, baseline: 24519, loc: 900 },
|
|
46
|
+
tooling: { codeBytes: 31000, baseline: 16420, loc: 950 },
|
|
47
|
+
adapter: { codeBytes: 5000, baseline: 1859, loc: 150 },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// `split("\n")` num arquivo terminado em newline devolve um elemento vazio no fim — linha
|
|
51
|
+
// fantasma. Sem o `replace`, cada arquivo inflava a conta em 1 e o kill-switch disparava
|
|
52
|
+
// por medição errada (foi o que aconteceu na primeira execução deste arquivo).
|
|
53
|
+
const linesOf = (f) => readFileSync(f, "utf8").replace(/\n$/, "").split("\n");
|
|
54
|
+
const lines = (f) => linesOf(f).length;
|
|
55
|
+
|
|
56
|
+
/** Bytes que não são linha em branco nem comentário de linha inteira. */
|
|
57
|
+
const codeBytes = (f) =>
|
|
58
|
+
linesOf(f)
|
|
59
|
+
.filter((l) => {
|
|
60
|
+
const t = l.trim();
|
|
61
|
+
return t && !t.startsWith("//") && !t.startsWith("*") && !t.startsWith("/*");
|
|
62
|
+
})
|
|
63
|
+
.reduce((n, l) => n + Buffer.byteLength(l, "utf8") + 1, 0);
|
|
64
|
+
|
|
65
|
+
/** Fecho transitivo dos imports relativos a partir de um entrypoint. */
|
|
66
|
+
function closure(entry) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const walk = (file) => {
|
|
69
|
+
if (seen.has(file)) { return; }
|
|
70
|
+
seen.add(file);
|
|
71
|
+
const src = readFileSync(file, "utf8");
|
|
72
|
+
for (const m of src.matchAll(/from\s+"(\.[^"]+)"/g)) {
|
|
73
|
+
walk(resolvePath(dirname(file), m[1]));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
walk(entry);
|
|
77
|
+
return seen;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const runtime = closure(join(KIT, "index.mjs"));
|
|
81
|
+
const all = readdirSync(KIT).filter((f) => f.endsWith(".mjs")).map((f) => join(KIT, f));
|
|
82
|
+
const tooling = all.filter((f) => !runtime.has(f));
|
|
83
|
+
|
|
84
|
+
const rel = (f) => f.replace(ROOT + "\\", "").replace(ROOT + "/", "").replaceAll("\\", "/");
|
|
85
|
+
|
|
86
|
+
let failed = false;
|
|
87
|
+
const report = (label, files, budget) => {
|
|
88
|
+
const bytes = files.reduce((n, f) => n + codeBytes(f), 0);
|
|
89
|
+
const loc = files.reduce((n, f) => n + lines(f), 0);
|
|
90
|
+
const over = bytes > budget.codeBytes;
|
|
91
|
+
failed ||= over;
|
|
92
|
+
console.log(`\n${over ? "!!" : "OK"} ${label}`);
|
|
93
|
+
const growth = bytes - budget.baseline;
|
|
94
|
+
const pct = ((growth / budget.baseline) * 100).toFixed(1);
|
|
95
|
+
console.log(` ${bytes}/${budget.codeBytes} bytes de código (${loc} linhas, informativo)`);
|
|
96
|
+
console.log(` baseline ${budget.baseline} B → ${growth >= 0 ? "+" : ""}${growth} B (${pct}%) desde a adoção da métrica`);
|
|
97
|
+
for (const f of [...files].sort((a, b) => codeBytes(b) - codeBytes(a))) {
|
|
98
|
+
console.log(` ${String(codeBytes(f)).padStart(6)} B ${String(lines(f)).padStart(4)} L ${rel(f)}`);
|
|
99
|
+
}
|
|
100
|
+
if (over) { console.log(` ESTOUROU em ${bytes - budget.codeBytes} bytes → decidir: cortar ou re-baselinar com delta justificado.`); }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
console.log("== engine-kit budget == (kill-switch auto-aplicado; métrica = bytes de código)");
|
|
104
|
+
report("runtime do consumidor (fecho de index.mjs)", [...runtime], BUDGETS.runtime);
|
|
105
|
+
report("ferramental de autor (nunca importado por consumidor)", tooling, BUDGETS.tooling);
|
|
106
|
+
|
|
107
|
+
for (const f of readdirSync(join(ROOT, "consumers")).filter((x) => x.endsWith(".mjs"))) {
|
|
108
|
+
report(`adaptador ${f}`, [join(ROOT, "consumers", f)], BUDGETS.adapter);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(failed ? "\nBUDGET ESTOURADO — kill-switch disparou." : "\nDentro do budget.");
|
|
112
|
+
process.exit(failed ? 1 : 0);
|
package/kit/cli.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// engine-kit — comando de instalação dos motores (campo `bin` do package.json).
|
|
3
|
+
//
|
|
4
|
+
// npx engine-kit ensure <motor> # instala/atualiza verificando sha256 + Ed25519
|
|
5
|
+
// npx engine-kit ensure <motor> --version <x.y.z> # pin de versão
|
|
6
|
+
// npx engine-kit ensure <motor> --registry-url <url>
|
|
7
|
+
//
|
|
8
|
+
// Por padrão o kit se atualiza (roda de novo como a última versão do npm) e atualiza o motor instalado.
|
|
9
|
+
// ENGINE_KIT_NO_AUTO_UPDATE=1 desliga os dois e deixa só o aviso de versão nova (ver update.mjs).
|
|
10
|
+
// Exit code: 0 = motor instalado (ou reusado). 1 = recusou/falhou, com o motivo na saída de erro.
|
|
11
|
+
import { ensureCommand } from "./ensure.mjs";
|
|
12
|
+
import { selfUpdate, autoUpdateEnabled } from "./update.mjs";
|
|
13
|
+
|
|
14
|
+
const USAGE = "uso: engine-kit ensure <motor> [--version <x.y.z>] [--registry-url <url>]\n" +
|
|
15
|
+
"ENGINE_KIT_NO_AUTO_UPDATE=1 desliga a atualização automática do kit e dos motores (fica só o aviso).";
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const flag = (n) => { const i = args.indexOf(n); return i === -1 ? undefined : args[i + 1]; };
|
|
19
|
+
const [cmd, name] = args;
|
|
20
|
+
|
|
21
|
+
if (cmd === "--help" || cmd === "-h") {
|
|
22
|
+
console.log(USAGE);
|
|
23
|
+
} else if (cmd !== "ensure" || !name || name.startsWith("-")) {
|
|
24
|
+
console.error(cmd && cmd !== "ensure" ? `comando desconhecido: ${cmd}\n${USAGE}` : USAGE);
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
} else if (args.includes("--version") && !/^\d+\.\d+\.\d+$/.test(flag("--version") ?? "")) {
|
|
27
|
+
console.error(`--version precisa de x.y.z (recebi: ${flag("--version") ?? "nada"})`);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
} else {
|
|
30
|
+
// Sem process.exit() depois de rede: no Windows o Node aborta com sockets ainda fechando (ver doctor.mjs).
|
|
31
|
+
const u = await selfUpdate(args);
|
|
32
|
+
if (u.action === "relaunched") {
|
|
33
|
+
process.exitCode = u.status;
|
|
34
|
+
} else {
|
|
35
|
+
const autoUpdate = autoUpdateEnabled();
|
|
36
|
+
const r = await ensureCommand(name, { version: flag("--version"), registryUrl: flag("--registry-url"), autoUpdate, log: (m) => console.log(m) });
|
|
37
|
+
if (r.ok) {
|
|
38
|
+
const how = r.reused ? "já instalado, reusado" : r.signed ? "assinatura Ed25519 conferida" : "SEM assinatura no registry";
|
|
39
|
+
if (r.previous) { console.log(`motor atualizado: ${name} v${r.previous} → v${r.version}`); }
|
|
40
|
+
console.log(`OK ${name} v${r.version} (${how}) em ${r.entryPath}`);
|
|
41
|
+
if (r.available) { console.log(`aviso: ${name} v${r.available} disponível; ENGINE_KIT_NO_AUTO_UPDATE ligado — rode sem ele para atualizar`); }
|
|
42
|
+
if (r.kind === "daemon") { console.log("daemon: quem sobe o motor é o consumidor (ensureEngine com healthCheck)."); }
|
|
43
|
+
} else {
|
|
44
|
+
console.error(`FALHOU ${name}: ${r.reason}`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/kit/doctor.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// engine-kit/doctor.mjs — concilia os MOTORES INSTALADOS nesta máquina com o registry.
|
|
3
|
+
//
|
|
4
|
+
// node kit/doctor.mjs # só diagnostica (não toca em nada)
|
|
5
|
+
// node kit/doctor.mjs --fix # instala o que falta, atualiza o que está velho
|
|
6
|
+
// node kit/doctor.mjs --local # usa o manifest.json LOCAL em vez do publicado
|
|
7
|
+
// node kit/doctor.mjs --engine <nome> # um motor só
|
|
8
|
+
//
|
|
9
|
+
// A regra é a mesma do kit e não muda aqui: **desatualizado → atualiza; ausente → baixa;
|
|
10
|
+
// atualizado → REUSA**. Reusar é o caminho comum e precisa ser barato — por isso o diagnóstico
|
|
11
|
+
// não baixa nada, e o `--fix` delega ao `provision` (SHA256 + assinatura fail-closed, lock e swap
|
|
12
|
+
// atômicos). O doctor não reimplementa instalação: ele só decide QUEM precisa.
|
|
13
|
+
//
|
|
14
|
+
// Exit code: 0 = tudo conciliado. 1 = há divergência (e `--fix` não foi passado) ou algo falhou.
|
|
15
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { resolve } from "./resolve.mjs";
|
|
18
|
+
import { provision, satisfiesPin } from "./provision.mjs";
|
|
19
|
+
import { shutdown } from "./lifecycle.mjs";
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const useLocal = args.includes("--local");
|
|
23
|
+
const doFix = args.includes("--fix");
|
|
24
|
+
const onlyIdx = args.indexOf("--engine");
|
|
25
|
+
const only = onlyIdx === -1 ? null : args[onlyIdx + 1];
|
|
26
|
+
const regIdx = args.indexOf("--registry-url");
|
|
27
|
+
const registryUrl = regIdx === -1 ? undefined : args[regIdx + 1];
|
|
28
|
+
|
|
29
|
+
const manifestPath = new URL("../manifest.json", import.meta.url);
|
|
30
|
+
let manifest;
|
|
31
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")); }
|
|
32
|
+
catch (e) { console.error(`FALHOU: manifest local ilegível: ${e.message}`); process.exit(1); }
|
|
33
|
+
|
|
34
|
+
/** Versão instalada no disco, lida do artefato — a mesma fonte que o provision usa para o pin. */
|
|
35
|
+
function installedVersion(engine) {
|
|
36
|
+
const binDir = join(engine.homeDir, engine.install.extractTo ?? "bin");
|
|
37
|
+
try { return JSON.parse(readFileSync(join(binDir, "package.json"), "utf8")).version ?? null; }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const names = (manifest.engines ?? []).map((e) => e.name).filter((n) => !only || n === only);
|
|
42
|
+
if (only && names.length === 0) {
|
|
43
|
+
console.error(`FALHOU: motor "${only}" não existe no manifest`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`== engine-kit doctor == (registry: ${useLocal ? "manifest LOCAL" : "publicado"}${doFix ? ", modo --fix" : ", só diagnóstico"})\n`);
|
|
48
|
+
|
|
49
|
+
// Sem process.exit() depois de rede: o fetch (undici) ainda tem sockets fechando e o Node do
|
|
50
|
+
// Windows aborta com "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" (exit 127).
|
|
51
|
+
// Deixar o event loop esvaziar e só marcar process.exitCode.
|
|
52
|
+
const rows = [];
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
// `refresh`: diagnóstico com cache velho mente — dizer "atualizado" olhando um descritor
|
|
55
|
+
// vencido é pior do que não diagnosticar. Aqui a requisição de rede vale a pena.
|
|
56
|
+
const r = await resolve(name, { allowNetwork: !useLocal, manifest: useLocal ? manifest : undefined, refresh: !useLocal, registryUrl });
|
|
57
|
+
if (!r.ok) { rows.push({ name, state: "ERRO", detail: r.reason }); continue; }
|
|
58
|
+
|
|
59
|
+
const engine = r.engine;
|
|
60
|
+
const entry = join(engine.homeDir, engine.install.entry);
|
|
61
|
+
const present = existsSync(entry);
|
|
62
|
+
const inst = present ? installedVersion(engine) : null;
|
|
63
|
+
|
|
64
|
+
let state, detail;
|
|
65
|
+
if (engine.status === "self-managed") {
|
|
66
|
+
// Traz o próprio instalador/updater. O doctor NÃO opina sobre a versão dele: olhar o disco
|
|
67
|
+
// pelo caminho do manifest daria "ausente" para um motor que está instalado e funcionando,
|
|
68
|
+
// só que em outro lugar. Reportar e não agir é a resposta honesta.
|
|
69
|
+
state = "SELF-MANAGED"; detail = `v${engine.version} no registry — atualiza pelo próprio updater`;
|
|
70
|
+
} else if (!present) {
|
|
71
|
+
state = "AUSENTE"; detail = `baixar v${engine.version}`;
|
|
72
|
+
} else if (!inst) {
|
|
73
|
+
// Estado desconhecido: o artefato está lá mas a versão não é legível. Não invente que está ok.
|
|
74
|
+
state = "ILEGÍVEL"; detail = "instalado, mas package.json ausente/corrompido → reprovisionar";
|
|
75
|
+
} else if (satisfiesPin(inst, engine.version)) {
|
|
76
|
+
state = "OK"; detail = `v${inst} satisfaz o pin v${engine.version} → reusar`;
|
|
77
|
+
} else {
|
|
78
|
+
state = "DESATUALIZADO"; detail = `v${inst} → v${engine.version}`;
|
|
79
|
+
}
|
|
80
|
+
rows.push({ name, state, detail, engine, needsWork: state !== "OK" && state !== "SELF-MANAGED" });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
84
|
+
const w = Math.max(12, ...rows.map((r) => r.name.length));
|
|
85
|
+
for (const r of rows) {
|
|
86
|
+
const mark = (r.state === "OK" || r.state === "SELF-MANAGED") ? " OK " : r.state === "ERRO" ? " ERRO " : " !! ";
|
|
87
|
+
console.log(`${mark} ${pad(r.name, w)} ${pad(r.state, 14)} ${r.detail}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pending = rows.filter((r) => r.needsWork);
|
|
91
|
+
if (pending.length === 0) {
|
|
92
|
+
console.log(`\n== todos os motores conciliados (${rows.length}) ==`);
|
|
93
|
+
process.exitCode = rows.some((r) => r.state === "ERRO") ? 1 : 0;
|
|
94
|
+
} else if (!doFix) {
|
|
95
|
+
console.log(`\n${pending.length} motor(es) precisam de ação. Rode com --fix para instalar/atualizar.`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
} else {
|
|
98
|
+
console.log("\n-- aplicando (--fix) --");
|
|
99
|
+
let failures = 0;
|
|
100
|
+
for (const r of pending) {
|
|
101
|
+
console.log(`\n${r.name}: ${r.detail}`);
|
|
102
|
+
const p = await provision(r.engine, {
|
|
103
|
+
log: (m) => console.log(` ${m}`),
|
|
104
|
+
// No Windows um arquivo EM USO não pode ser sobrescrito: sem derrubar o daemon antes, o
|
|
105
|
+
// update de um motor vivo falha com EPERM no meio do swap. O kit não adivinha como parar
|
|
106
|
+
// um motor — usa o `shutdownPath` que o próprio registry declara.
|
|
107
|
+
onBeforeReplace: async () => {
|
|
108
|
+
const s = await shutdown(r.engine, { log: (m) => console.log(` ${m}`) });
|
|
109
|
+
if (!s.ok) { console.log(` aviso: ${s.reason} — o swap pode falhar com o motor vivo`); }
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (p.ok) {
|
|
113
|
+
console.log(` OK ${r.name} v${p.version}${p.signed === false ? " (SEM assinatura — motor ainda não migrado)" : ""}`);
|
|
114
|
+
} else {
|
|
115
|
+
failures++;
|
|
116
|
+
console.log(` FALHA ${r.name} — ${p.reason}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`\n== ${failures === 0 ? "conciliado" : `${failures} falha(s)`} ==`);
|
|
121
|
+
process.exitCode = failures === 0 ? 0 : 1;
|
|
122
|
+
}
|