[build-utils] Add getNodeBinPaths() function (#10150)

The `getNodeBinPath()` function is problematic because it assumes that commands are installed in the `node_modules` directory alongside the detected lockfile. This works fine the majority of the time, but ends up not being the case when using a monorepo that uses a package manager in "linked" mode (i.e. pnpm by default).

Consider the following:

```
.
├── pnpm-lock.yaml
├── node_modules
├── blog
│   ├── node_modules
│   │   ├── hexo -> .pnpm/hexo@3.9.0/node_modules/hexo
```

In this setup, adding the root-level `node_modules/.bin` would not make the `hexo` command be visible in the `$PATH`.

To solve this issue, the new `getNodeBinPaths()` function returns an array of all directories up to the specified `root`, which can then be placed into the `$PATH`. It's also more efficient (synchronous) since it does not need to scan for a lockfile anymore (the `root` needs to be specified explicitly).

The new function is being used in `@vercel/next` and `@vercel/static-build`.

The `traverseUpDirectories()` function from CLI was moved to `build-utils` to implement this function. Consequently, that makes the implementations of `walkParentDirs()` and `walkParentDirsMulti()` simpler, since it's using this generator now.
This commit is contained in:
Nathan Rajlich
2023-06-27 18:53:34 -07:00
committed by GitHub
parent e4895d979b
commit 3468922108
11 changed files with 150 additions and 82 deletions

View File

@@ -0,0 +1,6 @@
---
'@vercel/static-build': patch
'@vercel/next': patch
---
Use `getNodeBinPaths()` function to improve monorepo support

View File

@@ -0,0 +1,5 @@
---
'@vercel/build-utils': minor
---
Add `getNodeBinPaths()` and `traverseUpDirectories()` functions

View File

@@ -44,23 +44,33 @@ export interface ScanParentDirsResult {
lockfileVersion?: number; lockfileVersion?: number;
} }
export interface WalkParentDirsProps { export interface TraverseUpDirectoriesProps {
/** /**
* The highest directory, typically the workPath root of the project. * The directory to start iterating from, typically the same directory of the entrypoint.
* If this directory is reached and it doesn't contain the file, null is returned.
*/
base: string;
/**
* The directory to start searching, typically the same directory of the entrypoint.
* If this directory doesn't contain the file, the parent is checked, etc.
*/ */
start: string; start: string;
/**
* The highest directory, typically the workPath root of the project.
*/
base?: string;
}
export interface WalkParentDirsProps
extends Required<TraverseUpDirectoriesProps> {
/** /**
* The name of the file to search for, typically `package.json` or `Gemfile`. * The name of the file to search for, typically `package.json` or `Gemfile`.
*/ */
filename: string; filename: string;
} }
export interface WalkParentDirsMultiProps
extends Required<TraverseUpDirectoriesProps> {
/**
* The name of the file to search for, typically `package.json` or `Gemfile`.
*/
filenames: string[];
}
export interface SpawnOptionsExtended extends SpawnOptions { export interface SpawnOptionsExtended extends SpawnOptions {
/** /**
* Pretty formatted command that is being spawned for logging purposes. * Pretty formatted command that is being spawned for logging purposes.
@@ -131,6 +141,24 @@ export async function execCommand(command: string, options: SpawnOptions = {}) {
return true; return true;
} }
export function* traverseUpDirectories({
start,
base,
}: TraverseUpDirectoriesProps) {
let current: string | undefined = path.normalize(start);
const normalizedRoot = base ? path.normalize(base) : undefined;
while (current) {
yield current;
if (current === normalizedRoot) break;
// Go up one directory
const next = path.join(current, '..');
current = next === current ? undefined : next;
}
}
/**
* @deprecated Use `getNodeBinPaths()` instead.
*/
export async function getNodeBinPath({ export async function getNodeBinPath({
cwd, cwd,
}: { }: {
@@ -141,6 +169,15 @@ export async function getNodeBinPath({
return path.join(dir, 'node_modules', '.bin'); return path.join(dir, 'node_modules', '.bin');
} }
export function getNodeBinPaths({
start,
base,
}: TraverseUpDirectoriesProps): string[] {
return Array.from(traverseUpDirectories({ start, base })).map(dir =>
path.join(dir, 'node_modules/.bin')
);
}
async function chmodPlusX(fsPath: string) { async function chmodPlusX(fsPath: string) {
const s = await fs.stat(fsPath); const s = await fs.stat(fsPath);
const newMode = s.mode | 64 | 8 | 1; // eslint-disable-line no-bitwise const newMode = s.mode | 64 | 8 | 1; // eslint-disable-line no-bitwise
@@ -297,22 +334,14 @@ export async function walkParentDirs({
}: WalkParentDirsProps): Promise<string | null> { }: WalkParentDirsProps): Promise<string | null> {
assert(path.isAbsolute(base), 'Expected "base" to be absolute path'); assert(path.isAbsolute(base), 'Expected "base" to be absolute path');
assert(path.isAbsolute(start), 'Expected "start" to be absolute path'); assert(path.isAbsolute(start), 'Expected "start" to be absolute path');
let parent = '';
for (let current = start; base.length <= current.length; current = parent) { for (const dir of traverseUpDirectories({ start, base })) {
const fullPath = path.join(current, filename); const fullPath = path.join(dir, filename);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
if (await fs.pathExists(fullPath)) { if (await fs.pathExists(fullPath)) {
return fullPath; return fullPath;
} }
parent = path.dirname(current);
if (parent === current) {
// Reached root directory of the filesystem
break;
}
} }
return null; return null;
@@ -322,14 +351,9 @@ async function walkParentDirsMulti({
base, base,
start, start,
filenames, filenames,
}: { }: WalkParentDirsMultiProps): Promise<(string | undefined)[]> {
base: string; for (const dir of traverseUpDirectories({ start, base })) {
start: string; const fullPaths = filenames.map(f => path.join(dir, f));
filenames: string[];
}): Promise<(string | undefined)[]> {
let parent = '';
for (let current = start; base.length <= current.length; current = parent) {
const fullPaths = filenames.map(f => path.join(current, f));
const existResults = await Promise.all( const existResults = await Promise.all(
fullPaths.map(f => fs.pathExists(f)) fullPaths.map(f => fs.pathExists(f))
); );
@@ -338,13 +362,6 @@ async function walkParentDirsMulti({
if (foundOneOrMore) { if (foundOneOrMore) {
return fullPaths.map((f, i) => (existResults[i] ? f : undefined)); return fullPaths.map((f, i) => (existResults[i] ? f : undefined));
} }
parent = path.dirname(current);
if (parent === current) {
// Reached root directory of the filesystem
break;
}
} }
return []; return [];

View File

@@ -30,7 +30,9 @@ import {
getNodeVersion, getNodeVersion,
getSpawnOptions, getSpawnOptions,
getNodeBinPath, getNodeBinPath,
getNodeBinPaths,
scanParentDirs, scanParentDirs,
traverseUpDirectories,
} from './fs/run-user-scripts'; } from './fs/run-user-scripts';
import { import {
getLatestNodeVersion, getLatestNodeVersion,
@@ -68,6 +70,7 @@ export {
spawnCommand, spawnCommand,
walkParentDirs, walkParentDirs,
getNodeBinPath, getNodeBinPath,
getNodeBinPaths,
runNpmInstall, runNpmInstall,
runBundleInstall, runBundleInstall,
runPipInstall, runPipInstall,
@@ -89,6 +92,7 @@ export {
getIgnoreFilter, getIgnoreFilter,
cloneEnv, cloneEnv,
hardLinkDir, hardLinkDir,
traverseUpDirectories,
validateNpmrc, validateNpmrc,
}; };

View File

@@ -0,0 +1,17 @@
import { join } from 'path';
import { getNodeBinPaths } from '../src/fs/run-user-scripts';
describe('getNodeBinPaths()', () => {
const cwd = process.cwd();
it('should return array of `node_modules/.bin` paths', () => {
const start = join(cwd, 'foo/bar/baz');
const paths = getNodeBinPaths({ start, base: cwd });
expect(paths).toEqual([
join(cwd, 'foo/bar/baz/node_modules/.bin'),
join(cwd, 'foo/bar/node_modules/.bin'),
join(cwd, 'foo/node_modules/.bin'),
join(cwd, 'node_modules/.bin'),
]);
});
});

View File

@@ -0,0 +1,50 @@
import { traverseUpDirectories } from '../src/fs/run-user-scripts';
const isWindows = process.platform === 'win32';
describe('traverseUpDirectories()', () => {
test.each(
isWindows
? [
{
start: 'C:\\foo\\bar\\baz',
expected: ['C:\\foo\\bar\\baz', 'C:\\foo\\bar', 'C:\\foo', 'C:\\'],
},
{
start: 'C:\\foo\\..\\bar\\.\\baz',
expected: ['C:\\bar\\baz', 'C:\\bar', 'C:\\'],
},
{
start: 'C:\\foo\\bar\\baz\\another',
base: 'C:\\foo\\bar',
expected: [
'C:\\foo\\bar\\baz\\another',
'C:\\foo\\bar\\baz',
'C:\\foo\\bar',
],
},
]
: [
{
start: '/foo/bar/baz',
expected: ['/foo/bar/baz', '/foo/bar', '/foo', '/'],
},
{
start: '/foo/../bar/./baz',
expected: ['/bar/baz', '/bar', '/'],
},
{
start: '/foo/bar/baz/another',
base: '/foo/bar',
expected: ['/foo/bar/baz/another', '/foo/bar/baz', '/foo/bar'],
},
]
)(
'should traverse start="$start", base="$base"',
({ start, base, expected }) => {
expect(Array.from(traverseUpDirectories({ start, base }))).toEqual(
expected
);
}
);
});

View File

@@ -2,7 +2,7 @@ import chalk from 'chalk';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { homedir } from 'os'; import { homedir } from 'os';
import { join, normalize } from 'path'; import { join, normalize } from 'path';
import { normalizePath } from '@vercel/build-utils'; import { normalizePath, traverseUpDirectories } from '@vercel/build-utils';
import { lstat, readJSON, outputJSON } from 'fs-extra'; import { lstat, readJSON, outputJSON } from 'fs-extra';
import confirm from '../input/confirm'; import confirm from '../input/confirm';
import toHumanPath from '../humanize-path'; import toHumanPath from '../humanize-path';
@@ -229,7 +229,7 @@ export async function findRepoRoot(
const REPO_JSON_PATH = join(VERCEL_DIR, VERCEL_DIR_REPO); const REPO_JSON_PATH = join(VERCEL_DIR, VERCEL_DIR_REPO);
const GIT_CONFIG_PATH = normalize('.git/config'); const GIT_CONFIG_PATH = normalize('.git/config');
for (const current of traverseUpDirectories(start)) { for (const current of traverseUpDirectories({ start })) {
if (current === home) { if (current === home) {
// Sometimes the $HOME directory is set up as a Git repo // Sometimes the $HOME directory is set up as a Git repo
// (for dotfiles, etc.). In this case it's safe to say that // (for dotfiles, etc.). In this case it's safe to say that
@@ -264,16 +264,6 @@ export async function findRepoRoot(
debug('Aborting search for repo root'); debug('Aborting search for repo root');
} }
export function* traverseUpDirectories(start: string) {
let current: string | undefined = normalize(start);
while (current) {
yield current;
// Go up one directory
const next = join(current, '..');
current = next === current ? undefined : next;
}
}
function sortByDirectory(a: RepoProjectConfig, b: RepoProjectConfig): number { function sortByDirectory(a: RepoProjectConfig, b: RepoProjectConfig): number {
const aParts = a.directory.split('/'); const aParts = a.directory.split('/');
const bParts = b.directory.split('/'); const bParts = b.directory.split('/');

View File

@@ -4,12 +4,9 @@ import {
findProjectsFromPath, findProjectsFromPath,
findRepoRoot, findRepoRoot,
RepoProjectConfig, RepoProjectConfig,
traverseUpDirectories,
} from '../../../../src/util/link/repo'; } from '../../../../src/util/link/repo';
import { client } from '../../../mocks/client'; import { client } from '../../../mocks/client';
const isWindows = process.platform === 'win32';
// Root of `vercel/vercel` repo // Root of `vercel/vercel` repo
const vercelRepoRoot = join(__dirname, '../../../../../..'); const vercelRepoRoot = join(__dirname, '../../../../../..');
@@ -30,34 +27,6 @@ describe('findRepoRoot()', () => {
}); });
}); });
describe('traverseUpDirectories()', () => {
test.each(
isWindows
? [
{
input: 'C:\\foo\\bar\\baz',
expected: ['C:\\foo\\bar\\baz', 'C:\\foo\\bar', 'C:\\foo', 'C:\\'],
},
{
input: 'C:\\foo\\..\\bar\\.\\baz',
expected: ['C:\\bar\\baz', 'C:\\bar', 'C:\\'],
},
]
: [
{
input: '/foo/bar/baz',
expected: ['/foo/bar/baz', '/foo/bar', '/foo', '/'],
},
{
input: '/foo/../bar/./baz',
expected: ['/bar/baz', '/bar', '/'],
},
]
)('should traverse "$input"', ({ input, expected }) => {
expect(Array.from(traverseUpDirectories(input))).toEqual(expected);
});
});
describe('findProjectsFromPath()', () => { describe('findProjectsFromPath()', () => {
const projects: RepoProjectConfig[] = [ const projects: RepoProjectConfig[] = [
{ id: 'root', name: 'r', directory: '.' }, { id: 'root', name: 'r', directory: '.' },

View File

@@ -18,7 +18,7 @@ import {
runPackageJsonScript, runPackageJsonScript,
execCommand, execCommand,
getEnvForPackageManager, getEnvForPackageManager,
getNodeBinPath, getNodeBinPaths,
scanParentDirs, scanParentDirs,
BuildV2, BuildV2,
PrepareCache, PrepareCache,
@@ -431,7 +431,11 @@ export const build: BuildV2 = async ({
if (buildCommand) { if (buildCommand) {
// Add `node_modules/.bin` to PATH // Add `node_modules/.bin` to PATH
const nodeBinPath = await getNodeBinPath({ cwd: entryPath }); const nodeBinPaths = getNodeBinPaths({
start: entryPath,
base: repoRootPath,
});
const nodeBinPath = nodeBinPaths.join(path.delimiter);
env.PATH = `${nodeBinPath}${path.delimiter}${env.PATH}`; env.PATH = `${nodeBinPath}${path.delimiter}${env.PATH}`;
// Yarn v2 PnP mode may be activated, so force "node-modules" linker style // Yarn v2 PnP mode may be activated, so force "node-modules" linker style

View File

@@ -21,7 +21,7 @@ import {
runNpmInstall, runNpmInstall,
getEnvForPackageManager, getEnvForPackageManager,
getPrefixedEnvVars, getPrefixedEnvVars,
getNodeBinPath, getNodeBinPaths,
runBundleInstall, runBundleInstall,
runPipInstall, runPipInstall,
runPackageJsonScript, runPackageJsonScript,
@@ -305,6 +305,7 @@ export const build: BuildV2 = async ({
files, files,
entrypoint, entrypoint,
workPath, workPath,
repoRootPath,
config, config,
meta = {}, meta = {},
}) => { }) => {
@@ -519,10 +520,15 @@ export const build: BuildV2 = async ({
const pathList = []; const pathList = [];
if (isNpmInstall || (pkg && (buildCommand || devCommand))) { if (isNpmInstall || (pkg && (buildCommand || devCommand))) {
const nodeBinPath = await getNodeBinPath({ cwd: entrypointDir }); const nodeBinPaths = getNodeBinPaths({
pathList.push(nodeBinPath); // Add `./node_modules/.bin` start: entrypointDir,
base: repoRootPath,
});
pathList.push(...nodeBinPaths); // Add `./node_modules/.bin`
debug( debug(
`Added "${nodeBinPath}" to PATH env because a package.json file was found` `Added "${nodeBinPaths.join(
path.delimiter
)}" to PATH env because a package.json file was found`
); );
} }