feat(cli): fase 8.13 — self-heal native deps (omniroute runtime check/repair/clean)

- bin/cli/runtime/nativeDeps.mjs: ensureRuntimeDir, hasModule, isBetterSqliteBinaryValid
  (ELF/Mach-O/PE magic bytes), npmInstallRuntime (shell:false, cmd.exe /c no Windows),
  ensureBetterSqliteRuntime, buildEnvWithRuntime com NODE_PATH extendido
- bin/cli/commands/runtime.mjs: subcomandos check/repair --force/clean --yes
- Registrado em commands/registry.mjs
- Chaves i18n runtime.* em en.json e pt-BR.json
- 9 testes unitários cobrindo todas as funções exportadas
This commit is contained in:
diegosouzapw
2026-05-15 03:46:26 -03:00
parent ed19c824c0
commit b329cfc84a
6 changed files with 318 additions and 0 deletions

View File

@@ -51,6 +51,7 @@ import { registerTunnel } from "./tunnel.mjs";
import { registerEnv } from "./env.mjs";
import { registerTestProvider } from "./test-provider.mjs";
import { registerCompletion } from "./completion.mjs";
import { registerRuntime } from "./runtime.mjs";
export function registerCommands(program) {
registerMemory(program);
@@ -107,4 +108,5 @@ export function registerCommands(program) {
registerEnv(program);
registerTestProvider(program);
registerCompletion(program);
registerRuntime(program);
}

View File

@@ -0,0 +1,80 @@
import { rmSync } from "node:fs";
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { withSpinner } from "../spinner.mjs";
import {
getRuntimeNodeModules,
hasModule,
isBetterSqliteBinaryValid,
ensureBetterSqliteRuntime,
} from "../runtime/nativeDeps.mjs";
async function confirm(msg) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r));
rl.close();
return /^y(es)?$/i.test(answer);
}
export function registerRuntime(program) {
const runtime = program
.command("runtime")
.description(t("runtime.description") || "Manage native runtime dependencies");
runtime
.command("check")
.description("Check status of native deps in runtime directory")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const status = {
runtimeDir: getRuntimeNodeModules(),
betterSqlite3: {
installed: hasModule("better-sqlite3"),
valid: isBetterSqliteBinaryValid(),
},
};
emit(status, globalOpts);
});
runtime
.command("repair")
.description("Reinstall native deps in runtime directory")
.option("--force", "Force reinstall even if valid")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
await withSpinner(
"Repairing native deps",
async () => ensureBetterSqliteRuntime({ silent: true, force: opts.force }),
globalOpts
);
const ok = hasModule("better-sqlite3") && isBetterSqliteBinaryValid();
if (ok) {
process.stdout.write("✓ better-sqlite3 repaired OK\n");
} else {
process.stderr.write("✗ Repair failed — check npm availability\n");
process.exit(1);
}
});
runtime
.command("clean")
.description("Remove runtime directory (frees disk space)")
.option("--yes", "Skip confirmation")
.action(async (opts) => {
if (!opts.yes) {
const ok = await confirm(`Remove ${getRuntimeNodeModules()}?`);
if (!ok) {
process.stdout.write("Cancelled.\n");
return;
}
}
try {
rmSync(getRuntimeNodeModules(), { recursive: true, force: true });
process.stdout.write("Cleaned runtime directory.\n");
} catch (e) {
process.stderr.write(`Failed: ${e instanceof Error ? e.message : String(e)}\n`);
process.exit(1);
}
});
}

View File

@@ -1080,5 +1080,13 @@
"durationMin": "Min request duration in ms",
"durationMax": "Max request duration in ms",
"export": "Save logs to file (json/jsonl/csv)"
},
"runtime": {
"description": "Manage native runtime dependencies",
"check": "Check status of native deps in runtime directory",
"repair": "Reinstall native deps in runtime directory",
"repair_force": "Force reinstall even if valid",
"clean": "Remove runtime directory (frees disk space)",
"clean_yes": "Skip confirmation"
}
}

View File

@@ -1080,5 +1080,13 @@
"durationMin": "Duração mínima do request em ms",
"durationMax": "Duração máxima do request em ms",
"export": "Salvar logs em arquivo (json/jsonl/csv)"
},
"runtime": {
"description": "Gerenciar dependências nativas do runtime",
"check": "Verificar status das deps nativas no diretório de runtime",
"repair": "Reinstalar deps nativas no diretório de runtime",
"repair_force": "Forçar reinstalação mesmo se estiver válido",
"clean": "Remover diretório de runtime (libera espaço em disco)",
"clean_yes": "Pular confirmação"
}
}

View File

@@ -0,0 +1,126 @@
import {
existsSync,
readFileSync,
writeFileSync,
openSync,
readSync,
closeSync,
mkdirSync,
} from "node:fs";
import { join, sep } from "node:path";
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
import { resolveDataDir } from "../data-dir.mjs";
const BETTER_SQLITE3_VERSION = "12.9.0";
function runtimeDir() {
return join(resolveDataDir(), "runtime");
}
function runtimeModules() {
return join(runtimeDir(), "node_modules");
}
export function ensureRuntimeDir() {
const dir = runtimeDir();
mkdirSync(dir, { recursive: true });
const pkgPath = join(dir, "package.json");
if (!existsSync(pkgPath)) {
writeFileSync(
pkgPath,
JSON.stringify(
{
name: "omniroute-runtime",
version: "1.0.0",
private: true,
description: "User-writable runtime deps for OmniRoute (native binaries)",
},
null,
2
)
);
}
return dir;
}
export function getRuntimeNodeModules() {
return runtimeModules();
}
export function hasModule(name) {
return existsSync(join(runtimeModules(), name, "package.json"));
}
export function isBetterSqliteBinaryValid() {
const binary = join(
runtimeModules(),
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
if (!existsSync(binary)) return false;
try {
const fd = openSync(binary, "r");
const buf = Buffer.alloc(4);
readSync(fd, buf, 0, 4, 0);
closeSync(fd);
const magic = buf.toString("hex");
const os = platform();
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
return true;
} catch {
return false;
}
}
export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
const npmArgs = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", "--no-save"];
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
// so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32";
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
if (!opts.silent) {
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
}
const res = spawnSync(exe, args, {
cwd,
stdio: opts.silent ? "ignore" : "inherit",
timeout: opts.timeout ?? 180_000,
shell: false,
env: { ...process.env },
});
return res.status === 0;
}
/**
* Ensure better-sqlite3 is installed and valid in the runtime dir.
* Returns { betterSqlite: boolean }.
*/
export function ensureBetterSqliteRuntime({ silent = false, force = false } = {}) {
ensureRuntimeDir();
const valid = hasModule("better-sqlite3") && isBetterSqliteBinaryValid();
if (valid && !force) {
if (!silent) process.stdout.write("[omniroute][runtime] better-sqlite3 OK\n");
return { betterSqlite: true };
}
const ok = npmInstallRuntime([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { silent });
if (!ok && !silent) {
process.stderr.write("[omniroute][runtime] better-sqlite3 install failed\n");
}
return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid() };
}
/**
* Build an env object with NODE_PATH extended to include the runtime node_modules.
*/
export function buildEnvWithRuntime(baseEnv = process.env) {
const runtimeNm = runtimeModules();
const existing = baseEnv.NODE_PATH || "";
const parts = [runtimeNm, existing].filter(Boolean);
return { ...baseEnv, NODE_PATH: parts.join(sep === "\\" ? ";" : ":") };
}

View File

@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
let tmpDir: string;
let origDataDir: string | undefined;
test.before(() => {
tmpDir = mkdtempSync(join(tmpdir(), "omniroute-runtime-test-"));
origDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = tmpDir;
});
test.after(() => {
if (origDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = origDataDir;
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {}
});
test("nativeDeps.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/runtime/nativeDeps.mjs");
assert.equal(typeof mod.ensureRuntimeDir, "function");
assert.equal(typeof mod.getRuntimeNodeModules, "function");
assert.equal(typeof mod.hasModule, "function");
assert.equal(typeof mod.isBetterSqliteBinaryValid, "function");
assert.equal(typeof mod.npmInstallRuntime, "function");
assert.equal(typeof mod.ensureBetterSqliteRuntime, "function");
assert.equal(typeof mod.buildEnvWithRuntime, "function");
});
test("ensureRuntimeDir cria diretório e package.json", async () => {
const { ensureRuntimeDir } = await import("../../bin/cli/runtime/nativeDeps.mjs");
const dir = ensureRuntimeDir();
const { existsSync } = await import("node:fs");
assert.ok(existsSync(dir), "runtime dir deve existir");
assert.ok(existsSync(join(dir, "package.json")), "package.json deve existir");
});
test("getRuntimeNodeModules retorna caminho dentro do DATA_DIR", async () => {
const { getRuntimeNodeModules } = await import("../../bin/cli/runtime/nativeDeps.mjs");
const nm = getRuntimeNodeModules();
assert.ok(nm.startsWith(tmpDir), "node_modules deve estar dentro do tmpDir");
assert.ok(nm.endsWith("node_modules"), "path deve terminar com node_modules");
});
test("hasModule retorna false para módulo inexistente", async () => {
const { hasModule } = await import("../../bin/cli/runtime/nativeDeps.mjs");
assert.equal(hasModule("definitely-not-installed-xyz"), false);
});
test("isBetterSqliteBinaryValid retorna false quando binário não existe", async () => {
const { isBetterSqliteBinaryValid } = await import("../../bin/cli/runtime/nativeDeps.mjs");
assert.equal(isBetterSqliteBinaryValid(), false);
});
test("buildEnvWithRuntime extende NODE_PATH com runtime node_modules", async () => {
const { buildEnvWithRuntime, getRuntimeNodeModules } =
await import("../../bin/cli/runtime/nativeDeps.mjs");
const nm = getRuntimeNodeModules();
const env = buildEnvWithRuntime({});
assert.ok(env.NODE_PATH.includes(nm), "NODE_PATH deve conter runtime node_modules");
});
test("buildEnvWithRuntime preserva NODE_PATH existente", async () => {
const { buildEnvWithRuntime } = await import("../../bin/cli/runtime/nativeDeps.mjs");
const env = buildEnvWithRuntime({ NODE_PATH: "/existing/path" });
assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado");
});
test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => {
const { getRuntimeNodeModules, isBetterSqliteBinaryValid } =
await import("../../bin/cli/runtime/nativeDeps.mjs");
const nm = getRuntimeNodeModules();
const buildDir = join(nm, "better-sqlite3", "build", "Release");
mkdirSync(buildDir, { recursive: true });
const binary = join(buildDir, "better_sqlite3.node");
const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]);
writeFileSync(binary, buf);
const result = isBetterSqliteBinaryValid();
const { platform } = await import("node:os");
if (platform() === "linux") {
assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux");
}
rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true });
});
test("commands/runtime.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/commands/runtime.mjs");
assert.equal(typeof mod.registerRuntime, "function");
});