engine-kit 0.2.5 → 0.2.6

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 CHANGED
@@ -64,13 +64,14 @@ O script faz, em ordem e com *fail-loud* em cada passo:
64
64
  2. assina cada artefato e **prova a assinatura na hora** (valida o legítimo, recusa o adulterado);
65
65
  3. cria a release no registry (recusa se a tag já existir — artefato assinado não se sobrescreve);
66
66
  4. espelha no repo privado, se `-SourceRepo` (falha aqui **avisa**, não aborta: não é o canônico);
67
- 5. atualiza o `manifest.json` **parseado** (nunca regex em JSON);
67
+ 5. num worktree temporário a partir da `main` (`tools/publish-manifest.mjs`), atualiza o `manifest.json`
68
+ **parseado** (nunca regex em JSON) e a linha do motor no README;
68
69
  6. **prova o caminho real**: baixa a release publicada e valida `sha256` + assinatura como um
69
70
  consumidor faria. *"Publiquei"* não é prova — asset faltando, `.sig` esquecido e tag errada só
70
- aparecem quando alguém tenta instalar, em produção.
71
-
72
- Depois: commitar e enviar o `manifest.json`. **Consumidores só enxergam a mudança após o push** —
73
- até lá o registry publicado continua descrevendo a versão anterior.
71
+ aparecem quando alguém tenta instalar, em produção;
72
+ 7. com a prova verde, commita e envia o manifest e o README para a `main` (é dela que os consumidores
73
+ leem) e mescla a `main` na `develop` (conflito só no manifest ou no README: a `develop` recebe a mesma mudança).
74
+ Prova vermelha não envia nada; a cópia de trabalho local não é tocada.
74
75
 
75
76
  ## 4. Onde o motor instala — e como o consumidor ACHA
76
77
 
package/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # engine-registry
2
2
 
3
- Vitrine de **MOTORES** compartilhados e, em uma seção separada, de aplicativos
3
+ Repositório (marketplace) de **MOTORES independentes** e, em uma seção separada, de aplicativos
4
4
  distribuídos. O `engine-kit` continua lendo somente `manifest.engines`.
5
5
 
6
+ O registry não é um motor que acopla outros motores: é a prateleira de onde cada motor é baixado,
7
+ conferido e atualizado. Cada motor tem a própria release, a própria chave de assinatura e a própria
8
+ versão; nenhum motor declara dependência de outro no manifest, e cada um recebe atualizações
9
+ separadamente (e pode ser fixado sozinho com `engine-kit pin`).
10
+
6
11
  Um motor **não é um plugin**: não se instala pela vitrine de extensões, não tem `hooks` nem
7
12
  `extensions`. Ele é **infraestrutura**: instala **uma vez por máquina** em `~/.<motor>` e serve
8
13
  **N consumidores** (Copilot, Claude Code, Cursor, VS Code, scripts…), sem depender do host.
@@ -26,13 +31,13 @@ vive num lugar só e o consumidor apenas **declara a dependência**.
26
31
  | Motor | Versão | Tipo | O que faz |
27
32
  |---|---|---|---|
28
33
  | `embed-house` | 1.0.5 | `daemon` (http) | Casa de embeddings (MiniLM-L6-v2, 384-dim): carrega o modelo uma vez e serve vetores a N consumidores. |
29
- | `vox-engine` | 0.28.7 | `daemon` (pipe), **self-managed** | Motor de voz (STT/TTS) compartilhado, com auto-unload quando ocioso. Traz o próprio instalador e updater. |
30
- | `mcp-gateway` | 0.17.2 | `daemon` (http) | Agregador MCP: conecta uma vez em cada servidor MCP e reexpõe tudo num endpoint único. |
31
- | `neural-link` | 0.7.15 | `cli` | Dispatcher adaptativo de hooks: recebe o evento por stdin, pontua os handlers e devolve uma decisão composta. |
32
- | `vxk` | 0.21.0 | `cli` | Explicações visuais animadas e narradas como servidor MCP (stdio): devolve UM HTML autocontido e offline, ou o vídeo MP4 (preset, aplicativo real, Motion) e o PDF. Um daemon único por máquina atende os agentes. A narração vem do `vox-engine`. |
34
+ | `vox-engine` | 0.33.1 | `daemon` (pipe), **self-managed** | Motor de voz (STT/TTS) compartilhado, com auto-unload quando ocioso. Traz o próprio instalador e updater. |
35
+ | `mcp-gateway` | 0.17.4 | `daemon` (http) | Agregador MCP: conecta uma vez em cada servidor MCP e reexpõe tudo num endpoint único. |
36
+ | `neural-link` | 0.7.16 | `cli` | Dispatcher adaptativo de hooks: recebe o evento por stdin, pontua os handlers e devolve uma decisão composta. |
37
+ | `vxk` | 0.23.0 | `cli` | Explicações visuais animadas e narradas como servidor MCP (stdio): devolve UM HTML autocontido e offline, ou o vídeo MP4 (preset, aplicativo real, Motion) e o PDF. Um daemon único por máquina atende os agentes. A narração vem do `vox-engine`. |
33
38
  | `cerne` | 0.4.14 | `daemon`, **self-managed** | Leitura ativa de vídeo (captura, quadros, transcrição, tradução) exposta via MCP. Instalador Inno por usuário, Windows. |
34
39
  | `skill-engine` | 0.2.1 | `cli` | Gerencia skills de agentes fora de qualquer IDE: busca repositórios em quarentena, varre com regras determinísticas e só ativa o que sai limpo. |
35
- | `client-harness` | 0.1.5 | `cli` | Daemon local que faz o Claude Code e o Codex respeitarem os padrões de cada cliente: confere cada ação do agente contra as regras da pasta e recusa antes de gravar, com o motivo. |
40
+ | `client-harness` | 0.1.8 | `cli` | Daemon local que faz o Claude Code e o Codex respeitarem os padrões de cada cliente: confere cada ação do agente contra as regras da pasta e recusa antes de gravar, com o motivo. |
36
41
 
37
42
  **Todos** são publicados aqui, assinados com Ed25519 e verificados antes de instalar.
38
43
 
@@ -59,7 +64,10 @@ Sem clonar o repositório, com Node 18 ou mais:
59
64
 
60
65
  ```sh
61
66
  npx engine-kit ensure vxk # instala ou atualiza; reusa se já estiver em dia
62
- npx engine-kit ensure vxk --version 0.21.0 # pin de versão
67
+ npx engine-kit ensure vxk --version 0.23.0 # versão exata (downgrade permitido)
68
+ npx engine-kit pin vxk # congela o vxk na versão instalada
69
+ npx engine-kit pin vxk --version 0.22.0 # instala essa versão (assinatura conferida) e congela
70
+ npx engine-kit unpin vxk # o vxk volta a receber atualizações
63
71
  ```
64
72
 
65
73
  O `npx` baixa o kit do npm e o kit baixa o motor da release, confere o **sha256**, a **assinatura Ed25519**
@@ -74,6 +82,12 @@ mesmo comando nela (o `npx` sem versão reusaria a da cache); o motor instalado
74
82
  registry, com o aviso `motor atualizado: <motor> vA → vB`. `ENGINE_KIT_NO_AUTO_UPDATE=1` desliga as duas trocas e
75
83
  deixa só o aviso de versão nova disponível. `--version` explícito sempre vale.
76
84
 
85
+ Pin por motor: `engine-kit pin <motor>` grava a versão fixada só desse motor no `trust.json` da máquina. A
86
+ atualização automática (o `ensure`, o `doctor --fix` e o `ensureEngine` dos consumidores) pula os motores fixados e
87
+ continua atualizando os outros. Um `ensure --version` diferente da versão fixada é recusado com a saída (trocar o pin
88
+ ou `unpin`); um consumidor que exige versão maior que a fixada recebe `available:false` explicando o `unpin`. Motores
89
+ `self-managed` não são fixados pelo kit.
90
+
77
91
  Publicar o kit: tag `engine-kit-v<versão do package.json>` criada na `main`; o workflow `.github/workflows/publish.yml`
78
92
  roda a suíte e publica por OIDC (sem token guardado). Conferir a proveniência: `npm audit signatures` num projeto
79
93
  que dependa do `engine-kit`, ou o selo "Provenance" na página do pacote.
@@ -85,7 +99,8 @@ node kit/doctor.mjs # diagnostica: o que reusar, atualizar ou baixar
85
99
  node kit/doctor.mjs --fix # aplica
86
100
  ```
87
101
 
88
- Desatualizado → atualiza. Ausente → baixa. **Atualizado → reusa** — e o diagnóstico não baixa nada,
102
+ Desatualizado → atualiza. Ausente → baixa. **Atualizado → reusa**. Fixado (`engine-kit pin`) → `FIXADO`, fica na
103
+ versão fixada; instalado diferente do pin → `FORA DO PIN`, e o `--fix` instala a versão fixada. O diagnóstico não baixa nada,
89
104
  porque reusar é o caminho comum e precisa ser barato. Antes de trocar um daemon vivo, o doctor pede
90
105
  o `shutdownPath` que o registry declara: no Windows, arquivo em uso não pode ser sobrescrito.
91
106
 
@@ -170,6 +185,16 @@ dele não exige reassinar nada):
170
185
  **Uma chave por motor**, não uma chave do publisher: um vazamento obriga a republicar aquele
171
186
  motor, não a casa inteira.
172
187
 
188
+ Backup da pasta de chaves, cifrado com uma frase-senha (scrypt + AES-256-GCM) e gravado igual em duas mídias offline:
189
+
190
+ ```sh
191
+ node tools/backup-keys.mjs backup --to E:\ --to F:\ # pede a frase-senha sem eco; relê e decifra as duas cópias
192
+ node tools/backup-keys.mjs check E:\engine-signing-<data>.ekb # decifra e compara com ~/.engine-signing
193
+ node tools/backup-keys.mjs restore E:\engine-signing-<data>.ekb --to <pasta nova>
194
+ ```
195
+
196
+ A frase-senha nunca vai em argumento, variável de ambiente ou log (`--passphrase-stdin` lê da entrada padrão).
197
+
173
198
  Política do `provision`, sem meio-termo silencioso:
174
199
 
175
200
  | Situação no registry | O que acontece |
@@ -192,7 +217,9 @@ assim:
192
217
  | **Pin do consumidor** | `ensureEngine(nome, { trustedKeys: { "<motor>": "<hex>" } })`; divergência = **ABORT** já no 1º contato | não depende do registry em momento nenhum (padrão do `vox-engine`) |
193
218
  | **Chave no pacote** (`npx engine-kit ensure`) | o comando fixa a chave do `manifest.json` embarcado no pacote do npm | registry comprometido **antes** do 1º contato: trocar a chave exige publicar um engine-kit novo, e a publicação deixa proveniência pública |
194
219
 
195
- Rotação legítima é **ato explícito**: apagar a entrada em `~/.engine-kit/trust.json`.
220
+ Rotação legítima é **ato explícito**: apagar a entrada em `~/.engine-kit/trust.json`. Um `trust.json` que existe e
221
+ não se lê falha fechado: `ensure`, `pin`, `unpin`, `ensureEngine` e o doctor recusam explicando e não regravam o arquivo,
222
+ para não apagar as chaves de primeiro contato e os pins dos outros motores.
196
223
 
197
224
  **O que isto NÃO cobre, dito na cara:** comprometimento **anterior** ao primeiro contato (o TOFU
198
225
  grava a chave do atacante), downgrade/replay e expiração. Fechar isso exige transparência pública
@@ -210,8 +237,10 @@ kit/
210
237
  resolve.mjs # descobre o motor no registry (cache 6h, degrada p/ cache obsoleto)
211
238
  provision.mjs # baixa/atualiza: SHA256 + assinatura fail-closed, lock e swap atômico
212
239
  lifecycle.mjs # garante o processo vivo (daemon) ou entrega o binário (cli)
213
- cli.mjs # comando `engine-kit` (bin do pacote): `npx engine-kit ensure <motor>`
214
- ensure.mjs # ensure: resolve → provision com a chave do manifest embarcado no pacote
240
+ trust.mjs # trust.json por motor: chave do 1º contato, maior versão vista (anti-rollback) e o pin
241
+ cli.mjs # comando `engine-kit` (bin do pacote): ensure, pin e unpin
242
+ ensure.mjs # ensure, pin e unpin: resolve → provision com a chave do manifest embarcado no pacote
243
+ doctor.mjs # concilia a máquina inteira (OK, DESATUALIZADO, AUSENTE, FIXADO...); --fix aplica
215
244
  update.mjs # o kit se atualiza (relança na última versão do npm); ENGINE_KIT_NO_AUTO_UPDATE=1 desliga
216
245
  signature.mjs # contrato ed25519-sha256-raw — assinar e verificar na MESMA fonte
217
246
  keystore.mjs # onde moram as chaves privadas (~/.engine-signing)
@@ -224,7 +253,7 @@ kit/
224
253
  consumers/
225
254
  mcp-gateway.mjs # adaptador pronto: handshake + token + shutdown do agregador MCP
226
255
  site/ # vitrine de produto dos motores (estática); processo em docs/PAGES.md
227
- tools/ # pages-branch.mjs: publica site/ na gh-pages
256
+ tools/ # pages-branch.mjs: publica site/ na gh-pages; publish-manifest.mjs: manifest e README na main
228
257
  tests/
229
258
  smoke.mjs # contratos do kit: node tests/smoke.mjs
230
259
  test-*.mjs # contratos hermeticos (assinatura, trust, tar, apps, cache, doctor, gateway)
@@ -243,6 +272,13 @@ node tests/e2e-install.mjs # baixa e sobe o mcp-gateway num HOME i
243
272
  node tests/e2e-install.mjs --local # idem, mas usando o manifest.json que você vai publicar
244
273
  ```
245
274
 
275
+ Para ver quais repositórios e workflows consomem os minutos do GitHub Actions da conta logada no `gh` (só leitura; os maiores
276
+ consumidores vêm marcados, assim como jobs que nem iniciaram por cobrança):
277
+
278
+ ```sh
279
+ node tools/actions-minutes.mjs [--month AAAA-MM] [--days 3] [--top 5] [--json]
280
+ ```
281
+
246
282
  ## Publicar um motor
247
283
 
248
284
  Veja o passo a passo completo em [`CONTRATO-MOTOR.md`](./CONTRATO-MOTOR.md). Em resumo:
@@ -256,8 +292,10 @@ pwsh kit/publish-base.ps1 -Engine <motor> -Version <x.y.z> -Asset <artefato.tgz>
256
292
 
257
293
  O `publish-base.ps1` confere a identidade do `gh` (o app Copilot injeta o token de uma conta de
258
294
  trabalho nos processos filhos e o `gh` publica pela conta errada), assina, publica no registry,
259
- atualiza o `manifest.json` **parseado** e então **prova**: baixa a release publicada e valida
260
- `sha256` + assinatura pelo caminho de um consumidor.
295
+ atualiza o `manifest.json` **parseado** e a linha do README num worktree da `main`, **prova** (baixa a
296
+ release publicada e valida `sha256` + assinatura pelo caminho de um consumidor) e só então envia o commit
297
+ para a `main` e mescla na `develop`, sem passo manual. Se a mescla conflitar só no `manifest.json` ou no
298
+ README (linhas vizinhas mudadas nos dois ramos), o arquivo da `develop` recebe a mesma mudança que a `main` recebeu.
261
299
 
262
300
  O artefato deve ser **self-contained** (traz o próprio `node_modules` trimado): o motor precisa
263
301
  subir numa máquina limpa, sem depender do consumidor.
@@ -271,7 +309,8 @@ plataforma, e a release mora aqui, no registry público, com download **sem logi
271
309
 
272
310
  | App | Versão | Plataformas | O que faz |
273
311
  |---|---|---|---|
274
- | `agent-launcher` | 1.3.5 | `win32-x64` | Abre Claude Code, Codex, Copilot CLI, VS Code e o Copilot app nos seus projetos por qualquer host de modelos. Leva só o launcher; cada ferramenta se instala pelo cartão dela. |
312
+ | `agent-launcher` | 1.3.6 | `win32-x64` | Abre Claude Code, Codex, Copilot CLI, VS Code e o Copilot app nos seus projetos por qualquer host de modelos. Leva só o launcher; cada ferramenta se instala pelo cartão dela. |
313
+ | `smart-design` | 0.2.1 | `win32-x64` | Serviço local que o agente de código chama por MCP para desenhar telas com sistema de design, renderizá-las e conferi-las em pixels antes de entregar. |
275
314
 
276
315
  Um app só entra nesta tabela e em `manifest.apps` **depois** de a release pública existir e passar
277
316
  em `node kit/verify-release.mjs --app <nome>` (download anônimo de todos os assets, `.sha256` e
@@ -19,6 +19,7 @@ import { join, dirname, resolve as resolvePath, sep } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { homedir } from "node:os";
21
21
  import { ensureEngine } from "../kit/index.mjs";
22
+ import { waitExit } from "../kit/lifecycle.mjs";
22
23
 
23
24
  const DEFAULT_HOME = join(homedir(), ".mcp-gateway");
24
25
  const DEFAULT_PORT = 7337;
@@ -32,8 +33,6 @@ function resolveHome() {
32
33
  return process.env.MCP_GATEWAY_DATA_DIR || DEFAULT_HOME;
33
34
  }
34
35
 
35
- const HOME = resolveHome();
36
-
37
36
  /**
38
37
  * Descritor do motor `mcp-gateway` lido do manifest.json do registry. Nunca lança: manifest
39
38
  * ilegível ou motor ausente do registro → null (quem chama sinaliza, não inventa um default).
@@ -50,7 +49,7 @@ function loadEngineDescriptor() {
50
49
  /** Lê a porta configurada pelo usuário (config.json), caindo no default do motor. */
51
50
  function configuredPort() {
52
51
  try {
53
- return JSON.parse(readFileSync(join(HOME, "config.json"), "utf8")).port ?? DEFAULT_PORT;
52
+ return JSON.parse(readFileSync(join(resolveHome(), "config.json"), "utf8")).port ?? DEFAULT_PORT;
54
53
  } catch {
55
54
  return DEFAULT_PORT;
56
55
  }
@@ -108,13 +107,30 @@ async function healthCheck() {
108
107
  }
109
108
  }
110
109
 
111
- /** Derruba o daemon antes de sobrescrever os arquivos (Windows trava arquivo em uso). */
112
- async function onBeforeReplace() {
110
+ /**
111
+ * Derruba o daemon antes de sobrescrever os arquivos (Windows trava arquivo em uso). Primeiro pede
112
+ * `POST /admin/drain` com `{reason, waitMs}`: o daemon espera as chamadas em curso (até `waitMs`),
113
+ * responde e sai com 0. Só se o gateway não drenar (sem a rota, token recusado, sem resposta em
114
+ * `drainMs` + 15 s ou ainda vivo `drainMs` depois de responder) cai no SIGTERM, que no Windows mata
115
+ * na hora.
116
+ */
117
+ export async function onBeforeReplace({ home = resolveHome(), drainMs = 60000, reason = "engine-kit: atualização do mcp-gateway", log = () => {} } = {}) {
118
+ let rt;
119
+ try { rt = JSON.parse(readFileSync(join(home, "runtime.json"), "utf8")); } catch { return; }
120
+ if (!rt?.pid) { return; }
121
+ const token = readToken({ home, log });
113
122
  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 */ }
123
+ const res = await fetch(`http://127.0.0.1:${rt.port}/admin/drain`, {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
126
+ body: JSON.stringify({ reason, waitMs: drainMs }),
127
+ signal: AbortSignal.timeout(drainMs + 15000),
128
+ });
129
+ if (res.ok && await waitExit(rt.pid, drainMs)) { return; }
130
+ log(`mcp-gateway: drain HTTP ${res.status}, ainda vivo; sinalizando`);
131
+ } catch (e) { log(`mcp-gateway: drain falhou (${e.message}); sinalizando`); }
132
+ try { process.kill(rt.pid, "SIGTERM"); } catch { return; }
133
+ await new Promise((r) => setTimeout(r, 1500));
118
134
  }
119
135
 
120
136
  /**
@@ -169,14 +185,14 @@ export function readInstructions() {
169
185
  export async function ensureGateway({ log = () => {}, version, allowNetwork = true, bootTimeoutMs = 45000, registryUrl, fetcher } = {}) {
170
186
  const r = await ensureEngine("mcp-gateway", {
171
187
  healthCheck,
172
- onBeforeReplace,
188
+ onBeforeReplace: () => onBeforeReplace({ log }),
173
189
  log,
174
190
  version,
175
191
  allowNetwork,
176
192
  bootTimeoutMs,
177
193
  registryUrl,
178
194
  fetcher,
179
- env: { MCP_GATEWAY_DATA_DIR: HOME },
195
+ env: { MCP_GATEWAY_DATA_DIR: resolveHome() },
180
196
  });
181
197
  if (!r.available) { return r; }
182
198
 
@@ -19,7 +19,7 @@ import { pathToFileURL } from "node:url";
19
19
  import { ensureEngine } from "../kit/index.mjs";
20
20
 
21
21
  /** Pin default: última versão do neural-link confirmada publicada no manifest deste kit. */
22
- const DEFAULT_PIN = "0.7.15";
22
+ const DEFAULT_PIN = "0.7.16";
23
23
 
24
24
  /**
25
25
  * Garante o dispatcher de hooks `neural-link` instalado/atualizado, e roda o `install()` dele
package/kit/budget.mjs CHANGED
@@ -44,10 +44,19 @@ const ROOT = resolvePath(KIT, "..");
44
44
  // 2026-11-09 tooling 31000 → 34000: diagnóstico de vida (pid, porta, idade do runtime) e do ponteiro
45
45
  // `cli.json` no doctor (diagnose.mjs), +2.7 KB. Só leitura, rodado na mão de quem diagnostica; o caminho do
46
46
  // `import "engine-kit"` não mudou.
47
+ // 2026-11-09 tooling 34000 → 37000 (loc 950 → 1050): pin por motor aprovado pelo dono — `engine-kit pin/unpin`
48
+ // (ensure.mjs, cli.mjs) e o estado FIXADO do doctor, +3.6 KB. No runtime o pin entrou sem subir o teto
49
+ // (dedup em provision/lifecycle pagou os bytes).
50
+ // 2026-11-11 adapter 5000 → 5500: o adaptador do mcp-gateway passou a pedir `POST /admin/drain` (bearer) antes
51
+ // de sinalizar o daemon, +0.6 KB. É o contrato de desligamento do motor, que só o adaptador do motor conhece:
52
+ // no Windows o SIGTERM mata na hora e corta as chamadas em curso. O caminho do `import "engine-kit"` não mudou.
53
+ // 2026-11-11 tooling 37000 → 37500: o `ensure` passou a avisar quando instala a partir do descritor em cache
54
+ // vencido (registry fora do ar) e quando o ponteiro `cli.json` do motor está quebrado, +0.3 KB (cli.mjs,
55
+ // ensure.mjs, reusando `pointerOf` do diagnose). O caminho do `import "engine-kit"` não mudou.
47
56
  const BUDGETS = {
48
57
  runtime: { codeBytes: 27000, baseline: 24519, loc: 900 },
49
- tooling: { codeBytes: 34000, baseline: 16420, loc: 950 },
50
- adapter: { codeBytes: 5000, baseline: 1859, loc: 150 },
58
+ tooling: { codeBytes: 37500, baseline: 16420, loc: 1050 },
59
+ adapter: { codeBytes: 5500, baseline: 1859, loc: 150 },
51
60
  };
52
61
 
53
62
  // `split("\n")` num arquivo terminado em newline devolve um elemento vazio no fim — linha
package/kit/cli.mjs CHANGED
@@ -4,14 +4,18 @@
4
4
  // npx engine-kit ensure <motor> # instala/atualiza verificando sha256 + Ed25519
5
5
  // npx engine-kit ensure <motor> --version <x.y.z> # pin de versão
6
6
  // npx engine-kit ensure <motor> --registry-url <url>
7
+ // npx engine-kit pin <motor> [--version <x.y.z>] # congela UM motor; a atualização automática pula só ele
8
+ // npx engine-kit unpin <motor> # o motor volta a receber atualizações
7
9
  //
8
10
  // Por padrão o kit se atualiza (roda de novo como a última versão do npm) e atualiza o motor instalado.
9
11
  // ENGINE_KIT_NO_AUTO_UPDATE=1 desliga os dois e deixa só o aviso de versão nova (ver update.mjs).
10
12
  // Exit code: 0 = motor instalado (ou reusado). 1 = recusou/falhou, com o motivo na saída de erro.
11
- import { ensureCommand } from "./ensure.mjs";
13
+ import { ensureCommand, pinCommand, unpinCommand } from "./ensure.mjs";
12
14
  import { selfUpdate, autoUpdateEnabled } from "./update.mjs";
13
15
 
14
16
  const USAGE = "uso: engine-kit ensure <motor> [--version <x.y.z>] [--registry-url <url>]\n" +
17
+ " engine-kit pin <motor> [--version <x.y.z>] fixa a versão instalada (ou instala e fixa x.y.z)\n" +
18
+ " engine-kit unpin <motor> solta o motor para voltar a atualizar\n" +
15
19
  "ENGINE_KIT_NO_AUTO_UPDATE=1 desliga a atualização automática do kit e dos motores (fica só o aviso).";
16
20
 
17
21
  const args = process.argv.slice(2);
@@ -20,8 +24,8 @@ const [cmd, name] = args;
20
24
 
21
25
  if (cmd === "--help" || cmd === "-h") {
22
26
  console.log(USAGE);
23
- } else if (cmd !== "ensure" || !name || name.startsWith("-")) {
24
- console.error(cmd && cmd !== "ensure" ? `comando desconhecido: ${cmd}\n${USAGE}` : USAGE);
27
+ } else if (!["ensure", "pin", "unpin"].includes(cmd) || !name || name.startsWith("-")) {
28
+ console.error(cmd && !["ensure", "pin", "unpin"].includes(cmd) ? `comando desconhecido: ${cmd}\n${USAGE}` : USAGE);
25
29
  process.exitCode = 1;
26
30
  } else if (args.includes("--version") && !/^\d+\.\d+\.\d+$/.test(flag("--version") ?? "")) {
27
31
  console.error(`--version precisa de x.y.z (recebi: ${flag("--version") ?? "nada"})`);
@@ -31,6 +35,14 @@ if (cmd === "--help" || cmd === "-h") {
31
35
  const u = await selfUpdate(args);
32
36
  if (u.action === "relaunched") {
33
37
  process.exitCode = u.status;
38
+ } else if (cmd === "unpin") {
39
+ const r = unpinCommand(name);
40
+ if (!r.ok) { console.error(`FALHOU ${name}: ${r.reason}`); process.exitCode = 1; }
41
+ else { console.log(r.was ? `OK ${name} solto (estava fixado em v${r.was}); o próximo ensure traz a versão do registry` : `OK ${name} não estava fixado`); }
42
+ } else if (cmd === "pin") {
43
+ const r = await pinCommand(name, { version: flag("--version"), registryUrl: flag("--registry-url"), log: (m) => console.log(m) });
44
+ if (!r.ok) { console.error(`FALHOU ${name}: ${r.reason}`); process.exitCode = 1; }
45
+ else { console.log(`OK ${name} fixado em v${r.pinned}; a atualização automática pula este motor (engine-kit unpin ${name} solta)`); }
34
46
  } else {
35
47
  const autoUpdate = autoUpdateEnabled();
36
48
  const r = await ensureCommand(name, { version: flag("--version"), registryUrl: flag("--registry-url"), autoUpdate, log: (m) => console.log(m) });
@@ -38,6 +50,9 @@ if (cmd === "--help" || cmd === "-h") {
38
50
  const how = r.reused ? "já instalado, reusado" : r.signed ? "assinatura Ed25519 conferida" : "SEM assinatura no registry";
39
51
  if (r.previous) { console.log(`motor atualizado: ${name} v${r.previous} → v${r.version}`); }
40
52
  console.log(`OK ${name} v${r.version} (${how}) em ${r.entryPath}`);
53
+ if (r.staleCache) { console.log(`aviso: registry inacessível; ${name} veio do descritor em cache vencido`); }
54
+ if (r.pointer && !["OK", "AUSENTE"].includes(r.pointer.state)) { console.log(`aviso: ponteiro cli.json ${r.pointer.state}: ${r.pointer.detail}`); }
55
+ if (r.pinned) { console.log(`fixado: ${name} fica em v${r.pinned} (engine-kit unpin ${name} volta a atualizar)`); }
41
56
  if (r.available) { console.log(`aviso: ${name} v${r.available} disponível; ENGINE_KIT_NO_AUTO_UPDATE ligado — rode sem ele para atualizar`); }
42
57
  if (r.kind === "daemon") { console.log("daemon: quem sobe o motor é o consumidor (ensureEngine com healthCheck)."); }
43
58
  } else {
package/kit/doctor.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // node kit/doctor.mjs --engine <nome> # um motor só
8
8
  //
9
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
10
+ // atualizado → REUSA**; motor fixado (`engine-kit pin`) fica na versão fixada. Reusar é o caminho comum e precisa ser barato — por isso o diagnóstico
11
11
  // não baixa nada, e o `--fix` delega ao `provision` (SHA256 + assinatura fail-closed, lock e swap
12
12
  // atômicos). O doctor não reimplementa instalação: ele só decide QUEM precisa.
13
13
  //
@@ -15,9 +15,10 @@
15
15
  import { readFileSync, existsSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { resolve } from "./resolve.mjs";
18
- import { provision, satisfiesPin } from "./provision.mjs";
18
+ import { provision, satisfiesPin, installedVersion } from "./provision.mjs";
19
19
  import { shutdown } from "./lifecycle.mjs";
20
20
  import { lifeOf, pointerOf } from "./diagnose.mjs";
21
+ import { pinOf, readTrust } from "./trust.mjs";
21
22
 
22
23
  const args = process.argv.slice(2);
23
24
  const useLocal = args.includes("--local");
@@ -32,13 +33,7 @@ let manifest;
32
33
  try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")); }
33
34
  catch (e) { console.error(`FALHOU: manifest local ilegível: ${e.message}`); process.exit(1); }
34
35
 
35
- /** Versão instalada no disco, lida do artefato — a mesma fonte que o provision usa para o pin. */
36
- function installedVersion(engine) {
37
- const binDir = join(engine.homeDir, engine.install.extractTo ?? "bin");
38
- try { return JSON.parse(readFileSync(join(binDir, "package.json"), "utf8")).version ?? null; }
39
- catch { return null; }
40
- }
41
-
36
+ try { readTrust(); } catch (e) { console.error(`FALHOU: ${e.message}`); process.exit(1); }
42
37
  const names = (manifest.engines ?? []).map((e) => e.name).filter((n) => !only || n === only);
43
38
  if (only && names.length === 0) {
44
39
  console.error(`FALHOU: motor "${only}" não existe no manifest`);
@@ -54,13 +49,16 @@ const rows = [];
54
49
  for (const name of names) {
55
50
  // `refresh`: diagnóstico com cache velho mente — dizer "atualizado" olhando um descritor
56
51
  // vencido é pior do que não diagnosticar. Aqui a requisição de rede vale a pena.
57
- const r = await resolve(name, { allowNetwork: !useLocal, manifest: useLocal ? manifest : undefined, refresh: !useLocal, registryUrl });
52
+ const opts = { allowNetwork: !useLocal, manifest: useLocal ? manifest : undefined, refresh: !useLocal, registryUrl };
53
+ let r = await resolve(name, opts);
54
+ const latest = r.engine?.version, pin = r.ok && r.engine.status !== "self-managed" && pinOf(name);
55
+ if (pin && pin !== latest) { r = await resolve(name, { ...opts, version: pin }); }
58
56
  if (!r.ok) { rows.push({ name, state: "ERRO", detail: r.reason }); continue; }
59
57
 
60
58
  const engine = r.engine;
61
59
  const entry = join(engine.homeDir, engine.install.entry);
62
60
  const present = existsSync(entry);
63
- const inst = present ? installedVersion(engine) : null;
61
+ const inst = present ? installedVersion(join(engine.homeDir, engine.install.extractTo ?? "bin")) : null;
64
62
 
65
63
  let state, detail;
66
64
  if (engine.status === "self-managed") {
@@ -68,23 +66,27 @@ for (const name of names) {
68
66
  // pelo caminho do manifest daria "ausente" para um motor que está instalado e funcionando,
69
67
  // só que em outro lugar. Reportar e não agir é a resposta honesta.
70
68
  state = "SELF-MANAGED"; detail = `v${engine.version} no registry — atualiza pelo próprio updater`;
69
+ } else if (pin && inst === pin) {
70
+ state = "FIXADO"; detail = `v${pin} fixado (registry v${latest}) → engine-kit unpin ${name} volta a atualizar`;
71
71
  } else if (!present) {
72
72
  state = "AUSENTE"; detail = `baixar v${engine.version}`;
73
73
  } else if (!inst) {
74
74
  // Estado desconhecido: o artefato está lá mas a versão não é legível. Não invente que está ok.
75
75
  state = "ILEGÍVEL"; detail = "instalado, mas package.json ausente/corrompido → reprovisionar";
76
+ } else if (pin) {
77
+ state = "FORA DO PIN"; detail = `v${inst} → v${pin} fixada`;
76
78
  } else if (satisfiesPin(inst, engine.version)) {
77
79
  state = "OK"; detail = `v${inst} satisfaz o pin v${engine.version} → reusar`;
78
80
  } else {
79
81
  state = "DESATUALIZADO"; detail = `v${inst} → v${engine.version}`;
80
82
  }
81
- rows.push({ name, state, detail, engine, needsWork: state !== "OK" && state !== "SELF-MANAGED" });
83
+ rows.push({ name, state, detail, engine, pin, needsWork: !["OK", "SELF-MANAGED", "FIXADO"].includes(state) });
82
84
  }
83
85
 
84
86
  const pad = (s, n) => String(s).padEnd(n);
85
87
  const w = Math.max(12, ...rows.map((r) => r.name.length));
86
88
  for (const r of rows) {
87
- const mark = (r.state === "OK" || r.state === "SELF-MANAGED") ? " OK " : r.state === "ERRO" ? " ERRO " : " !! ";
89
+ const mark = ["OK", "SELF-MANAGED", "FIXADO"].includes(r.state) ? " OK " : r.state === "ERRO" ? " ERRO " : " !! ";
88
90
  console.log(`${mark} ${pad(r.name, w)} ${pad(r.state, 14)} ${r.detail}`);
89
91
  if (r.engine && r.engine.status !== "self-managed") {
90
92
  const life = lifeOf(r.engine), ptr = pointerOf(r.engine);
@@ -106,6 +108,7 @@ let failures = 0;
106
108
  for (const r of pending) {
107
109
  console.log(`\n${r.name}: ${r.detail}`);
108
110
  const p = await provision(r.engine, {
111
+ exact: Boolean(r.pin),
109
112
  log: (m) => console.log(` ${m}`),
110
113
  // No Windows um arquivo EM USO não pode ser sobrescrito: sem derrubar o daemon antes, o
111
114
  // update de um motor vivo falha com EPERM no meio do swap. O kit não adivinha como parar
package/kit/ensure.mjs CHANGED
@@ -14,8 +14,11 @@ import { join } from "node:path";
14
14
  import { resolve } from "./resolve.mjs";
15
15
  import { provision, installedVersion, satisfiesPin } from "./provision.mjs";
16
16
  import { shutdown } from "./lifecycle.mjs";
17
+ import { pinOf, readTrust, saveTrust } from "./trust.mjs";
18
+ import { pointerOf } from "./diagnose.mjs";
17
19
 
18
20
  const BUNDLED_MANIFEST = new URL("../manifest.json", import.meta.url);
21
+ const installedOf = (e) => existsSync(join(e.homeDir, e.install.entry)) ? installedVersion(join(e.homeDir, e.install.extractTo ?? "bin")) : null;
19
22
 
20
23
  /** Manifest que viaja dentro do pacote (lança se ilegível; `ensureCommand` transforma em reason). */
21
24
  export function bundledManifest() {
@@ -32,8 +35,10 @@ export function bundledManifest() {
32
35
  * @param {Function=} opts.fetcher download injetável (testes).
33
36
  * @param {boolean=} opts.autoUpdate false = motor já instalado fica na versão dele e o retorno avisa a nova
34
37
  * (`available`). Default true. Um `version` explícito sempre vale: foi pedido.
38
+ * Motor fixado (`engine-kit pin`) fica sempre na versão fixada; `version` diferente dela é recusado.
35
39
  * @returns {Promise<{ok:true, entryPath:string, version:string, kind:string, reused?:boolean, signed?:boolean,
36
- * previous?:string, available?:string} | {ok:false, reason:string}>} `previous` = versão substituída.
40
+ * previous?:string, available?:string, pinned?:string} | {ok:false, reason:string}>} `previous` = versão
41
+ * substituída; `pinned` = versão em que o motor está fixado.
37
42
  */
38
43
  export async function ensureCommand(name, { version, registryUrl, bundled, manifest, fetcher, autoUpdate = true, log = () => {} } = {}) {
39
44
  let local;
@@ -49,21 +54,26 @@ export async function ensureCommand(name, { version, registryUrl, bundled, manif
49
54
  return { ok: false, reason: `${name}: o manifest embarcado não traz publicKey → sem chave do primeiro contato não instalo (ABORT)` };
50
55
  }
51
56
 
57
+ let pin;
58
+ try { pin = pinOf(name); } catch (e) { return { ok: false, reason: e.message }; }
59
+ if (pin && version && version !== pin) {
60
+ return { ok: false, reason: `${name} está fixado em v${pin} → engine-kit pin ${name} --version ${version} troca a versão fixada; engine-kit unpin ${name} solta` };
61
+ }
52
62
  // refresh: instalar quer a verdade do registry, não um descritor de cache.
53
- const r = await resolve(name, { version, registryUrl, refresh: true, ...(manifest ? { manifest } : {}) });
63
+ const r = await resolve(name, { version: version ?? pin, registryUrl, refresh: true, ...(manifest ? { manifest } : {}) });
54
64
  if (!r.ok) { return { ok: false, reason: r.reason }; }
55
65
 
56
66
  const engine = r.engine;
57
- const entryPath = join(engine.homeDir, engine.install.entry);
58
- const previous = existsSync(entryPath) ? installedVersion(join(engine.homeDir, engine.install.extractTo ?? "bin")) : null;
59
- if (!autoUpdate && !version && previous && !satisfiesPin(previous, engine.version)) {
60
- return { ok: true, entryPath, version: previous, kind: engine.kind, reused: true, available: engine.version };
67
+ const previous = installedOf(engine);
68
+ if (!autoUpdate && !version && !pin && previous && !satisfiesPin(previous, engine.version)) {
69
+ return { ok: true, entryPath: join(engine.homeDir, engine.install.entry), version: previous, kind: engine.kind, reused: true, available: engine.version };
61
70
  }
62
71
 
63
72
  const p = await provision(r.engine, {
64
73
  log,
65
74
  trustedKeys: { [name]: publicKey },
66
75
  allowDowngrade: Boolean(version),
76
+ exact: Boolean(pin),
67
77
  ...(fetcher ? { fetcher } : {}),
68
78
  // Windows: arquivo em uso não se sobrescreve. Derruba o daemon pelo shutdownPath que o registry declara.
69
79
  onBeforeReplace: async () => {
@@ -79,5 +89,57 @@ export async function ensureCommand(name, { version, registryUrl, bundled, manif
79
89
  return {
80
90
  ok: true, entryPath: p.entryPath, version: p.version, kind: engine.kind, reused: p.reused === true, signed: p.signed,
81
91
  ...(p.installed && previous && previous !== p.version ? { previous } : {}),
92
+ ...(pin ? { pinned: pin } : {}),
93
+ ...(engine.staleCache ? { staleCache: true } : {}),
94
+ ...(engine.kind === "cli" ? { pointer: pointerOf(engine) } : {}),
82
95
  };
83
96
  }
97
+
98
+ /** Fixa (`version`) ou solta (null) um motor em trust.json; os outros motores não mudam. `was` = pin anterior. */
99
+ function setPin(name, version) {
100
+ try {
101
+ const store = readTrust(), entry = { ...store[name] }, was = entry.pin ?? null;
102
+ if (!version && !was) { return { ok: true, was }; }
103
+ if (version) { entry.pin = version; } else { delete entry.pin; }
104
+ saveTrust({ ...store, [name]: entry });
105
+ return { ok: true, was };
106
+ } catch (e) {
107
+ return { ok: false, reason: e.message };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * `engine-kit pin <motor> [--version x.y.z]`: congela UM motor. Sem `version`, fixa a versão instalada; com ela,
113
+ * instala essa versão (sha256 + Ed25519, downgrade permitido) e fixa. A autoatualização pula só este motor.
114
+ * Nunca lança. Opções de teste iguais às de `ensureCommand`.
115
+ * @returns {Promise<{ok:true, version:string, pinned:string} | {ok:false, reason:string}>}
116
+ */
117
+ export async function pinCommand(name, { version, ...opts } = {}) {
118
+ if (version) {
119
+ const s = setPin(name, version), before = s.was;
120
+ if (!s.ok) { return s; }
121
+ const e = await ensureCommand(name, opts);
122
+ if (!e.ok) {
123
+ const back = setPin(name, before);
124
+ return { ok: false, reason: `${e.reason} → pin não aplicado${back.ok ? (before ? ` (pin anterior v${before} restaurado)` : "") : `; ${back.reason}`}` };
125
+ }
126
+ return e;
127
+ }
128
+ const r = await resolve(name, { registryUrl: opts.registryUrl, ...(opts.manifest ? { manifest: opts.manifest } : {}) });
129
+ if (!r.ok) { return { ok: false, reason: r.reason }; }
130
+ if (r.engine.status === "self-managed") {
131
+ return { ok: false, reason: `${name} é self-managed: atualiza pelo próprio updater, o engine-kit não fixa a versão dele` };
132
+ }
133
+ const inst = installedOf(r.engine);
134
+ if (!inst) {
135
+ return { ok: false, reason: `${name} não está instalado (ou a versão é ilegível) → engine-kit pin ${name} --version <x.y.z> instala e fixa` };
136
+ }
137
+ const s = setPin(name, inst);
138
+ return s.ok ? { ok: true, version: inst, pinned: inst } : s;
139
+ }
140
+
141
+ /** `engine-kit unpin <motor>`: o motor volta a receber atualizações. Nunca lança. */
142
+ export function unpinCommand(name) {
143
+ const s = setPin(name, null);
144
+ return s.ok ? { ok: true, was: s.was } : s;
145
+ }
package/kit/index.mjs CHANGED
@@ -20,15 +20,17 @@ export {
20
20
  export { SIGNING_DIR, keyPathFor } from "./keystore.mjs";
21
21
 
22
22
  import { resolve } from "./resolve.mjs";
23
- import { provision } from "./provision.mjs";
23
+ import { provision, satisfiesPin } from "./provision.mjs";
24
24
  import { lifecycle } from "./lifecycle.mjs";
25
+ import { pinOf } from "./trust.mjs";
25
26
 
26
27
  /**
27
28
  * RESOLVE → PROVISION → LIFECYCLE numa chamada: o caminho que 99% dos consumidores querem.
28
29
  * @param {string} name nome do motor no registry.
29
30
  * @param {object} opts
30
31
  * @param {(runtime:object)=>Promise<object|null>} opts.healthCheck validação de saúde/protocolo (obrigatória).
31
- * @param {string=} opts.version pin de versão (default: a do registry).
32
+ * @param {string=} opts.version versão mínima pedida pelo consumidor (default: a do registry). Motor fixado
33
+ * pelo dono (`engine-kit pin`) fica na versão fixada; se ela não serve o mínimo, `available:false`.
32
34
  * @param {boolean=} opts.allowNetwork permite baixar/atualizar (default true).
33
35
  * @param {Record<string,string>=} opts.trustedKeys chaves públicas FIXADAS pelo consumidor
34
36
  * (`{ "<motor>": "<hex>" }`). Divergência com o registry = ABORT. Sem isso vale TOFU:
@@ -41,10 +43,15 @@ import { lifecycle } from "./lifecycle.mjs";
41
43
  export async function ensureEngine(name, opts = {}) {
42
44
  const { healthCheck, version, allowNetwork = true, registryUrl, log = () => {}, bootTimeoutMs, onBeforeReplace, env, trustedKeys, refresh = false, fetcher } = opts;
43
45
 
44
- const r = await resolve(name, { version, allowNetwork, registryUrl, refresh });
46
+ let pin;
47
+ try { pin = pinOf(name); } catch (e) { return { available: false, reason: e.message }; }
48
+ if (pin && version && !satisfiesPin(pin, version)) {
49
+ return { available: false, reason: `${name} fixado em v${pin}; o consumidor pede v${version} → engine-kit unpin ${name}` };
50
+ }
51
+ const r = await resolve(name, { version: pin ?? version, allowNetwork, registryUrl, refresh });
45
52
  if (!r.ok) { return { available: false, reason: r.reason }; }
46
53
 
47
- const p = await provision(r.engine, { log, allowNetwork, onBeforeReplace, trustedKeys, ...(fetcher ? { fetcher } : {}) });
54
+ const p = await provision(r.engine, { log, allowNetwork, onBeforeReplace, trustedKeys, exact: Boolean(pin), ...(fetcher ? { fetcher } : {}) });
48
55
  if (!p.ok) { return { available: false, reason: p.reason }; }
49
56
 
50
57
  const l = await lifecycle(r.engine, p.entryPath, { healthCheck, log, bootTimeoutMs, env });
package/kit/lifecycle.mjs CHANGED
@@ -24,24 +24,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
24
  */
25
25
  const DEFAULT_STALE_MS = 120000;
26
26
 
27
+ const runtimeFile = (engine) => join(engine.homeDir, engine.runtime?.runtimeFile ?? "runtime.json");
28
+
27
29
  /** Lê o runtime.json do motor, sem julgar idade. `null` se ausente/ilegível. */
28
30
  export function readRuntime(engine) {
29
- try {
30
- const rt = join(engine.homeDir, engine.runtime?.runtimeFile ?? "runtime.json");
31
- return JSON.parse(readFileSync(rt, "utf8"));
32
- } catch {
33
- return null;
34
- }
31
+ try { return JSON.parse(readFileSync(runtimeFile(engine), "utf8")); } catch { return null; }
35
32
  }
36
33
 
37
34
  /** Lê o runtime.json do motor SE estiver fresco (heartbeat vivo). null se ausente/obsoleto. */
38
35
  export function readFreshRuntime(engine, { staleMs } = {}) {
39
- const rt = join(engine.homeDir, engine.runtime?.runtimeFile ?? "runtime.json");
40
36
  const hb = engine.runtime?.heartbeatMs;
41
37
  const limit = staleMs ?? (hb ? hb * 3 : DEFAULT_STALE_MS);
42
38
  try {
43
- if (Date.now() - statSync(rt).mtimeMs > limit) { return null; }
44
- return JSON.parse(readFileSync(rt, "utf8"));
39
+ return Date.now() - statSync(runtimeFile(engine)).mtimeMs > limit ? null : readRuntime(engine);
45
40
  } catch {
46
41
  return null;
47
42
  }
@@ -66,7 +61,7 @@ async function findAlive(engine, healthCheck) {
66
61
  }
67
62
 
68
63
  /** Espera o processo sair (até `ms`). true = saiu. */
69
- async function waitExit(pid, ms) {
64
+ export async function waitExit(pid, ms) {
70
65
  for (const fim = Date.now() + ms; Date.now() < fim; await sleep(500)) {
71
66
  try { process.kill(pid, 0); } catch { return true; }
72
67
  }
package/kit/provision.mjs CHANGED
@@ -50,7 +50,7 @@ function extractTgz(tgz, dest) {
50
50
  try {
51
51
  execFileSync(tar, ["--version"], { stdio: "ignore", windowsHide: true });
52
52
  } catch {
53
- 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." };
53
+ return { ok: false, reason: "`tar` não encontrado no PATH: instale-o para extrair o artefato." };
54
54
  }
55
55
  try {
56
56
  execFileSync(tar, ["-xzf", tgz, "-C", dest], { stdio: "ignore", windowsHide: true });
@@ -215,7 +215,7 @@ export async function fetchArtifact(engine, { log = () => {}, destDir, downloadT
215
215
  * adivinha como derrubar um daemon: quem sabe é o descritor/consumidor).
216
216
  * @returns {Promise<{ok:true, entryPath:string, version:string, reused?:boolean, installed?:boolean} | {ok:false, reason:string}>}
217
217
  */
218
- export async function provision(engine, { log = () => {}, allowNetwork = true, onBeforeReplace, downloadTimeoutMs = 120000, fetcher = fetchBuf, trustedKeys, allowDowngrade } = {}) {
218
+ export async function provision(engine, { log = () => {}, allowNetwork = true, onBeforeReplace, downloadTimeoutMs = 120000, fetcher = fetchBuf, trustedKeys, allowDowngrade, exact } = {}) {
219
219
  const home = engine.homeDir;
220
220
  const binDir = join(home, engine.install.extractTo ?? "bin");
221
221
  const entryPath = join(home, engine.install.entry);
@@ -228,14 +228,14 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
228
228
  // numa instalação quebrada para sempre, ignorando o pin do registry.
229
229
  if (existsSync(entryPath)) {
230
230
  const inst = installedVersion(binDir);
231
- if (inst && satisfiesPin(inst, engine.version)) {
231
+ if (inst && (exact ? inst === engine.version : satisfiesPin(inst, engine.version))) {
232
232
  noteVersion(engine.name, inst);
233
233
  return { ok: true, entryPath, version: inst, reused: true };
234
234
  }
235
235
  if (!inst && engine.status !== "self-managed") {
236
236
  log(`[engine-kit] ${engine.name}: instalação em estado desconhecido (versão ilegível) → reprovisionando`);
237
237
  if (!allowNetwork) {
238
- 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` };
238
+ return { ok: false, reason: `${engine.name}: instalação em estado desconhecido (package.json ausente/corrompido em ${binDir}) e rede desabilitada: sem como reparar` };
239
239
  }
240
240
  } else if (inst) {
241
241
  log(`[engine-kit] ${engine.name}: instalada v${inst} não satisfaz o pin v${engine.version} → atualizando`);
@@ -249,7 +249,7 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
249
249
  // Tentar provisionar aqui daria erro obscuro (o artefato é um instalador, não um tarball de
250
250
  // `extractTo`); melhor recusar dizendo QUEM instala.
251
251
  if (engine.status === "self-managed") {
252
- 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)` };
252
+ return { ok: false, reason: `${engine.name} é self-managed: instala e atualiza pelo próprio updater, não pelo engine-kit` };
253
253
  }
254
254
 
255
255
  if (!allowNetwork) { return { ok: false, reason: `${engine.name} ausente/desatualizado e rede desabilitada` }; }
@@ -257,7 +257,7 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
257
257
  return { ok: false, reason: `${engine.name} ainda não publicou release (status: pending-release)` };
258
258
  }
259
259
 
260
- const rb = !allowDowngrade && rollbackReason(engine.name, engine.version);
260
+ const rb = !allowDowngrade && !exact && rollbackReason(engine.name, engine.version);
261
261
  if (rb) { return { ok: false, reason: rb }; }
262
262
 
263
263
  mkdirSync(home, { recursive: true });
@@ -270,7 +270,7 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
270
270
  if (existsSync(entryPath)) {
271
271
  const inst = installedVersion(binDir);
272
272
  // Se o vencedor terminou mas a versão continua ilegível, não invente: sinalize.
273
- if (inst) { return { ok: true, entryPath, version: inst, reused: true }; }
273
+ if (inst && (!exact || inst === engine.version)) { return { ok: true, entryPath, version: inst, reused: true }; }
274
274
  }
275
275
  await sleep(1000);
276
276
  }
@@ -278,22 +278,10 @@ export async function provision(engine, { log = () => {}, allowNetwork = true, o
278
278
  }
279
279
 
280
280
  try {
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;
285
- const part = join(home, `${engine.assetName}.part`);
286
- const tgz = join(home, engine.assetName);
287
- writeFileSync(part, buf);
288
- renameSync(part, tgz);
289
-
290
- // 3.5) AUTENTICIDADE — antes de extrair. Um artefato não confiável não chega a tocar o disco
291
- // final: se a assinatura não confere, o .tgz é apagado e nada é instalado.
292
- const auth = await verifyAuthenticity(engine, buf, { log, fetcher, trustedKeys });
293
- if (!auth.ok) {
294
- dropFile(tgz);
295
- return { ok: false, reason: auth.reason };
296
- }
281
+ // 1-3.5) Sidecar SHA256 obrigatório, integridade e AUTENTICIDADE antes de qualquer byte tocar o disco.
282
+ const auth = await fetchArtifact(engine, { log, destDir: home, downloadTimeoutMs, fetcher, trustedKeys });
283
+ if (!auth.ok) { return auth; }
284
+ const tgz = auth.path;
297
285
 
298
286
  // 4) Extrai em staging e faz o swap (com shutdown-first se já existe).
299
287
  const staging = join(home, `stage-${engine.version}`);
@@ -112,7 +112,7 @@ if ($DryRun) {
112
112
  $upload | ForEach-Object { Write-Host " $([IO.Path]::GetFileName($_))" }
113
113
  if ($SourceRepo) { Write-Host " + espelho no repo privado $SourceRepo (nao-fatal)" }
114
114
  if ($MarketplaceMirror) { Write-Host " + ponte no canal antigo $MarketplaceMirror (nao-fatal)" }
115
- Write-Host " e atualizaria $manifest (publicKey=$pubKey, signatureRequired=true, repo=$Registry)"
115
+ Write-Host " e levaria manifest e README a main de $Registry (publicKey=$pubKey, signatureRequired=true), mesclando na develop"
116
116
  exit 0
117
117
  }
118
118
 
@@ -146,18 +146,19 @@ if ($MarketplaceMirror) {
146
146
  }
147
147
  }
148
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)" }
149
+ # --- 5. Manifest e README na main, provados antes do push --------------------------------
150
+ # Worktree temporario a partir de origin/main: update-manifest (parseado, nao regex), linha do README, verify-release
151
+ # (baixa do release como um consumidor e confere sha256 + assinatura), commit e push na main, mescla na develop.
152
+ # A copia de trabalho local nao e tocada.
153
+ if ($SkipManifest) {
154
+ Step "Provando o caminho REAL (baixa do release publicado e confere sha256 + assinatura)"
155
+ node (Join-Path $kit "verify-release.mjs") $Engine --manifest $manifest
156
+ if ($LASTEXITCODE -ne 0) { Fail "a release publicada NAO passa pelo caminho do consumidor - conserte antes de anunciar" }
157
+ } else {
158
+ Step "Levando manifest e README a main (prova a release antes do push) e mesclando na develop"
159
+ node (Join-Path $RegistryPath "tools\publish-manifest.mjs") $Engine --version $Version --public-key $pubKey --repo $Registry
160
+ if ($LASTEXITCODE -ne 0) { Fail "publish-manifest (a release esta publicada; se a prova falhou nada foi enviado - veja o erro acima)" }
154
161
  }
155
162
 
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
163
  Write-Host ""
162
164
  Write-Host "OK: $Engine v$Version publicado e VERIFICADO em $Registry." -ForegroundColor Green
163
- Write-Host "Falta: commitar e enviar o manifest.json ($manifest) para o registry publico."
package/kit/resolve.mjs CHANGED
@@ -77,7 +77,8 @@ export async function resolve(name, { registryUrl = DEFAULT_REGISTRY_URL, allowN
77
77
  if (!cached) { return { ok: false, reason: `registry inacessível (${e.message}) e sem cache` }; }
78
78
  manifest = cached.manifest;
79
79
  offline = true;
80
- degraded = cached.stale; // AQUI é a degradação real: rede falhou e o cache está vencido
80
+ // AQUI é a degradação real: rede falhou e o cache está vencido
81
+ degraded = cached.stale;
81
82
  }
82
83
  } else if (cached) {
83
84
  manifest = cached.manifest;
package/kit/trust.mjs CHANGED
@@ -26,18 +26,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  const trustDir = () => process.env.ENGINE_KIT_HOME || join(homedir(), ".engine-kit");
27
27
  const trustFile = () => join(trustDir(), "trust.json");
28
28
 
29
- function readTrust() {
30
- try {
31
- const v = JSON.parse(readFileSync(trustFile(), "utf8"));
32
- return v && typeof v === "object" && !Array.isArray(v) ? v : {};
33
- } catch {
34
- return {};
35
- }
29
+ /**
30
+ * Lê a raiz de confiança; ausente é `{}`. Existente e ilegível LANÇA: devolver `{}` faria a próxima gravação apagar
31
+ * a chave do primeiro contato, a maior versão vista e o pin dos outros motores.
32
+ */
33
+ export function readTrust() {
34
+ const f = trustFile();
35
+ if (!existsSync(f)) return {};
36
+ try { const v = JSON.parse(readFileSync(f, "utf8")); if (v && typeof v == "object" && !Array.isArray(v)) return v; } catch {}
37
+ throw Error(`${f} ilegível: nada foi gravado; corrija-o ou apague-o de propósito (perde chaves e pins)`);
36
38
  }
37
39
 
40
+ /** Grava a raiz de confiança inteira; lança se não conseguir. */
41
+ export const saveTrust = (store) => {
42
+ mkdirSync(trustDir(), { recursive: true });
43
+ writeFileSync(trustFile(), JSON.stringify(store, null, 2));
44
+ };
45
+
38
46
  /**
39
47
  * Confronta a chave que veio do registry com a raiz de confiança local.
40
- * @returns {{ok:true, tofu:boolean} | {ok:false, reason:string}} nunca lança.
48
+ * @returns {{ok:true, tofu:boolean} | {ok:false, reason:string}} lança só com trust.json ilegível (`readTrust`).
41
49
  */
42
50
  export function checkTrust(engineName, publicKey, { trustedKeys } = {}) {
43
51
  const pinned = trustedKeys?.[engineName];
@@ -59,9 +67,7 @@ export function checkTrust(engineName, publicKey, { trustedKeys } = {}) {
59
67
  if (known) return { ok: true, tofu: false };
60
68
 
61
69
  try {
62
- mkdirSync(trustDir(), { recursive: true });
63
- store[engineName] = { publicKey, firstSeen: new Date().toISOString(), source: pinned ? "pinned" : "tofu" };
64
- writeFileSync(trustFile(), JSON.stringify(store, null, 2));
70
+ saveTrust({ ...store, [engineName]: { publicKey, firstSeen: new Date().toISOString(), source: pinned ? "pinned" : "tofu" } });
65
71
  } catch {
66
72
  // Não poder gravar a raiz não pode impedir a instalação — mas também não vira silêncio:
67
73
  // sem gravar, a próxima execução repete o TOFU (e o consumidor vê `tofu:true` de novo).
@@ -69,7 +75,7 @@ export function checkTrust(engineName, publicKey, { trustedKeys } = {}) {
69
75
  return { ok: true, tofu: true };
70
76
  }
71
77
 
72
- export const trustPath = () => trustFile();
78
+ export const trustPath = trustFile;
73
79
 
74
80
  /** Compara versões `x.y.z` numericamente: negativo, zero ou positivo. */
75
81
  export const cmpVer = (a, b) => String(a).localeCompare(String(b), "en", { numeric: true });
@@ -80,12 +86,14 @@ export function rollbackReason(name, version) {
80
86
  return max && cmpVer(version, max) < 0 && `${name}: v${version} < v${max} já instalada → ABORT (rollback; downgrade só com --version)`;
81
87
  }
82
88
 
89
+ /** Versão em que o dono fixou o motor nesta máquina (`engine-kit pin`), ou undefined. */
90
+ export const pinOf = (name) => readTrust()[name]?.pin;
91
+
83
92
  /** Grava `version` como a maior vista do motor, se superar a registrada. */
84
93
  export function noteVersion(name, version) {
85
94
  const store = readTrust(), max = store[name]?.maxVersion;
86
95
  if (max && cmpVer(version, max) <= 0) return;
87
96
  try {
88
- mkdirSync(trustDir(), { recursive: true });
89
- writeFileSync(trustFile(), JSON.stringify({ ...store, [name]: { ...store[name], maxVersion: version } }, null, 2));
97
+ saveTrust({ ...store, [name]: { ...store[name], maxVersion: version } });
90
98
  } catch { /* sem gravar, a proteção vale a partir da próxima instalação */ }
91
99
  }
package/manifest.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "apps": [
11
11
  {
12
12
  "name": "agent-launcher",
13
- "version": "1.3.5",
13
+ "version": "1.3.6",
14
14
  "summary": "Abre Claude Code, Codex, Copilot CLI, VS Code e o Copilot app nos seus projetos por qualquer host de modelos. Leva só o launcher; cada ferramenta se instala pelo cartão dela. Windows.",
15
15
  "homepage": "https://allansantos-dv.github.io/agent-launcher/",
16
16
  "license": "MIT",
@@ -32,6 +32,31 @@
32
32
  "signatureAlgorithm": "ed25519-sha256-raw",
33
33
  "signatureRequired": true
34
34
  }
35
+ },
36
+ {
37
+ "name": "smart-design",
38
+ "version": "0.2.1",
39
+ "summary": "Serviço local que o agente de código chama por MCP para desenhar telas com sistema de design, renderizá-las e conferi-las em pixels antes de entregar. Windows.",
40
+ "homepage": "https://allansantos-dv.github.io/smart-design/",
41
+ "license": "MIT",
42
+ "platforms": [
43
+ "win32-x64"
44
+ ],
45
+ "release": {
46
+ "repo": "AllanSantos-DV/engine-registry",
47
+ "tag": "smart-design-v{version}",
48
+ "assets": [
49
+ {
50
+ "name": "smart_design-{version}-py3-none-any.whl",
51
+ "platform": "win32-x64",
52
+ "checksum": "{asset}.sha256",
53
+ "signature": "{asset}.sig"
54
+ }
55
+ ],
56
+ "publicKey": "03b4f450fe0ef68d249a6d3503e7577ae45f9dcd89c6301fb4232f0195ef3457",
57
+ "signatureAlgorithm": "ed25519-sha256-raw",
58
+ "signatureRequired": true
59
+ }
35
60
  }
36
61
  ],
37
62
  "engines": [
@@ -71,7 +96,7 @@
71
96
  },
72
97
  {
73
98
  "name": "vox-engine",
74
- "version": "0.28.7",
99
+ "version": "0.33.1",
75
100
  "kind": "daemon",
76
101
  "status": "self-managed",
77
102
  "summary": "Motor de voz (STT/TTS) compartilhado: transcreve e sintetiza para N consumidores a partir de uma única instância, com auto-unload quando ocioso.",
@@ -106,7 +131,7 @@
106
131
  },
107
132
  {
108
133
  "name": "mcp-gateway",
109
- "version": "0.17.2",
134
+ "version": "0.17.4",
110
135
  "kind": "daemon",
111
136
  "summary": "Agregador MCP: conecta UMA vez em cada servidor MCP e reexpõe todas as tools num endpoint Streamable HTTP único, com auth bearer, namespacing condicional, resiliência e auditoria. Mata a multiplicação de processos por sessão.",
112
137
  "pagesPendente": "sem página de produto com vídeo; ela nasce do repositório do próprio motor e este registro só aponta para ela",
@@ -139,7 +164,7 @@
139
164
  },
140
165
  {
141
166
  "name": "neural-link",
142
- "version": "0.7.15",
167
+ "version": "0.7.16",
143
168
  "kind": "cli",
144
169
  "summary": "Dispatcher adaptativo de hooks para agentes LLM: recebe o evento por stdin, pontua e executa os handlers registrados, e devolve UMA decisão composta. Não é daemon — é invocado a cada evento de hook, por qualquer host (Copilot CLI, VS Code, Claude Code).",
145
170
  "pagesPendente": "sem página de produto com vídeo; ela nasce do repositório do próprio motor e este registro só aponta para ela",
@@ -163,7 +188,7 @@
163
188
  },
164
189
  {
165
190
  "name": "vxk",
166
- "version": "0.21.0",
191
+ "version": "0.23.0",
167
192
  "kind": "cli",
168
193
  "summary": "VXK — explicações visuais animadas e narradas como servidor MCP (stdio): qualquer cliente MCP (Claude Code, Codex, Copilot) compõe a biblioteca de componentes, ícones e o controle único de reprodução e recebe UM HTML autocontido e offline, ou o vídeo MP4 da mesma explicação (por preset, do aplicativo real ou em Motion) e o PDF. A narração é assada pelo motor vox-engine.",
169
194
  "pagesUrl": "https://allansantos-dv.github.io/visual-explainer/",
@@ -247,7 +272,7 @@
247
272
  },
248
273
  {
249
274
  "name": "client-harness",
250
- "version": "0.1.5",
275
+ "version": "0.1.8",
251
276
  "kind": "cli",
252
277
  "summary": "Daemon local que faz o Claude Code e o Codex respeitarem os padrões de cada cliente: confere cada ação do agente (hooks) contra as regras do cliente da pasta e recusa antes de gravar, em cerca de 1 ms, com o motivo e o que usar no lugar.",
253
278
  "pagesUrl": "https://allansantos-dv.github.io/harness-padroes-cliente/",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engine-kit",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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,10 +24,12 @@
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-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",
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-ensure-avisos.mjs && node tests/test-update.mjs && node tests/test-trust.mjs && node tests/test-backup-keys.mjs && node tests/test-rollback.mjs && node tests/test-pin.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-gateway-drain.mjs && node tests/test-neural-link-alignment.mjs && node tests/test-site.mjs && node tests/test-pages-branch.mjs && node tests/test-publish-manifest.mjs && node tests/test-readme-versions.mjs && node tests/test-manifest-versions.mjs && node tests/test-pages-gate.mjs && node tests/test-regras-escopo.mjs && node tests/test-actions-minutes.mjs",
28
28
  "test:pages-live": "node tests/test-pages-gate.mjs --live",
29
+ "test:versions-live": "node tests/test-manifest-versions.mjs --live",
29
30
  "test:network": "node tests/smoke.mjs --network && node tests/test-signature.mjs --network",
30
31
  "test:e2e": "node tests/e2e-install.mjs",
32
+ "test:gateway-live": "node tests/e2e-gateway-drain-live.mjs",
31
33
  "gen-key": "node kit/gen-key.mjs",
32
34
  "verify-release": "node kit/verify-release.mjs",
33
35
  "budget": "node kit/budget.mjs"