147 lines
6.4 KiB
JavaScript
147 lines
6.4 KiB
JavaScript
// 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()
|
|
}
|