engine-kit 0.2.4 → 0.2.5

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/kit/budget.mjs CHANGED
@@ -41,9 +41,12 @@ const ROOT = resolvePath(KIT, "..");
41
41
  // e update.mjs (`npx engine-kit ensure <motor>` com autoatualização exigida pelo gate de release), +6.3 KB.
42
42
  // Escopo novo (comando de instalação), rodado pela mão de quem instala, nunca importado por consumidor:
43
43
  // o caminho do `import "engine-kit"` não mudou.
44
+ // 2026-11-09 tooling 31000 → 34000: diagnóstico de vida (pid, porta, idade do runtime) e do ponteiro
45
+ // `cli.json` no doctor (diagnose.mjs), +2.7 KB. Só leitura, rodado na mão de quem diagnostica; o caminho do
46
+ // `import "engine-kit"` não mudou.
44
47
  const BUDGETS = {
45
48
  runtime: { codeBytes: 27000, baseline: 24519, loc: 900 },
46
- tooling: { codeBytes: 31000, baseline: 16420, loc: 950 },
49
+ tooling: { codeBytes: 34000, baseline: 16420, loc: 950 },
47
50
  adapter: { codeBytes: 5000, baseline: 1859, loc: 150 },
48
51
  };
49
52
 
@@ -0,0 +1,58 @@
1
+ // engine-kit/diagnose.mjs — diagnóstico SOMENTE LEITURA de vida e de ponteiro, usado pelo doctor.
2
+ // Não sobe, não derruba e não fala com o motor: lê o runtime que o motor anuncia e o `cli.json`
3
+ // que ele publica (CONTRATO-MOTOR §4). Nunca lança.
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { isAbsolute, join, relative } from "node:path";
6
+ import { readRuntime } from "./lifecycle.mjs";
7
+
8
+ const alive = (pid) => {
9
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
10
+ };
11
+
12
+ /**
13
+ * Vida do motor segundo o arquivo de runtime que ele declara: pid vivo, porta e idade do heartbeat.
14
+ * Estados: SEM-DECLARAÇÃO (o registry não declara arquivo de runtime), PARADO (sem runtime),
15
+ * VIVO (pid existe), ÓRFÃO (runtime aponta para pid que não existe mais).
16
+ * @returns {{state:string, detail:string}}
17
+ */
18
+ export function lifeOf(engine, now = Date.now()) {
19
+ const rt = engine.runtime;
20
+ if (!rt?.runtimeFile) { return { state: "SEM-DECLARAÇÃO", detail: rt?.transport ? `transporte ${rt.transport}, sem arquivo de runtime` : "motor sem bloco runtime" }; }
21
+ const data = readRuntime(engine);
22
+ if (!data) { return { state: "PARADO", detail: `sem ${rt.runtimeFile}` }; }
23
+ let age = "";
24
+ try {
25
+ const ms = now - statSync(join(engine.homeDir, rt.runtimeFile)).mtimeMs;
26
+ const limit = rt.heartbeatMs ? ` (limite ${Math.round((rt.heartbeatMs * 3) / 1000)}s)` : "";
27
+ age = `, runtime atualizado há ${Math.round(ms / 1000)}s${limit}`;
28
+ } catch { /* o arquivo sumiu entre a leitura e o stat */ }
29
+ const where = `pid ${data.pid ?? "?"}${data.port ? `, porta ${data.port}` : ""}${age}`;
30
+ return data.pid && alive(data.pid)
31
+ ? { state: "VIVO", detail: where }
32
+ : { state: "ÓRFÃO", detail: `${where}; o pid não existe mais` };
33
+ }
34
+
35
+ /**
36
+ * Ponteiro `<home>/cli.json`: todo caminho absoluto que o motor publica precisa existir, e o kit
37
+ * avisa quando o ponteiro vive fora da instalação que ele provisionou.
38
+ * Estados: AUSENTE, ILEGÍVEL, QUEBRADO (algum caminho não existe), FORA-DA-INSTALAÇÃO, OK.
39
+ * @returns {{state:string, detail:string}}
40
+ */
41
+ export function pointerOf(engine) {
42
+ const file = join(engine.homeDir, "cli.json");
43
+ if (!existsSync(file)) { return { state: "AUSENTE", detail: "o motor ainda não publicou o ponteiro" }; }
44
+ let data;
45
+ try { data = JSON.parse(readFileSync(file, "utf8")); }
46
+ catch { return { state: "ILEGÍVEL", detail: `${file} não é JSON válido` }; }
47
+ const paths = Object.entries(data).filter(([, v]) => typeof v === "string" && isAbsolute(v));
48
+ const missing = paths.filter(([, v]) => !existsSync(v)).map(([k]) => k);
49
+ if (missing.length) { return { state: "QUEBRADO", detail: `aponta para caminho inexistente: ${missing.join(", ")}` }; }
50
+ const binDir = join(engine.homeDir, engine.install.extractTo ?? "bin");
51
+ const outside = paths.filter(([k, v]) => {
52
+ const rel = relative(binDir, v);
53
+ return k !== "node" && (rel.startsWith("..") || isAbsolute(rel));
54
+ });
55
+ return outside.length
56
+ ? { state: "FORA-DA-INSTALAÇÃO", detail: `${outside.map(([k]) => k).join(", ")} fora de ${binDir}` }
57
+ : { state: "OK", detail: `${paths.length} caminho(s) existem` };
58
+ }
package/kit/doctor.mjs CHANGED
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { resolve } from "./resolve.mjs";
18
18
  import { provision, satisfiesPin } from "./provision.mjs";
19
19
  import { shutdown } from "./lifecycle.mjs";
20
+ import { lifeOf, pointerOf } from "./diagnose.mjs";
20
21
 
21
22
  const args = process.argv.slice(2);
22
23
  const useLocal = args.includes("--local");
@@ -85,6 +86,11 @@ const w = Math.max(12, ...rows.map((r) => r.name.length));
85
86
  for (const r of rows) {
86
87
  const mark = (r.state === "OK" || r.state === "SELF-MANAGED") ? " OK " : r.state === "ERRO" ? " ERRO " : " !! ";
87
88
  console.log(`${mark} ${pad(r.name, w)} ${pad(r.state, 14)} ${r.detail}`);
89
+ if (r.engine && r.engine.status !== "self-managed") {
90
+ const life = lifeOf(r.engine), ptr = pointerOf(r.engine);
91
+ console.log(` ${pad("", w)} vida: ${life.state} (${life.detail})`);
92
+ console.log(` ${pad("", w)} ponteiro cli.json: ${ptr.state} (${ptr.detail})`);
93
+ }
88
94
  }
89
95
 
90
96
  const pending = rows.filter((r) => r.needsWork);
package/kit/ensure.mjs CHANGED
@@ -63,6 +63,7 @@ export async function ensureCommand(name, { version, registryUrl, bundled, manif
63
63
  const p = await provision(r.engine, {
64
64
  log,
65
65
  trustedKeys: { [name]: publicKey },
66
+ allowDowngrade: Boolean(version),
66
67
  ...(fetcher ? { fetcher } : {}),
67
68
  // Windows: arquivo em uso não se sobrescreve. Derruba o daemon pelo shutdownPath que o registry declara.
68
69
  onBeforeReplace: async () => {
package/kit/provision.mjs CHANGED
@@ -12,7 +12,7 @@ import { join, dirname } from "node:path";
12
12
  import { createHash } from "node:crypto";
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { verifyBlob, DEFAULT_ALGORITHM } from "./signature.mjs";
15
- import { checkTrust, trustPath } from "./trust.mjs";
15
+ import { checkTrust, trustPath, cmpVer, rollbackReason, noteVersion } from "./trust.mjs";
16
16
 
17
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
18
 
@@ -77,22 +77,29 @@ function claimLock(lock) {
77
77
  }
78
78
  }
79
79
 
80
- const parseVer = (v) => String(v || "").split(".").map((n) => parseInt(n, 10) || 0);
81
-
82
80
  /** true se a versão instalada serve o pin: MESMO major e >= (protocolo é atado ao major). */
83
- export function satisfiesPin(installed, pinned) {
84
- const a = parseVer(installed), b = parseVer(pinned);
85
- if (a[0] !== b[0]) { return false; }
86
- for (let i = 0; i < 3; i++) {
87
- const x = a[i] || 0, y = b[i] || 0;
88
- if (x > y) { return true; }
89
- if (x < y) { return false; }
81
+ export const satisfiesPin = (installed, pinned) => String(installed).split(".")[0] === String(pinned).split(".")[0] && cmpVer(installed, pinned) >= 0;
82
+
83
+ /**
84
+ * Baixa o artefato e confere o sidecar SHA256 (obrigatório, fail-closed) em memória.
85
+ * @returns {Promise<{buf:Buffer} | {reason:string}>}
86
+ */
87
+ async function downloadChecked(engine, { log, fetcher, timeoutMs }) {
88
+ let expected;
89
+ try {
90
+ expected = String(await fetcher(engine.checksumUrl, 15000)).trim().split(/\s+/)[0].toLowerCase();
91
+ } catch (e) {
92
+ return { reason: `${engine.name}: sha256 sidecar inacessível (${e.status || e.message}) → ABORT (fail-closed)` };
90
93
  }
91
- return true;
92
- }
94
+ if (!/^[0-9a-f]{64}$/.test(expected)) { return { reason: `${engine.name}: sha256 sidecar malformado → ABORT` }; }
93
95
 
94
- function sha256File(p) {
95
- return createHash("sha256").update(readFileSync(p)).digest("hex").toLowerCase();
96
+ log(`[engine-kit] baixando ${engine.assetUrl} …`);
97
+ let buf;
98
+ try { buf = await fetcher(engine.assetUrl, timeoutMs); }
99
+ catch (e) { return { reason: `${engine.name}: download falhou (${e.status || e.message})` }; }
100
+
101
+ const actual = createHash("sha256").update(buf).digest("hex").toLowerCase();
102
+ return actual === expected ? { buf } : { reason: `${engine.name}: SHA256 mismatch (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…) → ABORT` };
96
103
  }
97
104
 
98
105
  async function fetchBuf(url, timeoutMs) {
@@ -183,26 +190,9 @@ export async function fetchArtifact(engine, { log = () => {}, destDir, downloadT
183
190
  return { ok: false, reason: `${engine.name}: não consegui criar ${dir}: ${e?.message || e}` };
184
191
  }
185
192
 
186
- let expected;
187
- try {
188
- const buf = await fetcher(engine.checksumUrl, 15000);
189
- expected = String(buf).trim().split(/\s+/)[0].toLowerCase();
190
- } catch (e) {
191
- return { ok: false, reason: `${engine.name}: sha256 sidecar inacessível (${e.status || e.message}) → ABORT (fail-closed)` };
192
- }
193
- if (!/^[0-9a-f]{64}$/.test(expected)) {
194
- return { ok: false, reason: `${engine.name}: sha256 sidecar malformado → ABORT` };
195
- }
196
-
197
- log(`[engine-kit] baixando ${engine.assetUrl} …`);
198
- let buf;
199
- try { buf = await fetcher(engine.assetUrl, downloadTimeoutMs); }
200
- catch (e) { return { ok: false, reason: `${engine.name}: download falhou (${e.status || e.message})` }; }
201
-
202
- const actual = createHash("sha256").update(buf).digest("hex").toLowerCase();
203
- if (actual !== expected) {
204
- return { ok: false, reason: `${engine.name}: SHA256 mismatch (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…) → ABORT` };
205
- }
193
+ const dl = await downloadChecked(engine, { log, fetcher, timeoutMs: downloadTimeoutMs });
194
+ if (!dl.buf) { return { ok: false, reason: dl.reason }; }
195
+ const buf = dl.buf;
206
196
 
207
197
  const auth = await verifyAuthenticity(engine, buf, { log, fetcher, trustedKeys });
208
198
  if (!auth.ok) { return { ok: false, reason: auth.reason }; }
@@ -225,7 +215,7 @@ export async function fetchArtifact(engine, { log = () => {}, destDir, downloadT
225
215
  * adivinha como derrubar um daemon: quem sabe é o descritor/consumidor).
226
216
  * @returns {Promise<{ok:true, entryPath:string, version:string, reused?:boolean, installed?:boolean} | {ok:false, reason:string}>}
227
217
  */
228
- export async function provision(engine, { log = () => {}, allowNetwork = true, onBeforeReplace, downloadTimeoutMs = 120000, fetcher = fetchBuf, trustedKeys } = {}) {
218
+ export async function provision(engine, { log = () => {}, allowNetwork = true, onBeforeReplace, downloadTimeoutMs = 120000, fetcher = fetchBuf, trustedKeys, allowDowngrade } = {}) {
229
219
  const home = engine.homeDir;
230
220
  const binDir = join(home, engine.install.extractTo ?? "bin");
231
221
  const entryPath = join(home, engine.install.entry);
@@ -239,6 +229,7 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
239
229
  if (existsSync(entryPath)) {
240
230
  const inst = installedVersion(binDir);
241
231
  if (inst && satisfiesPin(inst, engine.version)) {
232
+ noteVersion(engine.name, inst);
242
233
  return { ok: true, entryPath, version: inst, reused: true };
243
234
  }
244
235
  if (!inst && engine.status !== "self-managed") {
@@ -266,6 +257,9 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
266
257
  return { ok: false, reason: `${engine.name} ainda não publicou release (status: pending-release)` };
267
258
  }
268
259
 
260
+ const rb = !allowDowngrade && rollbackReason(engine.name, engine.version);
261
+ if (rb) { return { ok: false, reason: rb }; }
262
+
269
263
  mkdirSync(home, { recursive: true });
270
264
 
271
265
  // LOCK atômico: só um consumidor provisiona; os demais aguardam o resultado dele.
@@ -284,35 +278,15 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
284
278
  }
285
279
 
286
280
  try {
287
- // 1) SHA256 sidecar OBRIGATÓRIO (fail-closed).
288
- let expected;
289
- try {
290
- const buf = await fetcher(engine.checksumUrl, 15000);
291
- expected = String(buf).trim().split(/\s+/)[0].toLowerCase();
292
- } catch (e) {
293
- return { ok: false, reason: `${engine.name}: sha256 sidecar inacessível (${e.status || e.message}) → ABORT (fail-closed)` };
294
- }
295
- if (!/^[0-9a-f]{64}$/.test(expected)) {
296
- return { ok: false, reason: `${engine.name}: sha256 sidecar malformado → ABORT` };
297
- }
298
-
299
- // 2) Download → .part → rename atômico.
300
- log(`[engine-kit] baixando ${engine.assetUrl} …`);
301
- let buf;
302
- try { buf = await fetcher(engine.assetUrl, downloadTimeoutMs); }
303
- catch (e) { return { ok: false, reason: `${engine.name}: download falhou (${e.status || e.message})` }; }
281
+ // 1-3) Sidecar SHA256 obrigatório, download e integridade: nada toca o disco antes de conferir.
282
+ const dl = await downloadChecked(engine, { log, fetcher, timeoutMs: downloadTimeoutMs });
283
+ if (!dl.buf) { return { ok: false, reason: dl.reason }; }
284
+ const buf = dl.buf;
304
285
  const part = join(home, `${engine.assetName}.part`);
305
286
  const tgz = join(home, engine.assetName);
306
287
  writeFileSync(part, buf);
307
288
  renameSync(part, tgz);
308
289
 
309
- // 3) Integridade (mismatch = apaga + ABORT).
310
- const actual = sha256File(tgz);
311
- if (actual !== expected) {
312
- dropFile(tgz);
313
- return { ok: false, reason: `${engine.name}: SHA256 mismatch (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…) → ABORT` };
314
- }
315
-
316
290
  // 3.5) AUTENTICIDADE — antes de extrair. Um artefato não confiável não chega a tocar o disco
317
291
  // final: se a assinatura não confere, o .tgz é apagado e nada é instalado.
318
292
  const auth = await verifyAuthenticity(engine, buf, { log, fetcher, trustedKeys });
@@ -373,6 +347,7 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
373
347
  if (!existsSync(entryPath)) {
374
348
  return { ok: false, reason: `${engine.name}: artefato extraído incompleto (sem ${engine.install.entry})` };
375
349
  }
350
+ noteVersion(engine.name, engine.version);
376
351
  log(`[engine-kit] ${engine.name} instalado em ${binDir} (v${engine.version})`);
377
352
  return { ok: true, entryPath, version: engine.version, installed: true, signed: auth.signed };
378
353
  } catch (e) {
package/kit/trust.mjs CHANGED
@@ -70,3 +70,22 @@ export function checkTrust(engineName, publicKey, { trustedKeys } = {}) {
70
70
  }
71
71
 
72
72
  export const trustPath = () => trustFile();
73
+
74
+ /** Compara versões `x.y.z` numericamente: negativo, zero ou positivo. */
75
+ export const cmpVer = (a, b) => String(a).localeCompare(String(b), "en", { numeric: true });
76
+
77
+ /** Motivo do ABORT se `version` é menor que a maior já instalada do motor (anti-rollback); falsy se pode seguir. */
78
+ export function rollbackReason(name, version) {
79
+ const max = readTrust()[name]?.maxVersion;
80
+ return max && cmpVer(version, max) < 0 && `${name}: v${version} < v${max} já instalada → ABORT (rollback; downgrade só com --version)`;
81
+ }
82
+
83
+ /** Grava `version` como a maior vista do motor, se superar a registrada. */
84
+ export function noteVersion(name, version) {
85
+ const store = readTrust(), max = store[name]?.maxVersion;
86
+ if (max && cmpVer(version, max) <= 0) return;
87
+ try {
88
+ mkdirSync(trustDir(), { recursive: true });
89
+ writeFileSync(trustFile(), JSON.stringify({ ...store, [name]: { ...store[name], maxVersion: version } }, null, 2));
90
+ } catch { /* sem gravar, a proteção vale a partir da próxima instalação */ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-kit",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Cliente das dependências compartilhadas (motores) do engine-registry: resolve, provisiona com SHA256 e assinatura Ed25519 fail-closed, e mantém o motor vivo — uma instalação por máquina, N consumidores.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "scripts": {
27
- "test": "node kit/budget.mjs && node tests/test-silencio.mjs && node tests/smoke.mjs && node tests/test-edge.mjs && node tests/test-signature.mjs && node tests/test-ensure.mjs && node tests/test-ensure-daemon.mjs && node tests/test-update.mjs && node tests/test-trust.mjs && node tests/test-tar.mjs && node tests/test-apps.mjs && node tests/test-resolve-cache.mjs && node tests/test-doctor-exit.mjs && node tests/test-mcp-gateway-token.mjs && node tests/test-gateway-provision.mjs && node tests/test-neural-link-alignment.mjs && node tests/test-site.mjs && node tests/test-pages-branch.mjs && node tests/test-readme-versions.mjs && node tests/test-pages-gate.mjs && node tests/test-regras-escopo.mjs",
27
+ "test": "node kit/budget.mjs && node tests/test-silencio.mjs && node tests/smoke.mjs && node tests/test-edge.mjs && node tests/test-signature.mjs && node tests/test-ensure.mjs && node tests/test-ensure-daemon.mjs && node tests/test-update.mjs && node tests/test-trust.mjs && node tests/test-rollback.mjs && node tests/test-diagnose.mjs && node tests/test-tar.mjs && node tests/test-apps.mjs && node tests/test-resolve-cache.mjs && node tests/test-doctor-exit.mjs && node tests/test-mcp-gateway-token.mjs && node tests/test-gateway-provision.mjs && node tests/test-neural-link-alignment.mjs && node tests/test-site.mjs && node tests/test-pages-branch.mjs && node tests/test-readme-versions.mjs && node tests/test-pages-gate.mjs && node tests/test-regras-escopo.mjs",
28
28
  "test:pages-live": "node tests/test-pages-gate.mjs --live",
29
29
  "test:network": "node tests/smoke.mjs --network && node tests/test-signature.mjs --network",
30
30
  "test:e2e": "node tests/e2e-install.mjs",