File size: 6,510 Bytes
c61f7ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// 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()
}