77 *
88 * - Root node_modules missing → pnpm scripts fail with "node_modules missing,
99 * did you mean to install?"
10+ * - A package directory inside node_modules/.pnpm unexpectedly empty (or a
11+ * dangling link) → the matching node_modules/.bin shim resolves to a path
12+ * without content, and nested tools fail with a confusing "Cannot find
13+ * module .../bin/<tool>" error, for example
14+ * ".../design-system/packages/ui/node_modules/typescript/bin/tsc"
1015 * - src/mobile-web/dist missing → cargo check -p openbitfun-desktop and
1116 * cargo check --workspace fail with "resource path '../../mobile-web/dist'
1217 * doesn't exist" in the openbitfun-desktop build script
2429 */
2530
2631import { execFileSync } from 'node:child_process' ;
27- import { existsSync } from 'node:fs' ;
28- import { join , dirname } from 'node:path' ;
32+ import { existsSync , readdirSync , rmSync } from 'node:fs' ;
33+ import { join , dirname , relative , sep } from 'node:path' ;
2934import { fileURLToPath } from 'node:url' ;
3035
3136const __dirname = dirname ( fileURLToPath ( import . meta. url ) ) ;
@@ -36,6 +41,69 @@ const FIX = process.argv.includes('--fix');
3641
3742// --- Check logic (extracted for re-use and testing) ---
3843
44+ const VIRTUAL_STORE_SAMPLE_LIMIT = 5 ;
45+
46+ function readdirWithTypes ( dir ) {
47+ try {
48+ return readdirSync ( dir , { withFileTypes : true } ) ;
49+ } catch {
50+ return [ ] ;
51+ }
52+ }
53+
54+ /**
55+ * Package directories inside node_modules/.pnpm that pnpm recorded but whose
56+ * files are gone: an existing but empty package directory, or a link whose
57+ * target disappeared. Either one makes the workspace bin shims resolve to a
58+ * path without content.
59+ */
60+ function findBrokenVirtualStorePackages ( rootDir ) {
61+ const virtualStoreDir = join ( rootDir , 'node_modules' , '.pnpm' ) ;
62+ if ( ! existsSync ( virtualStoreDir ) ) {
63+ return [ ] ;
64+ }
65+
66+ const isBroken = ( packageDir , entry ) => {
67+ // readdir reports the name of a dangling link, but the path cannot be opened.
68+ if ( ! existsSync ( packageDir ) ) {
69+ return true ;
70+ }
71+ // Links to peer packages hold no files themselves; their target is checked
72+ // through its own entry in the virtual store.
73+ if ( entry . isSymbolicLink ( ) ) {
74+ return false ;
75+ }
76+ return readdirWithTypes ( packageDir ) . length === 0 ;
77+ } ;
78+
79+ const broken = [ ] ;
80+ for ( const storeEntry of readdirWithTypes ( virtualStoreDir ) ) {
81+ if ( ! storeEntry . isDirectory ( ) ) {
82+ continue ;
83+ }
84+ const storeNodeModules = join ( virtualStoreDir , storeEntry . name , 'node_modules' ) ;
85+
86+ for ( const entry of readdirWithTypes ( storeNodeModules ) ) {
87+ if ( entry . name . startsWith ( '@' ) ) {
88+ const scopeDir = join ( storeNodeModules , entry . name ) ;
89+ for ( const scopedEntry of readdirWithTypes ( scopeDir ) ) {
90+ const packageDir = join ( scopeDir , scopedEntry . name ) ;
91+ if ( isBroken ( packageDir , scopedEntry ) ) {
92+ broken . push ( packageDir ) ;
93+ }
94+ }
95+ continue ;
96+ }
97+ const packageDir = join ( storeNodeModules , entry . name ) ;
98+ if ( isBroken ( packageDir , entry ) ) {
99+ broken . push ( packageDir ) ;
100+ }
101+ }
102+ }
103+
104+ return broken ;
105+ }
106+
39107function runChecks ( rootDir ) {
40108 const errors = [ ] ;
41109 const warnings = [ ] ;
@@ -49,7 +117,30 @@ function runChecks(rootDir) {
49117 } ) ;
50118 }
51119
52- // --- Check 2: mobile-web dist (required by openbitfun-desktop build script) ---
120+ // --- Check 2: pnpm virtual store integrity ---
121+ const brokenPackages = findBrokenVirtualStorePackages ( rootDir ) ;
122+ if ( brokenPackages . length > 0 ) {
123+ const samples = brokenPackages
124+ . slice ( 0 , VIRTUAL_STORE_SAMPLE_LIMIT )
125+ . map ( ( packageDir ) => relative ( rootDir , packageDir ) ) ;
126+ const remaining = brokenPackages . length - samples . length ;
127+ const sampleText = remaining > 0 ? `${ samples . join ( ', ' ) } , ...(+${ remaining } more)` : samples . join ( ', ' ) ;
128+
129+ errors . push ( {
130+ name : 'pnpm virtual store' ,
131+ message :
132+ `${ brokenPackages . length } installed package(s) are missing their files: ${ sampleText } . ` +
133+ 'The matching node_modules/.bin shims point at a path without content, so nested tools fail with ' +
134+ 'a confusing "Cannot find module .../bin/<tool>" error. pnpm install alone does not repair this: ' +
135+ 'pnpm leaves an existing empty package directory untouched, so the broken directories must be removed first.' ,
136+ fix : [ 'pnpm' , 'install' ] ,
137+ cleanPaths : brokenPackages ,
138+ fixNote :
139+ '--fix removes the broken package directories first, because pnpm install would otherwise leave them empty. To repair by hand, delete the paths above and run pnpm install.' ,
140+ } ) ;
141+ }
142+
143+ // --- Check 3: mobile-web dist (required by openbitfun-desktop build script) ---
53144 if ( ! existsSync ( join ( rootDir , 'src' , 'mobile-web' , 'dist' , 'index.html' ) ) ) {
54145 errors . push ( {
55146 name : 'mobile-web dist' ,
@@ -59,7 +150,7 @@ function runChecks(rootDir) {
59150 } ) ;
60151 }
61152
62- // --- Check 3 : OpenCode extension Host dist (product runtime resource) ---
153+ // --- Check 4 : OpenCode extension Host dist (product runtime resource) ---
63154 const pluginHostDist = join (
64155 rootDir ,
65156 'src' ,
@@ -77,7 +168,7 @@ function runChecks(rootDir) {
77168 } ) ;
78169 }
79170
80- // --- Check 4 : sherpa-onnx prebuilt libs ---
171+ // --- Check 5 : sherpa-onnx prebuilt libs ---
81172 // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt/<version>/lib/
82173 // and returns immediately without downloading. Only warn for the first-build
83174 // scenario where no prebuilt cache exists yet.
@@ -106,7 +197,11 @@ function runChecks(rootDir) {
106197function collectPendingFixes ( errors ) {
107198 return errors
108199 . filter ( ( e ) => e . fix )
109- . map ( ( e ) => ( { name : e . name , fix : e . fix } ) ) ;
200+ . map ( ( e ) => ( {
201+ name : e . name ,
202+ fix : e . fix ,
203+ cleanPaths : e . cleanPaths ?? [ ] ,
204+ } ) ) ;
110205}
111206
112207function reportResults ( { errors, warnings } ) {
@@ -117,6 +212,9 @@ function reportResults({ errors, warnings }) {
117212 if ( e . fix ) {
118213 console . error ( ` Fix: ${ e . fix . join ( ' ' ) } ` ) ;
119214 }
215+ if ( e . fixNote ) {
216+ console . error ( ` Note: ${ e . fixNote } ` ) ;
217+ }
120218 }
121219 console . error ( ) ;
122220 }
@@ -132,7 +230,20 @@ function reportResults({ errors, warnings }) {
132230
133231function runFixes ( pendingFixes , rootDir ) {
134232 let allSucceeded = true ;
135- for ( const { name, fix } of pendingFixes ) {
233+ for ( const { fix, cleanPaths } of pendingFixes ) {
234+ for ( const target of cleanPaths ) {
235+ // Only ever delete inside the virtual store: a broken path must not be
236+ // allowed to escape into source or user data.
237+ const virtualStorePrefix = `${ join ( rootDir , 'node_modules' , '.pnpm' ) } ${ sep } ` ;
238+ if ( ! target . startsWith ( virtualStorePrefix ) ) {
239+ console . error ( `Refusing to remove path outside the pnpm virtual store: ${ target } \n` ) ;
240+ allSucceeded = false ;
241+ continue ;
242+ }
243+ console . log ( `Removing broken package directory ${ relative ( rootDir , target ) } ` ) ;
244+ rmSync ( target , { recursive : true , force : true } ) ;
245+ }
246+
136247 const [ cmd , ...args ] = fix ;
137248 console . log ( `$ ${ fix . join ( ' ' ) } ` ) ;
138249 try {
0 commit comments