Skip to content

Commit

Permalink
Use shell: true and explicit cwd for all package manager commands (#963)
Browse files Browse the repository at this point in the history
  • Loading branch information
ecraig12345 authored Apr 24, 2024
1 parent 7d50f92 commit 349712e
Show file tree
Hide file tree
Showing 16 changed files with 100 additions and 110 deletions.
7 changes: 7 additions & 0 deletions change/beachball-14e77b2f-24c2-47d5-a6c0-aa1b536f322d.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Use shell: true and explicit cwd for all package manager commands",
"packageName": "beachball",
"email": "[email protected]",
"dependentChangeType": "patch"
}
2 changes: 1 addition & 1 deletion src/__e2e__/syncE2E.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('sync command (e2e)', () => {
const mockNpm = initNpmMock();

let repositoryFactory: RepositoryFactory | undefined;
const publishOptions: Parameters<typeof packagePublish>[1] = { registry: 'fake', retries: 3 };
const publishOptions: Parameters<typeof packagePublish>[1] = { registry: 'fake', retries: 3, path: undefined };
const tempDirs: string[] = [];

initMockLogs();
Expand Down
36 changes: 18 additions & 18 deletions src/__fixtures__/mockNpm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,48 +122,48 @@ describe('_mockNpmShow', () => {

it("errors if package doesn't exist", () => {
const emptyData = _makeRegistryData({});
const result = _mockNpmShow(emptyData, ['foo'], {});
const result = _mockNpmShow(emptyData, ['foo'], { cwd: undefined });
expect(result).toEqual(getShowResult({ error: '[fake] code E404 - foo - not found' }));
});

it('returns requested version plus dist-tags and version list', () => {
const result = _mockNpmShow(data, ['[email protected]'], {});
const result = _mockNpmShow(data, ['[email protected]'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data: data, name: 'foo', version: '1.0.0' }));
});

it('returns requested version of scoped package', () => {
const result = _mockNpmShow(data, ['@foo/[email protected]'], {});
const result = _mockNpmShow(data, ['@foo/[email protected]'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data, name: '@foo/bar', version: '2.0.0' }));
});

it('returns requested tag', () => {
const result = _mockNpmShow(data, ['foo@beta'], {});
const result = _mockNpmShow(data, ['foo@beta'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data, name: 'foo', version: '1.0.0-beta' }));
});

it('returns requested tag of scoped package', () => {
const result = _mockNpmShow(data, ['@foo/bar@beta'], {});
const result = _mockNpmShow(data, ['@foo/bar@beta'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data, name: '@foo/bar', version: '2.0.0-beta' }));
});

it('returns latest version if no version requested', () => {
const result = _mockNpmShow(data, ['foo'], {});
const result = _mockNpmShow(data, ['foo'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data, name: 'foo', version: '1.0.1' }));
});

it('returns latest version of scoped package if no version requested', () => {
const result = _mockNpmShow(data, ['@foo/bar'], {});
const result = _mockNpmShow(data, ['@foo/bar'], { cwd: undefined });
expect(result).toEqual(getShowResult({ data, name: '@foo/bar', version: '2.0.1' }));
});

it("errors if requested version doesn't exist", () => {
const result = _mockNpmShow(data, ['[email protected]'], {});
const result = _mockNpmShow(data, ['[email protected]'], { cwd: undefined });
expect(result).toEqual(getShowResult({ error: '[fake] code E404 - [email protected] - not found' }));
});

// support for this could be added later
it('currently throws if requested version is a range', () => {
expect(() => _mockNpmShow(data, ['foo@^1.0.0'], {})).toThrow(/not currently supported/);
expect(() => _mockNpmShow(data, ['foo@^1.0.0'], { cwd: undefined })).toThrow(/not currently supported/);
});
});

Expand Down Expand Up @@ -199,7 +199,7 @@ describe('_mockNpmPublish', () => {
});

it('throws if cwd is not specified', () => {
expect(() => _mockNpmPublish({}, [], {})).toThrow('cwd is required for mock npm publish');
expect(() => _mockNpmPublish({}, [], { cwd: undefined })).toThrow('cwd is required for mock npm publish');
});

it('errors if reading package.json fails', () => {
Expand Down Expand Up @@ -294,7 +294,7 @@ describe('mockNpm', () => {

it('mocks npm show', async () => {
npmMock.setRegistryData({ foo: { versions: ['1.0.0'] } });
const result = await npm(['show', 'foo']);
const result = await npm(['show', 'foo'], { cwd: undefined });
expect(result).toMatchObject({
success: true,
stdout: expect.stringContaining('"name":"foo"'),
Expand All @@ -304,7 +304,7 @@ describe('mockNpm', () => {
it('resets calls and registry after each test', async () => {
expect(npmMock.mock).not.toHaveBeenCalled();
// registry data for foo was set in the previous test but should have been cleared
const result = await npm(['show', 'foo']);
const result = await npm(['show', 'foo'], { cwd: undefined });
expect(result).toMatchObject({
success: false,
stderr: expect.stringContaining('not found'),
Expand All @@ -313,7 +313,7 @@ describe('mockNpm', () => {

it('can "publish" a package to registry with helper', async () => {
npmMock.publishPackage({ name: 'foo', version: '1.0.0' });
const result = await npm(['show', 'foo']);
const result = await npm(['show', 'foo'], { cwd: undefined });
expect(result).toMatchObject({
success: true,
stdout: expect.stringContaining('"name":"foo"'),
Expand All @@ -330,22 +330,22 @@ describe('mockNpm', () => {
});

it('throws on unsupported command', async () => {
await expect(() => npm(['pack'])).rejects.toThrow('Command not supported by mock npm: pack');
await expect(() => npm(['pack'], { cwd: undefined })).rejects.toThrow('Command not supported by mock npm: pack');
});

it('respects mocked command', async () => {
const mockShow = jest.fn(() => 'hi');
npmMock.setCommandOverride('show', mockShow as any);
const result = await npm(['show', 'foo']);
const result = await npm(['show', 'foo'], { cwd: undefined });
expect(result).toEqual('hi');
expect(mockShow).toHaveBeenCalledWith(expect.any(Object), ['foo'], undefined);
expect(mockShow).toHaveBeenCalledWith(expect.any(Object), ['foo'], { cwd: undefined });
});

it("respects extra mocked command that's not normally supported", async () => {
const mockPack = jest.fn(() => 'hi');
npmMock.setCommandOverride('pack', mockPack as any);
const result = await npm(['pack']);
const result = await npm(['pack'], { cwd: undefined });
expect(result).toEqual('hi');
expect(mockPack).toHaveBeenCalledWith(expect.any(Object), [], undefined);
expect(mockPack).toHaveBeenCalledWith(expect.any(Object), [], { cwd: undefined });
});
});
2 changes: 1 addition & 1 deletion src/__fixtures__/npmShow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export async function npmShow(
const timeout = env.isCI && os.platform() === 'win32' ? 4500 : 1500;
const registryArg = registry ? ['--registry', registry.getUrl()] : [];

const showResult = await npm([...registryArg, 'show', '--json', packageName], { timeout });
const showResult = await npm([...registryArg, 'show', '--json', packageName], { timeout, cwd: undefined });

expect(!!showResult.timedOut).toBe(false);
expect(showResult.failed).toBe(!!shouldFail);
Expand Down
24 changes: 16 additions & 8 deletions src/__functional__/packageManager/packagePublish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ describe('packagePublish', () => {
// Do a basic publishing test against the real registry
await registry.reset();
const testPackageInfo = getTestPackageInfo();
const publishResult = await packagePublish(testPackageInfo, { registry: registry.getUrl(), retries: 2 });
const publishResult = await packagePublish(testPackageInfo, {
registry: registry.getUrl(),
retries: 2,
path: tempRoot,
});
expect(publishResult).toEqual(successResult);
expect(npmSpy).toHaveBeenCalledTimes(1);

Expand All @@ -103,12 +107,16 @@ describe('packagePublish', () => {
// Use real npm for this because the republish detection relies on the real error message
await registry.reset();
const testPackageInfo = getTestPackageInfo();
let publishResult = await packagePublish(testPackageInfo, { registry: registry.getUrl(), retries: 2 });
let publishResult = await packagePublish(testPackageInfo, {
registry: registry.getUrl(),
retries: 2,
path: tempRoot,
});
expect(publishResult).toEqual(successResult);
expect(npmSpy).toHaveBeenCalledTimes(1);
logs.clear();

publishResult = await packagePublish(testPackageInfo, { registry: registry.getUrl(), retries: 2 });
publishResult = await packagePublish(testPackageInfo, { registry: registry.getUrl(), retries: 2, path: tempRoot });
expect(publishResult).toEqual(failedResult);
// `retries` should be ignored if the version already exists
expect(npmSpy).toHaveBeenCalledTimes(2);
Expand All @@ -130,7 +138,7 @@ describe('packagePublish', () => {
Promise.resolve({ success: false, all: 'sloooow', timedOut: true } as NpmResult)
);

const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3 });
const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3, path: tempRoot });
expect(publishResult).toEqual(successResult);
expect(npmSpy).toHaveBeenCalledTimes(3);

Expand All @@ -146,7 +154,7 @@ describe('packagePublish', () => {
// Again, mock all npm calls for this test instead of simulating an actual error condition.
npmSpy.mockImplementation(() => Promise.resolve({ success: false, all: 'some errors' } as NpmResult));

const publishResult = await packagePublish(getTestPackageInfo(), { registry: 'fake', retries: 3 });
const publishResult = await packagePublish(getTestPackageInfo(), { registry: 'fake', retries: 3, path: tempRoot });
expect(publishResult).toEqual(failedResult);
expect(npmSpy).toHaveBeenCalledTimes(4);

Expand All @@ -162,7 +170,7 @@ describe('packagePublish', () => {
const testPackageInfo = getTestPackageInfo();
npmSpy.mockImplementation(() => Promise.resolve({ success: false, all: 'ERR! code ENEEDAUTH' } as NpmResult));

const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3 });
const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3, path: tempRoot });
expect(publishResult).toEqual(failedResult);
expect(npmSpy).toHaveBeenCalledTimes(1);

Expand All @@ -177,7 +185,7 @@ describe('packagePublish', () => {
const testPackageInfo = getTestPackageInfo();
npmSpy.mockImplementation(() => Promise.resolve({ success: false, all: 'ERR! code E404' } as NpmResult));

const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3 });
const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3, path: tempRoot });
expect(publishResult).toEqual(failedResult);
expect(npmSpy).toHaveBeenCalledTimes(1);

Expand All @@ -188,7 +196,7 @@ describe('packagePublish', () => {
const testPackageInfo = getTestPackageInfo();
npmSpy.mockImplementation(() => Promise.resolve({ success: false, all: 'ERR! code E403' } as NpmResult));

const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3 });
const publishResult = await packagePublish(testPackageInfo, { registry: 'fake', retries: 3, path: tempRoot });
expect(publishResult).toEqual(failedResult);
expect(npmSpy).toHaveBeenCalledTimes(1);

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/packageManager/listPackageVersions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jest.mock('../../packageManager/npm');
describe('list npm versions', () => {
/** Mock the `npm show` command for `npmAsync` calls. This also handles cleanup after each test. */
const npmMock = initNpmMock();
const npmOptions: NpmOptions = { registry: 'https://fake' };
const npmOptions: NpmOptions = { registry: 'https://fake', path: undefined };

afterEach(() => {
_clearPackageVersionsCache();
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/packageManager/npmArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('getNpmAuthArgs', () => {
});

describe('getNpmPublishArgs', () => {
const options: NpmOptions = { registry: 'https://testRegistry' };
const options: Omit<NpmOptions, 'path'> = { registry: 'https://testRegistry' };

const packageInfos = makePackageInfos({
basic: {},
Expand Down
14 changes: 8 additions & 6 deletions src/bump/performBump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { BeachballOptions } from '../types/BeachballOptions';
import { PackageInfos, PackageJson } from '../types/PackageInfo';
import { findProjectRoot } from 'workspace-tools';
import { npm } from '../packageManager/npm';
import { pnpm } from '../packageManager/pnpm';
import { yarn } from '../packageManager/yarn';
import { packageManager } from '../packageManager/packageManager';
import { callHook } from './callHook';

export function writePackageJson(modifiedPackages: Set<string>, packageInfos: PackageInfos): void {
Expand Down Expand Up @@ -50,19 +49,22 @@ export async function updatePackageLock(cwd: string): Promise<void> {
const root = findProjectRoot(cwd);
if (root && fs.existsSync(path.join(root, 'package-lock.json'))) {
console.log('Updating package-lock.json after bumping packages');
const res = await npm(['install', '--package-lock-only', '--ignore-scripts'], { stdio: 'inherit' });
const res = await npm(['install', '--package-lock-only', '--ignore-scripts'], { stdio: 'inherit', cwd: root });
if (!res.success) {
console.warn('Updating package-lock.json failed. Continuing...');
}
} else if (root && fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) {
console.log('Updating pnpm-lock.yaml after bumping packages');
const res = await pnpm(['install', '--lockfile-only', '--ignore-scripts'], { stdio: 'inherit' });
const res = await packageManager('pnpm', ['install', '--lockfile-only', '--ignore-scripts'], {
stdio: 'inherit',
cwd: root,
});
if (!res.success) {
console.warn('Updating pnpm-lock.yaml failed. Continuing...');
}
} else if (root && fs.existsSync(path.join(root, 'yarn.lock'))) {
console.log('Updating yarn.lock after bumping packages');
const version = await yarn(['--version']);
const version = await packageManager('yarn', ['--version'], { cwd: root });
if (!version.success) {
console.warn('Failed to get yarn version. Continuing...');
return;
Expand All @@ -74,7 +76,7 @@ export async function updatePackageLock(cwd: string): Promise<void> {
}

// for yarn v2+
const res = await yarn(['install', '--mode', 'update-lockfile'], { stdio: 'inherit' });
const res = await packageManager('yarn', ['install', '--mode', 'update-lockfile'], { stdio: 'inherit', cwd: root });
if (!res.success) {
console.warn('Updating yarn.lock failed. Continuing...');
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function init(options: BeachballOptions): Promise<void> {
errorExit(`Cannot find package.json at ${packageJsonFilePath}`);
}

const npmResult = await npm(['info', 'beachball', '--json']);
const npmResult = await npm(['info', 'beachball', '--json'], { cwd: undefined });
if (!npmResult.success) {
errorExit('Failed to retrieve beachball version from npm');
}
Expand Down
7 changes: 4 additions & 3 deletions src/packageManager/listPackageVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ async function getNpmPackageInfo(packageName: string, options: NpmOptions): Prom
const { registry, token, authType, timeout } = options;

if (env.beachballDisableCache || !packageVersionsCache[packageName]) {
const args = ['show', '--registry', registry, '--json', packageName, ...getNpmAuthArgs(registry, token, authType)];

const showResult = await npm(args, { timeout });
const showResult = await npm(
['show', '--registry', registry, '--json', packageName, ...getNpmAuthArgs(registry, token, authType)],
{ timeout, cwd: options.path }
);

if (showResult.success && showResult.stdout !== '') {
const packageInfo = JSON.parse(showResult.stdout);
Expand Down
24 changes: 5 additions & 19 deletions src/packageManager/npm.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
import execa from 'execa';
import { PackageManagerResult, packageManager } from './packageManager';

export type NpmResult = Awaited<ReturnType<typeof npm>>;
// The npm wrapper for packageManager is preserved for convenience.

export type NpmResult = PackageManagerResult;

/**
* Run an npm command. Returns the error result instead of throwing on failure.
*/
export async function npm(
args: string[],
options: execa.Options = {}
): Promise<execa.ExecaReturnValue & { success: boolean }> {
try {
const result = await execa('npm', args, { ...options, shell: true });
return {
...result,
success: !result.failed,
};
} catch (e) {
return {
...(e as execa.ExecaError),
success: false,
};
}
}
export const npm = packageManager.bind(null, 'npm');
2 changes: 1 addition & 1 deletion src/packageManager/npmArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AuthType } from '../types/Auth';
import { NpmOptions } from '../types/NpmOptions';
import { PackageInfo } from '../types/PackageInfo';

export function getNpmPublishArgs(packageInfo: PackageInfo, options: NpmOptions): string[] {
export function getNpmPublishArgs(packageInfo: PackageInfo, options: Omit<NpmOptions, 'path'>): string[] {
const { registry, token, authType, access } = options;
const pkgCombinedOptions = packageInfo.combinedOptions;
const args = [
Expand Down
33 changes: 33 additions & 0 deletions src/packageManager/packageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import execa from 'execa';

export type PackageManagerResult = execa.ExecaReturnValue & { success: boolean };

/**
* Run a package manager command. Returns the error result instead of throwing on failure.
* @param manager The package manager to use
* @param args Package manager command and arguments
* @param options cwd must be specified in options to reduce the chance of accidentally running
* commands in the wrong place. If it's definitely irrelevant in this case, use undefined.
*/
export async function packageManager(
manager: 'npm' | 'yarn' | 'pnpm',
args: string[],
options: execa.Options & { cwd: string | undefined }
): Promise<execa.ExecaReturnValue & { success: boolean }> {
try {
const result = await execa(manager, args, {
...options,
// This is required for Windows due to https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
shell: true,
});
return {
...result,
success: !result.failed,
};
} catch (e) {
return {
...(e as execa.ExecaError),
success: false,
};
}
}
Loading

0 comments on commit 349712e

Please sign in to comment.