-
Notifications
You must be signed in to change notification settings - Fork 84
fix: resolve node_modules bins when running scripts inside .faststore #3440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5bb0e9b
fix: resolve node_modules bins when running scripts inside .faststore
hellofanny c7985df
Merge branch 'dev' into fix/cli-bin-resolution
hellofanny 998684f
Merge branch 'dev' into fix/cli-bin-resolution
hellofanny 6725c1e
fix: keep the nearest node_modules/.bin first when augmenting PATH
hellofanny 18e12f7
test: resolve the bin probe without a shell interpreter
hellofanny 7ca14d0
test: create the bin probe without loose file permissions
hellofanny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { spawnSync } from 'node:child_process' | ||
| import fs from 'node:fs' | ||
| import os from 'node:os' | ||
| import path from 'node:path' | ||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
|
|
||
| import { withNodeModulesBins } from './binPaths' | ||
|
|
||
| // mimics a hoisted monorepo: bins live at the workspace root, the store has its | ||
| // own node_modules, and `.faststore` has none | ||
| let workspaceRoot: string | ||
| let storeDir: string | ||
| let tmpDir: string | ||
|
|
||
| beforeAll(() => { | ||
| workspaceRoot = fs.realpathSync( | ||
| fs.mkdtempSync(path.join(os.tmpdir(), 'bin-paths-')) | ||
| ) | ||
| storeDir = path.join(workspaceRoot, 'stores', 'my-store') | ||
| tmpDir = path.join(storeDir, '.faststore') | ||
|
|
||
| fs.mkdirSync(path.join(workspaceRoot, 'node_modules', '.bin'), { | ||
| recursive: true, | ||
| }) | ||
| fs.mkdirSync(path.join(storeDir, 'node_modules', '.bin'), { recursive: true }) | ||
| fs.mkdirSync(tmpDir, { recursive: true }) | ||
| }) | ||
|
|
||
| afterAll(() => { | ||
| fs.rmSync(workspaceRoot, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| describe('withNodeModulesBins', () => { | ||
| it('prepends the bin directories of every ancestor, nearest first', () => { | ||
| const { PATH } = withNodeModulesBins(tmpDir, { PATH: '/usr/bin' }) | ||
| const entries = PATH?.split(path.delimiter) ?? [] | ||
|
|
||
| expect(entries.slice(0, 2)).toEqual([ | ||
| path.join(storeDir, 'node_modules', '.bin'), | ||
| path.join(workspaceRoot, 'node_modules', '.bin'), | ||
| ]) | ||
| expect(entries.at(-1)).toBe('/usr/bin') | ||
| }) | ||
|
|
||
| it('skips ancestors without a node_modules/.bin', () => { | ||
| const { PATH } = withNodeModulesBins(tmpDir, { PATH: '/usr/bin' }) | ||
|
|
||
| expect(PATH).not.toContain( | ||
| path.join(workspaceRoot, 'stores', 'node_modules', '.bin') | ||
| ) | ||
| }) | ||
|
|
||
| it('keeps the other environment variables untouched', () => { | ||
| const env = withNodeModulesBins(tmpDir, { | ||
| PATH: '/usr/bin', | ||
| VTEX_ACCOUNT: 'storeframework', | ||
| }) | ||
|
|
||
| expect(env.VTEX_ACCOUNT).toBe('storeframework') | ||
| }) | ||
|
|
||
| it('reuses the existing PATH key casing, as used on Windows', () => { | ||
| const env = withNodeModulesBins(tmpDir, { Path: 'C:\\Windows\\system32' }) | ||
|
|
||
| expect(env.PATH).toBeUndefined() | ||
| expect(env.Path).toContain(path.join(storeDir, 'node_modules', '.bin')) | ||
| expect(env.Path).toContain('C:\\Windows\\system32') | ||
| }) | ||
|
|
||
| it('moves a bin directory already in PATH to its nearest-first position instead of duplicating it', () => { | ||
| const storeBinDir = path.join(storeDir, 'node_modules', '.bin') | ||
| const { PATH } = withNodeModulesBins(tmpDir, { | ||
| PATH: [storeBinDir, '/usr/bin'].join(path.delimiter), | ||
| }) | ||
| const entries = PATH?.split(path.delimiter) ?? [] | ||
|
|
||
| expect(entries).toEqual([ | ||
| storeBinDir, | ||
| path.join(workspaceRoot, 'node_modules', '.bin'), | ||
| '/usr/bin', | ||
| ]) | ||
| }) | ||
|
|
||
| it('does not drop the bin directories when PATH is empty', () => { | ||
| const { PATH } = withNodeModulesBins(tmpDir, {}) | ||
|
|
||
| expect(PATH?.split(path.delimiter).slice(0, 2)).toEqual([ | ||
| path.join(storeDir, 'node_modules', '.bin'), | ||
| path.join(workspaceRoot, 'node_modules', '.bin'), | ||
| ]) | ||
| }) | ||
|
|
||
| it('returns the environment untouched when no ancestor has a node_modules/.bin', () => { | ||
| // Sibling of the fixture rather than a descendant: the walk from here goes | ||
| // straight up the OS temp dir, where no node_modules/.bin exists. | ||
| const binlessDir = fs.realpathSync( | ||
| fs.mkdtempSync(path.join(os.tmpdir(), 'bin-paths-binless-')) | ||
| ) | ||
| const env = { PATH: '/usr/bin', VTEX_ACCOUNT: 'storeframework' } | ||
|
|
||
| try { | ||
| expect(withNodeModulesBins(binlessDir, env)).toEqual(env) | ||
| } finally { | ||
| fs.rmSync(binlessDir, { recursive: true, force: true }) | ||
| } | ||
| }) | ||
|
|
||
| // The tests above only inspect the object we build. This one spawns a real | ||
| // child process from `.faststore`, the way `dev` and `build` do, to check | ||
| // that the environment is what makes a bare binary name resolve. Windows | ||
| // would need a `.cmd` shim for the fixture, so it stays on POSIX. | ||
| it.skipIf(process.platform === 'win32')( | ||
| 'lets a child process resolve a binary living in an ancestor bin directory', | ||
| () => { | ||
| const probe = path.join( | ||
| storeDir, | ||
| 'node_modules', | ||
| '.bin', | ||
| 'faststore-bin-probe' | ||
| ) | ||
| fs.writeFileSync(probe, '#!/bin/sh\necho resolved\n') | ||
| fs.chmodSync(probe, 0o755) | ||
|
|
||
| // no `shell: true`: the OS resolves the bare name from the environment | ||
| // we hand it, which is the behaviour under test, and it keeps a shell | ||
| // interpreter out of the test | ||
| const run = (env: NodeJS.ProcessEnv) => | ||
| spawnSync('faststore-bin-probe', { | ||
| cwd: tmpDir, | ||
| encoding: 'utf-8', | ||
| env, | ||
| }) | ||
|
|
||
| // A PATH that cannot resolve the probe on its own, so that a passing | ||
| // assertion below can only come from the directories we added. | ||
| const barePath = { PATH: path.join(workspaceRoot, 'nowhere') } | ||
|
|
||
| expect(run(barePath).status).not.toBe(0) | ||
|
|
||
| const resolved = run(withNodeModulesBins(tmpDir, barePath)) | ||
|
|
||
| expect(resolved.status).toBe(0) | ||
| expect(resolved.stdout.trim()).toBe('resolved') | ||
| } | ||
| ) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import fsExtra from 'fs-extra' | ||
| import path from 'node:path' | ||
|
|
||
| const { existsSync } = fsExtra | ||
|
|
||
| function collectBinDirs(fromDir: string) { | ||
| const binDirs: string[] = [] | ||
| let currentDir = path.resolve(fromDir) | ||
|
|
||
| while (true) { | ||
| const binDir = path.join(currentDir, 'node_modules', '.bin') | ||
|
|
||
| if (existsSync(binDir)) { | ||
| binDirs.push(binDir) | ||
| } | ||
|
|
||
| const parentDir = path.dirname(currentDir) | ||
|
|
||
| if (parentDir === currentDir) { | ||
| return binDirs | ||
| } | ||
|
|
||
| currentDir = parentDir | ||
| } | ||
| } | ||
|
|
||
| // Environment variables are case-insensitive on Windows, where the key is | ||
| // usually `Path`. Reusing the existing key avoids handing the child process | ||
| // both `Path` and `PATH`. | ||
| function getPathKey(env: NodeJS.ProcessEnv) { | ||
| return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') ?? 'PATH' | ||
| } | ||
|
|
||
| /** | ||
| * Prepends the `node_modules/.bin` of `fromDir` and of every ancestor | ||
| * directory to `PATH`. | ||
| * | ||
| * The generated `.faststore` package has no `node_modules` of its own, so the | ||
| * binaries its scripts call (`na`, `next`) live higher up the tree: in the | ||
| * store root or, on hoisted monorepos, the workspace root. | ||
| */ | ||
| export function withNodeModulesBins( | ||
| fromDir: string, | ||
| env: NodeJS.ProcessEnv = process.env | ||
| ): NodeJS.ProcessEnv { | ||
| const binDirs = collectBinDirs(fromDir) | ||
|
|
||
| if (binDirs.length === 0) { | ||
| return { ...env } | ||
| } | ||
|
|
||
| const pathKey = getPathKey(env) | ||
| const currentEntries = env[pathKey]?.split(path.delimiter) ?? [] | ||
| // Package managers already put some of these directories in PATH when they | ||
| // run a script, but not necessarily nearest first. Dropping their copies | ||
| // before prepending `binDirs` keeps the nearest ancestor in charge instead | ||
| // of letting a farther one that was missing from PATH jump ahead of it. | ||
| const binDirSet = new Set(binDirs) | ||
| const otherEntries = currentEntries.filter((entry) => !binDirSet.has(entry)) | ||
|
|
||
| return { | ||
| ...env, | ||
| [pathKey]: [...binDirs, ...otherEntries].join(path.delimiter), | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| import { runCommandSync } from './runCommandSync' | ||
|
|
||
| const execSyncMock = vi.hoisted(() => vi.fn()) | ||
|
|
||
| vi.mock('node:child_process', () => ({ execSync: execSyncMock })) | ||
|
|
||
| const optionsOfLastCall = () => execSyncMock.mock.calls.at(-1)?.[1] | ||
|
|
||
| describe('runCommandSync', () => { | ||
| beforeEach(() => { | ||
| execSyncMock.mockClear() | ||
| }) | ||
|
|
||
| it('forwards a custom environment to the child process', () => { | ||
| const env = { | ||
| PATH: '/store/node_modules/.bin', | ||
| VTEX_ACCOUNT: 'storeframework', | ||
| } | ||
|
|
||
| runCommandSync({ | ||
| cmd: 'yarn predev', | ||
| errorMessage: 'predev failed', | ||
| throws: 'error', | ||
| cwd: '/store/.faststore', | ||
| env, | ||
| }) | ||
|
|
||
| expect(optionsOfLastCall()).toMatchObject({ | ||
| cwd: '/store/.faststore', | ||
| env, | ||
| }) | ||
| }) | ||
|
|
||
| // execSync treats an undefined env as "inherit process.env", which is what | ||
| // the call sites that never pass one rely on. | ||
| it('leaves the environment to the child when the caller does not pass one', () => { | ||
| runCommandSync({ | ||
| cmd: 'yarn generate', | ||
| errorMessage: 'generate failed', | ||
| throws: 'error', | ||
| }) | ||
|
|
||
| expect(optionsOfLastCall()?.env).toBeUndefined() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.