2727import { existsSync , readFileSync } from "node:fs" ;
2828import { homedir , platform } from "node:os" ;
2929import { join , resolve } from "node:path" ;
30- import { detectPackageManager , PM_LOCKFILES , type PackageManager } from "./build/detect.js" ;
30+ import { detectPackageManager , PM_LOCKFILES_ALL , type PackageManager } from "./build/detect.js" ;
3131import { detectReferencedPackageManagers } from "./mod/packageManager.js" ;
3232import { runShell } from "./process.js" ;
3333import { sudo } from "./sudo.js" ;
@@ -98,9 +98,15 @@ export function pnpmInstallCommand(): string {
9898 return "curl -fsSL https://get.pnpm.io/install.sh | sh -" ;
9999}
100100
101- /** yarn's official path: corepack ships with Node and activates a yarn shim. */
102- export function yarnInstallCommand ( ) : string {
103- return "corepack enable && corepack prepare yarn@stable --activate" ;
101+ /**
102+ * yarn's official path: corepack (ships with Node) writes a yarn shim. We point
103+ * `--install-directory` at a user-owned dir instead of corepack's default (the
104+ * Node bin dir), because on a NodeSource Linux install that dir is root-owned
105+ * and a plain `corepack enable` hits EACCES. The caller prepends `installDir`
106+ * to PATH so the shim resolves. Works on macOS and Linux without sudo.
107+ */
108+ export function yarnInstallCommand ( installDir : string ) : string {
109+ return `corepack enable --install-directory "${ installDir } " && corepack prepare yarn@stable --activate` ;
104110}
105111
106112/**
@@ -157,7 +163,14 @@ const YARN_TOOL: PmTool = {
157163 name : "yarn" ,
158164 label : "yarn" ,
159165 check : ( ) => commandExists ( "yarn" ) ,
160- install : ( onData ) => runShell ( yarnInstallCommand ( ) , onData , { description : "install yarn" } ) ,
166+ install : async ( onData ) => {
167+ const binDir = resolve ( homedir ( ) , ".corepack/bin" ) ;
168+ await runShell ( yarnInstallCommand ( binDir ) , onData , { description : "install yarn" } ) ;
169+ // corepack wrote the yarn shim into our user-owned --install-directory
170+ // (avoids EACCES on a root-owned NodeSource bin dir); expose it now so the
171+ // very next step resolves `yarn`.
172+ prependPath ( binDir ) ;
173+ } ,
161174 manualHint : "https://yarnpkg.com/getting-started/install" ,
162175} ;
163176
@@ -167,8 +180,10 @@ const BUN_TOOL: PmTool = {
167180 check : ( ) => commandExists ( "bun" ) ,
168181 install : async ( onData ) => {
169182 await runShell ( bunInstallCommand ( ) , onData , { description : "install bun" } ) ;
170- // bun's installer drops the binary in ~/.bun/bin and edits shell rc files.
171- prependPath ( resolve ( homedir ( ) , ".bun/bin" ) ) ;
183+ // bun's installer writes the binary to $BUN_INSTALL/bin (default ~/.bun),
184+ // and edits shell rc files that don't reach the running process. Honor
185+ // BUN_INSTALL (the installer does) and expose the bin dir now.
186+ prependPath ( resolve ( process . env . BUN_INSTALL ?? resolve ( homedir ( ) , ".bun" ) , "bin" ) ) ;
172187 } ,
173188 manualHint : "https://bun.sh/docs/installation" ,
174189} ;
@@ -219,6 +234,23 @@ export class PackageManagerUnavailableError extends Error {
219234 }
220235}
221236
237+ // Process-wide single-flight for installs, keyed by tool name. `deploy-all`
238+ // runs builds for several apps concurrently in ONE process; when they share a
239+ // missing PM, every worker would otherwise launch the same installer at once
240+ // (two `get.pnpm.io | sh` runs, or racing `sudo apt`/`dpkg` locks). Collapsing
241+ // concurrent installs of the same tool onto one promise makes that safe.
242+ const inFlightInstalls = new Map < string , Promise < void > > ( ) ;
243+
244+ function installOnce ( tool : PmTool , onData ?: ( line : string ) => void ) : Promise < void > {
245+ const existing = inFlightInstalls . get ( tool . name ) ;
246+ if ( existing ) return existing ;
247+ const p = Promise . resolve ( tool . install ( onData ) ) . finally ( ( ) => {
248+ inFlightInstalls . delete ( tool . name ) ;
249+ } ) ;
250+ inFlightInstalls . set ( tool . name , p ) ;
251+ return p ;
252+ }
253+
222254/** Core orchestration, parameterized on the tool list so it is trivially testable. */
223255export async function ensurePackageManagerForTools (
224256 pm : PackageManager ,
@@ -239,7 +271,23 @@ export async function ensurePackageManagerForTools(
239271
240272 for ( const tool of missing ) {
241273 opts . onData ?.( `> installing ${ tool . label } ` ) ;
242- await tool . install ( opts . onData ) ;
274+ await installOnce ( tool , opts . onData ) ;
275+ }
276+
277+ // Re-verify: an installer can exit 0 yet leave the binary off the running
278+ // process's PATH (e.g. a prependPath target that doesn't match where the
279+ // installer actually wrote). Surface that here with the manual hint, instead
280+ // of letting the next build/setup step die with a confusing "command not
281+ // found" — the exact scary-error class this whole flow exists to kill.
282+ const stillMissing : PmTool [ ] = [ ] ;
283+ for ( const tool of missing ) {
284+ if ( ! ( await tool . check ( ) ) ) stillMissing . push ( tool ) ;
285+ }
286+ if ( stillMissing . length > 0 ) {
287+ const names = stillMissing . map ( ( t ) => t . label ) . join ( ", " ) ;
288+ throw new Error (
289+ `Installed ${ names } but it is still not on PATH. ${ packageManagerManualHint ( pm , stillMissing ) } ` ,
290+ ) ;
243291 }
244292 return pm ;
245293}
@@ -284,7 +332,7 @@ export function loadPackageManagerSnapshot(projectDir: string): PmSnapshot {
284332 }
285333 }
286334 const lockfiles = new Set < string > ( ) ;
287- for ( const name of Object . values ( PM_LOCKFILES ) ) {
335+ for ( const name of PM_LOCKFILES_ALL ) {
288336 if ( existsSync ( join ( projectDir , name ) ) ) lockfiles . add ( name ) ;
289337 }
290338 const setupPath = join ( projectDir , "setup.sh" ) ;
0 commit comments