Use shell: true and explicit cwd for all package manager commands (#963)

This commit is contained in:
Elizabeth Craig 2024-04-23 18:39:04 -07:00 коммит произвёл GitHub
Родитель 7d50f92160
Коммит 349712ee2f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
16 изменённых файлов: 100 добавлений и 110 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Use shell: true and explicit cwd for all package manager commands",
"packageName": "beachball",
"email": "elcraig@microsoft.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -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();

Просмотреть файл

@ -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, ['foo@1.0.0'], {});
const result = _mockNpmShow(data, ['foo@1.0.0'], { 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/bar@2.0.0'], {});
const result = _mockNpmShow(data, ['@foo/bar@2.0.0'], { 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, ['foo@2.0.0'], {});
const result = _mockNpmShow(data, ['foo@2.0.0'], { cwd: undefined });
expect(result).toEqual(getShowResult({ error: '[fake] code E404 - foo@2.0.0 - 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/);
});
});
@ -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', () => {
@ -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"'),
@ -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'),
@ -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"'),
@ -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 });
});
});

Просмотреть файл

@ -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);

Просмотреть файл

@ -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);
@ -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);
@ -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);
@ -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);
@ -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);
@ -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);
@ -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);

Просмотреть файл

@ -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();

Просмотреть файл

@ -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: {},

Просмотреть файл

@ -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 {
@ -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;
@ -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...');
}

Просмотреть файл

@ -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');
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -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,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 = [

Просмотреть файл

@ -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,
};
}
}

Просмотреть файл

@ -1,24 +0,0 @@
import execa from 'execa';
export type PnpmResult = Awaited<ReturnType<typeof pnpm>>;
/**
* Run an pnpm command. Returns the error result instead of throwing on failure.
*/
export async function pnpm(
args: string[],
options: execa.Options = {}
): Promise<execa.ExecaReturnValue & { success: boolean }> {
try {
const result = await execa('pnpm', args, { ...options });
return {
...result,
success: !result.failed,
};
} catch (e) {
return {
...(e as execa.ExecaError),
success: false,
};
}
}

Просмотреть файл

@ -1,24 +0,0 @@
import execa from 'execa';
export type YarnResult = Awaited<ReturnType<typeof yarn>>;
/**
* Run an yarn command. Returns the error result instead of throwing on failure.
*/
export async function yarn(
args: string[],
options: execa.Options = {}
): Promise<execa.ExecaReturnValue & { success: boolean }> {
try {
const result = await execa('yarn', args, { ...options });
return {
...result,
success: !result.failed,
};
} catch (e) {
return {
...(e as execa.ExecaError),
success: false,
};
}
}

Просмотреть файл

@ -1,4 +1,5 @@
import { BeachballOptions } from './BeachballOptions';
export type NpmOptions = Required<Pick<BeachballOptions, 'registry'>> &
Partial<Pick<BeachballOptions, 'token' | 'authType' | 'access' | 'timeout' | 'verbose'>>;
export type NpmOptions = Required<Pick<BeachballOptions, 'registry'>> & { path: string | undefined } & Partial<
Pick<BeachballOptions, 'token' | 'authType' | 'access' | 'timeout' | 'verbose'>
>;