Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add an opt-in --preserve-lockfile flag to rush update-autoinstaller for reconciling package.json with an existing PNPM lockfile. The default full-update behavior is unchanged.",
"type": "minor"
}
],
"packageName": "@microsoft/rush"
}
22 changes: 22 additions & 0 deletions libraries/rush-lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ to the **@microsoft/rush-lib** version number that it depends on.

API documentation for this package: https://rushjs.io/pages/advanced/api/

## Reconciling an autoinstaller manifest

`rush update-autoinstaller --name tools` deletes the existing lockfile and performs
a full update, as before. For PNPM, opt into using the existing lockfile instead:

```sh
rush update-autoinstaller --name tools --preserve-lockfile
```

This requires an existing lockfile and runs `pnpm install --no-frozen-lockfile`,
allowing manifest changes to be resolved even in CI. It does not freeze dependency
versions: PNPM can still change resolutions, including transitive dependencies,
when reconciling the dependency graph. Review the resulting lockfile diff.
The option does not change lifecycle-script, hook, or registry configuration.
NPM and Yarn are not supported by this option.

The `Autoinstaller.pnpm.test.ts` Heft test uses a loopback registry and the
repository's installed PNPM. Set `PNPM_TEST_EXECUTABLE` to another PNPM executable
to exercise the same fixture against other versions without changing `rush.json`.
The fixture supplies an isolated child-process environment and project-local
configuration and caches; CI package-manager settings are not inherited.

## Links

- [Experimental environment variables](../../docs/rush/environment-variables.md) - Daemon opt-in,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,14 @@ Object {
"required": true,
"shortName": undefined,
},
Object {
"description": "Reconcile package.json using the existing lockfile instead of deleting it for a full update. Only supported for PNPM and requires an existing lockfile. Runs a non-frozen install; this is not a guarantee that all existing resolutions remain unchanged. Review the lockfile diff.",
"environmentVariable": undefined,
"kind": "Flag",
"longName": "--preserve-lockfile",
"required": false,
"shortName": undefined,
},
],
},
Object {
Expand Down
18 changes: 16 additions & 2 deletions libraries/rush-lib/src/cli/actions/UpdateAutoinstallerAction.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { CommandLineFlagParameter } from '@rushstack/ts-command-line';

import type { RushCommandLineParser } from '../RushCommandLineParser';
import type { Autoinstaller } from '../../logic/Autoinstaller';
import { BaseAutoinstallerAction } from './BaseAutoinstallerAction';

export class UpdateAutoinstallerAction extends BaseAutoinstallerAction {
readonly #preserveLockfile: CommandLineFlagParameter;

public constructor(parser: RushCommandLineParser) {
super({
actionName: 'update-autoinstaller',
summary: 'Updates autoinstaller package dependencies',
documentation: 'Use this command to regenerate the shrinkwrap file for an autoinstaller folder.',
documentation:
'Use this command to regenerate the shrinkwrap file for an autoinstaller folder.' +
' By default, the existing lockfile is deleted to perform a full update.',
parser
});

this.#preserveLockfile = this.defineFlagParameter({
parameterLongName: '--preserve-lockfile',
description:
'Reconcile package.json using the existing lockfile instead of deleting it for a full update.' +
' Only supported for PNPM and requires an existing lockfile. Runs a non-frozen install;' +
' this is not a guarantee that all existing resolutions remain unchanged. Review the lockfile diff.'
});
}

protected async prepareAsync(autoinstaller: Autoinstaller): Promise<void> {
// Do not run `autoinstaller.prepareAsync` here. It tries to install the autoinstaller with
// --frozen-lockfile or equivalent, which will fail if the autoinstaller's dependencies
// have been changed.
await autoinstaller.updateAsync();
await autoinstaller.updateAsync({ preserveLockfile: this.#preserveLockfile.value });
}
}
116 changes: 114 additions & 2 deletions libraries/rush-lib/src/cli/test/Autoinstaller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import './mockRushCommandLineParser';

import { FileSystem } from '@rushstack/node-core-library';
import { FileSystem, JsonFile } from '@rushstack/node-core-library';

import { Autoinstaller } from '../../logic/Autoinstaller';
import { Autoinstaller, type IAutoinstallerUpdateOptions } from '../../logic/Autoinstaller';
import { InstallHelpers } from '../../logic/installManager/InstallHelpers';
import { RushConstants } from '../../logic/RushConstants';
import { Utilities } from '../../utilities/Utilities';
Expand Down Expand Up @@ -102,4 +102,116 @@ describe(Autoinstaller.name, () => {
);
}
});

describe('updateAsync', () => {
async function createAutoinstallerAsync(): Promise<Autoinstaller> {
const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller');
const autoinstaller: Autoinstaller = new Autoinstaller({
autoinstallerName: 'tools',
rushConfiguration: parser.rushConfiguration,
rushGlobalFolder: parser.rushGlobalFolder,
restrictConsoleOutput: true
});
await JsonFile.saveAsync(
{ name: 'tools', version: '1.0.0', dependencies: { 'example-tool': '1.0.0' } },
autoinstaller.packageJsonPath,
{ ensureFolderExists: true }
);
jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined);
jest.spyOn(Utilities, 'syncNpmrc').mockImplementation(() => undefined);
return autoinstaller;
}

it.each([undefined, { preserveLockfile: false }, { preserveLockfile: true }])(
'only retains both PNPM lockfiles when explicitly requested: %j',
async (options: IAutoinstallerUpdateOptions | undefined) => {
const autoinstaller: Autoinstaller = await createAutoinstallerAsync();
const internalLockfilePath: string = `${autoinstaller.folderFullPath}/node_modules/.pnpm/lock.yaml`;
await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'old lockfile\r\n');
await FileSystem.writeFileAsync(internalLockfilePath, 'old internal lockfile\r\n', {
ensureFolderExists: true
});

const executeSpy = jest.spyOn(Utilities, 'executeCommandAsync').mockImplementation(async (command) => {
if (options?.preserveLockfile) {
expect(await FileSystem.readFileAsync(autoinstaller.shrinkwrapFilePath)).toBe('old lockfile\r\n');
expect(await FileSystem.readFileAsync(internalLockfilePath)).toBe('old internal lockfile\r\n');
} else {
expect(await FileSystem.existsAsync(autoinstaller.shrinkwrapFilePath)).toBe(false);
expect(await FileSystem.existsAsync(internalLockfilePath)).toBe(false);
}
expect(command).toEqual(
expect.objectContaining({
args: options?.preserveLockfile ? ['install', '--no-frozen-lockfile'] : ['install'],
workingDirectory: autoinstaller.folderFullPath,
keepEnvironment: true,
useShell: true
})
);
await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'new lockfile\n');
});

await autoinstaller.updateAsync(options);

expect(executeSpy).toHaveBeenCalledTimes(1);
expect(Utilities.syncNpmrc).toHaveBeenCalledWith(
expect.objectContaining({
targetNpmrcFolder: autoinstaller.folderFullPath,
supportEnvVarFallbackSyntax: true
})
);
expect(
await FileSystem.existsAsync(`${autoinstaller.folderFullPath}/node_modules/rush-autoinstaller.flag`)
).toBe(false);
}
);

it('requires an existing lockfile instead of silently performing a full update', async () => {
const autoinstaller: Autoinstaller = await createAutoinstallerAsync();
const executeSpy = jest.spyOn(Utilities, 'executeCommandAsync');

await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow(
'requires an existing lockfile'
);
expect(executeSpy).not.toHaveBeenCalled();
expect(Utilities.syncNpmrc).not.toHaveBeenCalled();
});

it('propagates installation failures without deleting the existing lockfile', async () => {
const autoinstaller: Autoinstaller = await createAutoinstallerAsync();
await FileSystem.writeFileAsync(autoinstaller.shrinkwrapFilePath, 'old lockfile\n');
jest.spyOn(Utilities, 'executeCommandAsync').mockRejectedValue(new Error('install failed'));

await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow('install failed');
expect(await FileSystem.readFileAsync(autoinstaller.shrinkwrapFilePath)).toBe('old lockfile\n');
});

it.each(['npm', 'yarn'])('rejects preservation with %s before installing anything', async (packageManager) => {
const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller');
Object.assign(parser.rushConfiguration, { isPnpm: false, packageManager });
const ensureSpy = jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync');
const autoinstaller: Autoinstaller = new Autoinstaller({
autoinstallerName: 'tools',
rushConfiguration: parser.rushConfiguration,
rushGlobalFolder: parser.rushGlobalFolder
});

await expect(autoinstaller.updateAsync({ preserveLockfile: true })).rejects.toThrow(
'only supported when using PNPM'
);
expect(ensureSpy).not.toHaveBeenCalled();
});
});

it.each([false, true])('passes the CLI preserve-lockfile option through: %s', async (preserveLockfile) => {
const { parser } = await getCommandLineParserInstanceAsync('repo', 'update-autoinstaller');
process.argv.push('--name', 'tools', ...(preserveLockfile ? ['--preserve-lockfile'] : []));
const updateSpy = jest.spyOn(Autoinstaller.prototype, 'updateAsync').mockResolvedValue(undefined);
const prepareSpy = jest.spyOn(Autoinstaller.prototype, 'prepareAsync').mockResolvedValue(undefined);

await expect(parser.executeAsync()).resolves.toBe(true);

expect(updateSpy).toHaveBeenCalledWith({ preserveLockfile });
expect(prepareSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1431,15 +1431,23 @@ Optional arguments:

exports[`CommandLineHelp prints the help for each action: update-autoinstaller 1`] = `
"usage: rush update-autoinstaller [-h] --name AUTOINSTALLER_NAME
[--preserve-lockfile]


Use this command to regenerate the shrinkwrap file for an autoinstaller
folder.
folder. By default, the existing lockfile is deleted to perform a full update.

Optional arguments:
-h, --help Show this help message and exit.
--name AUTOINSTALLER_NAME
The name of the autoinstaller, which must be one of
the folders under common/autoinstallers.
--preserve-lockfile Reconcile package.json using the existing lockfile
instead of deleting it for a full update. Only
supported for PNPM and requires an existing lockfile.
Runs a non-frozen install; this is not a guarantee
that all existing resolutions remain unchanged.
Review the lockfile diff.
"
`;

Expand Down
50 changes: 34 additions & 16 deletions libraries/rush-lib/src/logic/Autoinstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export interface IAutoinstallerOptions {
restrictConsoleOutput?: boolean;
}

export interface IAutoinstallerUpdateOptions {
/**
* Reconcile package.json using the existing PNPM lockfile instead of deleting it for a full update.
* Requires an existing lockfile. PNPM may still change resolutions as needed; review the resulting diff.
* The default is false.
*/
preserveLockfile?: boolean;
}

export class Autoinstaller {
public readonly name: string;

Expand Down Expand Up @@ -181,7 +190,12 @@ export class Autoinstaller {
}
}

public async updateAsync(): Promise<void> {
public async updateAsync(options: IAutoinstallerUpdateOptions = {}): Promise<void> {
const preserveLockfile: boolean = options.preserveLockfile ?? false;
if (preserveLockfile && !this.#rushConfiguration.isPnpm) {
throw new Error('The --preserve-lockfile option is only supported when using PNPM.');
}

await InstallHelpers.ensureLocalPackageManagerAsync(
this.#rushConfiguration,
this.#rushGlobalFolder,
Expand All @@ -203,21 +217,25 @@ export class Autoinstaller {

if (await FileSystem.existsAsync(this.shrinkwrapFilePath)) {
oldFileContents = FileSystem.readFile(this.shrinkwrapFilePath, { convertLineEndings: NewlineKind.Lf });
this.#logIfConsoleOutputIsNotRestricted('Deleting ' + this.shrinkwrapFilePath);
await FileSystem.deleteFileAsync(this.shrinkwrapFilePath);
if (this.#rushConfiguration.isPnpm) {
// Workaround for https://github.com/pnpm/pnpm/issues/1890
//
// When "rush update-autoinstaller" is run, Rush deletes "common/autoinstallers/my-task/pnpm-lock.yaml"
// so that a new lockfile will be generated. However "pnpm install" by design will try to recover
// "pnpm-lock.yaml" from "my-task/node_modules/.pnpm/lock.yaml", which may prevent a full upgrade.
// Deleting both files ensures that a new lockfile will always be generated.
const pnpmPackageManager: PnpmPackageManager = this.#rushConfiguration
.packageManagerWrapper as PnpmPackageManager;
await FileSystem.deleteFileAsync(
path.join(this.folderFullPath, pnpmPackageManager.internalShrinkwrapRelativePath)
);
if (!preserveLockfile) {
this.#logIfConsoleOutputIsNotRestricted('Deleting ' + this.shrinkwrapFilePath);
await FileSystem.deleteFileAsync(this.shrinkwrapFilePath);
if (this.#rushConfiguration.isPnpm) {
// Workaround for https://github.com/pnpm/pnpm/issues/1890
//
// When "rush update-autoinstaller" is run, Rush deletes "common/autoinstallers/my-task/pnpm-lock.yaml"
// so that a new lockfile will be generated. However "pnpm install" by design will try to recover
// "pnpm-lock.yaml" from "my-task/node_modules/.pnpm/lock.yaml", which may prevent a full upgrade.
// Deleting both files ensures that a new lockfile will always be generated.
const pnpmPackageManager: PnpmPackageManager = this.#rushConfiguration
.packageManagerWrapper as PnpmPackageManager;
await FileSystem.deleteFileAsync(
path.join(this.folderFullPath, pnpmPackageManager.internalShrinkwrapRelativePath)
);
}
}
} else if (preserveLockfile) {
throw new Error('The --preserve-lockfile option requires an existing lockfile: ' + this.shrinkwrapFilePath);
}

// Detect a common mistake where PNPM prints "Already up-to-date" without creating a shrinkwrap file
Expand All @@ -243,7 +261,7 @@ export class Autoinstaller {

await Utilities.executeCommandAsync({
command: this.#rushConfiguration.packageManagerToolFilename,
args: ['install'],
args: preserveLockfile ? ['install', '--no-frozen-lockfile'] : ['install'],
workingDirectory: this.folderFullPath,
environment: this.#getPackageManagerEnvironment(this.folderFullPath),
keepEnvironment: true,
Expand Down
Loading
Loading