// Build-time guard: refuse to start a build the installed tree cannot finish. // // The desktop workspace's dependencies are hoisted to the repo-root // `node_modules`, so a root install that only covers *part* of the workspace // graph leaves this app importable-looking but unbuildable. The guard exists to // turn that into one actionable line ("run npm ci from the repo root") instead // of a failure deep inside vite. // // It runs from `prebuild`, ahead of `npm run clean`, so a tree that cannot // build is rejected before the build starts deleting its own outputs. `build` // re-runs it for anyone invoking the build steps directly; the check is pure // filesystem lookups, so paying for it twice costs nothing. import { existsSync, readFileSync } from "fs" import { createRequire } from "module" import { resolve, join, dirname } from "path" import { isMain } from "./utils.mjs" // Packages the build *consumes*, as opposed to merely declares. Each one is // load-bearing for a distinct build step, and each one has been observed // missing from a partial root install: // // vite — bundles the renderer (`vite build`). // katex — `src/styles.css` imports `katex/dist/katex.min.css`, so // the CSS transform fails before a single chunk is emitted. // electron — the runtime electron-builder packages; without it `pack` // cannot produce an unpacked app at all. // electron-builder — the packager `npm run builder` shells out to. // // Checking only `vite` (the original guard) passes a tree missing any of the // others, which is how an incomplete install reached `vite build` and died on // an unresolved `katex/dist/katex.min.css` with no hint that the install — not // the source — was at fault (#86443). // // These four are the documented floor — always checked, even when the app's // package.json cannot be read. The full class is wider: EVERY non-optional // package the workspace manifest declares is something the build may import // (`vite.config.ts` pulls `@rolldown/plugin-babel`, `@vitejs/plugin-react`, // `@tailwindcss/vite`; `bundle-electron-main.mjs` pulls `esbuild`; the renderer // imports the rest). A hand-maintained list drifts the moment a new import // lands, so `checkRootInstall` unions the floor with the manifest's declared // `dependencies` + `devDependencies` — a partial install is refused whichever // package it happened to drop. `optionalDependencies` are excluded by design: // npm legitimately skips them (platform-gated natives like `get-windows`). const BUILD_CRITICAL_PACKAGES = ["vite", "katex", "electron", "electron-builder"] export { BUILD_CRITICAL_PACKAGES } // Resolve the way Node's own lookup does — walk `node_modules` upward — rather // than through `require.resolve`. A package whose `exports` map does not expose // `./package.json` is not resolvable by path even when correctly installed, and // that must not read as "missing". Scoped names (`@scope/name`) are a nested // directory under `node_modules`, which `join` handles. function packageIsInstalled(name, fromDir) { let dir = fromDir for (;;) { if (existsSync(join(dir, "node_modules", name, "package.json"))) return true const parent = dirname(dir) if (parent === dir) return false dir = parent } } // Every package the workspace manifest at `appDir` declares as required // (`dependencies` + `devDependencies`; never `optionalDependencies`). An // unreadable or malformed manifest yields [] — the floor still applies, and // the build's own manifest read fails loudly on its own. export function requiredPackages(appDir) { try { const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) return [ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {}), ] } catch { return [] } } // Pure check — returns { ok: true } or { ok: false, error: "..." }. // Kept side-effect-free so it can be unit tested without spawning a process. export function checkRootInstall(appDir, rootDir) { const wanted = [...new Set([...BUILD_CRITICAL_PACKAGES, ...requiredPackages(appDir)])] const missing = wanted.filter(pkg => !packageIsInstalled(pkg, appDir)) if (missing.length > 0) { return { ok: false, error: `the desktop build needs ${missing.join(", ")}, which the current install ` + `does not provide. A partial root install leaves the workspace looking ` + `present while the build cannot complete. Reinstall from the repo root: ` + `cd ${rootDir} && npm ci` } } // `vite.config.ts` aliases react/react-dom to whatever this workspace resolves, // and React refuses to run when the two come from different installed copies // ("Minified React error #527" — it throws before the first paint, so the app // window stays blank). npm stays silent about the split because the hoisted // react still satisfies react-dom's caret peer range. Fail the build loudly // instead of shipping a white screen. const requireFromApp = createRequire(join(appDir, "package.json")) const installedVersion = pkg => JSON.parse(readFileSync(requireFromApp.resolve(`${pkg}/package.json`), "utf8")).version let react let reactDom try { react = installedVersion("react") reactDom = installedVersion("react-dom") } catch (err) { // Both are in BUILD_CRITICAL_PACKAGES' spirit but not its list: they are // checked by version, and an unreadable package.json is a broken install // rather than an absent one. Report it as such instead of throwing. return { ok: false, error: `could not read the installed react/react-dom versions (${err.message}). Reinstall from the repo root: cd ${rootDir} && npm ci` } } if (react !== reactDom) { return { ok: false, error: `react@${react} / react-dom@${reactDom} version mismatch — React would fail ` + `with error #527 and render a blank window. Pin both to the same version ` + `in ${join(appDir, "package.json")}, then reinstall: cd ${rootDir} && npm ci` } } return { ok: true } } function main() { const app = resolve(import.meta.dirname, "..") const root = resolve(app, "..", "..") const result = checkRootInstall(app, root) if (!result.ok) { console.error(`✗ assert-root-install: ${result.error}`) process.exit(1) } } if (isMain(import.meta.url)) { main() }