chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cursor-tap — capture cursor agent.v1.AgentService/Run wire bytes for tests.
|
||||
*
|
||||
* Usage:
|
||||
* CURSOR_TOKEN=... node scripts/ad-hoc/cursor-tap.cjs <fixture-name> <prompt>
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/ad-hoc/cursor-tap.cjs single-turn-chat "say only PING"
|
||||
* node scripts/ad-hoc/cursor-tap.cjs system-prompt "be brief|hi" # split on first '|'
|
||||
* node scripts/ad-hoc/cursor-tap.cjs tool-call "weather in Paris" --tools=get_weather
|
||||
* node scripts/ad-hoc/cursor-tap.cjs composer-2-fast "hi" --model=composer-2-fast
|
||||
*
|
||||
* Writes the upstream response bytes to tests/fixtures/cursor/<fixture-name>.bin
|
||||
* and prints decoded summary to stdout. Use these fixtures in unit tests to
|
||||
* catch schema drift in cursor-agent's protobuf format.
|
||||
*
|
||||
* Note: this is a one-time / on-demand tool. The .bin output is gitignored
|
||||
* by default; commit fixtures explicitly when you want them in the test
|
||||
* baseline (tests/fixtures/cursor/.gitignore controls this).
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http2 = require("http2");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error(
|
||||
"Usage: cursor-tap.cjs <fixture-name> <prompt> [--model=...] [--tools=name1,name2]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [fixtureName, prompt, ...flags] = args;
|
||||
const flagMap = Object.fromEntries(
|
||||
flags.map((f) => {
|
||||
const m = f.match(/^--([^=]+)=(.*)$/);
|
||||
return m ? [m[1], m[2]] : [f.replace(/^--/, ""), true];
|
||||
})
|
||||
);
|
||||
|
||||
const token = process.env.CURSOR_TOKEN;
|
||||
if (!token) {
|
||||
console.error("Set CURSOR_TOKEN environment variable.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = flagMap.model || "auto";
|
||||
const conversationId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
|
||||
|
||||
// ─── Minimal protobuf encoder (mirrors open-sse/utils/cursorAgentProtobuf.ts) ─
|
||||
|
||||
function encodeVarint(n) {
|
||||
const out = [];
|
||||
let v = BigInt(n);
|
||||
while (v > 0x7fn) {
|
||||
out.push(Number(v & 0x7fn) | 0x80);
|
||||
v >>= 7n;
|
||||
}
|
||||
out.push(Number(v));
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field, wt) {
|
||||
return encodeVarint((field << 3) | wt);
|
||||
}
|
||||
function lenField(f, payload) {
|
||||
return Buffer.concat([tag(f, 2), encodeVarint(payload.length), payload]);
|
||||
}
|
||||
function strField(f, s) {
|
||||
return lenField(f, Buffer.from(s, "utf8"));
|
||||
}
|
||||
function varintField(f, n) {
|
||||
return Buffer.concat([tag(f, 0), encodeVarint(n)]);
|
||||
}
|
||||
function wrapConnectFrame(payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// AgentRunRequest body
|
||||
const userMessage = lenField(
|
||||
1,
|
||||
Buffer.concat([
|
||||
strField(1, prompt),
|
||||
strField(2, crypto.randomUUID()),
|
||||
lenField(3, Buffer.alloc(0)),
|
||||
varintField(4, 1),
|
||||
])
|
||||
);
|
||||
const userMessageAction = lenField(1, userMessage);
|
||||
const action = lenField(2, userMessageAction);
|
||||
const conversationState = lenField(1, Buffer.alloc(0));
|
||||
const requestedModel = lenField(9, strField(1, model === "auto" ? "default" : model));
|
||||
const arr = Buffer.concat([
|
||||
conversationState,
|
||||
action,
|
||||
lenField(4, Buffer.alloc(0)), // mcp_tools
|
||||
strField(5, conversationId),
|
||||
requestedModel,
|
||||
varintField(12, 0),
|
||||
strField(16, conversationId),
|
||||
]);
|
||||
const acm = lenField(1, arr);
|
||||
const body = wrapConnectFrame(acm);
|
||||
|
||||
// ─── h2 request ────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanToken = token.includes("::") ? token.split("::")[1] : token;
|
||||
const client = http2.connect("https://agentn.global.api5.cursor.sh");
|
||||
|
||||
const collected = [];
|
||||
let responseStatus = 0;
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": "/agent.v1.AgentService/Run",
|
||||
":authority": "agentn.global.api5.cursor.sh",
|
||||
":scheme": "https",
|
||||
authorization: `Bearer ${cleanToken}`,
|
||||
"backend-traceparent": traceParent,
|
||||
"connect-accept-encoding": "gzip,br",
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/connect+proto",
|
||||
traceparent: traceParent,
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-cursor-client-type": "cli",
|
||||
"x-cursor-client-version": "cli-2025.10.21-b2dfaef",
|
||||
"x-ghost-mode": "true",
|
||||
"x-original-request-id": requestId,
|
||||
"x-request-id": requestId,
|
||||
});
|
||||
|
||||
req.on("response", (h) => {
|
||||
responseStatus = Number(h[":status"]);
|
||||
});
|
||||
req.on("data", (chunk) => {
|
||||
collected.push(Buffer.from(chunk));
|
||||
});
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(collected);
|
||||
const outDir = path.join(__dirname, "..", "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outFile = path.join(outDir, `${fixtureName}.bin`);
|
||||
fs.writeFileSync(outFile, raw);
|
||||
console.log(`[cursor-tap] status=${responseStatus} bytes=${raw.length} → ${outFile}`);
|
||||
client.close();
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
console.error("[cursor-tap] req error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
// NOTE: we never end the request; cursor closes the stream itself when the
|
||||
// turn is done. For tool-using captures, the script may need to write
|
||||
// follow-up frames before the stream closes — extend as needed.
|
||||
|
||||
// Safety timeout: if cursor doesn't close in 60s, dump what we have.
|
||||
setTimeout(() => {
|
||||
console.warn("[cursor-tap] safety timeout; closing");
|
||||
try {
|
||||
req.close();
|
||||
client.close();
|
||||
} catch {}
|
||||
const raw = Buffer.concat(collected);
|
||||
if (raw.length > 0) {
|
||||
const outDir = path.join(__dirname, "..", "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, `${fixtureName}.bin`), raw);
|
||||
}
|
||||
process.exit(0);
|
||||
}, 60_000);
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const env = { ...process.env };
|
||||
|
||||
await exec("npx", ["next", "build", "--experimental-build-mode", "generate"]);
|
||||
|
||||
// launch application
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (!command) {
|
||||
throw new Error("Missing command to launch after database setup");
|
||||
}
|
||||
await exec(command, args);
|
||||
|
||||
function exec(command, args = []) {
|
||||
const child = spawn(command, args, { stdio: "inherit", env });
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${[command, ...args].join(" ")} failed rc=${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
try {
|
||||
console.log("Fetching workflow runs...");
|
||||
const output = execSync("gh run list --limit 100 --json status,conclusion,databaseId", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const runs = JSON.parse(output);
|
||||
|
||||
console.log(`Found ${runs.length} runs.`);
|
||||
let count = 0;
|
||||
for (const run of runs) {
|
||||
if (run.conclusion !== "success") {
|
||||
console.log(`Deleting run ID ${run.databaseId} with conclusion '${run.conclusion}'...`);
|
||||
try {
|
||||
execSync(`gh run delete ${run.databaseId}`);
|
||||
count++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete run ID ${run.databaseId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Deleted ${count} runs successfully.`);
|
||||
} catch (error) {
|
||||
console.error("Error executing script:", error);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Trae SOLO auth diagnostic — probes the live API with your token across several
|
||||
// Authorization variants, so we can see which one (if any) the server accepts.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/ad-hoc/diag-trae-auth.mjs <YOUR_CLOUD_IDE_JWT>
|
||||
// TRAE_TOKEN=eyJ... node scripts/ad-hoc/diag-trae-auth.mjs
|
||||
//
|
||||
// Paste ONLY the token value (no "Cloud-IDE-JWT " prefix). The token is never
|
||||
// printed in full; only its length + last 6 chars are shown for sanity.
|
||||
|
||||
const token = (process.argv[2] || process.env.TRAE_TOKEN || "").trim();
|
||||
if (!token) {
|
||||
console.error("No token. Pass it as arg or set TRAE_TOKEN.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`token: len=${token.length} …${token.slice(-6)}`);
|
||||
|
||||
const BASE = "https://core-normal.trae.ai/api/remote/v1";
|
||||
const MODELS = `${BASE}/models?functions=solo_agent_remote,solo_work_remote`;
|
||||
|
||||
const commonHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Trae-Client-Type": "web",
|
||||
"X-Preferenced-Language": "en",
|
||||
"x-user-region": "US",
|
||||
Referer: "https://solo.trae.ai/",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
// Each variant = a set of auth-bearing headers to try.
|
||||
const variants = [
|
||||
{
|
||||
name: "Authorization: Cloud-IDE-JWT <t>",
|
||||
headers: { Authorization: `Cloud-IDE-JWT ${token}` },
|
||||
},
|
||||
{ name: "Authorization: Bearer <t>", headers: { Authorization: `Bearer ${token}` } },
|
||||
{ name: "Authorization: <t> (raw)", headers: { Authorization: token } },
|
||||
{ name: "Cloud-IDE-JWT: <t> (header)", headers: { "Cloud-IDE-JWT": token } },
|
||||
{ name: "x-cloud-ide-jwt: <t>", headers: { "x-cloud-ide-jwt": token } },
|
||||
{ name: "x-cloud-ide-token: <t>", headers: { "x-cloud-ide-token": token } },
|
||||
];
|
||||
|
||||
async function probe(label, url, method, headers) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { ...commonHeaders, ...headers },
|
||||
body:
|
||||
method === "POST"
|
||||
? JSON.stringify({ mode: "code", env: "remote", origin: "web" })
|
||||
: undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const snippet = text.replace(/\s+/g, " ").slice(0, 160);
|
||||
const ok = res.ok && !/"code"\s*:\s*1001/.test(text) && !/not able to authenticate/i.test(text);
|
||||
console.log(`${ok ? "✅" : "❌"} [${res.status}] ${label}\n ${snippet}`);
|
||||
return ok;
|
||||
} catch (e) {
|
||||
console.log(`💥 ${label} → ${e?.message || e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== GET ${MODELS} ===`);
|
||||
let anyOk = false;
|
||||
for (const v of variants) {
|
||||
const ok = await probe(v.name, MODELS, "GET", v.headers);
|
||||
anyOk = anyOk || ok;
|
||||
}
|
||||
|
||||
console.log(`\n=== POST ${BASE}/chat_sessions (only the first auth variant, sanity) ===`);
|
||||
await probe(variants[0].name, `${BASE}/chat_sessions`, "POST", variants[0].headers);
|
||||
|
||||
console.log(
|
||||
anyOk
|
||||
? "\n→ At least one variant authenticated. Tell me which ✅ line, I'll set the executor to it."
|
||||
: "\n→ No header-only variant worked. The web client likely authenticates via a COOKIE, not a\n" +
|
||||
" bearer header. Next step: in DevTools → Network, right-click a request to\n" +
|
||||
" core-normal.trae.ai → Copy → Copy as cURL, and paste it here (redact the token). That\n" +
|
||||
" shows the exact auth header/cookie the server actually accepts."
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const REPO = "diegosouzapw/OmniRoute";
|
||||
const artifactsDir =
|
||||
process.env.ARTIFACTS_DIR ||
|
||||
path.join(process.cwd(), "artifacts");
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 1. Get PR numbers
|
||||
console.log("Fetching open PR numbers...");
|
||||
const prNumbersOutput = execSync(
|
||||
`gh pr list --repo ${REPO} --state open --limit 500 --json number --jq '.[].number'`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
const prNumbers = prNumbersOutput.trim().split("\n").map(Number).filter(Boolean);
|
||||
console.log(`Found ${prNumbers.length} open PRs:`, prNumbers);
|
||||
|
||||
if (!fs.existsSync(artifactsDir)) {
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Fetch metadata and diff for each PR
|
||||
for (const prNum of prNumbers) {
|
||||
console.log(`\n--- Fetching PR #${prNum} ---`);
|
||||
|
||||
// Metadata
|
||||
try {
|
||||
const metadataCmd = `gh pr view ${prNum} --repo ${REPO} --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`;
|
||||
const metadataJson = execSync(metadataCmd, { encoding: "utf-8" });
|
||||
const metadataPath = path.join(artifactsDir, `pr_${prNum}_meta.json`);
|
||||
fs.writeFileSync(metadataPath, metadataJson);
|
||||
console.log(`Saved metadata to ${metadataPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch metadata for PR #${prNum}:`, err.message);
|
||||
}
|
||||
|
||||
// Diff
|
||||
try {
|
||||
const diffCmd = `gh pr diff ${prNum} --repo ${REPO}`;
|
||||
const diffText = execSync(diffCmd, { encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 });
|
||||
const diffPath = path.join("/tmp", `pr${prNum}.diff`);
|
||||
fs.writeFileSync(diffPath, diffText);
|
||||
console.log(`Saved diff to ${diffPath} (Size: ${diffText.length} bytes)`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch diff for PR #${prNum}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nAll PR data fetched successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error during PR fetching:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute v3.3 -> v3.4 Environment Migration Script
|
||||
* Resolves breaking changes in environment variables format and validation.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log("No .env file found. Migration skipped.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(envPath, "utf8");
|
||||
let modified = false;
|
||||
|
||||
// 1. Migrate NEXTAUTH_SECRET to JWT_SECRET if missing
|
||||
const nextAuthMatch = content.match(/^NEXTAUTH_SECRET=(.+)$/m);
|
||||
const jwtMatch = content.match(/^JWT_SECRET=(.+)$/m);
|
||||
|
||||
if (nextAuthMatch && !jwtMatch) {
|
||||
console.log("Migrating NEXTAUTH_SECRET to JWT_SECRET...");
|
||||
let newJwt = nextAuthMatch[1].trim();
|
||||
|
||||
// Enforce 32 char minimum for secretsValidator.ts
|
||||
if (newJwt.length < 32) {
|
||||
console.warn(
|
||||
`Original NEXTAUTH_SECRET was too short (${newJwt.length} chars). Generating a secure one...`
|
||||
);
|
||||
newJwt = crypto.randomBytes(48).toString("base64");
|
||||
}
|
||||
|
||||
content += `\n# Migrated from NEXTAUTH_SECRET\nJWT_SECRET=${newJwt}\n`;
|
||||
modified = true;
|
||||
} else if (jwtMatch && jwtMatch[1].trim().length < 32) {
|
||||
console.warn(
|
||||
`JWT_SECRET is too short (${jwtMatch[1].trim().length} chars). Generating a secure one for v3.4.0+...`
|
||||
);
|
||||
const newJwt = crypto.randomBytes(48).toString("base64");
|
||||
content = content.replace(/^JWT_SECRET=(.*)$/m, `JWT_SECRET=${newJwt}`);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 2. Ensure API_KEY_SECRET exists (required in 3.4.0)
|
||||
if (!content.match(/^API_KEY_SECRET=/m)) {
|
||||
console.log("Adding required API_KEY_SECRET for v3.4.0...");
|
||||
const newApiSecret = crypto.randomBytes(32).toString("hex");
|
||||
content += `\n# Required for v3.4.0 API Key HMAC\nAPI_KEY_SECRET=${newApiSecret}\n`;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
// Backup old .env
|
||||
fs.writeFileSync(envPath + ".bak", fs.readFileSync(envPath));
|
||||
console.log("Created backup at .env.bak");
|
||||
|
||||
// Write new .env
|
||||
fs.writeFileSync(envPath, content, "utf8");
|
||||
console.log("Successfully migrated .env file for OmniRoute 3.4.x.");
|
||||
} else {
|
||||
console.log(".env file is already compatible with OmniRoute 3.4.x.");
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Diagnóstico NVIDIA NIM — `s.startsWith is not a function` (issue #2463 / report 3.8.5+)
|
||||
*
|
||||
* Como rodar (a chave NUNCA é commitada — vem por env):
|
||||
* NVIDIA_API_KEY="nvapi-..." node --import tsx/esm scripts/ad-hoc/nvidia-startswith-diag.ts
|
||||
*
|
||||
* Opcional — apontar para outra base/model:
|
||||
* NVIDIA_BASE_URL="https://integrate.api.nvidia.com/v1/chat/completions"
|
||||
* NVIDIA_MODEL="openai/gpt-oss-120b"
|
||||
*
|
||||
* O que ele faz:
|
||||
* Parte A — Validação real via validateProviderApiKey() (caminho do botão "testar conexão").
|
||||
* Parte B — Sanidade do upstream: POST direto na NVIDIA (isola a chave/model do nosso pipeline).
|
||||
* Parte C — Probes de type-crash SEM chave: alimenta model malformado em resolveModelAlias +
|
||||
* replica o strip de prefixo de chatCore.ts:3316 e parseModel(), capturando o stack
|
||||
* NÃO-minificado (rodamos contra a fonte TS) para cravar a linha exata do startsWith.
|
||||
*
|
||||
* Parte C não precisa de chave — prova quais linhas são vulneráveis hoje.
|
||||
*/
|
||||
|
||||
const KEY = process.env.NVIDIA_API_KEY ?? "";
|
||||
const BASE_URL = process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
|
||||
const MODEL = process.env.NVIDIA_MODEL || "openai/gpt-oss-120b";
|
||||
|
||||
// Neutralize CR/LF before logging so env-derived values (NVIDIA_MODEL, etc.)
|
||||
// cannot forge extra log lines (S5145 log injection).
|
||||
const line = (s = "") => console.log(String(s).replace(/[\r\n]+/g, " "));
|
||||
const hr = () => line("─".repeat(72));
|
||||
|
||||
function show(label: string, value: unknown) {
|
||||
line(` ${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Parte A — validateProviderApiKey (caminho de validação/teste de conexão)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
async function partA() {
|
||||
hr();
|
||||
line("PARTE A — validateProviderApiKey({ provider: 'nvidia' })");
|
||||
hr();
|
||||
if (!KEY) {
|
||||
line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
const providerSpecificData = { baseUrl: BASE_URL };
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "nvidia",
|
||||
apiKey: KEY,
|
||||
providerSpecificData,
|
||||
});
|
||||
line(" ✅ validateProviderApiKey retornou (sem crash):");
|
||||
show("resultado", result);
|
||||
if (typeof (result as any)?.error === "string" && (result as any).error.includes("startsWith")) {
|
||||
line(" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
line(" ❌ validateProviderApiKey LANÇOU (crash não tratado):");
|
||||
line(` ${err?.message}`);
|
||||
line(err?.stack ?? String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Parte B — sanidade do upstream NVIDIA (isola chave/model do nosso pipeline)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
async function partB() {
|
||||
hr();
|
||||
line("PARTE B — POST direto no upstream NVIDIA (sanidade da chave/model)");
|
||||
hr();
|
||||
if (!KEY) {
|
||||
line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar.");
|
||||
return;
|
||||
}
|
||||
const url = BASE_URL.endsWith("/chat/completions") ? BASE_URL : `${BASE_URL}/chat/completions`;
|
||||
show("url", url);
|
||||
show("model", MODEL);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
const text = await res.text();
|
||||
show("status", res.status);
|
||||
line(` body (256): ${text.slice(0, 256)}`);
|
||||
if (res.ok) line(" ✅ upstream OK — chave e model válidos.");
|
||||
else if (res.status === 401 || res.status === 403) line(" ❌ chave inválida (401/403).");
|
||||
else line(" ⚠️ não-OK não-auth — chave provavelmente válida, ver corpo.");
|
||||
} catch (err: any) {
|
||||
line(` ❌ fetch falhou: ${err?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Parte C — probes de type-crash (sem chave) — crava a linha do startsWith
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
async function partC() {
|
||||
hr();
|
||||
line("PARTE C — probes de type-crash (resolveModelAlias + strip chatCore:3316 + parseModel)");
|
||||
hr();
|
||||
|
||||
const { resolveModelAlias } = await import("../../open-sse/services/modelDeprecation.ts");
|
||||
const { parseModel } = await import("../../open-sse/services/model.ts");
|
||||
|
||||
// Replica EXATA do trecho de chatCore.ts:3315-3320 (feature #1261), sem guard.
|
||||
function stripPrefixLikeChatCore(effectiveModel: any, provider: string, alias?: string) {
|
||||
let finalModelToUpstream = effectiveModel;
|
||||
if (finalModelToUpstream.startsWith(`${provider}/`)) {
|
||||
finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1);
|
||||
} else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) {
|
||||
finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1);
|
||||
}
|
||||
return finalModelToUpstream;
|
||||
}
|
||||
|
||||
const inputs: Array<{ label: string; model: any }> = [
|
||||
{ label: "string normal (multi-barra NVIDIA)", model: "nvidia/openai/gpt-oss-120b" },
|
||||
{ label: "objeto {} (UI bug / providerSpecificData mal salvo)", model: {} },
|
||||
{ label: "objeto {id: '...'}", model: { id: "openai/gpt-oss-120b" } },
|
||||
{ label: "number", model: 123 },
|
||||
{ label: "array", model: ["openai/gpt-oss-120b"] },
|
||||
{ label: "null", model: null },
|
||||
{ label: "undefined", model: undefined },
|
||||
];
|
||||
|
||||
for (const { label, model } of inputs) {
|
||||
line("");
|
||||
line(` ▶ input: ${label} (typeof=${typeof model})`);
|
||||
|
||||
// 1) resolveModelAlias — deixa não-string passar? (if (!modelId) return modelId)
|
||||
let effective: any;
|
||||
try {
|
||||
effective = resolveModelAlias(model as any);
|
||||
line(` resolveModelAlias → ${typeof effective} ${JSON.stringify(effective)}`);
|
||||
} catch (err: any) {
|
||||
line(` resolveModelAlias THROW: ${err?.message}`);
|
||||
effective = model;
|
||||
}
|
||||
|
||||
// 2) strip de prefixo (chatCore:3316) — captura o stack EXATO
|
||||
try {
|
||||
const out = stripPrefixLikeChatCore(effective, "nvidia", "nvidia");
|
||||
line(` chatCore strip → ${JSON.stringify(out)} ✅ sem crash`);
|
||||
} catch (err: any) {
|
||||
line(` ❌ chatCore:3316 strip THROW: ${err?.message}`);
|
||||
const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes(".ts"));
|
||||
if (at) line(` ${at.trim()}`);
|
||||
}
|
||||
|
||||
// 3) parseModel (model.ts:315) — captura o stack EXATO
|
||||
try {
|
||||
const parsed = parseModel(model as any);
|
||||
line(` parseModel → ${JSON.stringify(parsed)} ✅ sem crash`);
|
||||
} catch (err: any) {
|
||||
line(` ❌ model.ts parseModel THROW: ${err?.message}`);
|
||||
const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes("model.ts"));
|
||||
if (at) line(` ${at.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
line("");
|
||||
line("NVIDIA NIM — diagnóstico `startsWith is not a function`");
|
||||
// Never echo any portion of the key (js/clear-text-logging) — presence only.
|
||||
show("NVIDIA_API_KEY presente", KEY ? "sim" : "não");
|
||||
show("BASE_URL", BASE_URL);
|
||||
show("MODEL", MODEL);
|
||||
line("");
|
||||
await partA();
|
||||
await partB();
|
||||
await partC();
|
||||
hr();
|
||||
line("FIM.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("erro fatal no diagnóstico:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* One-shot regen of ~/.config/opencode/opencode.json from the live
|
||||
* OmniRoute /v1/models catalog. Run after a catalog change to refresh
|
||||
* the opencode client.
|
||||
*
|
||||
* Usage: bun run scripts/regen-opencode-config.ts
|
||||
* or npx tsx scripts/regen-opencode-config.ts
|
||||
*/
|
||||
import { generateOpencodeConfig } from "../src/lib/cli-helper/config-generator/opencode.ts";
|
||||
|
||||
const baseURL = process.env.OMNIROUTE_URL ?? "http://localhost:20128";
|
||||
const apiKey = process.env.OMNIROUTE_KEY ?? process.env.OPENCODE_API_KEY ?? "";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(
|
||||
"OMNIROUTE_KEY (or OPENCODE_API_KEY) env var is required. " +
|
||||
"Find it in OmniRoute dashboard → Settings → API Keys."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const out = await generateOpencodeConfig({ baseUrl: baseURL, apiKey });
|
||||
console.log(out);
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
|
||||
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
||||
|
||||
const filesToCheckoutOurs = [
|
||||
".source/browser.ts",
|
||||
".source/server.ts",
|
||||
"package-lock.json",
|
||||
"electron/package-lock.json",
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx",
|
||||
"src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx",
|
||||
"src/lib/db/contextHandoffs.ts",
|
||||
"src/app/api/keys/groups/[id]/keys/route.ts",
|
||||
"src/app/api/keys/groups/[id]/permissions/route.ts",
|
||||
"src/app/api/keys/groups/[id]/route.ts",
|
||||
"src/app/api/keys/groups/route.ts",
|
||||
"src/app/api/middleware/hooks/[name]/route.ts",
|
||||
"src/app/api/middleware/hooks/route.ts",
|
||||
"src/app/api/relay/tokens/[id]/route.ts",
|
||||
"src/app/api/relay/tokens/route.ts",
|
||||
"src/app/api/playground/simulate-route/route.ts",
|
||||
];
|
||||
|
||||
function runCmd(cmd) {
|
||||
console.log(`Running: ${cmd}`);
|
||||
return execSync(cmd, { cwd: projectRoot, encoding: "utf-8" });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Checkout ours for the files where HEAD is the preferred up-to-date state
|
||||
for (const file of filesToCheckoutOurs) {
|
||||
try {
|
||||
runCmd(`git checkout --ours "${file}"`);
|
||||
runCmd(`git add "${file}"`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to checkout --ours for ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Resolve .dockerignore (keep release/v3.8.4 doc rules)
|
||||
try {
|
||||
runCmd("git checkout --theirs .dockerignore");
|
||||
runCmd("git add .dockerignore");
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve .dockerignore:", err.message);
|
||||
}
|
||||
|
||||
// 3. Resolve docs/reference/ENVIRONMENT.md (keep release/v3.8.4 table formatting)
|
||||
try {
|
||||
runCmd("git checkout --theirs docs/reference/ENVIRONMENT.md");
|
||||
runCmd("git add docs/reference/ENVIRONMENT.md");
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve docs/reference/ENVIRONMENT.md:", err.message);
|
||||
}
|
||||
|
||||
// 4. Resolve open-sse/executors/index.ts (keep both ClaudeWebExecutor and InnerAiExecutor)
|
||||
const execIndexFile = path.join(projectRoot, "open-sse/executors/index.ts");
|
||||
if (fs.existsSync(execIndexFile)) {
|
||||
let content = fs.readFileSync(execIndexFile, "utf-8");
|
||||
|
||||
// Resolve imports conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nimport \{ ClaudeWebExecutor \} from "\.\/claude-web\.ts";\r?\n=======\r?\nimport \{ InnerAiExecutor \} from "\.\/inner-ai\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
'import { ClaudeWebExecutor } from "./claude-web.ts";\nimport { InnerAiExecutor } from "./inner-ai.ts";'
|
||||
);
|
||||
|
||||
// Resolve executor registration conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+"claude-web": new ClaudeWebExecutor\(\),\r?\n\s+"cw-web": new ClaudeWebExecutor\(\), \/\/ Alias\r?\n=======\r?\n\s+"inner-ai": new InnerAiExecutor\(\),\r?\n\s+"in-ai": new InnerAiExecutor\(\), \/\/ Alias\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
' "claude-web": new ClaudeWebExecutor(),\n "cw-web": new ClaudeWebExecutor(), // Alias\n "inner-ai": new InnerAiExecutor(),\n "in-ai": new InnerAiExecutor(), // Alias'
|
||||
);
|
||||
|
||||
fs.writeFileSync(execIndexFile, content);
|
||||
runCmd("git add open-sse/executors/index.ts");
|
||||
}
|
||||
|
||||
// 7. Resolve src/app/api/providers/[id]/models/route.ts (combine imports)
|
||||
const modelsRoute = path.join(projectRoot, "src/app/api/providers/[id]/models/route.ts");
|
||||
if (fs.existsSync(modelsRoute)) {
|
||||
let content = fs.readFileSync(modelsRoute, "utf-8");
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n=======\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error";\r?\nimport \{ getStaticQoderModels \} from "@omniroute\/open-sse\/services\/qoderCli\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
'import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";\nimport { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";'
|
||||
);
|
||||
fs.writeFileSync(modelsRoute, content);
|
||||
runCmd("git add src/app/api/providers/[id]/models/route.ts");
|
||||
}
|
||||
|
||||
// 8. Resolve src/sse/handlers/chat.ts
|
||||
const sseChat = path.join(projectRoot, "src/sse/handlers/chat.ts");
|
||||
if (fs.existsSync(sseChat)) {
|
||||
let content = fs.readFileSync(sseChat, "utf-8");
|
||||
|
||||
// Resolve comment / modelStr conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n=======\r?\n\s+\/\/ `let` because the middleware-hook pipeline \(line ~319\) may reassign this\r?\n\s+\/\/ when a hook rewrites the target model\. Previously declared `const`, which\r?\n\s+\/\/ broke turbopack\/strict-mode builds \(PR #2670 regression\)\.\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+let modelStr = body\.model;/g,
|
||||
" // `let` because the middleware-hook pipeline (line ~319) may reassign this\n // when a hook rewrites the target model. Previously declared `const`, which\n // broke turbopack/strict-mode builds (PR [PR #2670](file:///home/diegosouzapw/dev/proxys/OmniRoute/package.json#L2670) regression).\n let modelStr = body.model;"
|
||||
);
|
||||
|
||||
// Resolve trafficType / modelAbortSignal conflict (1st occurrence)
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+trafficType\?: "production" \| "shadow";\r?\n=======\r?\n\s+modelAbortSignal\?: AbortSignal \| null;\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
' trafficType?: "production" | "shadow";\n modelAbortSignal?: AbortSignal | null;'
|
||||
);
|
||||
|
||||
fs.writeFileSync(sseChat, content);
|
||||
runCmd("git add src/sse/handlers/chat.ts");
|
||||
}
|
||||
|
||||
// 9. Resolve bin/cli/tray/autostart.mjs (keep execFileSync, combine ignoreFailure and systemd CI fallback)
|
||||
const autostart = path.join(projectRoot, "bin/cli/tray/autostart.mjs");
|
||||
if (fs.existsSync(autostart)) {
|
||||
let content = fs.readFileSync(autostart, "utf-8");
|
||||
|
||||
// runUserSystemctl conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+\} catch \{\r?\n=======\r?\n\s+\} catch \(err\) \{\r?\n\s+if \(!ignoreFailure\) throw err;\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
` } catch (err) { \n if (!ignoreFailure) throw err;`
|
||||
);
|
||||
|
||||
// isSystemdServiceEnabled conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+return false;\r?\n=======\r?\n\s+\/\/ systemctl --user can't query the bus \(headless environments \/ CI runners\)\.\r?\n\s+\/\/ Treat the presence of the unit file as the source of truth, matching the\r?\n\s+\/\/ fallback used in enableLinux\(\) where unit-file existence counts as success\.\r?\n\s+return true;\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
` // systemctl --user can't query the bus (headless environments / CI runners).\n // Treat the presence of the unit file as the source of truth, matching the\n // fallback used in enableLinux() where unit-file existence counts as success.\n return true;`
|
||||
);
|
||||
|
||||
fs.writeFileSync(autostart, content);
|
||||
runCmd("git add bin/cli/tray/autostart.mjs");
|
||||
}
|
||||
|
||||
// 10. Resolve electron/package.json
|
||||
const electronPkg = path.join(projectRoot, "electron/package.json");
|
||||
if (fs.existsSync(electronPkg)) {
|
||||
let content = fs.readFileSync(electronPkg, "utf-8");
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+"electron": "\^42\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.0"\r?\n=======\r?\n\s+"electron": "\^41\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.1"\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
' "electron": "^42.2.0",\n "electron-builder": "^26.11.1"'
|
||||
);
|
||||
fs.writeFileSync(electronPkg, content);
|
||||
runCmd("git add electron/package.json");
|
||||
}
|
||||
|
||||
// 11. Resolve .github/workflows/ci.yml
|
||||
const ciYaml = path.join(projectRoot, ".github/workflows/ci.yml");
|
||||
if (fs.existsSync(ciYaml)) {
|
||||
let content = fs.readFileSync(ciYaml, "utf-8");
|
||||
|
||||
// Run c8 over shard title
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+rm -rf coverage-shard coverage-shard-report\r?\n=======\r?\n\s+# `--temp-directory` \(writable via NODE_V8_COVERAGE\) is what the merge\r?\n\s+# job reads with `c8 report --temp-directory \.\.\.`\. Using `--output-dir`\r?\n\s+# only produces the final json \*report\* and leaves the raw v8 files in\r?\n\s+# `coverage\/tmp`, so uploading `coverage-shard\/` was empty\. Pin the temp\r?\n\s+# dir so the raw coverage files live there and the artifact upload picks\r?\n\s+# them up regardless of `--test-force-exit` timing\.\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" rm -rf coverage-shard coverage-shard-report\n # `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge\n # job reads with `c8 report --temp-directory ...`. Using `--output-dir`\n # only produces the final json *report* and leaves the raw v8 files in\n # `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp\n # dir so the raw coverage files live there and the artifact upload picks\n # them up regardless of `--test-force-exit` timing."
|
||||
);
|
||||
|
||||
// c8 temp-directory arg
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n=======\r?\n\s+--temp-directory=coverage-shard\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" --temp-directory=coverage-shard"
|
||||
);
|
||||
|
||||
fs.writeFileSync(ciYaml, content);
|
||||
runCmd("git add .github/workflows/ci.yml");
|
||||
}
|
||||
|
||||
// 12. Resolve Dockerfile
|
||||
const dockerfile = path.join(projectRoot, "Dockerfile");
|
||||
if (fs.existsSync(dockerfile)) {
|
||||
let content = fs.readFileSync(dockerfile, "utf-8");
|
||||
|
||||
// FROM node
|
||||
content = content.replace(
|
||||
/FROM node:26\.2\.0-trixie-slim AS builder\r?\nFROM node:24-trixie-slim AS builder/g,
|
||||
"FROM node:24-trixie-slim AS builder"
|
||||
);
|
||||
|
||||
// apt-get cache mounts
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/var\/cache\/apt,sharing=locked \\\r?\n\s+--mount=type=cache,target=\/var\/lib\/apt\/lists,sharing=locked \\\r?\n\s+apt-get update \\\r?\n=======\r?\nRUN apt-get update \\\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
"RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\\n apt-get update \\"
|
||||
);
|
||||
|
||||
// npm ci script ignore and reproducible build check
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+if \[ -f package-lock\.json \]; then \\\r?\n\s+npm ci --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+else \\\r?\n\s+npm install --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+fi\r?\n=======\r?\n# `--ignore-scripts` blocks the install\/postinstall hooks of dependencies,[\s\S]*?RUN npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
`# --ignore-scripts blocks the install/postinstall hooks of dependencies,
|
||||
# closing the supply-chain attack surface where a transitive dep can run
|
||||
# arbitrary code at install time. OmniRoute's own postinstall (
|
||||
# better-sqlite3 binary touchups, @swc/helpers copy) is only needed when
|
||||
# a packaged app/node_modules is unpacked — inside the Docker builder we
|
||||
# are doing a fresh native-platform install, so dropping the scripts is safe.
|
||||
#
|
||||
# We REQUIRE a committed package-lock.json so resolved dependency versions
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \\
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
RUN --mount=type=cache,target=/root/.npm \\
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts`
|
||||
);
|
||||
|
||||
// npm global install
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n=======\r?\nRUN npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n\r?\nUSER node\r?\n\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
"RUN --mount=type=cache,target=/root/.npm \\\n npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest\n\nUSER node"
|
||||
);
|
||||
|
||||
fs.writeFileSync(dockerfile, content);
|
||||
runCmd("git add Dockerfile");
|
||||
}
|
||||
|
||||
// 13. Resolve open-sse/services/combo.ts
|
||||
const openSseCombo = path.join(projectRoot, "open-sse/services/combo.ts");
|
||||
if (fs.existsSync(openSseCombo)) {
|
||||
let content = fs.readFileSync(openSseCombo, "utf-8");
|
||||
|
||||
// IntentClassifierConfig imports
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nimport \{\r?\n\s+classifyWithConfig,\r?\n\s+DEFAULT_INTENT_CONFIG,\r?\n\s+type IntentClassifierConfig,\r?\n\} from "\.\/intentClassifier\.ts";\r?\n=======\r?\nimport \{ notifyWebhookEvent \} from "\.\.\/\.\.\/src\/lib\/webhookDispatcher";\r?\nimport \{ classifyWithConfig, DEFAULT_INTENT_CONFIG \} from "\.\/intentClassifier\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
'import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";\nimport {\n classifyWithConfig,\n DEFAULT_INTENT_CONFIG,\n type IntentClassifierConfig,\n} from "./intentClassifier.ts";'
|
||||
);
|
||||
|
||||
// handlePipelineCombo call
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+handleChatCore: handleSingleModel,\r?\n\s+log: \{\r?\n\s+info: log\.info,\r?\n\s+warn: log\.warn,\r?\n\s+error: log\.error \?\? log\.warn,\r?\n\s+\},\r?\n\s+settings: settings \?\? \{\},\r?\n\s+signal: signal \?\? undefined,\r?\n=======\r?\n\s+handleChatCore: handleSingleModelWithTimeout,\r?\n\s+log,\r?\n\s+settings,\r?\n\s+signal,\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" handleChatCore: handleSingleModelWithTimeout,\n log: {\n info: log.info,\n warn: log.warn,\n error: log.error ?? log.warn,\n },\n settings: settings ?? {},\n signal: signal ?? undefined,"
|
||||
);
|
||||
|
||||
// handleSingleModel call in loop
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+const result = await handleSingleModelWrapped\(attemptBody, modelStr, \{\r?\n=======\r?\n\s+const result = await handleSingleModelWithTimeout\(body, modelStr, \{\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {"
|
||||
);
|
||||
|
||||
// recordSessionModelUsage conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+recordSessionModelUsage\([\s\S]*?\);\r?\n\s+\r?\n=======\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" recordSessionModelUsage(\n relayOptions.sessionId,\n combo.name,\n modelStr,\n provider,\n target.connectionId ?? undefined\n );"
|
||||
);
|
||||
|
||||
fs.writeFileSync(openSseCombo, content);
|
||||
runCmd("git add open-sse/services/combo.ts");
|
||||
}
|
||||
|
||||
// 14. Resolve src/app/api/copilot/chat/route.ts
|
||||
const copilotChatRoute = path.join(projectRoot, "src/app/api/copilot/chat/route.ts");
|
||||
if (fs.existsSync(copilotChatRoute)) {
|
||||
let content = fs.readFileSync(copilotChatRoute, "utf-8");
|
||||
|
||||
// Imports conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\nimport \{ requireManagementAuth \} from "@\/lib\/api\/requireManagementAuth";\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport \{ isValidationFailure, validateBody \} from "@\/shared\/validation\/helpers";\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error\.ts";\r?\n=======\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport type \{ CopilotRequest \} from "@\/lib\/copilot\/engine";\r?\nimport \{ buildErrorBody \} from "@omniroute\/open-sse\/utils\/error";\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
'import { requireManagementAuth } from "@/lib/api/requireManagementAuth";\nimport { processCopilotChat } from "@/lib/copilot/engine";\nimport { isValidationFailure, validateBody } from "@/shared/validation/helpers";\nimport { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";'
|
||||
);
|
||||
|
||||
// Schema content min length
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+content: z\.string\(\)\.min\(1, "message content is required"\),\r?\n=======\r?\n\s+content: z\.string\(\),\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
' content: z.string().min(1, "message content is required"),'
|
||||
);
|
||||
|
||||
// POST implementation conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+const authError = await requireManagementAuth\(request\);\r?\n\s+if \(authError\) return authError;\r?\n\r?\n\s+try \{\r?\n\s+const rawBody = await request.json\(\);\r?\n\s+const validation = validateBody\(copilotRequestSchema, rawBody\);\r?\n\s+if \(isValidationFailure\(validation\)\) \{\r?\n\s+return NextResponse\.json\(\{ error: validation\.error \}, \{ status: 400 \}\);\r?\n=======\r?\n\s+try \{\r?\n\s+const raw = await request.json\(\);\r?\n\s+const parsed = copilotRequestSchema\.safeParse\(raw\);\r?\n\s+if \(!parsed\.success\) \{\r?\n\s+return NextResponse\.json\r?\n\s+buildErrorBody\(400, parsed\.error\.issues\[0\]\?\.message \?\? "Invalid request"\),\r?\n\s+\{ status: 400 \}\r?\n\s+\);\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+\}\r?\n\s+const body = parsed\.data as CopilotRequest;\r?\n\r?\n\s+const response = await processCopilotChat\(body\);/g,
|
||||
" const authError = await requireManagementAuth(request);\n if (authError) return authError;\n\n try {\n const rawBody = await request.json();\n const validation = validateBody(copilotRequestSchema, rawBody);\n if (isValidationFailure(validation)) {\n return NextResponse.json(\n buildErrorBody(400, validation.error),\n { status: 400 }\n );\n }\n const response = await processCopilotChat(validation.data);"
|
||||
);
|
||||
|
||||
// Error handling conflict
|
||||
content = content.replace(
|
||||
/<<<<<<< HEAD\r?\n\s+const message = sanitizeErrorMessage\(error\);\r?\n\s+return NextResponse\.json\(\{ error: `Copilot error: \$\{message\}` \}, \{ status: 500 \}\);\r?\n=======\r?\n\s+\/\/ buildErrorBody\(\) routes through sanitizeErrorMessage\(\), which strips\r?\n\s+\/\/ stack traces and absolute file paths\. Hard rule #12\.\r?\n\s+const message = error instanceof Error \? error\.message : "Unknown error";\r?\n\s+return NextResponse\.json\(buildErrorBody\(500, message\), \{ status: 500 \}\);\r?\n>>>>>>> release\/v3\.8\.4/g,
|
||||
" const message = sanitizeErrorMessage(error);\n return NextResponse.json(buildErrorBody(500, `Copilot error: ${message}`), { status: 500 });"
|
||||
);
|
||||
|
||||
fs.writeFileSync(copilotChatRoute, content);
|
||||
runCmd("git add src/app/api/copilot/chat/route.ts");
|
||||
}
|
||||
|
||||
console.log("Resolutions written and staged!");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,61 @@
|
||||
// End-to-end smoke test: drives TraeExecutor against the real Trae API with your
|
||||
// JWT from trae_solo.env (kept outside the repo), printing content + usage.
|
||||
// Run: node --import tsx/esm scripts/ad-hoc/smoke-trae.mjs
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "../../../trae_solo.env");
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.error(`Не найден ${envPath}. Положи туда TRAE_TOKEN= и TRAE_WEB_ID= и т.д.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const cfg = Object.fromEntries(
|
||||
fs
|
||||
.readFileSync(envPath, "utf8")
|
||||
.split("\n")
|
||||
.filter((l) => l && !l.startsWith("#") && l.includes("="))
|
||||
.map((l) => {
|
||||
const i = l.indexOf("=");
|
||||
return [l.slice(0, i), l.slice(i + 1)];
|
||||
})
|
||||
);
|
||||
|
||||
const { TraeExecutor } = await import("../../open-sse/executors/trae.ts");
|
||||
const ex = new TraeExecutor();
|
||||
|
||||
const credentials = {
|
||||
accessToken: cfg.TRAE_TOKEN,
|
||||
providerSpecificData: {
|
||||
webId: cfg.TRAE_WEB_ID || "",
|
||||
bizUserId: cfg.TRAE_BIZ_USER_ID || "",
|
||||
userUniqueId: cfg.TRAE_USER_UNIQUE_ID || "",
|
||||
scope: cfg.TRAE_SCOPE || "marscode-us",
|
||||
tenant: cfg.TRAE_TENANT || "marscode",
|
||||
region: cfg.TRAE_REGION || "US-East",
|
||||
aiRegion: cfg.TRAE_AIREGION || cfg.TRAE_REGION || "US-East",
|
||||
appLanguage: cfg.TRAE_APP_LANGUAGE || "en",
|
||||
appVersion: cfg.TRAE_APP_VERSION || "1.0.0.1229",
|
||||
},
|
||||
};
|
||||
|
||||
const model = process.argv[2] || "auto";
|
||||
const prompt = process.argv.slice(3).join(" ") || "Ответь одним словом: столица Франции?";
|
||||
|
||||
console.log(`[smoke] model=${model} prompt=${JSON.stringify(prompt)}`);
|
||||
const { response } = await ex.execute({
|
||||
model,
|
||||
body: { messages: [{ role: "user", content: prompt }] },
|
||||
stream: false,
|
||||
credentials,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status !== 200) {
|
||||
console.error(`[smoke] HTTP ${response.status}\n${text}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const json = JSON.parse(text);
|
||||
console.log("content:", JSON.stringify(json.choices?.[0]?.message?.content));
|
||||
console.log("usage: ", json.usage);
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync the cursor models list in open-sse/config/providerRegistry.ts from
|
||||
// cursor-agent's runtime model list. Triggers an intentional invalid --model
|
||||
// invocation so cursor-agent prints "Available models: ..." on stderr.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/ad-hoc/sync-cursor-models.mjs # spawn cursor-agent and apply
|
||||
// node scripts/ad-hoc/sync-cursor-models.mjs --dry-run # print proposed block, don't write
|
||||
// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read the error message from stdin
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts");
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const DRY_RUN = args.has("--dry-run");
|
||||
const FROM_STDIN = args.has("--from-stdin");
|
||||
|
||||
function readSource() {
|
||||
if (FROM_STDIN) return readFileSync(0, "utf8");
|
||||
// cursor-agent prints "Available models: ..." to stderr and exits non-zero.
|
||||
const r = spawnSync("cursor-agent", ["--model", "--help"], { encoding: "utf8" });
|
||||
return `${r.stdout || ""}\n${r.stderr || ""}`;
|
||||
}
|
||||
|
||||
// `auto` is a CLI-side abstraction (cursor-agent resolves it locally before
|
||||
// sending) and `composer-*` targets cursor's edit/composer endpoint, neither
|
||||
// of which work via the chat RPC. Filter them so the dashboard never offers
|
||||
// them to the chat path. Pass --include-unsupported to keep them.
|
||||
function isUnsupportedChatModel(id) {
|
||||
if (id === "auto") return true;
|
||||
if (id.startsWith("composer-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseModelIds(text) {
|
||||
const m = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (!m) throw new Error("Could not find 'Available models:' line in cursor-agent output");
|
||||
const includeUnsupported = args.has("--include-unsupported");
|
||||
return m[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => includeUnsupported || !isUnsupportedChatModel(id));
|
||||
}
|
||||
|
||||
const SEGMENT_OVERRIDES = {
|
||||
gpt: "GPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
composer: "Composer",
|
||||
opus: "Opus",
|
||||
sonnet: "Sonnet",
|
||||
haiku: "Haiku",
|
||||
codex: "Codex",
|
||||
mini: "Mini",
|
||||
nano: "Nano",
|
||||
max: "Max",
|
||||
high: "High",
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
xhigh: "XHigh",
|
||||
none: "None",
|
||||
fast: "Fast",
|
||||
thinking: "Thinking",
|
||||
extra: "Extra",
|
||||
spark: "Spark",
|
||||
preview: "Preview",
|
||||
flash: "Flash",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
// Pretty-print an id by:
|
||||
// 1) collapsing claude-NAME-X-Y dotted version (e.g. claude-opus-4-7 → claude-opus-4.7)
|
||||
// 2) splitting on '-'
|
||||
// 3) applying SEGMENT_OVERRIDES; falling back to capitalize-first
|
||||
function humanize(id) {
|
||||
if (id === "auto") return "Auto (Server Picks)";
|
||||
|
||||
// Collapse "X-Y" numeric suffix in claude-foo-X-Y- patterns into "X.Y"
|
||||
const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2");
|
||||
const parts = collapsed.split("-");
|
||||
const labelled = parts.map((p) => {
|
||||
if (SEGMENT_OVERRIDES[p]) return SEGMENT_OVERRIDES[p];
|
||||
if (/^\d/.test(p)) return p; // leave version numbers / "k2.5" alone
|
||||
return p.charAt(0).toUpperCase() + p.slice(1);
|
||||
});
|
||||
return labelled.join(" ");
|
||||
}
|
||||
|
||||
function buildModelsArrayLines(ids) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(` { id: ${JSON.stringify(id)}, name: ${JSON.stringify(humanize(id))} },`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function replaceCursorModels(source, modelsBlock) {
|
||||
// Match the `cursor:` provider entry and replace just its `models: [ ... ],` array.
|
||||
const re = /(\n cursor:\s*\{[\s\S]*?\n models:\s*\[)([\s\S]*?)(\n \],)/;
|
||||
if (!re.test(source)) throw new Error("Could not locate cursor.models array in registry");
|
||||
return source.replace(re, `$1\n${modelsBlock}$3`);
|
||||
}
|
||||
|
||||
const ids = parseModelIds(readSource());
|
||||
const block = buildModelsArrayLines(ids);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(block);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const before = readFileSync(REGISTRY_PATH, "utf8");
|
||||
const after = replaceCursorModels(before, block);
|
||||
if (before === after) {
|
||||
console.log("No changes — cursor models already in sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
writeFileSync(REGISTRY_PATH, after);
|
||||
console.log(`Updated ${ids.length} cursor models in ${REGISTRY_PATH}`);
|
||||
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* assembleStandalone.mjs - Shared standalone bundle assembler for OmniRoute.
|
||||
*
|
||||
* Task 0.1 Inventory: Copy/sync operations across the three build scripts
|
||||
* -----------------------------------------------------------------------
|
||||
* Operation build-next-isolated prepublish electron Status
|
||||
* --------------------------------------------------- ------------------- ----------- -------- ------
|
||||
* .next/standalone -> outDir (cp) Y Y Y SHARED
|
||||
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
|
||||
* public/ -> outDir/public/ (cp) Y Y Y SHARED
|
||||
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
|
||||
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
|
||||
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
|
||||
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
|
||||
* pino-pretty -> outDir/node_modules/pino-pretty Y - - SHARED (extra module)
|
||||
* split2 -> outDir/node_modules/split2 Y - - SHARED (extra module)
|
||||
* src/lib/db/migrations -> outDir/migrations Y Y - SHARED (extra module)
|
||||
* src/mitm/server.cjs -> outDir/src/mitm/server.cjs Y - - SHARED (extra module)
|
||||
* scripts/dev/run-standalone.mjs -> outDir/dev/run-standalone Y - - SHARED (extra module)
|
||||
* scripts/dev/standalone-server-ws.mjs -> outDir/server-ws Y Y - SHARED (extra module)
|
||||
* scripts/dev/peer-stamp.mjs -> outDir/peer-stamp.mjs Y Y - SHARED (extra module)
|
||||
* scripts/dev/responses-ws-proxy.mjs -> outDir/responses-ws- Y Y - SHARED (extra module)
|
||||
* scripts/build/runtime-env.mjs -> outDir/build/runtime-env Y - - SHARED (extra module)
|
||||
* scripts/build/bootstrap-env.mjs -> outDir/build/bootstrap- Y - - SHARED (extra module)
|
||||
* scripts/dev/healthcheck.mjs -> outDir/healthcheck.mjs Y - - SHARED (extra module)
|
||||
* playwright-core -> outDir/node_modules/playwright-core Y - - SHARED (extra module)
|
||||
* sqlite-vec -> outDir/node_modules/sqlite-vec Y - - SHARED (extra module)
|
||||
* sqlite-vec-linux-x64/arm64/darwin-x64/arm64/win-x64 (same) Y - - SHARED (extra module)
|
||||
* abs-path sanitization in server.js + required-server-files - Y Y SHARED (opt-in: sanitizePaths)
|
||||
* Turbopack hashed-chunk patch (.next/server/ *.js) - Y - SHARED (opt-in: patchTurbopackChunks)
|
||||
* --- npm-UNIQUE ---
|
||||
* MITM tsc compile -> app/src/mitm/ - Y - UNIQUE (prepublish)
|
||||
* MCP server esbuild -> dist/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish)
|
||||
* CLI esbuild -> bin/omniroute.mjs - Y - UNIQUE (prepublish)
|
||||
* sidecar/doc copies (.env.example, docs/, sync-env, etc.) - Y - UNIQUE (prepublish)
|
||||
* prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish)
|
||||
* data/ dir creation - Y - UNIQUE (prepublish)
|
||||
* --- electron-UNIQUE ---
|
||||
* better-sqlite3 + keytar native strip (ABI rebuild) - - Y UNIQUE (electron)
|
||||
* symlink guard (assertBundleIsPackagable) - - Y UNIQUE (electron)
|
||||
* removeGeneratedElectronArtifacts - - Y UNIQUE (electron)
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Check whether a path exists (async).
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function exists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SINGLE SOURCE OF TRUTH for the standalone bundle's native assets and extra
|
||||
* modules/sidecars. Both the async path (syncStandaloneNativeAssets /
|
||||
* syncStandaloneExtraModules, used by build-next-isolated + tests) and the sync
|
||||
* path (copyNativeAssetsAndExtraModules, used by assembleStandalone) derive their
|
||||
* copy lists from these arrays. Add a sidecar in ONE place — never two.
|
||||
*
|
||||
* Each entry uses path SEGMENT arrays (not pre-joined strings) so the source
|
||||
* (relative to projectRoot) and destination (relative to outDir) can be joined
|
||||
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
|
||||
*/
|
||||
const NATIVE_ASSET_ENTRIES = [
|
||||
{
|
||||
label: "wreq-js native runtime",
|
||||
src: ["node_modules", "wreq-js", "rust"],
|
||||
dest: ["node_modules", "wreq-js", "rust"],
|
||||
},
|
||||
{
|
||||
label: "better-sqlite3 native binary",
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
|
||||
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
|
||||
// builds → syncNativeAssetsToDir skips it gracefully. The runtime loader
|
||||
// (transparentSocket.ts) resolves it cwd-relative to this same dest.
|
||||
label: "TPROXY transparent-socket addon (Linux-only, opt-in)",
|
||||
src: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
|
||||
dest: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {{label:string, src:string[], dest:string[]}[]} */
|
||||
const EXTRA_MODULE_ENTRIES = [
|
||||
{
|
||||
label: "@swc/helpers",
|
||||
src: ["node_modules", "@swc", "helpers"],
|
||||
dest: ["node_modules", "@swc", "helpers"],
|
||||
},
|
||||
{
|
||||
label: "pino-abstract-transport",
|
||||
src: ["node_modules", "pino-abstract-transport"],
|
||||
dest: ["node_modules", "pino-abstract-transport"],
|
||||
},
|
||||
{
|
||||
label: "pino-pretty",
|
||||
src: ["node_modules", "pino-pretty"],
|
||||
dest: ["node_modules", "pino-pretty"],
|
||||
},
|
||||
{ label: "split2", src: ["node_modules", "split2"], dest: ["node_modules", "split2"] },
|
||||
{ label: "migrations", src: ["src", "lib", "db", "migrations"], dest: ["migrations"] },
|
||||
{ label: "MITM server", src: ["src", "mitm", "server.cjs"], dest: ["src", "mitm", "server.cjs"] },
|
||||
{
|
||||
label: "run-standalone script",
|
||||
src: ["scripts", "dev", "run-standalone.mjs"],
|
||||
dest: ["dev", "run-standalone.mjs"],
|
||||
},
|
||||
{
|
||||
// WS-aware wrapper that run-standalone.mjs prefers over bare server.js.
|
||||
// It installs the trusted peer-IP stamp the authz middleware needs to allow
|
||||
// loopback/LAN access to LOCAL_ONLY routes; without it the Docker container
|
||||
// fails closed (every LOCAL_ONLY request 403s). Imports peer-stamp.mjs +
|
||||
// responses-ws-proxy.mjs, so all three are co-located.
|
||||
label: "WS/peer-stamp standalone server wrapper",
|
||||
src: ["scripts", "dev", "standalone-server-ws.mjs"],
|
||||
dest: ["server-ws.mjs"],
|
||||
},
|
||||
{
|
||||
label: "peer-stamp helper (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "peer-stamp.mjs"],
|
||||
dest: ["peer-stamp.mjs"],
|
||||
},
|
||||
{
|
||||
label: "HTTP method guard (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "http-method-guard.cjs"],
|
||||
dest: ["http-method-guard.cjs"],
|
||||
},
|
||||
{
|
||||
label: "responses-ws-proxy (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
|
||||
dest: ["responses-ws-proxy.mjs"],
|
||||
},
|
||||
{
|
||||
label: "webdav-handler (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "webdav-handler.mjs"],
|
||||
dest: ["webdav-handler.mjs"],
|
||||
},
|
||||
{
|
||||
// #5242: opt-in HTTPS/TLS resolver (server-ws.mjs dependency).
|
||||
label: "tls-options (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "tls-options.mjs"],
|
||||
dest: ["tls-options.mjs"],
|
||||
},
|
||||
{
|
||||
label: "runtime-env script",
|
||||
src: ["scripts", "build", "runtime-env.mjs"],
|
||||
dest: ["build", "runtime-env.mjs"],
|
||||
},
|
||||
{
|
||||
label: "bootstrap-env script",
|
||||
src: ["scripts", "build", "bootstrap-env.mjs"],
|
||||
dest: ["build", "bootstrap-env.mjs"],
|
||||
},
|
||||
{
|
||||
label: "healthcheck script",
|
||||
src: ["scripts", "dev", "healthcheck.mjs"],
|
||||
dest: ["healthcheck.mjs"],
|
||||
},
|
||||
{ label: "public directory", src: ["public"], dest: ["public"] },
|
||||
{
|
||||
label: "playwright-core (dynamic import by gemini-web executor)",
|
||||
src: ["node_modules", "playwright-core"],
|
||||
dest: ["node_modules", "playwright-core"],
|
||||
},
|
||||
{
|
||||
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
|
||||
src: ["node_modules", "sqlite-vec"],
|
||||
dest: ["node_modules", "sqlite-vec"],
|
||||
},
|
||||
// sqlite-vec's native vec0.so lives in a platform-specific package resolved at
|
||||
// runtime via require.resolve(). Next.js does NOT trace it into the standalone
|
||||
// (the externalized wrapper is copied, but its optional platform dep is missed -
|
||||
// Next.js #88844), so without this the bundled/Docker build silently degrades
|
||||
// vector search to FTS5: the wrapper loads but getLoadablePath() throws
|
||||
// MODULE_NOT_FOUND. Copy whichever platform package npm actually installed. See #3066.
|
||||
...[
|
||||
"sqlite-vec-linux-x64",
|
||||
"sqlite-vec-linux-arm64",
|
||||
"sqlite-vec-darwin-x64",
|
||||
"sqlite-vec-darwin-arm64",
|
||||
"sqlite-vec-windows-x64",
|
||||
].map((pkg) => ({ label: pkg, src: ["node_modules", pkg], dest: ["node_modules", pkg] })),
|
||||
];
|
||||
|
||||
/**
|
||||
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
|
||||
*
|
||||
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
|
||||
* for backward compatibility with existing callers and tests.
|
||||
*
|
||||
* @param {string} rootDir - project root (node_modules are read from here)
|
||||
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
|
||||
* @param {Console|{log:Function}} [log] - logger
|
||||
* @returns {Promise<boolean>} true if any asset was copied
|
||||
*/
|
||||
export async function syncStandaloneNativeAssets(rootDir, fsImpl = fs, log = console, outDir) {
|
||||
const standaloneRoot =
|
||||
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
|
||||
return syncNativeAssetsToDir(rootDir, standaloneRoot, fsImpl, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy extra modules and sidecars into the Next.js standalone output.
|
||||
*
|
||||
* The destination is derived as <rootDir>/<distDir>/standalone/...
|
||||
* where distDir defaults to ".build/next" (overridable via NEXT_DIST_DIR).
|
||||
*
|
||||
* @param {string} rootDir - project root
|
||||
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
|
||||
* @param {Console|{log:Function}} [log] - logger
|
||||
* @returns {Promise<boolean>} true if any module was copied
|
||||
*/
|
||||
export async function syncStandaloneExtraModules(rootDir, fsImpl = fs, log = console, outDir) {
|
||||
const standaloneRoot =
|
||||
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
|
||||
return syncExtraModulesToDir(rootDir, standaloneRoot, fsImpl, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: copy native assets to an arbitrary outDir.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
* @param {string} outDir
|
||||
* @param {typeof fs} fsImpl
|
||||
* @param {Console|{log:Function}} log
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
|
||||
let changed = false;
|
||||
|
||||
for (const entry of NATIVE_ASSET_ENTRIES) {
|
||||
const sourcePath = path.join(projectRoot, ...entry.src);
|
||||
if (!(await exists(sourcePath))) continue;
|
||||
|
||||
const destinationPath = path.join(outDir, ...entry.dest);
|
||||
const mkdir =
|
||||
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fsImpl.cp(sourcePath, destinationPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
log.log(
|
||||
`[assembleStandalone] Copied native standalone asset: ${path.relative(
|
||||
projectRoot,
|
||||
destinationPath
|
||||
)}`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: copy extra modules/sidecars to an arbitrary outDir.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
* @param {string} outDir
|
||||
* @param {typeof fs} fsImpl
|
||||
* @param {Console|{log:Function}} log
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
|
||||
let changed = false;
|
||||
|
||||
for (const entry of EXTRA_MODULE_ENTRIES) {
|
||||
const sourcePath = path.join(projectRoot, ...entry.src);
|
||||
if (!(await exists(sourcePath))) continue;
|
||||
|
||||
const destPath = path.join(outDir, ...entry.dest);
|
||||
const mkdir =
|
||||
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
|
||||
await mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fsImpl.cp(sourcePath, destPath, { recursive: true, force: true });
|
||||
log.log(`[assembleStandalone] Synced standalone module: ${entry.label}`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize absolute build-machine paths in server.js and required-server-files.json.
|
||||
* Replaces the build root with "." so paths resolve relative to wherever the standalone
|
||||
* bundle is installed.
|
||||
*
|
||||
* @param {string} projectRoot - repo root (the path to replace)
|
||||
* @param {string} outDir - assembled standalone output directory
|
||||
* @returns {number} number of path replacements made
|
||||
*/
|
||||
export function assemblePathSanitize(projectRoot, outDir, distDir = ".next") {
|
||||
const buildRoot = projectRoot.replaceAll("\\", "/"); // normalise for regex safety
|
||||
const sanitizeTargets = [
|
||||
path.join(outDir, "server.js"),
|
||||
// required-server-files.json lives under the distDir (e.g. .build/next), not
|
||||
// a literal .next — the standalone preserves the configured distDir path.
|
||||
path.join(outDir, distDir, "required-server-files.json"),
|
||||
];
|
||||
|
||||
let sanitisedCount = 0;
|
||||
for (const filePath of sanitizeTargets) {
|
||||
if (!fsSync.existsSync(filePath)) continue;
|
||||
let content = fsSync.readFileSync(filePath, "utf8");
|
||||
// Escape special regex characters in the path
|
||||
const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||
const re = new RegExp(escaped, "g");
|
||||
const matches = content.match(re);
|
||||
if (matches) {
|
||||
content = content.replace(re, ".");
|
||||
fsSync.writeFileSync(filePath, content);
|
||||
sanitisedCount += matches.length;
|
||||
}
|
||||
}
|
||||
return sanitisedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Turbopack hashed externals from compiled chunks.
|
||||
* Even when Turbopack is disabled at build time, some instrumentation chunks
|
||||
* may still emit require('package-<16hexchars>') instead of require('package').
|
||||
* We strip the hex suffix from all .js files in outDir/.next/server/.
|
||||
*
|
||||
* @param {string} outDir - assembled standalone output directory
|
||||
* @returns {{ patchedFiles: number, patchedMatches: number }}
|
||||
*/
|
||||
export function patchTurbopackChunks(outDir, distDir = ".next") {
|
||||
const serverOutput = path.join(outDir, distDir, "server");
|
||||
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+?-[0-9a-f]{16}(?:\/[^'"\\]+)?)\1/g;
|
||||
let patchedFiles = 0;
|
||||
let patchedMatches = 0;
|
||||
|
||||
const walkDir = (dir) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fsSync.readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry);
|
||||
try {
|
||||
const st = fsSync.statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
walkDir(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".js")) continue;
|
||||
const src = fsSync.readFileSync(full, "utf8");
|
||||
let count = 0;
|
||||
const patched = src.replace(HASH_RE, (_, q, name) => {
|
||||
const base = name.replace(/-[0-9a-f]{16}(?=\/|$)/, "");
|
||||
count++;
|
||||
return `${q}${base}${q}`;
|
||||
});
|
||||
if (count > 0) {
|
||||
fsSync.writeFileSync(full, patched);
|
||||
patchedFiles++;
|
||||
patchedMatches += count;
|
||||
}
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (fsSync.existsSync(serverOutput)) {
|
||||
walkDir(serverOutput);
|
||||
}
|
||||
|
||||
return { patchedFiles, patchedMatches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js standalone's server.js is CommonJS (uses require()), but the root package.json
|
||||
* (which Next copies into the standalone) has "type":"module". Strip "type" so Node treats
|
||||
* .js files as CJS in the bundle dir — otherwise `node server.js` fails with
|
||||
* "require is not defined in ES module scope".
|
||||
*
|
||||
* @param {string} resolvedOutDir - assembled standalone output directory
|
||||
*/
|
||||
function patchStandalonePackageJson(resolvedOutDir) {
|
||||
const outDirPkgJson = path.join(resolvedOutDir, "package.json");
|
||||
if (!fsSync.existsSync(outDirPkgJson)) return;
|
||||
try {
|
||||
const pkg = JSON.parse(fsSync.readFileSync(outDirPkgJson, "utf8"));
|
||||
if (pkg.type !== "module") return;
|
||||
delete pkg.type;
|
||||
fsSync.writeFileSync(outDirPkgJson, JSON.stringify(pkg, null, 2) + "\n");
|
||||
console.log(
|
||||
"[assembleStandalone] Removed 'type':'module' from standalone package.json (server.js is CJS)"
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[assembleStandalone] Could not patch standalone package.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
|
||||
* The static dest mirrors the configured distDir (e.g. .build/next), which is where the
|
||||
* standalone server serves /_next/static from. See step 2 in assembleStandalone for why.
|
||||
*
|
||||
* @param {{ distDir: string, relDistDir: string, projectRoot: string, resolvedOutDir: string }} opts
|
||||
*/
|
||||
function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir }) {
|
||||
const staticSrc = path.join(distDir, "static");
|
||||
const staticDest = path.join(resolvedOutDir, relDistDir, "static");
|
||||
if (fsSync.existsSync(staticSrc)) {
|
||||
fsSync.mkdirSync(path.dirname(staticDest), { recursive: true });
|
||||
fsSync.cpSync(staticSrc, staticDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const publicSrc = path.join(projectRoot, "public");
|
||||
if (fsSync.existsSync(publicSrc)) {
|
||||
fsSync.cpSync(publicSrc, path.join(resolvedOutDir, "public"), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
|
||||
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
|
||||
* into the assembled bundle. Missing sources are skipped silently.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
* @param {string} resolvedOutDir
|
||||
*/
|
||||
function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
|
||||
for (const asset of NATIVE_ASSET_ENTRIES) {
|
||||
const src = path.join(projectRoot, ...asset.src);
|
||||
if (!fsSync.existsSync(src)) continue;
|
||||
const dest = path.join(resolvedOutDir, ...asset.dest);
|
||||
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fsSync.cpSync(src, dest, { recursive: true, force: true });
|
||||
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
|
||||
}
|
||||
|
||||
for (const mod of EXTRA_MODULE_ENTRIES) {
|
||||
const src = path.join(projectRoot, ...mod.src);
|
||||
if (!fsSync.existsSync(src)) continue;
|
||||
const dest = path.join(resolvedOutDir, ...mod.dest);
|
||||
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fsSync.cpSync(src, dest, { recursive: true, force: true });
|
||||
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the Next.js standalone bundle into outDir.
|
||||
*
|
||||
* Copies <distDir>/standalone -> outDir, then <distDir>/static -> outDir/.next/static,
|
||||
* projectRoot/public -> outDir/public, native assets, and extra modules/sidecars.
|
||||
* Optionally sanitizes abs paths and patches Turbopack chunks.
|
||||
*
|
||||
* This is a synchronous function for use in build scripts.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.distDir - Next.js distDir (e.g. ".next" or ".build/next")
|
||||
* @param {string} opts.outDir - destination directory for the assembled bundle
|
||||
* @param {string} [opts.projectRoot] - repo root; defaults to process.cwd()
|
||||
* @param {boolean} [opts.sanitizePaths] - replace build-machine abs paths with "." (default false)
|
||||
* @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false)
|
||||
* @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true)
|
||||
* @returns {void}
|
||||
*/
|
||||
export function assembleStandalone({
|
||||
distDir,
|
||||
outDir,
|
||||
projectRoot = process.cwd(),
|
||||
sanitizePaths = false,
|
||||
patchTurbopackChunks: doPatchChunks = false,
|
||||
copyNatives = true,
|
||||
}) {
|
||||
if (!distDir) throw new Error("[assembleStandalone] distDir is required");
|
||||
if (!outDir) throw new Error("[assembleStandalone] outDir is required");
|
||||
|
||||
// The standalone bundle preserves the distDir path RELATIVE to projectRoot
|
||||
// (the server's baked config uses e.g. "./.build/next"), so output dest paths
|
||||
// for static / required-server-files / server chunks must use the relative
|
||||
// distDir appended to outDir — never the absolute build-machine distDir.
|
||||
const relDistDir = path.isAbsolute(distDir) ? path.relative(projectRoot, distDir) : distDir;
|
||||
|
||||
const standaloneDir = path.resolve(path.join(distDir, "standalone"));
|
||||
const resolvedOutDir = path.resolve(outDir);
|
||||
if (!fsSync.existsSync(standaloneDir)) {
|
||||
throw new Error(
|
||||
`[assembleStandalone] standalone dir not found: ${standaloneDir}. Run \`next build\` first.`
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Copy <distDir>/standalone -> outDir (skip when outDir IS the standalone dir — in-place mode)
|
||||
fsSync.mkdirSync(resolvedOutDir, { recursive: true });
|
||||
if (resolvedOutDir !== standaloneDir) {
|
||||
fsSync.cpSync(standaloneDir, resolvedOutDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 1.5. Standalone server.js is CJS — strip "type":"module" from the copied package.json.
|
||||
patchStandalonePackageJson(resolvedOutDir);
|
||||
|
||||
// 2/3. Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
|
||||
// CRITICAL: the standalone server.js is built with distDir baked into its config
|
||||
// (e.g. "./.build/next"), so it serves /_next/static from <outDir>/<relDistDir>/static,
|
||||
// NOT a literal <outDir>/.next/static. Copying to .next/static leaves the server's
|
||||
// static dir empty → every JS/CSS chunk 404s → blank page. Mirror the distDir path.
|
||||
copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir });
|
||||
|
||||
// 4. Optionally sanitize abs paths
|
||||
if (sanitizePaths) {
|
||||
const count = assemblePathSanitize(projectRoot, resolvedOutDir, relDistDir);
|
||||
if (count > 0) {
|
||||
console.log(`[assembleStandalone] Sanitised ${count} hardcoded path references`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Optionally patch Turbopack hashed chunks
|
||||
if (doPatchChunks) {
|
||||
const { patchedFiles, patchedMatches } = patchTurbopackChunks(resolvedOutDir, relDistDir);
|
||||
if (patchedMatches > 0) {
|
||||
console.log(
|
||||
`[assembleStandalone] Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Optionally copy native assets + extra modules (synchronous)
|
||||
if (copyNatives) {
|
||||
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Backend-only build helper.
|
||||
*
|
||||
* When `OMNIROUTE_BUILD_BACKEND_ONLY=1` (or `OMNIROUTE_BUILD_PROFILE=backend`) is set,
|
||||
* `build-next-isolated.mjs` calls `stubDashboardPages()` BEFORE `next build` and
|
||||
* `restoreDashboardPages()` in a `finally` afterward.
|
||||
*
|
||||
* WHY: OmniRoute embedders that only consume the HTTP API (`/api/*`, `/v1/*`, `/v1beta/*`)
|
||||
* — e.g. the VibeProxy desktop app, headless self-hosters, CI that only needs the router —
|
||||
* do NOT need the Next.js dashboard UI. Building it dominates `next build`: the ~126 leaf
|
||||
* pages pull in heavy client vendor chunks (recharts, monaco-editor, @xyflow, mermaid,
|
||||
* @lobehub/icons), the static-generation pass renders every route, and React Server Actions
|
||||
* generate a client-entry manifest. Replacing every App-Router UI file (page/layout/template/
|
||||
* loading/error/not-found/default) with a trivial server stub removes the client graph, the
|
||||
* prerender pass, AND all `"use server"` actions, while leaving EVERY `route.ts` (API) handler,
|
||||
* `middleware`, and metadata route (sitemap/robots/manifest/icon/...) fully intact. The
|
||||
* resulting standalone `server.js` serves the complete backend API; the dashboard renders
|
||||
* nothing.
|
||||
*
|
||||
* WHY STUB LAYOUTS TOO (not just pages): Next's FlightClientEntryPlugin builds a per-route
|
||||
* Server-Actions manifest. Stubbing only the pages while leaving layouts (which import client
|
||||
* providers and inline `"use server"` actions) leaves actions registered whose page-level
|
||||
* client entry no longer exists — `createActionAssets` then dereferences an undefined module
|
||||
* map ("Cannot read properties of undefined (reading 'server')"). Stubbing the whole UI tree
|
||||
* removes every action and client reference, so the plugin has nothing to reconcile.
|
||||
*
|
||||
* SAFETY: these files are git-tracked, so a hard kill is recoverable via `git checkout -- src/app`.
|
||||
* The caller also restores in a `finally` block and on SIGINT/SIGTERM. Stubs carry a marker so
|
||||
* the operation is idempotent and detectable.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const BACKEND_ONLY_STUB_MARKER =
|
||||
"/* omniroute:backend-only-stub (auto-restored after build) */";
|
||||
|
||||
const HEADER = `${BACKEND_ONLY_STUB_MARKER}\n`;
|
||||
|
||||
// Leaf page → force-dynamic (never prerendered) server component returning null.
|
||||
const PAGE_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function BackendOnlyPageStub() {\n return null;\n}\n`;
|
||||
// Root layout MUST render <html>/<body>. Minimal server component, no client imports.
|
||||
const ROOT_LAYOUT_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function RootLayout({ children }) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n );\n}\n`;
|
||||
// Non-root layout / template → pass children through unchanged.
|
||||
const PASSTHROUGH_STUB = `${HEADER}export default function BackendOnlyPassthroughStub({ children }) {\n return children;\n}\n`;
|
||||
// loading / default / not-found → render nothing.
|
||||
const NULL_STUB = `${HEADER}export default function BackendOnlyNullStub() {\n return null;\n}\n`;
|
||||
// Error boundaries must be Client Components in Next; a no-op client stub carries no action.
|
||||
const ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyErrorStub() {\n return null;\n}\n`;
|
||||
// global-error replaces the root layout on a root error, so it must render <html>/<body>.
|
||||
const GLOBAL_ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyGlobalErrorStub() {\n return (\n <html>\n <body></body>\n </html>\n );\n}\n`;
|
||||
|
||||
const UI_BASENAME_RE = /^(page|layout|template|loading|error|global-error|not-found|default)\.(tsx|jsx|ts|js)$/;
|
||||
const ROUTE_FILE_RE = /[\\/]route\.(ts|js|tsx|jsx)$/;
|
||||
|
||||
/**
|
||||
* Strip a leading `"use server"` module directive. Some OmniRoute API Route Handlers
|
||||
* (`src/app/api/**\/route.ts`) carry a top-level `"use server"` — which registers the module
|
||||
* as a React Server-Actions provider. Once the dashboard pages that import those exports as
|
||||
* actions are stubbed away, Next's FlightClientEntryPlugin still has the action registered but
|
||||
* no client entry to bind it to, and `createActionAssets` dereferences an undefined module map
|
||||
* ("Cannot read properties of undefined (reading 'server')"). Removing the directive turns the
|
||||
* file back into a plain Route Handler — the GET/POST HTTP endpoint is UNCHANGED (route handlers
|
||||
* are server-side regardless of the directive) — so the API keeps working while the phantom
|
||||
* action registration disappears. The directive is only removed when it is the first real
|
||||
* statement (comments may precede it); a `"use server"` string appearing later is untouched.
|
||||
*/
|
||||
function stripLeadingUseServer(src) {
|
||||
const lines = src.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i].trim().replace(/^\uFEFF/, "");
|
||||
if (t === "") continue;
|
||||
if (/^["']use server["'];?$/.test(t)) {
|
||||
lines.splice(i, 1);
|
||||
return { changed: true, src: lines.join("\n") };
|
||||
}
|
||||
if (t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
|
||||
break; // first real statement is not the directive
|
||||
}
|
||||
return { changed: false, src };
|
||||
}
|
||||
|
||||
/** True when the current build should skip the dashboard frontend. */
|
||||
export function isBackendOnlyBuild(env = process.env) {
|
||||
return env.OMNIROUTE_BUILD_BACKEND_ONLY === "1" || env.OMNIROUTE_BUILD_PROFILE === "backend";
|
||||
}
|
||||
|
||||
function walkFiles(dir, out = []) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkFiles(full, out);
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pick the stub source for an App-Router UI file (null = don't stub this file). */
|
||||
function stubFor(file, appDir) {
|
||||
const base = path.basename(file);
|
||||
const m = UI_BASENAME_RE.exec(base);
|
||||
if (!m) return null;
|
||||
const kind = m[1];
|
||||
const relDir = path.relative(appDir, path.dirname(file));
|
||||
const isRoot = relDir === "" || relDir === ".";
|
||||
|
||||
switch (kind) {
|
||||
case "page":
|
||||
return PAGE_STUB;
|
||||
case "layout":
|
||||
return isRoot ? ROOT_LAYOUT_STUB : PASSTHROUGH_STUB;
|
||||
case "template":
|
||||
return PASSTHROUGH_STUB;
|
||||
case "loading":
|
||||
case "default":
|
||||
case "not-found":
|
||||
return NULL_STUB;
|
||||
case "error":
|
||||
return ERROR_STUB;
|
||||
case "global-error":
|
||||
return GLOBAL_ERROR_STUB;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every App-Router UI file under src/app with a trivial stub.
|
||||
* @returns {{file:string, original:string}[]} stubbed files + their original contents.
|
||||
*/
|
||||
export function stubDashboardPages(rootDir = process.cwd(), log = console) {
|
||||
const appDir = path.join(rootDir, "src", "app");
|
||||
if (!fs.existsSync(appDir)) {
|
||||
log.warn?.("[backend-only] src/app not found — nothing to stub");
|
||||
return [];
|
||||
}
|
||||
|
||||
const stubbed = [];
|
||||
for (const file of walkFiles(appDir)) {
|
||||
const stub = stubFor(file, appDir);
|
||||
if (stub) {
|
||||
let original;
|
||||
try {
|
||||
original = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (original.includes(BACKEND_ONLY_STUB_MARKER)) continue; // idempotent
|
||||
try {
|
||||
fs.writeFileSync(file, stub, "utf8");
|
||||
stubbed.push({ file, original });
|
||||
} catch (err) {
|
||||
log.warn?.(`[backend-only] Could not stub ${file}: ${err?.message || err}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Route Handlers with a leading "use server" directive: strip the directive so the module
|
||||
// is no longer registered as a Server-Actions provider (the HTTP endpoint is unchanged).
|
||||
if (ROUTE_FILE_RE.test(file)) {
|
||||
let original;
|
||||
try {
|
||||
original = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { changed, src } = stripLeadingUseServer(original);
|
||||
if (!changed) continue;
|
||||
try {
|
||||
fs.writeFileSync(file, src, "utf8");
|
||||
stubbed.push({ file, original });
|
||||
} catch (err) {
|
||||
log.warn?.(`[backend-only] Could not de-action ${file}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uiCount = stubbed.filter((e) => ROUTE_FILE_RE.test(e.file) === false).length;
|
||||
log.log?.(
|
||||
`[backend-only] Stubbed ${uiCount} App-Router UI file(s) + de-actioned ${
|
||||
stubbed.length - uiCount
|
||||
} route handler(s); route.ts HTTP endpoints left intact`
|
||||
);
|
||||
return stubbed;
|
||||
}
|
||||
|
||||
/** Restore the original contents of every stubbed file. Best-effort; logs failures. */
|
||||
export function restoreDashboardPages(stubbed, log = console) {
|
||||
if (!Array.isArray(stubbed) || stubbed.length === 0) return;
|
||||
let restored = 0;
|
||||
for (const entry of stubbed) {
|
||||
if (!entry) continue;
|
||||
try {
|
||||
fs.writeFileSync(entry.file, entry.original, "utf8");
|
||||
restored += 1;
|
||||
} catch (err) {
|
||||
log.error?.(
|
||||
`[backend-only] FAILED to restore ${entry.file}: ${err?.message || err} — ` +
|
||||
`run \`git checkout -- ${entry.file}\` to recover`
|
||||
);
|
||||
}
|
||||
}
|
||||
log.log?.(`[backend-only] Restored ${restored}/${stubbed.length} App-Router UI file(s)`);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — Zero-Config Bootstrap
|
||||
*
|
||||
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
|
||||
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in DATA_DIR
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ keys: ["ANTIGRAVITY_OAUTH_CLIENT_SECRET"], label: "Antigravity OAuth" },
|
||||
{ keys: ["QODER_OAUTH_CLIENT_SECRET"], label: "Qoder OAuth" },
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath?.trim()) return resolve(overridePath);
|
||||
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => existsSync(filePath)) ?? null;
|
||||
}
|
||||
|
||||
function isNativeSqliteLoadError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
|
||||
return (
|
||||
message.includes("Module did not self-register") ||
|
||||
message.includes("NODE_MODULE_VERSION") ||
|
||||
message.includes("ERR_DLOPEN_FAILED") ||
|
||||
message.includes("Could not locate the bindings file") ||
|
||||
message.includes("Cannot find module 'better-sqlite3'") ||
|
||||
code === "ERR_DLOPEN_FAILED" ||
|
||||
code === "MODULE_NOT_FOUND"
|
||||
);
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dataDir) {
|
||||
const dbPath = join(dataDir, "storage.sqlite");
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNativeSqliteLoadError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
const env = {};
|
||||
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim());
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (value.length < 2) return value;
|
||||
const quote = value[0];
|
||||
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap — do not delete",
|
||||
`# Created: ${new Date().toISOString()}`,
|
||||
"",
|
||||
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||
"",
|
||||
];
|
||||
writeFileSync(filePath, lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
// ── Main bootstrap function ──────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
|
||||
* @returns {Record<string, string>} merged env to pass to child process
|
||||
*/
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
|
||||
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
|
||||
//
|
||||
// We strip empty values from preferredEnv so an empty placeholder
|
||||
// (e.g. `STORAGE_ENCRYPTION_KEY=` in the project .env template) does not
|
||||
// override the real value persisted in server.env. Only the .env entries
|
||||
// that the operator actually set should win.
|
||||
const preferredEnvFiltered = Object.fromEntries(
|
||||
Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0)
|
||||
);
|
||||
const merged = { ...persisted, ...preferredEnvFiltered, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
|
||||
if (!merged.JWT_SECRET?.trim()) {
|
||||
persisted.JWT_SECRET = randomBytes(64).toString("hex");
|
||||
merged.JWT_SECRET = persisted.JWT_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ JWT_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
if (hasEncryptedCredentials(dataDir)) {
|
||||
throw new Error(
|
||||
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
}
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
|
||||
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
|
||||
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
|
||||
needsPersist = true;
|
||||
}
|
||||
|
||||
if (!merged.API_KEY_SECRET?.trim()) {
|
||||
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
|
||||
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ API_KEY_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
// ── Persist new secrets ────────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
// Only persist keys that we auto-generated (not .env or process.env vals)
|
||||
writeEnvFile(serverEnvPath, persisted);
|
||||
log(`📁 Secrets persisted to: ${serverEnvPath}`);
|
||||
} catch (e) {
|
||||
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark as bootstrapped ───────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
merged.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
}
|
||||
|
||||
// ── Warn about missing optional OAuth secrets ──────────────────────────────
|
||||
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(
|
||||
({ keys }) => !keys.some((key) => merged[key]?.trim())
|
||||
);
|
||||
if (missingOauth.length > 0) {
|
||||
log("ℹ️ The following OAuth integrations are not configured:");
|
||||
for (const { keys, label } of missingOauth) {
|
||||
log(` • ${label} (${keys.join(" or ")}) — set in .env or ${serverEnvPath}`);
|
||||
}
|
||||
log(" These providers will not work until configured.");
|
||||
}
|
||||
|
||||
// ── Warn about default password ────────────────────────────────────────────
|
||||
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
|
||||
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
|
||||
}
|
||||
|
||||
// ── Decrypt-probe: verify STORAGE_ENCRYPTION_KEY matches encrypted data (#1622) ─
|
||||
if (merged.STORAGE_ENCRYPTION_KEY?.trim() && hasEncryptedCredentials(dataDir)) {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(join(dataDir, "storage.sqlite"), {
|
||||
readonly: true,
|
||||
fileMustExist: true,
|
||||
});
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
if (row) {
|
||||
const ciphertext = row.api_key || row.access_token || row.refresh_token || row.id_token;
|
||||
if (ciphertext?.startsWith("enc:v1:")) {
|
||||
const parts = ciphertext.split(":");
|
||||
// enc:v1:<iv>:<ct>:<tag>
|
||||
if (parts.length >= 5) {
|
||||
const iv = Buffer.from(parts[2], "hex");
|
||||
const ct = Buffer.from(parts[3], "hex");
|
||||
const tag = Buffer.from(parts[4], "hex");
|
||||
|
||||
// Try decrypting with both key derivation methods matching encryption.ts
|
||||
const tryDecrypt = (derivedKey) => {
|
||||
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
decipher.update(ct);
|
||||
decipher.final();
|
||||
};
|
||||
|
||||
// Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32)
|
||||
const dynamicSalt = createHash("sha256")
|
||||
.update(merged.STORAGE_ENCRYPTION_KEY)
|
||||
.digest()
|
||||
.slice(0, 16);
|
||||
const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32);
|
||||
|
||||
// Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32)
|
||||
const legacySalt = "omniroute-field-encryption-v1";
|
||||
const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32);
|
||||
|
||||
let keyMatched = false;
|
||||
try {
|
||||
tryDecrypt(dynamicKey);
|
||||
keyMatched = true;
|
||||
} catch {
|
||||
// Try legacy key as fallback
|
||||
try {
|
||||
tryDecrypt(legacyKey);
|
||||
keyMatched = true;
|
||||
} catch {
|
||||
// Both failed — key truly doesn't match
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyMatched) {
|
||||
log(
|
||||
"⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials."
|
||||
);
|
||||
log(
|
||||
" Either restore your previous key via ~/.omniroute/server.env or ~/.omniroute/.env,"
|
||||
);
|
||||
log(
|
||||
" or run: omniroute reset-encrypted-columns --force (wipes credentials, keeps provider config)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — probe is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── CLI usage: node scripts/build/bootstrap-env.mjs ──────────────────────────────
|
||||
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
|
||||
const env = bootstrapEnv();
|
||||
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
|
||||
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
|
||||
process.stderr.write(
|
||||
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
assembleStandalone,
|
||||
syncStandaloneNativeAssets as _syncNativeAssets,
|
||||
syncStandaloneExtraModules as _syncExtraModules,
|
||||
} from "./assembleStandalone.mjs";
|
||||
import {
|
||||
isBackendOnlyBuild,
|
||||
stubDashboardPages,
|
||||
restoreDashboardPages,
|
||||
} from "./backendOnlyPages.mjs";
|
||||
|
||||
/**
|
||||
* Layer 1: `app/` has been renamed to `dist/` and the App-Router collision is gone.
|
||||
* The only transient paths remaining are `.tmp/wine32` (Wine prefix used by some
|
||||
* older build tools) and `_tasks` (planning workspace).
|
||||
*/
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const distDir = path.resolve(process.env.NEXT_DIST_DIR || ".build/next");
|
||||
const backupRoot = path.join(os.tmpdir(), `omniroute-build-isolated-${process.pid}-${Date.now()}`);
|
||||
|
||||
export function getTransientBuildPaths(rootDir = projectRoot, env = process.env) {
|
||||
const paths = [
|
||||
{
|
||||
label: "local Wine prefix",
|
||||
sourcePath: path.join(rootDir, ".tmp", "wine32"),
|
||||
backupPath: path.join(backupRoot, "wine32"),
|
||||
},
|
||||
];
|
||||
|
||||
if (env.OMNIROUTE_BUILD_MOVE_TASKS === "1") {
|
||||
paths.push({
|
||||
label: "task planning workspace",
|
||||
sourcePath: path.join(rootDir, "_tasks"),
|
||||
backupPath: path.join(backupRoot, "_tasks"),
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function exists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
|
||||
const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
|
||||
try {
|
||||
await fsImpl.rename(sourcePath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[build-next-isolated] EXDEV while moving ${sourcePath} -> ${destinationPath}; falling back to copy/remove`
|
||||
);
|
||||
await fsImpl.cp(sourcePath, destinationPath, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
await fsImpl.rm(sourcePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runNextBuild() {
|
||||
return new Promise((resolve) => {
|
||||
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
|
||||
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: resolveNextBuildEnv(process.env),
|
||||
});
|
||||
|
||||
const forward = (signal) => {
|
||||
if (!child.killed) child.kill(signal);
|
||||
};
|
||||
|
||||
process.on("SIGINT", forward);
|
||||
process.on("SIGTERM", forward);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
process.off("SIGINT", forward);
|
||||
process.off("SIGTERM", forward);
|
||||
if (signal) {
|
||||
resolve({ code: 1, signal });
|
||||
return;
|
||||
}
|
||||
resolve({ code: code ?? 1, signal: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
|
||||
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on
|
||||
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
|
||||
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
|
||||
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
|
||||
// the explicit escape hatch (=0) for bundler-compat regressions.
|
||||
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
|
||||
}
|
||||
|
||||
export function resolveNextBuildEnv(baseEnv = process.env) {
|
||||
const env = {
|
||||
...baseEnv,
|
||||
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
|
||||
};
|
||||
|
||||
// Raise the Node heap for the spawned `next build`. The webpack production pass
|
||||
// ("Compiling instrumentation" bundles the whole server graph) is the heaviest
|
||||
// phase and overflows V8's default ~2 GB ceiling on memory-constrained machines,
|
||||
// stalling/OOMing local `npm run build` (npm-global installs). #4076/#4104 fixed
|
||||
// this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path
|
||||
// was left unprotected. Respect an existing --max-old-space-size (Docker already
|
||||
// sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override.
|
||||
if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) {
|
||||
// Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack
|
||||
// production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives
|
||||
// headroom without risk. NOTE: heap size does NOT fix a poisoned scope — if the build
|
||||
// OOMs/livelocks far above this, check for worktrees/cruft leaking into the tsconfig
|
||||
// scope (run `npm run check:build-scope`), not for "more heap". See incident 2026-06-25.
|
||||
const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 8192;
|
||||
env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim();
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) {
|
||||
// Use the module-level distDir so NEXT_DIST_DIR is respected
|
||||
const resolvedDistDir =
|
||||
rootDir === projectRoot
|
||||
? distDir
|
||||
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
|
||||
const standaloneRoot = path.join(resolvedDistDir, "standalone");
|
||||
if (!(await exists(standaloneRoot))) return;
|
||||
|
||||
const staleStandaloneBackup = path.join(backupRoot, "standalone-stale");
|
||||
|
||||
await movePath(standaloneRoot, staleStandaloneBackup, fsImpl);
|
||||
console.log("[build-next-isolated] Moved stale standalone output out of the build path");
|
||||
}
|
||||
|
||||
export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) {
|
||||
const resolvedDistDirForPrune =
|
||||
rootDir === projectRoot
|
||||
? distDir
|
||||
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
|
||||
const standaloneRoot = path.join(resolvedDistDirForPrune, "standalone");
|
||||
const pruneTargets = [path.join(standaloneRoot, "_tasks")];
|
||||
|
||||
for (const targetPath of pruneTargets) {
|
||||
if (!(await exists(targetPath))) continue;
|
||||
await fsImpl.rm(targetPath, { recursive: true, force: true });
|
||||
console.log(
|
||||
`[build-next-isolated] Pruned standalone artifact: ${path.relative(rootDir, targetPath)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncStandaloneNativeAssets(
|
||||
rootDir = projectRoot,
|
||||
fsImpl = fs,
|
||||
log = console
|
||||
) {
|
||||
return _syncNativeAssets(rootDir, fsImpl, log);
|
||||
}
|
||||
|
||||
export async function syncStandaloneExtraModules(
|
||||
rootDir = projectRoot,
|
||||
fsImpl = fs,
|
||||
log = console
|
||||
) {
|
||||
return _syncExtraModules(rootDir, fsImpl, log);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const movedPaths = [];
|
||||
const transientBuildPaths = getTransientBuildPaths();
|
||||
|
||||
// Backend-only fast build: replace the dashboard leaf pages with zero-cost stubs so
|
||||
// `next build` skips the frontend (client vendor chunks + prerender) while keeping every
|
||||
// API route handler. Restored in `finally` and on SIGINT/SIGTERM (git-recoverable regardless).
|
||||
let stubbedPages = [];
|
||||
const restoreStubbedPagesOnce = () => {
|
||||
if (stubbedPages.length > 0) {
|
||||
restoreDashboardPages(stubbedPages);
|
||||
stubbedPages = [];
|
||||
}
|
||||
};
|
||||
const onFatalSignal = (signal) => {
|
||||
console.warn(`[build-next-isolated] Received ${signal} — restoring stubbed pages before exit`);
|
||||
restoreStubbedPagesOnce();
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const entry of transientBuildPaths) {
|
||||
if (!(await exists(entry.sourcePath))) continue;
|
||||
await movePath(entry.sourcePath, entry.backupPath);
|
||||
movedPaths.push(entry);
|
||||
}
|
||||
|
||||
if (isBackendOnlyBuild()) {
|
||||
console.log(
|
||||
"[build-next-isolated] OMNIROUTE_BUILD_BACKEND_ONLY set — building API only (dashboard UI stubbed)"
|
||||
);
|
||||
stubbedPages = stubDashboardPages(projectRoot);
|
||||
process.once("SIGINT", onFatalSignal);
|
||||
process.once("SIGTERM", onFatalSignal);
|
||||
}
|
||||
|
||||
await resetStandaloneOutput(projectRoot);
|
||||
|
||||
const result = await runNextBuild();
|
||||
const standaloneDir = path.join(distDir, "standalone");
|
||||
if (result.code === 0 && (await exists(standaloneDir))) {
|
||||
try {
|
||||
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
|
||||
recursive: true,
|
||||
});
|
||||
console.log("[build-next-isolated] Copied docs/ to standalone output");
|
||||
} catch (docsCopyErr) {
|
||||
console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await pruneStandaloneArtifacts(projectRoot);
|
||||
} catch (pruneErr) {
|
||||
console.warn(
|
||||
"[build-next-isolated] Non-fatal error pruning standalone artifacts:",
|
||||
pruneErr
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort: build the TPROXY native addon (Linux-only, opt-in) BEFORE
|
||||
// assembling, so its transparent.node is present for assembleStandalone's
|
||||
// NATIVE_ASSET_ENTRIES copy. Non-Linux / no-toolchain is non-fatal — the
|
||||
// capture mode degrades gracefully when the addon is absent.
|
||||
try {
|
||||
const { buildTproxyNative } = await import("./build-tproxy-native.mjs");
|
||||
const res = buildTproxyNative(projectRoot);
|
||||
console.log(
|
||||
res.built
|
||||
? "[build-next-isolated] Built TPROXY native addon (transparent.node)"
|
||||
: `[build-next-isolated] TPROXY native addon skipped: ${res.reason}`
|
||||
);
|
||||
} catch (nativeErr) {
|
||||
console.warn(
|
||||
"[build-next-isolated] Non-fatal error building TPROXY native addon:",
|
||||
nativeErr?.message
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
|
||||
);
|
||||
assembleStandalone({
|
||||
distDir,
|
||||
outDir: standaloneDir,
|
||||
projectRoot,
|
||||
copyNatives: true,
|
||||
});
|
||||
} catch (assembleErr) {
|
||||
console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
|
||||
}
|
||||
}
|
||||
process.exitCode = result.code;
|
||||
} catch (error) {
|
||||
console.error("[build-next-isolated] Build failed:", error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Restore the stubbed dashboard pages FIRST so the working tree is clean even if the
|
||||
// transient-path restore below throws.
|
||||
restoreStubbedPagesOnce();
|
||||
process.off("SIGINT", onFatalSignal);
|
||||
process.off("SIGTERM", onFatalSignal);
|
||||
|
||||
while (movedPaths.length > 0) {
|
||||
const entry = movedPaths.pop();
|
||||
if (!entry) continue;
|
||||
try {
|
||||
await movePath(entry.backupPath, entry.sourcePath);
|
||||
} catch (restoreError) {
|
||||
console.error(
|
||||
`[build-next-isolated] Failed to restore ${entry.label} from ${entry.backupPath}:`,
|
||||
restoreError
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(backupRoot, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn("[build-next-isolated] Failed to clean temporary backup root:", cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
|
||||
|
||||
if (entryScript === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Best-effort build of the TPROXY IP_TRANSPARENT native addon so the production
|
||||
* build can copy `build/Release/transparent.node` into the standalone bundle
|
||||
* (assembleStandalone's NATIVE_ASSET_ENTRIES). Called from build-next-isolated.mjs
|
||||
* before the standalone is assembled.
|
||||
*
|
||||
* IP_TRANSPARENT is Linux-only, so this is a no-op everywhere else. A missing C
|
||||
* toolchain is NOT fatal — the TPROXY capture mode degrades gracefully when the
|
||||
* addon is absent (transparentSocket.ts returns "unavailable"). Every effectful
|
||||
* seam (platform/run/exists) is injectable so the decision logic is unit-testable.
|
||||
*
|
||||
* Hard Rule #13: the command + args are a fixed allowlist (no interpolation of
|
||||
* external/runtime values); `cwd` is derived from `projectRoot`, never user input.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* @param {string} projectRoot
|
||||
* @param {{ platform?: string, run?: (cmd:string, args:string[], cwd:string) => void,
|
||||
* exists?: (p:string) => boolean }} [opts]
|
||||
* @returns {{ built: boolean, reason?: string }}
|
||||
*/
|
||||
export function buildTproxyNative(projectRoot, opts = {}) {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const run = opts.run ?? defaultRun;
|
||||
const exists = opts.exists ?? existsSync;
|
||||
|
||||
if (platform !== "linux") {
|
||||
return { built: false, reason: "non-linux host (IP_TRANSPARENT is Linux-only)" };
|
||||
}
|
||||
|
||||
const nativeDir = path.join(projectRoot, "src", "mitm", "tproxy", "native");
|
||||
if (!exists(path.join(nativeDir, "binding.gyp"))) {
|
||||
return { built: false, reason: "native sources absent (binding.gyp not found)" };
|
||||
}
|
||||
|
||||
const out = path.join(nativeDir, "build", "Release", "transparent.node");
|
||||
try {
|
||||
run("npx", ["--yes", "node-gyp", "rebuild"], nativeDir);
|
||||
} catch (err) {
|
||||
return { built: false, reason: `toolchain/build failed: ${err?.message ?? String(err)}` };
|
||||
}
|
||||
if (!exists(out)) {
|
||||
return { built: false, reason: "node-gyp produced no transparent.node" };
|
||||
}
|
||||
return { built: true };
|
||||
}
|
||||
|
||||
/** @type {(cmd: string, args: string[], cwd: string) => void} */
|
||||
function defaultRun(cmd, args, cwd) {
|
||||
execFileSync(cmd, args, { cwd, stdio: "inherit" });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
|
||||
*
|
||||
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
|
||||
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
|
||||
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
|
||||
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
|
||||
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
|
||||
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
|
||||
* SLM packages.
|
||||
*
|
||||
* ## Why this matters (the instance-split bug)
|
||||
*
|
||||
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
|
||||
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
|
||||
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
|
||||
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
|
||||
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
|
||||
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
|
||||
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
|
||||
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
|
||||
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
|
||||
*
|
||||
* ## The fix
|
||||
*
|
||||
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
|
||||
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
|
||||
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
|
||||
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
|
||||
* model loads.
|
||||
*
|
||||
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
|
||||
* `@atjsh/llmlingua-2` (not a regular dependency) and is already bundled in `dist/node_modules`,
|
||||
* so the closure walk never reaches it and the no-clobber guard would skip it anyway.
|
||||
*
|
||||
* ## Validation (Hard Rule #18)
|
||||
*
|
||||
* Manual co-location of this exact closure on the production VPS produced real 54.8% compression
|
||||
* (11520 → 5203 chars) via real ONNX inference — both the default and the `modelPath` (PR #4257)
|
||||
* code paths. See the unit test for the closure-walk + no-clobber contract.
|
||||
*
|
||||
* Idempotent + fail-soft: skips when the optionals are absent (the common case — they are OPTIONAL)
|
||||
* or already co-located; a per-package copy failure only disables the SLM tier, which is itself
|
||||
* fail-open, so this never throws into the install.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
|
||||
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
|
||||
*/
|
||||
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
|
||||
|
||||
/**
|
||||
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
|
||||
* `optionalDependencies` from a `node_modules` directory. Packages that are not present in that
|
||||
* tree (e.g. peers provided elsewhere, like `@huggingface/transformers` in `dist`) are skipped —
|
||||
* the closure only contains packages that actually exist in `nodeModulesDir`.
|
||||
*
|
||||
* @param {string} nodeModulesDir absolute path to the source `node_modules`
|
||||
* @param {string[]} [seeds] closure roots (defaults to {@link SEED_PACKAGES})
|
||||
* @returns {string[]} package names in discovery order, seeds first
|
||||
*/
|
||||
export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) {
|
||||
const closure = [];
|
||||
const seen = new Set();
|
||||
const stack = [...seeds];
|
||||
|
||||
while (stack.length) {
|
||||
const name = stack.shift();
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
const pkgDir = join(nodeModulesDir, name);
|
||||
if (!existsSync(pkgDir)) continue; // absent in this tree (peer provided elsewhere) — skip
|
||||
|
||||
closure.push(name);
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
||||
} catch {
|
||||
continue; // unreadable/absent manifest — copy the dir but do not recurse
|
||||
}
|
||||
|
||||
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
|
||||
for (const dep of Object.keys(deps)) {
|
||||
if (!seen.has(dep)) stack.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
return closure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
|
||||
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
|
||||
* absent, and idempotent once co-located. Never throws.
|
||||
*
|
||||
* @param {{ rootDir: string, log?: (message: string) => void }} opts
|
||||
* @returns {{ skipped: true, reason: string }
|
||||
* | { skipped: false, copied: number, closure: number }}
|
||||
*/
|
||||
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
|
||||
const rootNm = join(rootDir, "node_modules");
|
||||
const distNm = join(rootDir, "dist", "node_modules");
|
||||
|
||||
if (!existsSync(distNm)) {
|
||||
return { skipped: true, reason: "no standalone dist/node_modules" };
|
||||
}
|
||||
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
|
||||
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
|
||||
return { skipped: true, reason: "SLM optionals not installed at root" };
|
||||
}
|
||||
// Idempotent: the entry package is already co-located → nothing to do.
|
||||
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
|
||||
return { skipped: true, reason: "already co-located" };
|
||||
}
|
||||
|
||||
const closure = computeDependencyClosure(rootNm);
|
||||
let copied = 0;
|
||||
|
||||
for (const name of closure) {
|
||||
const dest = join(distNm, name);
|
||||
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
|
||||
try {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
cpSync(join(rootNm, name), dest, { recursive: true });
|
||||
copied++;
|
||||
} catch (err) {
|
||||
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (copied > 0) {
|
||||
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
|
||||
}
|
||||
|
||||
return { skipped: false, copied, closure: closure.length };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Decide whether the Turbopack build should alias @/mitm/manager to the
|
||||
* feature-degraded stub (src/mitm/manager.stub.ts).
|
||||
*
|
||||
* History (#6344): the alias used to be UNCONDITIONAL in next.config.mjs
|
||||
* because Docker images were the only Turbopack consumers (webpack was the
|
||||
* production default and never aliased the manager). When v3.8.45 flipped the
|
||||
* production bundler default to Turbopack, the stub silently shipped to every
|
||||
* npm / Electron / VPS artifact — Agent Bridge start then threw
|
||||
* "MITM manager stub reached at runtime" for all non-Docker users.
|
||||
*
|
||||
* The stub is only correct where the runtime genuinely cannot run the MITM
|
||||
* stack (containers without host access — #3390 graceful degradation), so it
|
||||
* is now opt-in via OMNIROUTE_MITM_STUB=1, set by the Dockerfile.
|
||||
*/
|
||||
export function shouldStubMitmManager(env = process.env) {
|
||||
return env.OMNIROUTE_MITM_STUB === "1";
|
||||
}
|
||||
|
||||
/** Turbopack resolveAlias fragment for @/mitm/manager, derived from the env. */
|
||||
export function mitmManagerAliasFor(env = process.env) {
|
||||
return shouldStubMitmManager(env) ? { "@/mitm/manager": "./src/mitm/manager.stub.ts" } : {};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { existsSync, openSync, readSync, closeSync } from "node:fs";
|
||||
|
||||
export const PUBLISHED_BUILD_PLATFORM = "linux";
|
||||
export const PUBLISHED_BUILD_ARCH = "x64";
|
||||
|
||||
const HEADER_SIZE = 4096;
|
||||
const MAX_FAT_ARCH_COUNT = 30;
|
||||
|
||||
function mapElfMachine(machine) {
|
||||
switch (machine) {
|
||||
case 62:
|
||||
return "x64";
|
||||
case 183:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapMachCpuType(cpuType) {
|
||||
switch (cpuType) {
|
||||
case 0x01000007:
|
||||
return "x64";
|
||||
case 0x0100000c:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPeMachine(machine) {
|
||||
switch (machine) {
|
||||
case 0x8664:
|
||||
return "x64";
|
||||
case 0xaa64:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readUInt16(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
}
|
||||
|
||||
function readUInt32(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||
}
|
||||
|
||||
const ELF_MAGIC = 0x7f454c46;
|
||||
|
||||
function detectElfTarget(buffer) {
|
||||
if (buffer.length < 20) return null;
|
||||
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
|
||||
|
||||
const littleEndian = buffer[5] !== 2;
|
||||
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
|
||||
if (!arch) return null;
|
||||
|
||||
return { platform: "linux", architectures: [arch] };
|
||||
}
|
||||
|
||||
const THIN_MACH_MAGIC = new Map([
|
||||
[0xfeedface, false],
|
||||
[0xfeedfacf, false],
|
||||
[0xcefaedfe, true],
|
||||
[0xcffaedfe, true],
|
||||
]);
|
||||
const FAT_MACH_MAGIC = new Map([
|
||||
[0xcafebabe, false],
|
||||
[0xcafebabf, false],
|
||||
[0xbebafeca, true],
|
||||
[0xbfbafeca, true],
|
||||
]);
|
||||
|
||||
function detectMachTarget(buffer) {
|
||||
if (buffer.length < 8) return null;
|
||||
|
||||
const magic = buffer.readUInt32BE(0);
|
||||
|
||||
if (THIN_MACH_MAGIC.has(magic)) {
|
||||
const littleEndian = THIN_MACH_MAGIC.get(magic);
|
||||
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
|
||||
if (!arch) return null;
|
||||
return { platform: "darwin", architectures: [arch] };
|
||||
}
|
||||
|
||||
if (!FAT_MACH_MAGIC.has(magic)) return null;
|
||||
|
||||
const littleEndian = FAT_MACH_MAGIC.get(magic);
|
||||
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
|
||||
const archCount = readUInt32(buffer, 4, littleEndian);
|
||||
if (archCount > MAX_FAT_ARCH_COUNT) return null;
|
||||
const entrySize = isFat64 ? 32 : 20;
|
||||
const architectures = new Set();
|
||||
|
||||
for (let index = 0; index < archCount; index += 1) {
|
||||
const offset = 8 + index * entrySize;
|
||||
if (offset + 4 > buffer.length) break;
|
||||
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
|
||||
if (arch) architectures.add(arch);
|
||||
}
|
||||
|
||||
if (architectures.size === 0) return null;
|
||||
return { platform: "darwin", architectures: [...architectures] };
|
||||
}
|
||||
|
||||
function detectPeTarget(buffer) {
|
||||
if (buffer.length < 0x40) return null;
|
||||
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
|
||||
|
||||
const peHeaderOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peHeaderOffset + 6 > buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
|
||||
|
||||
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
|
||||
if (!arch) return null;
|
||||
return { platform: "win32", architectures: [arch] };
|
||||
}
|
||||
|
||||
export function detectNativeBinaryTarget(buffer) {
|
||||
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
|
||||
}
|
||||
|
||||
export function readNativeBinaryTarget(binaryPath) {
|
||||
if (!existsSync(binaryPath)) return null;
|
||||
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(binaryPath, "r");
|
||||
const buffer = Buffer.alloc(HEADER_SIZE);
|
||||
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
|
||||
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeBinaryCompatible(
|
||||
binaryPath,
|
||||
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
|
||||
) {
|
||||
const target = readNativeBinaryTarget(binaryPath);
|
||||
|
||||
if (target) {
|
||||
if (
|
||||
(target.platform !== runtimePlatform &&
|
||||
!(target.platform === "linux" && runtimePlatform === "android")) ||
|
||||
!target.architectures.includes(runtimeArch)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dlopen({ exports: {} }, binaryPath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Shared policy for OmniRoute npm publish artifact hygiene.
|
||||
*
|
||||
* The package publishes the standalone runtime under dist/ (Layer 1: renamed from app/).
|
||||
* This policy keeps local backups, QA scratch files, and development-only
|
||||
* directories out of the staged dist/ tree and out of the final tarball.
|
||||
*/
|
||||
|
||||
const STAGING_FORBIDDEN_DIRECTORIES = [
|
||||
"app.__qa_backup",
|
||||
"coverage",
|
||||
"electron",
|
||||
"logs",
|
||||
"scripts/scratch",
|
||||
"tests",
|
||||
"vscode-extension",
|
||||
"_ideia",
|
||||
"_mono_repo",
|
||||
"_references",
|
||||
"_tasks",
|
||||
];
|
||||
|
||||
const STAGING_FORBIDDEN_FILES = ["audit-report.json", "package-lock.json"];
|
||||
|
||||
export const APP_STAGING_REMOVAL_PATHS: string[] = [
|
||||
...STAGING_FORBIDDEN_DIRECTORIES,
|
||||
...STAGING_FORBIDDEN_FILES,
|
||||
// onnxruntime CUDA provider binary (~316 MB) inflates the npm tarball
|
||||
// past the registry 413 limit for npm.org. It's only needed on systems
|
||||
// with a CUDA GPU — users install CUDA providers separately.
|
||||
"node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so",
|
||||
];
|
||||
|
||||
export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
|
||||
".env.example",
|
||||
"BUILD_SHA",
|
||||
"docs/openapi.yaml",
|
||||
"http-method-guard.cjs",
|
||||
"open-sse/mcp-server/server.js",
|
||||
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
|
||||
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
|
||||
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
|
||||
"package.json",
|
||||
"peer-stamp.mjs",
|
||||
"responses-ws-proxy.mjs",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"server.js",
|
||||
"server-ws.mjs",
|
||||
// #5452: dist/tls-options.mjs is copied by assembleStandalone (EXTRA_MODULE_ENTRIES)
|
||||
// and imported by dist/server-ws.mjs for opt-in native HTTPS/TLS (#5361). Without
|
||||
// this bare entry the prepublish prune (Step 10.7) deletes it → `omniroute serve`
|
||||
// crashes with ERR_MODULE_NOT_FOUND (regressed in the published 3.8.41 tarball).
|
||||
"tls-options.mjs",
|
||||
"webdav-handler.mjs",
|
||||
];
|
||||
|
||||
export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [
|
||||
// Layer 1: Next.js distDir changed from ".next" to ".build/next"; the server
|
||||
// bundle now lives under .build/next/ inside the standalone output.
|
||||
".build/next/",
|
||||
".next/",
|
||||
"data/",
|
||||
"node_modules/",
|
||||
"open-sse/services/compression/engines/rtk/filters/",
|
||||
"open-sse/services/compression/rules/",
|
||||
"public/",
|
||||
"src/lib/db/migrations/",
|
||||
"src/mitm/",
|
||||
];
|
||||
|
||||
export const PACK_ARTIFACT_ALLOWED_EXACT_PATHS: string[] = APP_STAGING_ALLOWED_EXACT_PATHS.map(
|
||||
(filePath: string) => `dist/${filePath}`
|
||||
);
|
||||
|
||||
export const PACK_ARTIFACT_ALLOWED_PATH_PREFIXES: string[] = APP_STAGING_ALLOWED_PATH_PREFIXES.map(
|
||||
(directoryPath: string) => `dist/${directoryPath}`
|
||||
);
|
||||
|
||||
export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
".env.example",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"bin/mcp-server.mjs",
|
||||
"bin/nodeRuntimeSupport.mjs",
|
||||
"bin/omniroute.mjs",
|
||||
"bin/reset-password.mjs",
|
||||
// Operator incident-recovery / cold-start shell tooling (rollback, snapshot,
|
||||
// restore, cold-start bench) shipped in bin/ for self-hosters — not imported by
|
||||
// the runtime. Included via the package.json "files": ["bin/"] entry, so they
|
||||
// must be allowed here. Each script is self-documenting via --help.
|
||||
"bin/_ops-common.sh",
|
||||
"bin/cold-start-bench.sh",
|
||||
"bin/restore-data.sh",
|
||||
"bin/restore-policies.sh",
|
||||
"bin/rollback.sh",
|
||||
"bin/snapshot-data.sh",
|
||||
"open-sse/mcp-server/README.md",
|
||||
"open-sse/mcp-server/audit.ts",
|
||||
"open-sse/mcp-server/httpTransport.ts",
|
||||
"open-sse/mcp-server/index.ts",
|
||||
"open-sse/mcp-server/runtimeHeartbeat.ts",
|
||||
"open-sse/mcp-server/scopeEnforcement.ts",
|
||||
"open-sse/mcp-server/server.ts",
|
||||
// Runtime polyfill eagerly imported by bin/omniroute.mjs (Node <22 compat);
|
||||
// shipped via package.json "files", so it must be allowed in the tarball.
|
||||
"open-sse/utils/setupPolyfill.ts",
|
||||
"package.json",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
// #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration).
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"scripts/build/sync-env.mjs",
|
||||
"scripts/dev/responses-ws-proxy.mjs",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
// #5361: imported at runtime by bin/cli/commands/serve.mjs + the standalone
|
||||
// server wrapper for opt-in native HTTPS/TLS serving (kept dependency-light).
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/postinstall.mjs",
|
||||
"src/shared/utils/nodeRuntimeSupport.ts",
|
||||
];
|
||||
|
||||
export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
|
||||
"@omniroute/opencode-plugin/",
|
||||
"@omniroute/opencode-provider/",
|
||||
"bin/cli/",
|
||||
// Broad open-sse + src source dirs added to package.json "files" in v3.8.21
|
||||
// to allow TypeScript-first imports from the published package.
|
||||
"open-sse/",
|
||||
"src/domain/",
|
||||
"src/lib/",
|
||||
"src/models/",
|
||||
"src/mitm/",
|
||||
"src/server/",
|
||||
"src/shared/",
|
||||
"src/sse/",
|
||||
"src/types/",
|
||||
];
|
||||
|
||||
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
|
||||
"dist/open-sse/services/compression/engines/rtk/filters/generic-output.json",
|
||||
"dist/open-sse/services/compression/rules/en/filler.json",
|
||||
"dist/server.js",
|
||||
"dist/server-ws.mjs",
|
||||
"dist/responses-ws-proxy.mjs",
|
||||
"dist/peer-stamp.mjs",
|
||||
"dist/http-method-guard.cjs",
|
||||
// #5452: regression guard — make check:pack-artifact fail loudly if the TLS
|
||||
// opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball.
|
||||
"dist/tls-options.mjs",
|
||||
"dist/webdav-handler.mjs",
|
||||
"bin/cli/program.mjs",
|
||||
"bin/mcp-server.mjs",
|
||||
"bin/nodeRuntimeSupport.mjs",
|
||||
"bin/omniroute.mjs",
|
||||
"package.json",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"src/shared/utils/nodeRuntimeSupport.ts",
|
||||
];
|
||||
|
||||
PACK_ARTIFACT_ALLOWED_EXACT_PATHS.push(...PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS);
|
||||
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES.push(...PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES);
|
||||
|
||||
export function normalizeArtifactPath(filePath: string): string {
|
||||
return String(filePath || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\//, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
export function findUnexpectedArtifactPaths(
|
||||
filePaths: string[],
|
||||
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
|
||||
): string[] {
|
||||
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
|
||||
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
|
||||
|
||||
return filePaths
|
||||
.map(normalizeArtifactPath)
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(filePath) =>
|
||||
!normalizedExact.has(filePath) &&
|
||||
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix))
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function findMissingArtifactPaths(
|
||||
filePaths: string[],
|
||||
requiredPaths: string[] = []
|
||||
): string[] {
|
||||
const normalizedPaths = new Set(filePaths.map(normalizeArtifactPath).filter(Boolean));
|
||||
return requiredPaths
|
||||
.map(normalizeArtifactPath)
|
||||
.filter(Boolean)
|
||||
.filter((requiredPath) => !normalizedPaths.has(requiredPath))
|
||||
.sort();
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Postinstall Native Module Fix
|
||||
*
|
||||
* The npm package ships with a Next.js standalone build that includes
|
||||
* native modules compiled for the build platform (Linux x64) inside
|
||||
* dist/node_modules/. However, npm also installs these as top-level
|
||||
* dependencies (in the root node_modules/), correctly compiled for
|
||||
* the user's platform.
|
||||
*
|
||||
* This script copies the correctly-built native binaries from the root
|
||||
* into the standalone dist directory — no rebuild or build tools needed.
|
||||
*
|
||||
* Modules repaired:
|
||||
* - better-sqlite3 (SQLite bindings)
|
||||
* - wreq-js (TLS client for OAuth providers)
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
|
||||
*/
|
||||
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
|
||||
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
|
||||
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
|
||||
const appBinary = join(
|
||||
ROOT,
|
||||
"dist",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
const rootBinary = join(
|
||||
ROOT,
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
|
||||
async function fixBetterSqliteBinary() {
|
||||
if (!existsSync(join(ROOT, "dist", "node_modules", "better-sqlite3"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const platformMatch =
|
||||
process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH;
|
||||
|
||||
if (platformMatch) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`);
|
||||
|
||||
if (existsSync(rootBinary)) {
|
||||
try {
|
||||
mkdirSync(dirname(appBinary), { recursive: true });
|
||||
copyFileSync(rootBinary, appBinary);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Failed to copy binary: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Native module fixed successfully!\n");
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" 📥 Attempting to download prebuilt binary via node-pre-gyp...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
const preGypBin = join(
|
||||
ROOT,
|
||||
"dist",
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "node-pre-gyp.cmd" : "node-pre-gyp"
|
||||
);
|
||||
const preGypFallback = join(
|
||||
ROOT,
|
||||
"dist",
|
||||
"node_modules",
|
||||
"@mapbox",
|
||||
"node-pre-gyp",
|
||||
"bin",
|
||||
"node-pre-gyp"
|
||||
);
|
||||
const preGypCmd = existsSync(preGypBin) ? preGypBin : preGypFallback;
|
||||
|
||||
if (existsSync(preGypCmd)) {
|
||||
execSync(`"${process.execPath}" "${preGypCmd}" install --fallback-to-build=false`, {
|
||||
cwd: join(ROOT, "dist", "node_modules", "better-sqlite3"),
|
||||
stdio: "inherit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
mkdirSync(dirname(appBinary), { recursive: true });
|
||||
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Prebuilt binary downloaded and loaded successfully!\n");
|
||||
return;
|
||||
} catch (loadErr) {
|
||||
console.warn(` ⚠️ Downloaded binary failed to load: ${loadErr.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(" ⚠️ node-pre-gyp not found, skipping prebuilt download.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ node-pre-gyp download failed: ${err.message.split("\n")[0]}`);
|
||||
}
|
||||
|
||||
console.log(" ⚠️ Attempting npm rebuild (requires build tools)...");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
|
||||
// On Android/Termux, rebuild from source with --build-from-source flag
|
||||
const isAndroid = process.platform === "android" || isTermux();
|
||||
const rebuildCmd = isAndroid
|
||||
? "npm install better-sqlite3 --build-from-source --force"
|
||||
: "npm rebuild better-sqlite3";
|
||||
|
||||
const env = { ...process.env };
|
||||
if (isAndroid) {
|
||||
env.GYP_DEFINES = "android_ndk_path=''";
|
||||
}
|
||||
|
||||
execSync(rebuildCmd, {
|
||||
cwd: join(ROOT, "dist"),
|
||||
stdio: "inherit",
|
||||
timeout: isAndroid ? 600_000 : 300_000, // ARM compilation is slower
|
||||
env,
|
||||
});
|
||||
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
return;
|
||||
} catch (err) {
|
||||
const isTimeout = err.killed || err.signal === "SIGTERM";
|
||||
if (isTimeout) {
|
||||
const secs = isAndroid ? 600 : 300;
|
||||
console.warn(` ⚠️ npm rebuild timed out after ${secs}s.`);
|
||||
} else {
|
||||
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("\n ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
console.warn(" The server may not start correctly.");
|
||||
console.warn(" Manual fix options:");
|
||||
if (process.platform === "win32") {
|
||||
console.warn(" Option A (easiest — no build tools needed):");
|
||||
console.warn(` cd "${join(ROOT, "dist", "node_modules", "better-sqlite3")}"`);
|
||||
console.warn(" npx @mapbox/node-pre-gyp install --fallback-to-build=false");
|
||||
console.warn(" Option B (requires Build Tools for Visual Studio):");
|
||||
console.warn(` cd "${join(ROOT, "dist")}" && npm rebuild better-sqlite3`);
|
||||
console.warn(" Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/");
|
||||
console.warn(" Also ensure Python is installed: https://python.org");
|
||||
} else if (process.platform === "darwin") {
|
||||
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
|
||||
console.warn(" If build tools are missing: xcode-select --install");
|
||||
} else {
|
||||
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
|
||||
}
|
||||
console.warn("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix wreq-js native binary for the standalone dist directory.
|
||||
*
|
||||
* wreq-js ships platform-specific .node binaries under rust/.
|
||||
* The standalone build may only contain Linux binaries from the CI.
|
||||
* This copies the correct platform binary from the root install.
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
|
||||
*/
|
||||
async function fixWreqJsBinary() {
|
||||
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
|
||||
// The runtime already falls back gracefully when wreq-js is unavailable.
|
||||
if (process.platform === "android" || isTermux()) {
|
||||
console.log(
|
||||
" [postinstall] wreq-js: skipped on Termux/Android " +
|
||||
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const appWreqDir = join(ROOT, "dist", "node_modules", "wreq-js", "rust");
|
||||
const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust");
|
||||
|
||||
if (!existsSync(join(ROOT, "dist", "node_modules", "wreq-js"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
|
||||
const appBinaryPath = join(appWreqDir, binaryName);
|
||||
const rootBinaryPath = join(rootWreqDir, binaryName);
|
||||
|
||||
// Check if the platform binary already exists and loads
|
||||
if (existsSync(appBinaryPath)) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
return; // Already working
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`);
|
||||
|
||||
// Strategy 1: Copy from root node_modules
|
||||
if (existsSync(rootBinaryPath)) {
|
||||
try {
|
||||
mkdirSync(appWreqDir, { recursive: true });
|
||||
copyFileSync(rootBinaryPath, appBinaryPath);
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module fixed successfully!\n");
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Copy entire rust/ directory from root (gets all platform binaries)
|
||||
if (existsSync(rootWreqDir)) {
|
||||
try {
|
||||
mkdirSync(appWreqDir, { recursive: true });
|
||||
const files = readdirSync(rootWreqDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".node")) {
|
||||
copyFileSync(join(rootWreqDir, file), join(appWreqDir, file));
|
||||
}
|
||||
}
|
||||
if (existsSync(appBinaryPath)) {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Rebuild wreq-js inside dist/
|
||||
console.log(" 📥 Attempting npm rebuild wreq-js...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("npm rebuild wreq-js", {
|
||||
cwd: join(ROOT, "dist"),
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (existsSync(appBinaryPath)) {
|
||||
process.dlopen({ exports: {} }, appBinaryPath);
|
||||
console.log(" ✅ wreq-js native module rebuilt successfully!\n");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
|
||||
);
|
||||
console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work.");
|
||||
console.warn(` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js --no-save\n`);
|
||||
}
|
||||
|
||||
async function ensureSwcHelpers() {
|
||||
if (!hasStandaloneAppBundle(ROOT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swcHelpersApp = join(ROOT, "dist", "node_modules", "@swc", "helpers");
|
||||
const swcHelpersRoot = join(ROOT, "node_modules", "@swc", "helpers");
|
||||
|
||||
if (existsSync(swcHelpersApp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(swcHelpersRoot)) {
|
||||
try {
|
||||
const { cpSync } = await import("node:fs");
|
||||
mkdirSync(join(ROOT, "dist", "node_modules", "@swc"), { recursive: true });
|
||||
cpSync(swcHelpersRoot, swcHelpersApp, { recursive: true });
|
||||
console.log(" ✅ @swc/helpers copied to standalone dist/node_modules.\n");
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Could not copy @swc/helpers: ${err.message}`);
|
||||
console.warn(
|
||||
" Try manually: cp -r node_modules/@swc/helpers dist/node_modules/@swc/helpers\n"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(" ⚠️ @swc/helpers not found in root node_modules either.");
|
||||
console.warn(" Try: npm install --save-exact @swc/helpers@0.5.19\n");
|
||||
}
|
||||
|
||||
async function syncProjectEnv() {
|
||||
try {
|
||||
const { syncEnv } = await import("./sync-env.mjs");
|
||||
syncEnv({ rootDir: ROOT });
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ .env sync skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the LLMLingua-2 SLM optional dependency closure into dist/node_modules so the
|
||||
* compression "ultra" SLM tier (PR #4257) resolves a single @huggingface/transformers instance at
|
||||
* runtime. No-op unless the optionals were installed (`--include=optional`). See colocateOptionals.mjs.
|
||||
*/
|
||||
async function ensureLlmlinguaOptionals() {
|
||||
try {
|
||||
colocateLlmlinguaOptionals({ rootDir: ROOT, log: (m) => console.log(m) });
|
||||
} catch (err) {
|
||||
// Best-effort: the SLM tier is itself fail-open, so a co-location hiccup never fails the install.
|
||||
console.warn(` ⚠️ LLMLingua optional co-location skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fixBetterSqliteBinary();
|
||||
await fixWreqJsBinary();
|
||||
await ensureSwcHelpers();
|
||||
await ensureLlmlinguaOptionals();
|
||||
await syncProjectEnv();
|
||||
|
||||
// Warm up native runtimes (better-sqlite3 in ~/.omniroute/runtime/).
|
||||
// Non-fatal: errors are caught inside postinstall.mjs.
|
||||
try {
|
||||
await import("../postinstall.mjs");
|
||||
} catch {
|
||||
// Silently skip — runtime warm-up is best-effort.
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Detect whether the current install tree contains the published standalone bundle.
|
||||
* Checks for dist/server.js (Layer 1: renamed from app/server.js).
|
||||
* Source checkouts will not have dist/ so postinstall skips platform-specific
|
||||
* native repairs (which only apply to the shipped pre-built bundle).
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasStandaloneAppBundle(rootDir) {
|
||||
// The published bundle ships in dist/ (build-output-isolation). Also accept the
|
||||
// legacy app/ location so an upgrade over a partially-replaced install is still
|
||||
// detected as a published bundle — mirrors the serve CLI's dist/ -> app/ fallback.
|
||||
return (
|
||||
existsSync(join(rootDir, "dist", "server.js")) ||
|
||||
existsSync(join(rootDir, "app", "server.js"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when running inside a Termux environment on Android.
|
||||
*
|
||||
* Node.js on Termux reports process.platform === "linux" (not "android"),
|
||||
* so OS-level platform checks are insufficient. Use Termux-specific signals:
|
||||
* 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable)
|
||||
* 2. PREFIX env var containing "com.termux"
|
||||
* 3. Filesystem probe at /data/data/com.termux (last resort, no env needed)
|
||||
*
|
||||
* @param {object} [env] Override process.env for testing.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTermux(env = process.env) {
|
||||
if (env.TERMUX_VERSION) return true;
|
||||
if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true;
|
||||
try {
|
||||
return existsSync("/data/data/com.termux");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
|
||||
const NEXT_DIST_DIR = process.env.NEXT_DIST_DIR || ".build/next";
|
||||
const DIST_DIR = join(ROOT, NEXT_DIST_DIR);
|
||||
const STANDALONE_DIR = join(DIST_DIR, "standalone");
|
||||
const ELECTRON_STANDALONE_DIR = join(ROOT, ".build", "electron-standalone");
|
||||
|
||||
// --- Electron-UNIQUE: resolve the nested server.js location ----------------
|
||||
|
||||
function resolveStandaloneBundleDir() {
|
||||
const directServer = join(STANDALONE_DIR, "server.js");
|
||||
if (existsSync(directServer)) {
|
||||
return STANDALONE_DIR;
|
||||
}
|
||||
|
||||
const nestedCandidates = [
|
||||
join(STANDALONE_DIR, "projects", "OmniRoute"),
|
||||
join(STANDALONE_DIR, basename(ROOT)),
|
||||
];
|
||||
|
||||
for (const candidate of nestedCandidates) {
|
||||
if (existsSync(join(candidate, "server.js"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Electron-UNIQUE: symlink guard (electron-builder fails on symlinked node_modules) ---
|
||||
|
||||
function assertBundleIsPackagable(bundleDir) {
|
||||
const nodeModulesPath = join(bundleDir, "node_modules");
|
||||
if (!existsSync(nodeModulesPath)) return;
|
||||
|
||||
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
|
||||
throw new Error(
|
||||
[
|
||||
"Next standalone emitted app/node_modules as a symlink.",
|
||||
"electron-builder preserves extraResources symlinks, which would make the packaged app",
|
||||
"depend on the original build machine path at runtime.",
|
||||
"",
|
||||
`Offending path: ${nodeModulesPath}`,
|
||||
"Use a real node_modules directory in the build worktree before packaging Electron.",
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Electron-UNIQUE: strip generated electron artifacts from staged dir ---
|
||||
|
||||
function removeGeneratedElectronArtifacts() {
|
||||
const generatedDirs = [join(ELECTRON_STANDALONE_DIR, "electron", "dist-electron")];
|
||||
|
||||
for (const dir of generatedDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Electron-UNIQUE: remove native modules for electron-builder ABI rebuild ---
|
||||
|
||||
function removeNativeModules(baseDir, prefixes = ["keytar"]) {
|
||||
if (!existsSync(baseDir)) return;
|
||||
const dirs = readdirSync(baseDir);
|
||||
for (const dir of dirs) {
|
||||
if (prefixes.some((p) => dir.startsWith(p))) {
|
||||
const fullPath = join(baseDir, dir);
|
||||
rmSync(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Electron-UNIQUE: rebuild better-sqlite3 against the Electron ABI --------
|
||||
//
|
||||
// The `npm ci` at the repo root compiles better-sqlite3 for the CI *Node* ABI
|
||||
// (e.g. 137 for Node 24). The packaged app runs its Next.js server via
|
||||
// ELECTRON_RUN_AS_NODE, so it needs the *Electron* ABI (146 for electron 42,
|
||||
// 148 for electron 43). We cannot rely on electron-builder's @electron/rebuild
|
||||
// here: it searches `electron/node_modules` (where better-sqlite3 does not live)
|
||||
// and, with the default prebuild path, tries to fetch a prebuilt binary — but
|
||||
// better-sqlite3@12.11.1 only ships prebuilds up to electron-v146, so electron
|
||||
// 43 (v148) silently gets no rebuild and the app dies with "Nenhum driver
|
||||
// SQLite disponível — better-sqlite3 (falhou)".
|
||||
//
|
||||
// Instead we copy the *full* module (source + binding.gyp) from the root into
|
||||
// the standalone and compile it from source against the Electron headers, so
|
||||
// `bindings` finds a correct build/Release/better_sqlite3.node regardless of
|
||||
// prebuild availability. Robust to any current/future electron version.
|
||||
|
||||
function readElectronVersion() {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8"));
|
||||
const raw = pkg.devDependencies?.electron || pkg.dependencies?.electron || "";
|
||||
return String(raw).replace(/^[\^~]/, "");
|
||||
}
|
||||
|
||||
function rebuildBetterSqlite3ForElectron(standaloneNodeModules) {
|
||||
const srcMod = join(ROOT, "node_modules", "better-sqlite3");
|
||||
if (!existsSync(srcMod)) {
|
||||
console.warn("[electron] better-sqlite3 not found at repo root — skipping ABI rebuild.");
|
||||
return;
|
||||
}
|
||||
const electronVersion = readElectronVersion();
|
||||
if (!electronVersion) {
|
||||
throw new Error("[electron] could not resolve electron version for better-sqlite3 rebuild.");
|
||||
}
|
||||
const destMod = join(standaloneNodeModules, "better-sqlite3");
|
||||
// copyNatives only copies build/; we need the full module (src + binding.gyp)
|
||||
// to compile from source. Overwrite the copied Node-ABI build in the process.
|
||||
cpSync(srcMod, destMod, { recursive: true, force: true });
|
||||
rmSync(join(destMod, "build"), { recursive: true, force: true });
|
||||
|
||||
console.log(`[electron] rebuilding better-sqlite3 against electron ${electronVersion} ABI…`);
|
||||
const result = spawnSync(
|
||||
process.platform === "win32" ? "npx.cmd" : "npx",
|
||||
["--yes", "node-gyp", "rebuild"],
|
||||
{
|
||||
cwd: destMod,
|
||||
stdio: "inherit",
|
||||
// Compile against the Electron headers (not Node's) so the .node lands in
|
||||
// build/Release with the Electron NODE_MODULE_VERSION. No shell interpolation.
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_runtime: "electron",
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_disturl: "https://electronjs.org/headers",
|
||||
npm_config_arch: process.arch,
|
||||
npm_config_build_from_source: "true",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`[electron] better-sqlite3 rebuild against electron ${electronVersion} failed (exit ${result.status}).`
|
||||
);
|
||||
}
|
||||
// Drop the now-unneeded compile inputs to keep the packaged app lean.
|
||||
for (const dir of ["deps", "src", "build/Debug", "build/obj.target"]) {
|
||||
rmSync(join(destMod, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function logContextualError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[electron] failed to prepare standalone bundle: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
process.on("uncaughtException", logContextualError);
|
||||
|
||||
// Resolve the bundle dir (handles nested project layout) and check for symlinks
|
||||
const bundleDir = resolveStandaloneBundleDir();
|
||||
assertBundleIsPackagable(bundleDir);
|
||||
|
||||
// Clean the stage dir before assembly
|
||||
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
|
||||
|
||||
// Shared assembly: standalone copy + .next/static + public + abs-path sanitization + natives/@swc/helpers
|
||||
assembleStandalone({
|
||||
distDir: DIST_DIR,
|
||||
outDir: ELECTRON_STANDALONE_DIR,
|
||||
projectRoot: ROOT,
|
||||
sanitizePaths: true,
|
||||
copyNatives: true,
|
||||
});
|
||||
|
||||
// Electron-UNIQUE post-assembly steps
|
||||
removeGeneratedElectronArtifacts();
|
||||
|
||||
// Rebuild better-sqlite3 from source against the Electron ABI in the primary
|
||||
// node_modules (where the standalone server resolves it). keytar is still
|
||||
// stripped so electron-builder's @electron/rebuild handles it (it has electron
|
||||
// prebuilds); also drop any stray Node-ABI better-sqlite3 under .next/node_modules
|
||||
// so it cannot shadow the rebuilt one.
|
||||
rebuildBetterSqlite3ForElectron(join(ELECTRON_STANDALONE_DIR, "node_modules"));
|
||||
removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules"), ["keytar"]);
|
||||
removeNativeModules(join(ELECTRON_STANDALONE_DIR, ".next", "node_modules"), [
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
]);
|
||||
|
||||
console.log(
|
||||
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
|
||||
);
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Prepublish Build Script
|
||||
*
|
||||
* Consumes the .build/next/standalone artifact produced by `npm run build`
|
||||
* (build-next-isolated.mjs) and assembles the npm staging `dist/` directory.
|
||||
* Does NOT run a second `next build` — the caller must run `npm run build` first,
|
||||
* or this script will invoke it exactly once if the artifact is absent.
|
||||
*
|
||||
* Run with: node scripts/build/prepublish.ts
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
import {
|
||||
APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
APP_STAGING_ALLOWED_PATH_PREFIXES,
|
||||
APP_STAGING_REMOVAL_PATHS,
|
||||
findUnexpectedArtifactPaths,
|
||||
} from "./pack-artifact-policy.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
|
||||
const DIST_DIR = join(ROOT, "dist");
|
||||
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
|
||||
|
||||
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkFiles(fullPath, rootDir, files);
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(
|
||||
fullPath
|
||||
.replace(rootDir, "")
|
||||
.replace(/^[/\\]/, "")
|
||||
.replace(/\\/g, "/")
|
||||
);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function removeEmptyDirectories(dir: string): boolean {
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasFiles = false;
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const childHasFiles = removeEmptyDirectories(fullPath);
|
||||
if (!childHasFiles) {
|
||||
rmSync(fullPath, { recursive: true, force: true });
|
||||
} else {
|
||||
hasFiles = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
hasFiles = true;
|
||||
}
|
||||
|
||||
return hasFiles;
|
||||
}
|
||||
|
||||
console.log("🔨 OmniRoute — Building for npm publish...\n");
|
||||
|
||||
// ── Step 1: Clean previous dist/ directory ─────────────────
|
||||
if (existsSync(DIST_DIR)) {
|
||||
console.log(" 🧹 Cleaning previous dist/ directory...");
|
||||
rmSync(DIST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ── Step 2: Assert / trigger the Next.js standalone build ──
|
||||
// prepublish no longer runs its own `next build`. It consumes the
|
||||
// .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs).
|
||||
// If the artifact is absent we invoke it exactly once.
|
||||
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
|
||||
const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js");
|
||||
if (!existsSync(standaloneServerJs)) {
|
||||
console.log(" 🏗️ .build/next/standalone not found — running `npm run build` once...");
|
||||
execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (!existsSync(standaloneServerJs)) {
|
||||
console.error(
|
||||
"\n ❌ Standalone build not found after `npm run build` at:",
|
||||
standaloneServerJs
|
||||
);
|
||||
console.error(" Make sure next.config.mjs has: output: 'standalone'");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(" ✅ Standalone artifact present:", standaloneServerJs);
|
||||
|
||||
// ── Step 3–7: Assemble standalone into dist/ ───────────────
|
||||
// All shared copy/sync/sanitize/chunk-patch operations are delegated to
|
||||
// assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow.
|
||||
console.log(" 📋 Assembling standalone bundle into dist/...");
|
||||
assembleStandalone({
|
||||
distDir: join(ROOT, NEXT_DIST),
|
||||
outDir: DIST_DIR,
|
||||
projectRoot: ROOT,
|
||||
sanitizePaths: true,
|
||||
patchTurbopackChunks: true,
|
||||
copyNatives: true,
|
||||
});
|
||||
console.log(" ✅ Standalone bundle assembled to dist/");
|
||||
|
||||
const distServer = join(DIST_DIR, "server.js");
|
||||
const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs");
|
||||
const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs");
|
||||
if (existsSync(methodGuardSrc)) {
|
||||
cpSync(methodGuardSrc, methodGuardDest);
|
||||
}
|
||||
if (existsSync(distServer)) {
|
||||
const serverSource = readFileSync(distServer, "utf8");
|
||||
if (!serverSource.includes("installHttpMethodGuard")) {
|
||||
writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource);
|
||||
console.log(" ✅ Patched dist/server.js with HTTP method guard.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8: Compile + copy MITM cert utilities ─────────────
|
||||
const mitmSrc = join(ROOT, "src", "mitm");
|
||||
const mitmDest = join(DIST_DIR, "src", "mitm");
|
||||
if (existsSync(mitmSrc)) {
|
||||
console.log(" 🔨 Compiling MITM utilities (TypeScript → JavaScript)...");
|
||||
mkdirSync(mitmDest, { recursive: true });
|
||||
|
||||
// Write a temporary tsconfig.json targeting the mitm directory
|
||||
const mitmTsconfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
outDir: mitmDest,
|
||||
rootDir: mitmSrc,
|
||||
strict: false,
|
||||
noImplicitAny: false,
|
||||
strictNullChecks: false,
|
||||
noEmitOnError: true,
|
||||
allowImportingTsExtensions: true,
|
||||
rewriteRelativeImportExtensions: true,
|
||||
ignoreDeprecations: "6.0",
|
||||
resolveJsonModule: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
types: ["node"],
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@/*": ["src/*"],
|
||||
},
|
||||
},
|
||||
include: [mitmSrc + "/**/*"],
|
||||
};
|
||||
const tmpTsconfigPath = join(ROOT, "tsconfig.mitm.tmp.json");
|
||||
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
|
||||
|
||||
try {
|
||||
execFileSync(NPX_BIN, ["tsc", "-p", "tsconfig.mitm.tmp.json"], {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const mitmServerSrc = join(mitmSrc, "server.cjs");
|
||||
if (existsSync(mitmServerSrc)) {
|
||||
cpSync(mitmServerSrc, join(mitmDest, "server.cjs"));
|
||||
}
|
||||
console.log(" ✅ MITM utilities compiled to dist/src/mitm/");
|
||||
} catch (err: any) {
|
||||
console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message);
|
||||
// Fallback: copy source files so at least they are present
|
||||
cpSync(mitmSrc, mitmDest, { recursive: true });
|
||||
} finally {
|
||||
// Cleanup temp tsconfig
|
||||
try {
|
||||
rmSync(tmpTsconfigPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.5: Bundle MCP server ────────────────────────────
|
||||
const mcpSrcFile = join(ROOT, "open-sse", "mcp-server", "server.ts");
|
||||
const mcpDestDir = join(DIST_DIR, "open-sse", "mcp-server");
|
||||
const mcpDestFile = join(mcpDestDir, "server.js");
|
||||
|
||||
if (existsSync(mcpSrcFile)) {
|
||||
console.log(" 🔨 Bundling MCP Server (TypeScript → JavaScript)...");
|
||||
mkdirSync(mcpDestDir, { recursive: true });
|
||||
try {
|
||||
execFileSync(
|
||||
NPX_BIN,
|
||||
[
|
||||
"esbuild",
|
||||
"open-sse/mcp-server/server.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
"--outfile=dist/open-sse/mcp-server/server.js",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
console.log(" ✅ MCP Server bundled to dist/open-sse/mcp-server/server.js");
|
||||
} catch (err: any) {
|
||||
console.warn(" ⚠️ MCP Server bundle error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.6: Bundle LLMLingua ONNX worker ────────────────────────────
|
||||
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
|
||||
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
|
||||
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
|
||||
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
|
||||
// dynamically imported at runtime, and the worker fail-opens if any is absent.
|
||||
const llmWorkerSrc = join(
|
||||
ROOT,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"engines",
|
||||
"llmlingua",
|
||||
"onnxWorker.ts"
|
||||
);
|
||||
const llmWorkerDestDir = join(
|
||||
DIST_DIR,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"engines",
|
||||
"llmlingua"
|
||||
);
|
||||
if (existsSync(llmWorkerSrc)) {
|
||||
console.log(" 🔨 Bundling LLMLingua ONNX worker (TypeScript → JavaScript)...");
|
||||
mkdirSync(llmWorkerDestDir, { recursive: true });
|
||||
try {
|
||||
execFileSync(
|
||||
NPX_BIN,
|
||||
[
|
||||
"esbuild",
|
||||
"open-sse/services/compression/engines/llmlingua/onnxWorker.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
"--outfile=dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
console.log(
|
||||
" ✅ LLMLingua worker bundled to dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js"
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.warn(" ⚠️ LLMLingua worker bundle error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
|
||||
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
|
||||
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
|
||||
|
||||
if (existsSync(cliSrcFile)) {
|
||||
console.log(" 🔨 Bundling CLI Entrypoint (TypeScript → JavaScript)...");
|
||||
try {
|
||||
execFileSync(
|
||||
NPX_BIN,
|
||||
[
|
||||
"esbuild",
|
||||
"bin/omniroute.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
"--outfile=bin/omniroute.mjs",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
chmodSync(cliDestFile, 0o755);
|
||||
console.log(" ✅ CLI Entrypoint bundled to bin/omniroute.mjs");
|
||||
} catch (err: any) {
|
||||
console.warn(" ⚠️ CLI bundle error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.8: Build @omniroute/opencode-plugin ──────────────
|
||||
// The plugin ships bundled inside the omniroute npm package (see root
|
||||
// package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be
|
||||
// present in the publish tarball so `omniroute setup opencode` can copy it
|
||||
// into the user's OpenCode plugin dir. If the build fails we surface the
|
||||
// error — shipping without the plugin's dist breaks the documented install
|
||||
// flow for every downstream user.
|
||||
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
|
||||
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
|
||||
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
|
||||
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
|
||||
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
|
||||
if (!pluginAlreadyBuilt) {
|
||||
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
|
||||
try {
|
||||
// The plugin is a standalone package (not an npm workspace), so the root
|
||||
// install never populates its node_modules — and tsup with `dts: true`
|
||||
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
|
||||
// types). Without this install a fresh CI publish fails at this step.
|
||||
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
|
||||
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], {
|
||||
cwd: opencodePluginSrc,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
execFileSync(NPX_BIN, ["tsup"], {
|
||||
cwd: opencodePluginSrc,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_ENV: "production" },
|
||||
});
|
||||
console.log(" ✅ @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/");
|
||||
} catch (err: any) {
|
||||
console.error(" ❌ Failed to build @omniroute/opencode-plugin:", err.message);
|
||||
console.error(" The published package would be missing the plugin dist.");
|
||||
console.error(
|
||||
" Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(" ✅ @omniroute/opencode-plugin dist/ already present (skipping rebuild)");
|
||||
}
|
||||
// Remove plugin node_modules after build — hard links created by npm install on Linux
|
||||
// (CI runner) end up in the tarball as LINK entries, which npm registry rejects with
|
||||
// E415 "Hard link is not allowed". The node_modules are only needed for the tsup build;
|
||||
// they must not ship in the published package.
|
||||
const pluginNodeModules = join(opencodePluginSrc, "node_modules");
|
||||
if (existsSync(pluginNodeModules)) {
|
||||
rmSync(pluginNodeModules, { recursive: true, force: true });
|
||||
console.log(" 🧹 Removed @omniroute/opencode-plugin/node_modules (hard link guard)");
|
||||
}
|
||||
} else {
|
||||
console.log(" ⏭️ @omniroute/opencode-plugin not found in workspace (skipping build)");
|
||||
}
|
||||
|
||||
// ── Step 9: Copy shared utilities needed at runtime ────────
|
||||
const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js");
|
||||
const sharedApiKeyDest = join(DIST_DIR, "src", "shared", "utils");
|
||||
if (existsSync(sharedApiKey)) {
|
||||
console.log(" 📋 Copying shared utilities...");
|
||||
mkdirSync(sharedApiKeyDest, { recursive: true });
|
||||
cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js"));
|
||||
}
|
||||
|
||||
// ── Step 9.5: Copy minimal runtime sidecars required outside .next ─────────
|
||||
const envExampleSrc = join(ROOT, ".env.example");
|
||||
if (existsSync(envExampleSrc)) {
|
||||
cpSync(envExampleSrc, join(DIST_DIR, ".env.example"));
|
||||
}
|
||||
|
||||
const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml");
|
||||
if (existsSync(openapiSpecSrc)) {
|
||||
const docsDest = join(DIST_DIR, "docs");
|
||||
mkdirSync(docsDest, { recursive: true });
|
||||
cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml"));
|
||||
}
|
||||
|
||||
const docsMarkdownSrc = join(ROOT, "docs");
|
||||
if (existsSync(docsMarkdownSrc)) {
|
||||
const docsDest = join(DIST_DIR, "docs");
|
||||
mkdirSync(docsDest, { recursive: true });
|
||||
const mdFiles = readdirSync(docsMarkdownSrc).filter(
|
||||
(f) => f.endsWith(".md") || f.endsWith(".mdx")
|
||||
);
|
||||
for (const mdFile of mdFiles) {
|
||||
cpSync(join(docsMarkdownSrc, mdFile), join(docsDest, mdFile));
|
||||
}
|
||||
if (mdFiles.length > 0) {
|
||||
console.log(`[prepublish] Copied ${mdFiles.length} docs markdown files to dist/docs/`);
|
||||
}
|
||||
}
|
||||
|
||||
const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs");
|
||||
if (existsSync(syncEnvSrc)) {
|
||||
const scriptsDest = join(DIST_DIR, "scripts");
|
||||
mkdirSync(scriptsDest, { recursive: true });
|
||||
cpSync(syncEnvSrc, join(scriptsDest, "sync-env.mjs"));
|
||||
}
|
||||
|
||||
const migrationsSrc = join(ROOT, "src", "lib", "db", "migrations");
|
||||
if (existsSync(migrationsSrc)) {
|
||||
const migrationsDest = join(DIST_DIR, "src", "lib", "db", "migrations");
|
||||
mkdirSync(join(DIST_DIR, "src", "lib", "db"), { recursive: true });
|
||||
cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const runtimeAssetDirs = [
|
||||
{
|
||||
source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"),
|
||||
destination: join(DIST_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"),
|
||||
},
|
||||
{
|
||||
source: join(ROOT, "open-sse", "services", "compression", "rules"),
|
||||
destination: join(DIST_DIR, "open-sse", "services", "compression", "rules"),
|
||||
},
|
||||
];
|
||||
for (const assetDir of runtimeAssetDirs) {
|
||||
if (existsSync(assetDir.source)) {
|
||||
mkdirSync(dirname(assetDir.destination), { recursive: true });
|
||||
cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 10: Ensure data/ directory exists ──────────────────
|
||||
mkdirSync(join(DIST_DIR, "data"), { recursive: true });
|
||||
|
||||
// ── Step 10.5: Copy @swc/helpers into standalone ───────────
|
||||
// Next.js standalone tracer sometimes omits @swc/helpers from dist/node_modules/,
|
||||
// causing MODULE_NOT_FOUND at runtime. Always copy it explicitly.
|
||||
const swcHelpersSrc = join(ROOT, "node_modules", "@swc", "helpers");
|
||||
const swcHelpersDst = join(DIST_DIR, "node_modules", "@swc", "helpers");
|
||||
if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) {
|
||||
console.log(" 📋 Copying @swc/helpers to standalone dist/node_modules...");
|
||||
mkdirSync(join(DIST_DIR, "node_modules", "@swc"), { recursive: true });
|
||||
cpSync(swcHelpersSrc, swcHelpersDst, { recursive: true });
|
||||
console.log(" ✅ @swc/helpers included in standalone build.");
|
||||
}
|
||||
|
||||
// ── Step 10.6: Remove development-only residue from staged dist/ ────────────
|
||||
for (const relativePath of APP_STAGING_REMOVAL_PATHS) {
|
||||
const targetPath = join(DIST_DIR, relativePath);
|
||||
if (existsSync(targetPath)) {
|
||||
console.log(` 🧹 Removing dist/${relativePath} (not needed in npm package)...`);
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
console.log(` ✅ dist/${relativePath} removed.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 10.7: Prune any staged dist/ file outside the allowed runtime set ──
|
||||
const stagedFiles = walkFiles(DIST_DIR);
|
||||
const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
|
||||
});
|
||||
|
||||
if (unexpectedStagedFiles.length > 0) {
|
||||
console.log(" 🧹 Pruning unexpected files from staged dist/...");
|
||||
unexpectedStagedFiles.forEach((unexpectedPath: string) => {
|
||||
rmSync(join(DIST_DIR, unexpectedPath), { force: true });
|
||||
console.log(` ✅ Removed dist/${unexpectedPath}`);
|
||||
});
|
||||
removeEmptyDirectories(DIST_DIR);
|
||||
}
|
||||
|
||||
const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
|
||||
});
|
||||
|
||||
if (remainingUnexpectedFiles.length > 0) {
|
||||
console.error("\n ❌ Staged dist/ still contains unexpected publish artifacts:");
|
||||
remainingUnexpectedFiles.forEach((violation: string) =>
|
||||
console.error(` - dist/${violation}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────
|
||||
const distPkg = join(DIST_DIR, "package.json");
|
||||
if (existsSync(distPkg)) {
|
||||
JSON.parse(readFileSync(distPkg, "utf8"));
|
||||
console.log(`\n ✅ Build complete!`);
|
||||
console.log(` Dist directory: dist/`);
|
||||
console.log(` Server entry: dist/server.js`);
|
||||
} else {
|
||||
console.log(`\n ✅ Build complete! (dist/ ready for publish)`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
@@ -0,0 +1,140 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function parsePort(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the V8 heap ceiling (MB) for the server process from
|
||||
* `OMNIROUTE_MEMORY_MB`, mirroring `omniroute serve`. Clamped to [64, 16384];
|
||||
* invalid/unset → fallback (512). The standalone launcher uses this so
|
||||
* OMNIROUTE_MEMORY_MB can override the Docker image's NODE_OPTIONS fallback
|
||||
* without clobbering any other runtime flags (#2939).
|
||||
* @param {string | number | undefined | null} value
|
||||
* @param {number} [fallback]
|
||||
*/
|
||||
export function resolveMaxOldSpaceMb(value, fallback = 512) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a sane DEFAULT V8 heap ceiling (MB) from the host's physical RAM, used
|
||||
* when `OMNIROUTE_MEMORY_MB` is unset. A fixed 512MB default crashed boxes with
|
||||
* plenty of RAM under load (65 providers / 2600 models → "Ineffective
|
||||
* mark-compacts near heap limit ~500MB"); see #5172 / #5160 / #5152. Targets
|
||||
* ~35% of total RAM, clamped to [512, 4096]. Invalid/zero totalmem → 512.
|
||||
* Pass the result as the `fallback` of {@link resolveMaxOldSpaceMb} so an
|
||||
* explicit OMNIROUTE_MEMORY_MB override always wins.
|
||||
* @param {number | undefined | null} totalmemBytes — typically `os.totalmem()`
|
||||
*/
|
||||
export function calibrateHeapFallbackMb(totalmemBytes) {
|
||||
const totalMb = Number(totalmemBytes) / (1024 * 1024);
|
||||
if (!Number.isFinite(totalMb) || totalMb <= 0) return 512;
|
||||
const target = Math.floor(totalMb * 0.35);
|
||||
return Math.min(4096, Math.max(512, target));
|
||||
}
|
||||
|
||||
const MAX_OLD_SPACE_FLAG = "--max-old-space-size";
|
||||
|
||||
/**
|
||||
* True when the caller already pinned the V8 heap via NODE_OPTIONS
|
||||
* (`--max-old-space-size=…`). Used to decide whether `omniroute serve` may
|
||||
* append/inject the calibrated default — a user-set value must always win.
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function envHasExplicitHeapFlag(env) {
|
||||
const sourceEnv = arguments.length === 0 ? process.env : env;
|
||||
return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the NODE_OPTIONS string for the spawned server, preserving any flags
|
||||
* the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY
|
||||
* overwrite NODE_OPTIONS with the calibrated `--max-old-space-size`, silently
|
||||
* discarding a user-set `NODE_OPTIONS=--max-old-space-size=8192` (reporter set
|
||||
* 8192 and still OOM'd at ~505MB). Mirrors the Electron (electron/main.js) and
|
||||
* standalone (scripts/dev/run-standalone.mjs) launchers:
|
||||
* - if NODE_OPTIONS already contains `--max-old-space-size`, keep it as-is
|
||||
* (the user's value wins);
|
||||
* - otherwise append the calibrated `--max-old-space-size=<memoryLimit>` to
|
||||
* the existing NODE_OPTIONS, preserving unrelated flags (e.g.
|
||||
* `--enable-source-maps`).
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
||||
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
|
||||
* @returns {string} the NODE_OPTIONS value to pass to the child process
|
||||
*/
|
||||
export function buildServerNodeOptions(env = process.env, memoryLimit) {
|
||||
const existing = String(env?.NODE_OPTIONS || "").trim();
|
||||
if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing;
|
||||
return `${existing} ${MAX_OLD_SPACE_FLAG}=${memoryLimit}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the leading `node` CLI args that pin the V8 heap. When the user already
|
||||
* pinned the heap via NODE_OPTIONS, return `[]` so we do NOT inject a
|
||||
* conflicting/shadowing CLI `--max-old-space-size` (CLI args override
|
||||
* NODE_OPTIONS, which would re-introduce #5238). Otherwise return the calibrated
|
||||
* flag — NODE_OPTIONS already carries the same value, so this stays redundant
|
||||
* (identical value), never conflicting.
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
||||
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildNodeHeapArgs(env = process.env, memoryLimit) {
|
||||
return envHasExplicitHeapFlag(env) ? [] : [`${MAX_OLD_SPACE_FLAG}=${memoryLimit}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
|
||||
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
|
||||
*/
|
||||
export function resolveRuntimePorts(fromEnv = process.env) {
|
||||
const basePort = parsePort(fromEnv.PORT || "20128", 20128);
|
||||
const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort);
|
||||
const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort);
|
||||
|
||||
return { basePort, apiPort, dashboardPort };
|
||||
}
|
||||
|
||||
export function withRuntimePortEnv(env, runtimePorts) {
|
||||
const { basePort, apiPort, dashboardPort } = runtimePorts;
|
||||
|
||||
return {
|
||||
...env,
|
||||
OMNIROUTE_PORT: String(basePort),
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeColorEnv(env = {}) {
|
||||
const sanitized = { ...env };
|
||||
|
||||
// Node warns when both FORCE_COLOR and NO_COLOR are set.
|
||||
// Prefer NO_COLOR in test tooling to avoid noisy process warnings.
|
||||
if (typeof sanitized.FORCE_COLOR !== "undefined" && typeof sanitized.NO_COLOR !== "undefined") {
|
||||
delete sanitized.FORCE_COLOR;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function spawnWithForwardedSignals(command, args, options = {}) {
|
||||
const child = spawn(command, args, options);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => child.kill("SIGINT"));
|
||||
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
||||
|
||||
return child;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
export { getEnvSyncPlan, parseEnvFile, syncEnv } from "../dev/sync-env.mjs";
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fullUninstall = args.includes("--full");
|
||||
const uninstallAlreadyInProgress =
|
||||
process.env.OMNIROUTE_SKIP_UNINSTALL_HOOK === "1" ||
|
||||
process.env.npm_lifecycle_event === "uninstall";
|
||||
|
||||
console.log("🛑 OmniRoute Uninstaller");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
|
||||
// 1. Stop PM2 process if it exists
|
||||
try {
|
||||
console.log("Stopping and removing background PM2 processes...");
|
||||
execSync("pm2 delete omniroute 2>/dev/null", { stdio: "ignore" });
|
||||
} catch {
|
||||
// It's perfectly fine if pm2 is not installed or the process doesn't exist.
|
||||
}
|
||||
|
||||
// 2. Local AppData / Config Folder cleanup (Only on Full Uninstall)
|
||||
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
|
||||
|
||||
if (fullUninstall) {
|
||||
console.log(`🧹 Full Uninstall selected. Erasing database and files at: ${dataDir}`);
|
||||
try {
|
||||
if (fs.existsSync(dataDir)) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log("✅ Data directory removed.");
|
||||
} else {
|
||||
console.log("ℹ️ Data directory did not exist. Skipping.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Failed to remove data directory:", error.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`💾 Keeping data files at: ${dataDir} intact.`);
|
||||
}
|
||||
|
||||
// 3. NPM uninstall
|
||||
if (uninstallAlreadyInProgress) {
|
||||
console.log("ℹ️ npm uninstall is already in progress. Skipping nested uninstall command.");
|
||||
} else {
|
||||
console.log("🗑️ Removing npm package...");
|
||||
try {
|
||||
execSync("npm uninstall -g omniroute", {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OMNIROUTE_SKIP_UNINSTALL_HOOK: "1",
|
||||
},
|
||||
});
|
||||
console.log("\n✅ OmniRoute has been successfully uninstalled from your system.");
|
||||
if (!fullUninstall) {
|
||||
console.log(`ℹ️ Your configurations and databases were preserved in ${dataDir}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Failed to remove npm package. You might need to run this command with 'sudo'."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
PACK_ARTIFACT_REQUIRED_PATHS,
|
||||
findMissingArtifactPaths,
|
||||
findUnexpectedArtifactPaths,
|
||||
} from "./pack-artifact-policy.ts";
|
||||
|
||||
const __filename: string = fileURLToPath(import.meta.url);
|
||||
const __dirname: string = dirname(__filename);
|
||||
const ROOT: string = join(__dirname, "..", "..");
|
||||
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
const isBunRuntime = "Bun" in globalThis;
|
||||
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
|
||||
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
|
||||
|
||||
return execFileSync(command, commandArgs, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAppStagingReady(): void {
|
||||
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
|
||||
requiredPath.startsWith("dist/")
|
||||
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
|
||||
|
||||
if (missingAppRequiredPaths.length === 0) return;
|
||||
|
||||
console.log("📦 dist/ staging is missing required runtime files; running npm run build:cli...");
|
||||
runNpm(["run", "build:cli"], "inherit");
|
||||
}
|
||||
|
||||
function runPackDryRun(): any {
|
||||
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
||||
|
||||
const jsonStart = output.indexOf("[");
|
||||
const jsonEnd = output.lastIndexOf("]");
|
||||
const jsonPayload =
|
||||
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
|
||||
const parsed = JSON.parse(jsonPayload);
|
||||
const packReport = Array.isArray(parsed) ? parsed[0] : null;
|
||||
|
||||
if (!packReport || !Array.isArray(packReport.files)) {
|
||||
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
|
||||
}
|
||||
|
||||
return packReport;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 1024) {
|
||||
return `${bytes || 0} B`;
|
||||
}
|
||||
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
|
||||
// required-runtime-files check (which needs the built dist/), running ONLY the
|
||||
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
|
||||
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
|
||||
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
|
||||
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
|
||||
const POLICY_ONLY = process.argv.includes("--policy-only");
|
||||
|
||||
try {
|
||||
if (!POLICY_ONLY) ensureAppStagingReady();
|
||||
const packReport = runPackDryRun();
|
||||
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
|
||||
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
|
||||
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
});
|
||||
const missingRequiredPaths: string[] = POLICY_ONLY
|
||||
? []
|
||||
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
|
||||
|
||||
console.log("📦 npm pack artifact summary");
|
||||
console.log(` File: ${packReport.filename}`);
|
||||
console.log(` Entry count: ${packReport.entryCount}`);
|
||||
console.log(` Packed size: ${formatBytes(packReport.size)}`);
|
||||
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
|
||||
|
||||
if (unexpectedPaths.length > 0) {
|
||||
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
|
||||
for (const unexpectedPath of unexpectedPaths) {
|
||||
console.error(` - ${unexpectedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingRequiredPaths.length > 0) {
|
||||
console.error("\n❌ Required runtime files are missing from the npm publish artifact:");
|
||||
for (const missingPath of missingRequiredPaths) {
|
||||
console.error(` - ${missingPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n✅ Pack artifact policy check passed.");
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Pack artifact validation failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* write-build-sha.mjs — HEAD sentinel guard for OmniRoute release builds.
|
||||
*
|
||||
* Writes OMNIROUTE_BUILD_SHA (or git rev-parse --short HEAD) into:
|
||||
* - dist/BUILD_SHA
|
||||
* - .build/next/standalone/BUILD_SHA (present after `npm run build`)
|
||||
*
|
||||
* Exits 1 if the standalone directory does not exist, which guards against
|
||||
* stale-cache shipping where a previous build artifact is accidentally published
|
||||
* without a fresh `next build`.
|
||||
*
|
||||
* Usage: node scripts/build/write-build-sha.mjs
|
||||
* Called by: npm run build:release (after npm run build)
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// Resolve the build SHA: prefer the env var (set by build:release script), fall
|
||||
// back to running git rev-parse.
|
||||
function resolveBuildSha() {
|
||||
if (process.env.OMNIROUTE_BUILD_SHA) {
|
||||
return process.env.OMNIROUTE_BUILD_SHA.trim();
|
||||
}
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
} catch (err) {
|
||||
console.error("[write-build-sha] Could not determine build SHA:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const sha = resolveBuildSha();
|
||||
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
|
||||
const standaloneDir = path.join(ROOT, NEXT_DIST, "standalone");
|
||||
const distDir = path.join(ROOT, "dist");
|
||||
|
||||
// Guard: standalone must exist (ensures build:release runs after npm run build)
|
||||
if (!fs.existsSync(standaloneDir)) {
|
||||
console.error(
|
||||
`[write-build-sha] FATAL: standalone dir not found: ${standaloneDir}\n` +
|
||||
` Run \`npm run build\` before \`npm run build:release\`.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Write sentinel to the standalone dir (Docker/dev path)
|
||||
const standaloneSentinel = path.join(standaloneDir, "BUILD_SHA");
|
||||
fs.writeFileSync(standaloneSentinel, sha + "\n");
|
||||
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, standaloneSentinel)}`);
|
||||
|
||||
// Write sentinel to dist/ (npm publish path) if it exists
|
||||
if (fs.existsSync(distDir)) {
|
||||
const distSentinel = path.join(distDir, "BUILD_SHA");
|
||||
fs.writeFileSync(distSentinel, sha + "\n");
|
||||
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, distSentinel)}`);
|
||||
}
|
||||
|
||||
console.log(`[write-build-sha] Build SHA: ${sha}`);
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Memory limit override ──────────────────────────────────────────────
|
||||
# If OMNIROUTE_MEMORY_MB is set, build NODE_OPTIONS dynamically so the
|
||||
# user can tune heap size via environment without editing the Dockerfile.
|
||||
if [ -n "$OMNIROUTE_MEMORY_MB" ]; then
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
fi
|
||||
|
||||
DATA_PATH="${DATA_DIR:-/app/data}"
|
||||
if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then
|
||||
echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))."
|
||||
if [ "${CONTAINER_HOST:-}" = "podman" ]; then
|
||||
echo "Rootless Podman maps container UIDs into a subordinate range."
|
||||
echo "Run this on the host to fix (using the host-side bind-mount path):"
|
||||
echo " podman unshare chown -R $(id -u):$(id -g) <host-data-dir>"
|
||||
else
|
||||
echo "Run this on the Docker host to fix (using the host-side bind-mount path):"
|
||||
echo " sudo chown -R $(id -u):$(id -g) <host-data-dir>"
|
||||
echo " chmod -R u+rwX <host-data-dir>"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
// check-build-scope.mjs — guards against worktrees / cruft leaking into the
|
||||
// TypeScript build scope and poisoning `next build`.
|
||||
//
|
||||
// Root cause of the 2026-06-25 build OOM/GC-livelock incident: `tsconfig.json`
|
||||
// uses `include: ["**/*.ts","**/*.tsx","**/*.js","**/*.jsx"]` (recursive glob),
|
||||
// and 69 git worktrees under `.claude/worktrees/` were NOT in `exclude` — so the
|
||||
// TS scope ballooned to 355,215 files (vs 4,547 real source files) and `next build`
|
||||
// processed ~70x the codebase, OOMing even at a 64 GB heap. The CI built fine
|
||||
// because its checkout is clean.
|
||||
//
|
||||
// This gate counts the .ts/.tsx/.js/.jsx files that tsconfig's include would match
|
||||
// (respecting its top-level exclude dirs) and FAILS if the count exceeds a
|
||||
// threshold — catching a leak BEFORE it detonates the build. Heap size is NOT the
|
||||
// fix for an over-large scope; a clean scope is.
|
||||
//
|
||||
// Usage: node scripts/check/check-build-scope.mjs [--max N] [--json]
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const args = process.argv.slice(2);
|
||||
const MAX = Number(args[args.indexOf("--max") + 1]) || 12000;
|
||||
const JSON_OUT = args.includes("--json");
|
||||
|
||||
const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
||||
|
||||
// Read tsconfig.json exclude (the source of truth for what's out of scope).
|
||||
const tsconfigPath = path.join(ROOT, "tsconfig.json");
|
||||
let exclude = [];
|
||||
try {
|
||||
exclude = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")).exclude || [];
|
||||
} catch {
|
||||
console.error("[build-scope] could not read tsconfig.json exclude — aborting");
|
||||
process.exit(2);
|
||||
}
|
||||
// Always skip VCS + the exclude dirs (normalise to bare top-level names).
|
||||
const SKIP_DIRS = new Set([".git", ...exclude.map((e) => e.replace(/^\.\//, "").replace(/\/.*$/, ""))]);
|
||||
|
||||
let count = 0;
|
||||
const byTop = {};
|
||||
function walk(dir, top) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.isSymbolicLink()) continue; // don't follow symlinks (e.g. node_modules)
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
walk(full, top);
|
||||
} else if (EXT.has(path.extname(e.name))) {
|
||||
count++;
|
||||
byTop[top] = (byTop[top] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
||||
if (!e.isDirectory()) {
|
||||
if (EXT.has(path.extname(e.name))) {
|
||||
count++;
|
||||
byTop["(root)"] = (byTop["(root)"] || 0) + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (SKIP_DIRS.has(e.name)) continue;
|
||||
walk(path.join(ROOT, e.name), e.name);
|
||||
}
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(JSON.stringify({ count, max: MAX, byTop }, null, 2));
|
||||
} else {
|
||||
console.log(`[build-scope] ${count} .ts/.tsx/.js/.jsx files in tsconfig scope (max ${MAX})`);
|
||||
const top = Object.entries(byTop)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8);
|
||||
for (const [d, n] of top) console.log(` ${String(n).padStart(7)} ${d}`);
|
||||
}
|
||||
|
||||
if (count > MAX) {
|
||||
console.error(
|
||||
`\n❌ [build-scope] scope of ${count} files exceeds ${MAX} — something is leaking into the\n` +
|
||||
` tsconfig include scope (worktree, vendored copy, or build output). This poisons\n` +
|
||||
` \`next build\` (OOM/GC-livelock). Add the offending dir to tsconfig.json "exclude"\n` +
|
||||
` (and .dockerignore). Worktrees MUST live under .claude/worktrees/ (already excluded).\n` +
|
||||
` Heap size does NOT fix this — a clean scope does. See incident 2026-06-25.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ [build-scope] OK — no leak into the build scope.");
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-bundle-size.mjs
|
||||
// Catraca de bundle size (Task 12 — Fase 7).
|
||||
//
|
||||
// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin):
|
||||
// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada
|
||||
// entry e somar. Emite `bundleSize=<bytes>`.
|
||||
//
|
||||
// MODO FALLBACK — raw fs.statSync() (sem plugins instalados):
|
||||
// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o
|
||||
// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync()
|
||||
// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=<bytes>`.
|
||||
//
|
||||
// MODO SKIP — entradas inexistentes:
|
||||
// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os
|
||||
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
|
||||
// e sai 0.
|
||||
//
|
||||
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
|
||||
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
|
||||
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
|
||||
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
|
||||
//
|
||||
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
|
||||
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
|
||||
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
|
||||
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
|
||||
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
|
||||
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
|
||||
// medida na MESMA métrica do baseline bloqueia.
|
||||
//
|
||||
// Uso:
|
||||
// node scripts/check/check-bundle-size.mjs
|
||||
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
|
||||
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
|
||||
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
|
||||
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
|
||||
const RATCHET = process.argv.includes("--ratchet");
|
||||
|
||||
/**
|
||||
* Tenta rodar size-limit --json e retorna o array de resultados.
|
||||
* Lança se size-limit não tiver plugins instalados (plugins.isEmpty).
|
||||
*
|
||||
* @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>}
|
||||
* @throws {SizeLimitNoPluginsError}
|
||||
*/
|
||||
export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) {
|
||||
if (!fs.existsSync(binPath)) {
|
||||
throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" });
|
||||
}
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("node", [binPath, "--json"], {
|
||||
encoding: "utf8",
|
||||
cwd,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
const combined = (err.stdout || "") + (err.stderr || "");
|
||||
if (
|
||||
combined.includes("Install Size Limit preset") ||
|
||||
combined.includes("plugins.isEmpty") ||
|
||||
combined.includes("@size-limit/preset")
|
||||
) {
|
||||
throw Object.assign(new Error("size-limit: no plugins installed"), {
|
||||
code: "SL_NO_PLUGINS",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return JSON.parse(stdout.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parseia o JSON de saída do size-limit e retorna o total em bytes.
|
||||
* Lança se o JSON não tiver o campo `size` em pelo menos uma entrada.
|
||||
*
|
||||
* @param {Array<{name: string, size?: number}>} results
|
||||
* @returns {number} total em bytes
|
||||
*/
|
||||
export function parseSizeLimitResults(results) {
|
||||
if (!Array.isArray(results)) {
|
||||
throw new TypeError("parseSizeLimitResults: esperado array de resultados");
|
||||
}
|
||||
let total = 0;
|
||||
let hasMeasured = false;
|
||||
for (const entry of results) {
|
||||
if (typeof entry.size === "number") {
|
||||
total += entry.size;
|
||||
hasMeasured = true;
|
||||
}
|
||||
}
|
||||
if (!hasMeasured) {
|
||||
throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: lê os `path` do .size-limit.json via fs.statSync().
|
||||
* Retorna {total, entries, allMissing} onde:
|
||||
* - total: soma dos bytes dos arquivos encontrados
|
||||
* - entries: [{name, path, size}]
|
||||
* - allMissing: true se NENHUM arquivo existia (skip)
|
||||
*
|
||||
* @param {string} configPath
|
||||
* @param {string} cwd
|
||||
*/
|
||||
export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { total: 0, entries: [], allMissing: true };
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
let total = 0;
|
||||
let found = 0;
|
||||
const entries = [];
|
||||
for (const entry of config) {
|
||||
const entryPath = path.isAbsolute(entry.path) ? entry.path : path.join(cwd, entry.path);
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
entries.push({ name: entry.name, path: entry.path, size: null });
|
||||
continue;
|
||||
}
|
||||
const size = fs.statSync(entryPath).size;
|
||||
total += size;
|
||||
found++;
|
||||
entries.push({ name: entry.name, path: entry.path, size });
|
||||
}
|
||||
return { total, entries, allMissing: found === 0 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ratchet (direction:down) — exported for tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Avalia o total MEDIDO de bytes contra o baseline.
|
||||
* Direction: down (o tamanho só pode CAIR — maior = regressão).
|
||||
*
|
||||
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
|
||||
* @param {number} baseline - Total congelado em quality-baseline.json.
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateBundleSizeRatchet(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê metrics.bundleSize.value do quality-baseline.json.
|
||||
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
|
||||
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
|
||||
*
|
||||
* @param {string} baselinePath
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
|
||||
if (!fs.existsSync(baselinePath)) return null;
|
||||
let baselineJson;
|
||||
try {
|
||||
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const metric = baselineJson?.metrics?.bundleSize;
|
||||
if (!metric || typeof metric.value !== "number") return null;
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
|
||||
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
|
||||
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
|
||||
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
|
||||
*
|
||||
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
|
||||
*/
|
||||
function applyRatchet(totalBytes) {
|
||||
if (!RATCHET) {
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
|
||||
if (baselineValue === null) {
|
||||
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
|
||||
if (regressed) {
|
||||
console.error(
|
||||
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
|
||||
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
|
||||
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
|
||||
);
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: tenta com size-limit + plugin instalado
|
||||
let totalBytes = null;
|
||||
let mode = "size-limit";
|
||||
|
||||
try {
|
||||
const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN);
|
||||
totalBytes = parseSizeLimitResults(results);
|
||||
} catch (err) {
|
||||
if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") {
|
||||
// Step 2: fallback para leitura direta de arquivo
|
||||
mode = "fallback-stat";
|
||||
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
|
||||
|
||||
if (allMissing) {
|
||||
// Step 3: skip gracioso — entradas não existem (build necessário).
|
||||
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
|
||||
console.log("bundleSize=SKIP reason=no-build");
|
||||
if (process.env.CI) {
|
||||
console.log(
|
||||
"::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
totalBytes = total;
|
||||
for (const e of entries) {
|
||||
if (e.size !== null) {
|
||||
const kb = (e.size / 1024).toFixed(2);
|
||||
console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`);
|
||||
} else {
|
||||
console.log(` ${e.name}: ausente (não contabilizado)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Erro inesperado — reporta mas não falha (advisory).
|
||||
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
|
||||
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
|
||||
console.log("bundleSize=SKIP reason=size-limit-error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const kb = (totalBytes / 1024).toFixed(2);
|
||||
console.log(`bundleSize=${totalBytes}`);
|
||||
|
||||
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
|
||||
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
|
||||
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
|
||||
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
|
||||
if (mode !== "size-limit") {
|
||||
if (RATCHET) {
|
||||
console.log(
|
||||
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
|
||||
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
|
||||
);
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RATCHET) {
|
||||
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
||||
}
|
||||
applyRatchet(totalBytes);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-changelog-integrity.mjs
|
||||
//
|
||||
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
|
||||
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
|
||||
// git's merge auto-resolve silently dropping sibling bullets (or whole version
|
||||
// sections) when two branches touch adjacent CHANGELOG lines — incident
|
||||
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
|
||||
// sections, 130 bullets), only recovered by hand from the pre-merge ref.
|
||||
//
|
||||
// On pull_request CI the checkout is refs/pull/N/merge — the auto-resolved
|
||||
// merge result — so comparing it against origin/<base> catches the eat BEFORE
|
||||
// the merge lands, in the PR that would cause it.
|
||||
//
|
||||
// Policy (Princípio Zero): this only ever ADDS work for the maintainer side —
|
||||
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
|
||||
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
|
||||
// but that happens on the release PR (PR → main, ci.yml), which does not run
|
||||
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
|
||||
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check/check-changelog-integrity.mjs
|
||||
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
|
||||
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
|
||||
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const CHANGELOG = "CHANGELOG.md";
|
||||
|
||||
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
|
||||
export function extractBullets(text) {
|
||||
const bullets = new Set();
|
||||
for (const raw of String(text || "").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
|
||||
}
|
||||
return bullets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bullet lines present in the base CHANGELOG but absent from the head
|
||||
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
|
||||
*/
|
||||
export function findLostBullets(baseText, headText) {
|
||||
const headBullets = extractBullets(headText);
|
||||
const lost = [];
|
||||
for (const b of extractBullets(baseText)) {
|
||||
if (!headBullets.has(b)) lost.push(b);
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function resolveBaseRef() {
|
||||
if (process.env.CHANGELOG_BASE_REF) return process.env.CHANGELOG_BASE_REF;
|
||||
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
||||
// Local fallback: the highest release/v* on origin (the active development base).
|
||||
try {
|
||||
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
return branches[branches.length - 1] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const baseRef = resolveBaseRef();
|
||||
if (!baseRef) {
|
||||
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let baseText;
|
||||
try {
|
||||
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
|
||||
} catch {
|
||||
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
|
||||
return 0;
|
||||
}
|
||||
const headText = readFileSync(join(ROOT, CHANGELOG), "utf8");
|
||||
|
||||
const lost = findLostBullets(baseText, headText);
|
||||
if (lost.length === 0) {
|
||||
console.log(`[changelog-integrity] OK — no base bullets lost vs ${baseRef}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
|
||||
);
|
||||
for (const b of lost.slice(0, 15)) console.error(` ✗ ${b.slice(0, 160)}`);
|
||||
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
|
||||
console.error(
|
||||
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
|
||||
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
|
||||
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
|
||||
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
|
||||
);
|
||||
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
|
||||
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-circular-deps.mjs
|
||||
// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs).
|
||||
//
|
||||
// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente
|
||||
// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via
|
||||
// tsconfig.json e cobre entrypoints de alto risco.
|
||||
//
|
||||
// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline.
|
||||
// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json
|
||||
// como `{ value: N, direction: "down" }` após a primeira run verde no CI.
|
||||
//
|
||||
// Escopo limitado a 4 entrypoints principais para manter o tempo de análise
|
||||
// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry.
|
||||
// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se
|
||||
// repetem via transitividade).
|
||||
//
|
||||
// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta
|
||||
// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos.
|
||||
// Isso é esperado — ferramentas diferentes, métricas complementares.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, "../..");
|
||||
|
||||
// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB).
|
||||
// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s.
|
||||
const ENTRYPOINTS = [
|
||||
"open-sse/handlers/chatCore.ts",
|
||||
"open-sse/services/combo.ts",
|
||||
"open-sse/mcp-server/index.ts",
|
||||
"src/lib/db/core.ts",
|
||||
];
|
||||
|
||||
const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm");
|
||||
const TSCONFIG = resolve(projectRoot, "tsconfig.json");
|
||||
|
||||
/**
|
||||
* Parseia a saída JSON do dpdm e retorna a contagem de ciclos.
|
||||
* Função exportada para ser testada isoladamente sem executar o dpdm.
|
||||
*
|
||||
* @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars").
|
||||
* @returns {{ count: number, circulars: string[][] }}
|
||||
*/
|
||||
export function parseDpdmOutput(jsonStr) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`);
|
||||
}
|
||||
const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : [];
|
||||
return { count: circulars.length, circulars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o dpdm e retorna a string JSON bruta do arquivo de saída.
|
||||
*
|
||||
* @returns {string} conteúdo JSON do arquivo temporário.
|
||||
*/
|
||||
function runDpdm() {
|
||||
if (!existsSync(DPDM_BIN)) {
|
||||
throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`);
|
||||
}
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
DPDM_BIN,
|
||||
"--circular",
|
||||
"--no-warning",
|
||||
"--no-tree",
|
||||
"-T",
|
||||
"--tsconfig",
|
||||
TSCONFIG,
|
||||
"-o",
|
||||
tmpFile,
|
||||
...ENTRYPOINTS,
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!existsSync(tmpFile)) {
|
||||
throw new Error(`dpdm did not produce output file at ${tmpFile}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(tmpFile, "utf8");
|
||||
return raw;
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("[circular-deps] Running dpdm cross-check...");
|
||||
console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`);
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = runDpdm();
|
||||
} catch (err) {
|
||||
console.error(`[circular-deps] ERROR running dpdm: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = parseDpdmOutput(raw);
|
||||
} catch (err) {
|
||||
console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json
|
||||
// após baseline ser estabelecida.
|
||||
console.log(`[circular-deps] circularDeps=${result.count}`);
|
||||
console.log(
|
||||
`[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Permite que o módulo seja importado em testes sem executar main().
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates that:
|
||||
* 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json.
|
||||
* 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections).
|
||||
* 3. No raw string literals are passed to .description() in commands without going
|
||||
* through t() — only warns, does not fail hard (many descriptions use || fallback).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands");
|
||||
const LOCALES_DIR = join(ROOT, "bin", "cli", "locales");
|
||||
|
||||
// Paths that look like t() keys but are actually import paths — skip them.
|
||||
const IGNORE_AS_KEY = new Set([".", ".."]);
|
||||
const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/;
|
||||
|
||||
function walk(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...walk(full));
|
||||
} else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function flattenKeys(obj, prefix = "") {
|
||||
const keys = new Set();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const full = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
for (const sub of flattenKeys(v, full)) keys.add(sub);
|
||||
} else {
|
||||
keys.add(full);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectTKeys(files) {
|
||||
const used = new Set();
|
||||
const re = /\bt\(\s*["']([^"']+)["']/g;
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let m;
|
||||
re.lastIndex = 0;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
const key = m[1];
|
||||
if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue;
|
||||
used.add(key);
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
function loadJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
const files = walk(COMMANDS_DIR);
|
||||
const usedKeys = collectTKeys(files);
|
||||
const en = loadJson(join(LOCALES_DIR, "en.json"));
|
||||
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
|
||||
const enKeys = flattenKeys(en);
|
||||
|
||||
let errors = 0;
|
||||
|
||||
// Check 1: all used keys exist in en.json
|
||||
const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k));
|
||||
if (missingInEn.length > 0) {
|
||||
console.error("[cli-i18n] Keys used in commands but missing in en.json:");
|
||||
for (const k of missingInEn) console.error(` ✗ ${k}`);
|
||||
errors += missingInEn.length;
|
||||
} else {
|
||||
console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`);
|
||||
}
|
||||
|
||||
// Check 2: pt-BR.json has the same top-level sections as en.json
|
||||
const enTopLevel = Object.keys(en);
|
||||
const ptTopLevel = new Set(Object.keys(ptBR));
|
||||
const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k));
|
||||
if (missingTopLevel.length > 0) {
|
||||
console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:");
|
||||
for (const k of missingTopLevel) console.error(` ✗ ${k}`);
|
||||
errors += missingTopLevel.length;
|
||||
} else {
|
||||
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("[cli-i18n] PASS — CLI i18n is consistent");
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-codeql-ratchet.mjs
|
||||
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
|
||||
//
|
||||
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
|
||||
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
|
||||
//
|
||||
// Saída (stdout):
|
||||
// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed
|
||||
// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH
|
||||
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
|
||||
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
|
||||
//
|
||||
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
|
||||
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
|
||||
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
|
||||
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
|
||||
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
|
||||
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
|
||||
//
|
||||
// Uso:
|
||||
// node scripts/check/check-codeql-ratchet.mjs
|
||||
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
|
||||
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
|
||||
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
|
||||
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const QUIET = process.argv.includes("--quiet");
|
||||
const PRINT_JSON = process.argv.includes("--json");
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
|
||||
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
|
||||
const ADVISORY = process.argv.includes("--advisory");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASELINE_PATH = path.resolve(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: path.join(ROOT, "config/quality/quality-baseline.json")
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure parsing function (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
|
||||
*
|
||||
* A GitHub API /code-scanning/alerts retorna um array de:
|
||||
* {
|
||||
* number: number,
|
||||
* state: "open" | "dismissed" | "fixed",
|
||||
* dismissed_reason: string | null,
|
||||
* dismissed_at: string | null,
|
||||
* tool: { name: string, ... },
|
||||
* rule: { id: string, severity: string, security_severity_level?: string, ... },
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
|
||||
* Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
|
||||
* Alertas de outras ferramentas (ex: Semgrep) são ignorados.
|
||||
*
|
||||
* @param {Array|null} alerts - Array de alertas da API GitHub
|
||||
* @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
|
||||
*/
|
||||
export function parseCodeQLAlerts(alerts) {
|
||||
if (!Array.isArray(alerts)) {
|
||||
return { alertCount: 0, bySeverity: {}, byRule: {} };
|
||||
}
|
||||
|
||||
let alertCount = 0;
|
||||
const bySeverity = {};
|
||||
const byRule = {};
|
||||
|
||||
for (const alert of alerts) {
|
||||
// Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
|
||||
const toolName = alert?.tool?.name ?? "";
|
||||
if (!toolName.toLowerCase().includes("codeql")) continue;
|
||||
|
||||
// Hard Rule #14: alertas dismissed não contam
|
||||
if (alert.state === "dismissed") continue;
|
||||
|
||||
// Só alertas abertos
|
||||
if (alert.state !== "open") continue;
|
||||
|
||||
alertCount++;
|
||||
|
||||
// Coletar por severidade (security_severity_level ou severity da rule)
|
||||
const severity = (
|
||||
alert?.rule?.security_severity_level ??
|
||||
alert?.rule?.severity ??
|
||||
"unknown"
|
||||
).toLowerCase();
|
||||
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
||||
|
||||
// Coletar por rule ID
|
||||
const ruleId = alert?.rule?.id ?? "unknown";
|
||||
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return { alertCount, bySeverity, byRule };
|
||||
}
|
||||
|
||||
/**
|
||||
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
|
||||
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
|
||||
*
|
||||
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
|
||||
*
|
||||
* @param {number} current - Contagem de alertas medida agora.
|
||||
* @param {number} baseline - Contagem congelada em quality-baseline.json.
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateCodeqlRatchet(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detecta o owner/repo do repositório atual usando `gh repo view`.
|
||||
* Retorna null se `gh` não estiver disponível ou não autenticado.
|
||||
*
|
||||
* @param {string} ghBin - Caminho para o binário gh
|
||||
* @returns {string|null} "owner/repo" ou null
|
||||
*/
|
||||
export function detectRepo(ghBin) {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
ghBin,
|
||||
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
}
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detecta se o binário `gh` está disponível no PATH.
|
||||
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
|
||||
*
|
||||
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
|
||||
*/
|
||||
export function findGhCli() {
|
||||
try {
|
||||
const result = spawnSync("which", ["gh"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
} catch {
|
||||
// which não disponível
|
||||
}
|
||||
|
||||
// Fallback: tentar executar diretamente para verificar ENOENT
|
||||
try {
|
||||
const result = spawnSync("gh", ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (result.error?.code === "ENOENT") return null;
|
||||
if (result.status !== null) return "gh"; // found in PATH
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API caller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Busca alertas CodeQL abertos via `gh api`.
|
||||
* Pagina automaticamente (GitHub retorna max 100 por página).
|
||||
*
|
||||
* @param {string} ghBin - Caminho para o binário gh
|
||||
* @param {string} repo - "owner/repo"
|
||||
* @returns {Array} Array de alertas
|
||||
*/
|
||||
function fetchCodeQLAlerts(ghBin, repo) {
|
||||
const allAlerts = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;
|
||||
|
||||
if (!QUIET) {
|
||||
process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
|
||||
}
|
||||
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(ghBin, ["api", endpoint], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = String(err.stderr ?? err.message ?? "");
|
||||
|
||||
// Sem autenticação
|
||||
if (
|
||||
errMsg.includes("authentication") ||
|
||||
errMsg.includes("401") ||
|
||||
errMsg.includes("not logged")
|
||||
) {
|
||||
return { error: "no-auth", message: errMsg };
|
||||
}
|
||||
|
||||
// Rate limit ou outro erro HTTP
|
||||
const codeMatch = /HTTP (\d{3})/.exec(errMsg);
|
||||
const code = codeMatch ? codeMatch[1] : "unknown";
|
||||
return { error: `api-error:${code}`, message: errMsg };
|
||||
}
|
||||
|
||||
let page_alerts;
|
||||
try {
|
||||
page_alerts = JSON.parse(stdout);
|
||||
} catch (parseErr) {
|
||||
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
|
||||
// regression. A blocking gate must never red on it — return the same
|
||||
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
|
||||
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
|
||||
}
|
||||
|
||||
// A API retorna null quando não há mais páginas (ou array vazio)
|
||||
if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;
|
||||
|
||||
allAlerts.push(...page_alerts);
|
||||
|
||||
// Se retornou menos que perPage, chegamos à última página
|
||||
if (page_alerts.length < perPage) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return allAlerts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Baseline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
|
||||
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
|
||||
* sem baseline não há ratchet, só emissão da contagem).
|
||||
*
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function readBaselineCodeqlValue() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) return null;
|
||||
let baselineJson;
|
||||
try {
|
||||
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const metric = baselineJson?.metrics?.codeqlAlerts;
|
||||
if (!metric || typeof metric.value !== "number") return null;
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
|
||||
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
|
||||
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
|
||||
*
|
||||
* Exported for unit testing (drives o efeito em process.exitCode).
|
||||
*
|
||||
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
|
||||
*/
|
||||
export function applyRatchet(alertCount) {
|
||||
const baselineValue = readBaselineCodeqlValue();
|
||||
|
||||
// Sem baseline → modo coletor puro (emite a contagem, não falha).
|
||||
if (baselineValue === null) {
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
baselineJson.metrics.codeqlAlerts.value = alertCount;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
|
||||
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
|
||||
}
|
||||
|
||||
if (regressed && !ADVISORY) {
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
|
||||
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
|
||||
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] ${verdict} — ${alertCount} alertas (baseline ${baselineValue})\n`
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const ghBin = findGhCli();
|
||||
|
||||
if (!ghBin) {
|
||||
console.log("codeqlAlerts=SKIP reason=binary-absent");
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
|
||||
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
|
||||
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar repositório
|
||||
const repo = detectRepo(ghBin);
|
||||
if (!repo) {
|
||||
console.log("codeqlAlerts=SKIP reason=no-repo");
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
|
||||
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
|
||||
}
|
||||
|
||||
// Buscar alertas
|
||||
const result = fetchCodeQLAlerts(ghBin, repo);
|
||||
|
||||
// Tratar erros da API com skip gracioso
|
||||
if (!Array.isArray(result)) {
|
||||
const { error, message } = result;
|
||||
console.log(`codeqlAlerts=SKIP reason=${error}`);
|
||||
if (!QUIET) {
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
|
||||
);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PRINT_JSON) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);
|
||||
|
||||
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
|
||||
console.log(`codeqlAlerts=${alertCount}`);
|
||||
|
||||
if (!QUIET) {
|
||||
const severitySummary =
|
||||
Object.entries(bySeverity)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ") || "nenhum";
|
||||
const topRules =
|
||||
Object.entries(byRule)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([r, n]) => `${r}(${n})`)
|
||||
.join(", ") || "nenhum";
|
||||
|
||||
process.stderr.write(
|
||||
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
|
||||
);
|
||||
if (alertCount > 0) {
|
||||
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
|
||||
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
|
||||
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
|
||||
applyRatchet(alertCount);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-cognitive-complexity.mjs
|
||||
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
|
||||
// Fase 7 INT: promovido de ADVISORY para RATCHET.
|
||||
//
|
||||
// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE
|
||||
// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` —
|
||||
// mantendo a contagem ISOLADA do orçamento de warnings do lint principal.
|
||||
//
|
||||
// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity).
|
||||
// Falha com exit 1 se a contagem SUBIR. Suporta --update.
|
||||
//
|
||||
// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs)
|
||||
//
|
||||
// Uso:
|
||||
// node scripts/check/check-cognitive-complexity.mjs
|
||||
// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica
|
||||
// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const QUIET = process.argv.includes("--quiet");
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
|
||||
|
||||
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
|
||||
|
||||
const BASELINE_PATH = path.resolve(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: path.join(ROOT, "config/quality/quality-baseline.json")
|
||||
);
|
||||
|
||||
const ESLINT_ARGS = [
|
||||
"--no-config-lookup",
|
||||
"--config",
|
||||
CONFIG_PATH,
|
||||
"--format",
|
||||
"json",
|
||||
"src",
|
||||
"open-sse",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parses the ESLint JSON output (array of file results) and counts total
|
||||
* `sonarjs/cognitive-complexity` violations.
|
||||
*
|
||||
* Exported so unit tests can call it directly with synthetic data.
|
||||
*
|
||||
* @param {Array<{messages: Array<{ruleId: string}>}>} report
|
||||
* @returns {number}
|
||||
*/
|
||||
export function countCognitiveViolations(report) {
|
||||
let count = 0;
|
||||
for (const file of report) {
|
||||
for (const msg of file.messages) {
|
||||
if (msg.ruleId === "sonarjs/cognitive-complexity") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avalia a contagem atual de violações cognitivas contra o baseline.
|
||||
* Direction: down (contagem só pode CAIR).
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*
|
||||
* @param {number} current
|
||||
* @param {number} baseline
|
||||
* @returns {{ regressed: boolean, improved: boolean }}
|
||||
*/
|
||||
export function evaluateCognitiveComplexity(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
function runEslint() {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
// ESLint exits non-zero when there are lint errors; the JSON report is still
|
||||
// in stdout. Re-throw only if there is no parseable output.
|
||||
stdout = err.stdout ? String(err.stdout) : "";
|
||||
if (!stdout.trim()) throw err;
|
||||
}
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
process.stderr.write(
|
||||
`[cognitive-complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
const baselineMetric = baselineJson.metrics && baselineJson.metrics.cognitiveComplexity;
|
||||
if (!baselineMetric || typeof baselineMetric.value !== "number") {
|
||||
process.stderr.write(
|
||||
"[cognitive-complexity] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json.\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const baselineValue = baselineMetric.value;
|
||||
|
||||
const report = runEslint();
|
||||
const count = countCognitiveViolations(report);
|
||||
|
||||
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
|
||||
console.log(`cognitiveComplexity=${count}`);
|
||||
|
||||
if (!QUIET) {
|
||||
console.log(
|
||||
`[cognitive-complexity] ${count} function(s) exceed the cognitive-complexity threshold (15).`
|
||||
);
|
||||
}
|
||||
|
||||
const { regressed, improved } = evaluateCognitiveComplexity(count, baselineValue);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
baselineJson.metrics.cognitiveComplexity.value = count;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
|
||||
console.log(`[cognitive-complexity] baseline ratcheado: ${count} (era ${baselineValue})`);
|
||||
}
|
||||
|
||||
if (regressed) {
|
||||
process.stderr.write(
|
||||
`[cognitive-complexity] REGRESSÃO — ${count} violações > baseline ${baselineValue}\n` +
|
||||
` → Quebre as funções complexas em helpers menores, ou rode\n` +
|
||||
` 'node scripts/check/check-cognitive-complexity.mjs --update' se a contagem caiu legitimamente.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
console.log(`[cognitive-complexity] OK — ${count} violações (baseline ${baselineValue})`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-complexity.mjs
|
||||
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
|
||||
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
|
||||
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
|
||||
// e compara a contagem total de violações contra um baseline congelado
|
||||
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
|
||||
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
|
||||
//
|
||||
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
|
||||
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
|
||||
// ratcheta (a contagem só pode CAIR).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BASELINE_PATH = path.resolve(
|
||||
process.argv.includes("--baseline")
|
||||
? process.argv[process.argv.indexOf("--baseline") + 1]
|
||||
: path.join(ROOT, "config/quality/complexity-baseline.json")
|
||||
);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
|
||||
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
|
||||
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
|
||||
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
|
||||
// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron
|
||||
// is inert unless the directory is also passed as a positional argument.
|
||||
export const ESLINT_ARGS = [
|
||||
"eslint",
|
||||
"--no-config-lookup",
|
||||
"--config",
|
||||
CONFIG_PATH,
|
||||
"--format",
|
||||
"json",
|
||||
"src",
|
||||
"open-sse",
|
||||
"electron",
|
||||
"bin",
|
||||
];
|
||||
|
||||
/** Avalia a contagem atual de violações contra o baseline. */
|
||||
export function evaluateComplexity(current, baseline) {
|
||||
return {
|
||||
regressed: current > baseline,
|
||||
improved: current < baseline,
|
||||
};
|
||||
}
|
||||
|
||||
function measureComplexityCount() {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
|
||||
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
|
||||
stdout = err.stdout ? String(err.stdout) : "";
|
||||
if (!stdout.trim()) throw err;
|
||||
}
|
||||
const report = JSON.parse(stdout);
|
||||
return report.reduce((sum, file) => sum + file.errorCount, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
console.error(`[complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
|
||||
process.exit(2);
|
||||
}
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
||||
const current = measureComplexityCount();
|
||||
const { regressed, improved } = evaluateComplexity(current, baseline.count);
|
||||
|
||||
if (UPDATE && improved) {
|
||||
console.log(`[complexity] baseline ratcheado: ${current} (era ${baseline.count})`);
|
||||
baseline.count = current;
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
|
||||
}
|
||||
if (regressed) {
|
||||
console.error(
|
||||
`[complexity] REGRESSÃO — ${current} violações > baseline ${baseline.count}\n` +
|
||||
` → quebre a função em helpers menores (reduza ramos/tamanho) ou rode\n` +
|
||||
` 'node scripts/check/check-complexity.mjs --update' se a contagem caiu legitimamente.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[complexity] OK — ${current} violações (baseline ${baseline.count})`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Compression budget gate (F2.4 / N4 ratchet).
|
||||
*
|
||||
* Runs the deterministic compression engines over BENCHMARK_CORPUS and fails if any engine's
|
||||
* mean compressed-tokens-per-task RISES beyond the tolerance versus the frozen baseline — i.e. a
|
||||
* change made compression worse. Falling cost (better compression) always passes.
|
||||
*
|
||||
* node --import tsx scripts/check/check-compression-budget.ts # check (CI)
|
||||
* node --import tsx scripts/check/check-compression-budget.ts --update # refresh the baseline
|
||||
*
|
||||
* The benchmark is deterministic + API-free (chars/4 estimate over a fixed corpus), so the
|
||||
* committed baseline is portable across local/CI.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
BENCHMARK_CORPUS,
|
||||
DEFAULT_BENCHMARK_ENGINES,
|
||||
benchmarkEngines,
|
||||
runBenchmarkGate,
|
||||
} from "../../open-sse/services/compression/harness/benchmark.ts";
|
||||
import {
|
||||
tokensPerTask,
|
||||
type BudgetBaseline,
|
||||
} from "../../open-sse/services/compression/harness/budgetGate.ts";
|
||||
|
||||
const BASELINE_PATH = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"compression-budget-baseline.json"
|
||||
);
|
||||
const TOLERANCE_PERCENT = 2;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const update = process.argv.includes("--update");
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, DEFAULT_BENCHMARK_ENGINES);
|
||||
|
||||
if (update) {
|
||||
const baselines: Record<string, BudgetBaseline> = {};
|
||||
for (const [engine, report] of Object.entries(reports)) {
|
||||
baselines[engine] = { tasks: tokensPerTask(report) };
|
||||
}
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselines, null, 2) + "\n");
|
||||
console.log(`Updated compression budget baseline (${Object.keys(baselines).length} engines).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
console.error("compression budget baseline missing — run with --update to generate it.");
|
||||
process.exit(1);
|
||||
}
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as Record<
|
||||
string,
|
||||
BudgetBaseline
|
||||
>;
|
||||
|
||||
const results = runBenchmarkGate(reports, baselines, TOLERANCE_PERCENT);
|
||||
const failed = results.filter((r) => !r.gate.passed);
|
||||
|
||||
if (failed.length === 0) {
|
||||
console.log(`✓ compression budget gate: no regressions (tolerance ${TOLERANCE_PERCENT}%)`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("✗ compression budget gate: tokens-per-task regressed (compression got worse):");
|
||||
for (const { engine, gate } of failed) {
|
||||
for (const reg of gate.regressions) {
|
||||
console.error(
|
||||
` ${engine}/${reg.task}: ${reg.baseline} -> ${reg.current} tokens (+${reg.deltaPercent}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
"\nIf this is an intentional improvement/change, refresh: npm run check:compression-budget -- --update"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("compression budget gate failed:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const defaultRoots = [
|
||||
"src/shared/components",
|
||||
"src/lib/db",
|
||||
"src/lib/compliance",
|
||||
"open-sse/translator",
|
||||
"open-sse/mcp-server",
|
||||
];
|
||||
const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots;
|
||||
const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"];
|
||||
|
||||
function toPosix(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function listSourceFiles(rootDir) {
|
||||
const absRoot = path.resolve(cwd, rootDir);
|
||||
if (!fs.existsSync(absRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stack = [absRoot];
|
||||
const files = [];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceExtensions.includes(path.extname(entry.name))) {
|
||||
files.push(path.resolve(fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolveRelativeImport(fromFile, specifier) {
|
||||
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||
const ext = path.extname(base);
|
||||
|
||||
if (ext && fs.existsSync(base) && fs.statSync(base).isFile()) {
|
||||
return path.resolve(base);
|
||||
}
|
||||
|
||||
for (const extension of sourceExtensions) {
|
||||
const candidate = `${base}${extension}`;
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
for (const extension of sourceExtensions) {
|
||||
const candidate = path.join(base, `index${extension}`);
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractImportSpecifiers(fileContents) {
|
||||
const specs = [];
|
||||
const regex = /\b(?:import|export)\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g;
|
||||
let match = regex.exec(fileContents);
|
||||
while (match) {
|
||||
specs.push(match[1]);
|
||||
match = regex.exec(fileContents);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function buildGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const filePath of files) {
|
||||
const code = fs.readFileSync(filePath, "utf8");
|
||||
const dependencies = new Set();
|
||||
const importSpecifiers = extractImportSpecifiers(code);
|
||||
|
||||
for (const specifier of importSpecifiers) {
|
||||
if (!specifier.startsWith(".")) continue;
|
||||
const resolved = resolveRelativeImport(filePath, specifier);
|
||||
if (!resolved) continue;
|
||||
if (!fileSet.has(resolved)) continue;
|
||||
dependencies.add(resolved);
|
||||
}
|
||||
|
||||
graph.set(filePath, dependencies);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function stronglyConnectedComponents(graph) {
|
||||
const indexMap = new Map();
|
||||
const lowLinkMap = new Map();
|
||||
const onStack = new Set();
|
||||
const stack = [];
|
||||
const components = [];
|
||||
let indexCounter = 0;
|
||||
|
||||
function strongConnect(node) {
|
||||
indexMap.set(node, indexCounter);
|
||||
lowLinkMap.set(node, indexCounter);
|
||||
indexCounter += 1;
|
||||
stack.push(node);
|
||||
onStack.add(node);
|
||||
|
||||
for (const neighbor of graph.get(node) || []) {
|
||||
if (!indexMap.has(neighbor)) {
|
||||
strongConnect(neighbor);
|
||||
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(neighbor)));
|
||||
} else if (onStack.has(neighbor)) {
|
||||
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), indexMap.get(neighbor)));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowLinkMap.get(node) === indexMap.get(node)) {
|
||||
const component = [];
|
||||
while (stack.length > 0) {
|
||||
const candidate = stack.pop();
|
||||
onStack.delete(candidate);
|
||||
component.push(candidate);
|
||||
if (candidate === node) break;
|
||||
}
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
if (!indexMap.has(node)) {
|
||||
strongConnect(node);
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function isSelfCycle(component, graph) {
|
||||
if (component.length !== 1) return false;
|
||||
const [file] = component;
|
||||
return (graph.get(file) || new Set()).has(file);
|
||||
}
|
||||
|
||||
const files = roots.flatMap((root) => listSourceFiles(root));
|
||||
const graph = buildGraph(files);
|
||||
const components = stronglyConnectedComponents(graph);
|
||||
const cycles = components.filter(
|
||||
(component) => component.length > 1 || isSelfCycle(component, graph)
|
||||
);
|
||||
|
||||
if (cycles.length === 0) {
|
||||
console.log(
|
||||
`[cycles] OK - no cycles detected across ${graph.size} files in: ${roots.join(", ")}`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`[cycles] FAIL - detected ${cycles.length} strongly connected component(s):`);
|
||||
for (const component of cycles) {
|
||||
const sorted = [...component].sort((a, b) => a.localeCompare(b));
|
||||
console.error(`\n- SCC (${sorted.length} files)`);
|
||||
for (const filePath of sorted) {
|
||||
console.error(` - ${toPosix(path.relative(cwd, filePath))}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-db-rules.mjs
|
||||
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
|
||||
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
|
||||
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
|
||||
// re-exportado (e não está congelado) falha — força a decisão consciente
|
||||
// de expor ou justificar (Hard Rule #2).
|
||||
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
|
||||
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
|
||||
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
|
||||
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
|
||||
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
|
||||
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
|
||||
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
|
||||
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { assertNoStale } from "./lib/allowlist.mjs";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const DB_DIR = path.join(cwd, "src/lib/db");
|
||||
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
|
||||
const API_DIR = path.join(cwd, "src/app/api");
|
||||
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
|
||||
|
||||
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
|
||||
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
|
||||
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
|
||||
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
|
||||
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
|
||||
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
|
||||
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
|
||||
// Legenda de classificação:
|
||||
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
|
||||
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
|
||||
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
|
||||
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
|
||||
// sem investigação — pode ser reserva de schema ou F2 pendente
|
||||
export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
|
||||
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
|
||||
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
|
||||
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
|
||||
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
|
||||
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
|
||||
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
|
||||
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
|
||||
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
|
||||
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
|
||||
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
|
||||
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
|
||||
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
|
||||
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
|
||||
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
|
||||
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
|
||||
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
|
||||
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
|
||||
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
|
||||
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
|
||||
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
|
||||
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
|
||||
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
|
||||
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
|
||||
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
|
||||
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
|
||||
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
|
||||
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
|
||||
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
|
||||
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
|
||||
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
|
||||
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
|
||||
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
|
||||
]);
|
||||
|
||||
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
|
||||
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
|
||||
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
|
||||
|
||||
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
|
||||
// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o
|
||||
// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais.
|
||||
// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
|
||||
// são leituras read-only de um arquivo externo, com caminho/escopo próprios.
|
||||
// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
|
||||
// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers.
|
||||
// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para
|
||||
// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções.
|
||||
const EXTERNAL_DB_ALLOWED = new Set([
|
||||
"src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo)
|
||||
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
|
||||
]);
|
||||
|
||||
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
|
||||
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
|
||||
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
|
||||
|
||||
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
|
||||
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p, acc);
|
||||
else acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
|
||||
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
|
||||
export function collectDbModules(dbDir = DB_DIR) {
|
||||
if (!fs.existsSync(dbDir)) return [];
|
||||
return fs
|
||||
.readdirSync(dbDir, { withFileTypes: true })
|
||||
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
|
||||
.map((e) => e.name.replace(/\.ts$/, ""))
|
||||
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
|
||||
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
|
||||
export function extractReexportedModules(localDbSource) {
|
||||
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
|
||||
const out = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(localDbSource))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// (a) Módulos db/ que não são re-exportados e não estão na lista de
|
||||
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
|
||||
// qualquer módulo NOVO que não seja re-exportado nem justificado.
|
||||
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
|
||||
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
|
||||
}
|
||||
|
||||
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
|
||||
// Remove comentários e strings, depois procura declarações de runtime.
|
||||
export function hasLogic(localDbSource) {
|
||||
const stripped = localDbSource
|
||||
// comentários de bloco
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
// comentários de linha
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
// template strings
|
||||
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
|
||||
// strings simples/duplas (paths de import etc.)
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
.replace(/'(?:\\.|[^'\\])*'/g, '""');
|
||||
|
||||
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
|
||||
const logicPatterns = [
|
||||
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
|
||||
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
|
||||
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
|
||||
];
|
||||
return logicPatterns.some((rx) => rx.test(stripped));
|
||||
}
|
||||
|
||||
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
|
||||
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
|
||||
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
|
||||
const SQL_PATTERNS = [
|
||||
/\bSELECT\b[\s\S]*?\bFROM\b/i, // SELECT … FROM (multi-linha)
|
||||
/\bINSERT\s+INTO\b/i,
|
||||
/\bUPDATE\b[\s\S]*?\bSET\b/i, // UPDATE … SET (multi-linha)
|
||||
/\bDELETE\s+FROM\b/i,
|
||||
/\bCREATE\s+TABLE\b/i,
|
||||
];
|
||||
|
||||
// Remove comentários (linha // … e blocos /* */) — SQL em comentário não conta.
|
||||
function stripComments(source) {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
// Extrai o conteúdo de todos os literais de string (template, aspas duplas, aspas
|
||||
// simples) de um trecho de código já sem comentários. Retorna a concatenação dos
|
||||
// corpos — é nesse corpo que SQL cru vive.
|
||||
export function extractStringLiterals(code) {
|
||||
const re = /`(?:\\[\s\S]|[^\\`])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(code))) {
|
||||
// tira as aspas/crases delimitadoras
|
||||
out.push(m[0].slice(1, -1));
|
||||
}
|
||||
return out.join("\n | ||||