chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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}`);
|
||||
Reference in New Issue
Block a user