mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
Stage 7 of issue #10321 moves the optional ML and browser automation dependency closures out of the desktop bundle into checksummed, versioned packs installed on demand through the omniroute packs command. - scripts/build/optionalPackStaging.mjs stages pack members under .build/optional-packs, creates release tarballs, and emits optional-packs.index.json with per-member SHA-256 checksums. - scripts/packs provides manifest, install, remove, and verification helpers plus the packs CLI commands. - Runtime lookup includes installed pack node_modules directories, while LLMLingua and browser executors continue to degrade gracefully when packs are absent. The measured darwin-arm64 staging closure was about 534 MB of the 929 MB standalone node_modules tree (57%).
182 lines
6.4 KiB
JavaScript
182 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* OmniRoute — Stage 7 build-time optional-pack staging (issue #10321).
|
|
*
|
|
* Runs ONLY against the Electron staging tree (`.build/electron-standalone`),
|
|
* after `assembleStandalone()` and the native-module steps. For each optional
|
|
* pack in OPTIONAL_PACKS it:
|
|
*
|
|
* 1. checksums every member from the staged `node_modules` closure and emits
|
|
* `optional-packs.index.json` at the bundle root (one source of truth for
|
|
* the CLI installer and `verify`),
|
|
* 2. MOVES the member trees out of the staging bundle into
|
|
* `.build/optional-packs/<name>/node_modules/…` (same volume → cheap rename),
|
|
* 3. emits `optional-pack-<name>.tar.gz` next to them (bsdtar; disable with
|
|
* `OMNIROUTE_OPTIONAL_PACK_TAR=0`) for the desktop release workflow to
|
|
* upload as versioned assets.
|
|
*
|
|
* The shared Next standalone bundle (Docker / non-Electron deploys) is never
|
|
* touched — only the Electron staging copy, mirroring the Stage 5 doc pruner's
|
|
* boundary. Fail-open: members missing from staging are skipped with a warning
|
|
* (a future bundle graph change must not break packaging), but the index only
|
|
* records packs whose members were actually staged.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
OPTIONAL_PACKS,
|
|
PACK_INDEX_FILENAME,
|
|
buildPackIndexEntry,
|
|
} from "../packs/optionalPackManifest.mjs";
|
|
|
|
/**
|
|
* Locate every `node_modules/<member>` copy inside the staging tree (bounded:
|
|
* the standalone bundle only nests node_modules under the root and under
|
|
* `.build/next/`, but a defensive two-level walk costs nothing on ~1k dirs).
|
|
*
|
|
* @param {string} stagingRoot
|
|
* @param {string} member package name (scoped names keep their slash)
|
|
* @returns {string[]} absolute member dir paths found
|
|
*/
|
|
export function findMemberDirs(stagingRoot, member) {
|
|
const rel = member.split("/").join(path.sep);
|
|
const found = [];
|
|
const visit = (dir, depth) => {
|
|
if (depth > 3) return;
|
|
const candidate = path.join(dir, "node_modules", rel);
|
|
if (fs.existsSync(candidate)) found.push(candidate);
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name === "node_modules") continue;
|
|
if (entry.name.startsWith(".") || entry.name === "dist") continue;
|
|
visit(path.join(dir, entry.name), depth + 1);
|
|
}
|
|
};
|
|
visit(stagingRoot, 0);
|
|
return found;
|
|
}
|
|
|
|
/** @returns {{removedFiles: number, removedBytes: number}} */
|
|
function moveTree(src, dest) {
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
try {
|
|
fs.renameSync(src, dest);
|
|
} catch {
|
|
fs.cpSync(src, dest, { recursive: true });
|
|
fs.rmSync(src, { recursive: true, force: true });
|
|
}
|
|
let files = 0;
|
|
let bytes = 0;
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full);
|
|
else {
|
|
files++;
|
|
bytes += fs.statSync(full).size;
|
|
}
|
|
}
|
|
};
|
|
walk(dest);
|
|
return { removedFiles: files, removedBytes: bytes };
|
|
}
|
|
|
|
function tarPack(packOutDir, tarballPath) {
|
|
// bsdtar ships with macOS, Linux images, and Windows runners (System32\tar.exe).
|
|
const result = spawnSync(
|
|
process.platform === "win32" ? "tar.exe" : "tar",
|
|
["-czf", tarballPath, "-C", packOutDir, "node_modules"],
|
|
{ stdio: "pipe" }
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`optional-pack tar failed for ${path.basename(tarballPath)} (exit ${result.status})`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stage all optional packs out of the Electron bundle.
|
|
*
|
|
* @param {{stagingRoot: string, packsOutDir: string, emitTarballs?: boolean, log?: (msg: string) => void}} opts
|
|
* @returns {{index: object, packs: {name: string, removedFiles: number, removedBytes: number, tarball?: string}[]}}
|
|
*/
|
|
export async function stageOptionalPacks({
|
|
stagingRoot,
|
|
packsOutDir,
|
|
emitTarballs = process.env.OMNIROUTE_OPTIONAL_PACK_TAR !== "0",
|
|
log = () => {},
|
|
}) {
|
|
const packsOut = [];
|
|
const indexPacks = [];
|
|
|
|
for (const pack of OPTIONAL_PACKS) {
|
|
const packOutDir = path.join(packsOutDir, pack.name);
|
|
let removedFiles = 0;
|
|
let removedBytes = 0;
|
|
let stagedMembers = 0;
|
|
|
|
for (const member of pack.packages) {
|
|
const memberDirs = findMemberDirs(stagingRoot, member.name);
|
|
if (memberDirs.length === 0) {
|
|
// Fail-open: a member absent from the bundle (dependency-graph change,
|
|
// pruning by an earlier stage) must not break packaging. It is simply
|
|
// not part of the staged pack; `buildPackIndexEntry` below refuses to
|
|
// index a pack with missing members, so such a pack is skipped wholly.
|
|
log(`[optional-packs] member not found in staging tree (skipped): ${member.name}`);
|
|
continue;
|
|
}
|
|
const dest = path.join(packOutDir, "node_modules", ...member.name.split("/"));
|
|
const stats = moveTree(memberDirs[0], dest);
|
|
// Any duplicate copies (nested `.build/next/node_modules`) are deleted:
|
|
// they would ship member bytes inside the installer again.
|
|
for (const extra of memberDirs.slice(1)) {
|
|
fs.rmSync(extra, { recursive: true, force: true });
|
|
}
|
|
removedFiles += stats.removedFiles;
|
|
removedBytes += stats.removedBytes;
|
|
stagedMembers++;
|
|
}
|
|
|
|
if (stagedMembers !== pack.packages.length) {
|
|
log(
|
|
`[optional-packs] pack "${pack.name}" incomplete (${stagedMembers}/${pack.packages.length}) — not indexed`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const indexEntry = await buildPackIndexEntry(pack, path.join(packOutDir, "node_modules"));
|
|
indexPacks.push(indexEntry);
|
|
|
|
let tarball;
|
|
if (emitTarballs) {
|
|
tarball = path.join(packsOutDir, indexEntry.tarball);
|
|
tarPack(packOutDir, tarball);
|
|
}
|
|
packsOut.push({ name: pack.name, removedFiles, removedBytes, tarball });
|
|
log(
|
|
`[optional-packs] staged "${pack.name}": ${removedFiles} files, ${(removedBytes / 1024 / 1024).toFixed(1)} MB out of the desktop bundle`
|
|
);
|
|
}
|
|
|
|
const index = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
packs: indexPacks,
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(stagingRoot, PACK_INDEX_FILENAME),
|
|
`${JSON.stringify(index, null, 2)}\n`
|
|
);
|
|
log(`[optional-packs] wrote ${PACK_INDEX_FILENAME} (${indexPacks.length} pack(s))`);
|
|
return { index, packs: packsOut };
|
|
}
|