Skip to content

Commit a0cf941

Browse files
committed
Use AsyncRecycler for autoinstaller cleanup
1 parent 32536c2 commit a0cf941

File tree

3 files changed

+155
-23
lines changed

3 files changed

+155
-23
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Move stale autoinstaller node_modules folders into Rush's recycler before deleting them, instead of clearing them in place.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'node:path';
5+
6+
import './mockRushCommandLineParser';
7+
8+
import { FileSystem } from '@rushstack/node-core-library';
9+
10+
import { Autoinstaller } from '../../logic/Autoinstaller';
11+
import { InstallHelpers } from '../../logic/installManager/InstallHelpers';
12+
import { RushConstants } from '../../logic/RushConstants';
13+
import { Utilities } from '../../utilities/Utilities';
14+
import {
15+
getCommandLineParserInstanceAsync,
16+
isolateEnvironmentConfigurationForTests,
17+
type IEnvironmentConfigIsolation
18+
} from './TestUtils';
19+
20+
describe('Autoinstaller', () => {
21+
let _envIsolation: IEnvironmentConfigIsolation;
22+
23+
beforeEach(() => {
24+
_envIsolation = isolateEnvironmentConfigurationForTests();
25+
});
26+
27+
afterEach(() => {
28+
_envIsolation.restore();
29+
jest.restoreAllMocks();
30+
});
31+
32+
it('moves an existing node_modules folder into the Rush recycler before reinstalling', async () => {
33+
const { parser, repoPath, spawnMock } = await getCommandLineParserInstanceAsync(
34+
'pluginWithBuildCommandRepo',
35+
'update'
36+
);
37+
const autoinstallerPath: string = path.join(repoPath, 'common/autoinstallers/plugins');
38+
const nodeModulesFolder: string = path.join(autoinstallerPath, RushConstants.nodeModulesFolderName);
39+
const staleFilePath: string = path.join(nodeModulesFolder, 'stale-package/index.js');
40+
const recyclerFolder: string = path.join(
41+
parser.rushConfiguration.commonTempFolder,
42+
RushConstants.rushRecyclerFolderName
43+
);
44+
45+
FileSystem.writeFile(staleFilePath, 'stale', {
46+
ensureFolderExists: true
47+
});
48+
49+
const recyclerEntriesBefore: Set<string> = FileSystem.exists(recyclerFolder)
50+
? new Set(FileSystem.readFolderItemNames(recyclerFolder))
51+
: new Set();
52+
53+
jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined);
54+
jest.spyOn(Utilities, 'syncNpmrc').mockImplementation(() => undefined);
55+
jest
56+
.spyOn(Utilities, 'executeCommandAsync')
57+
.mockImplementation(async (options: Parameters<typeof Utilities.executeCommandAsync>[0]) => {
58+
FileSystem.ensureFolder(path.join(options.workingDirectory, RushConstants.nodeModulesFolderName));
59+
});
60+
61+
const autoinstaller: Autoinstaller = new Autoinstaller({
62+
autoinstallerName: 'plugins',
63+
rushConfiguration: parser.rushConfiguration,
64+
rushGlobalFolder: parser.rushGlobalFolder
65+
});
66+
67+
await autoinstaller.prepareAsync();
68+
69+
const recyclerEntriesAfter: string[] = FileSystem.readFolderItemNames(recyclerFolder).filter(
70+
(entry: string) => !recyclerEntriesBefore.has(entry)
71+
);
72+
73+
expect(recyclerEntriesAfter).toHaveLength(1);
74+
expect(
75+
FileSystem.exists(path.join(recyclerFolder, recyclerEntriesAfter[0], 'stale-package/index.js'))
76+
).toBe(true);
77+
expect(FileSystem.exists(staleFilePath)).toBe(false);
78+
expect(FileSystem.exists(path.join(nodeModulesFolder, 'rush-autoinstaller.flag'))).toBe(true);
79+
80+
if (process.platform === 'win32') {
81+
expect(spawnMock).toHaveBeenCalledWith(
82+
'cmd.exe',
83+
expect.arrayContaining(['/c']),
84+
expect.objectContaining({
85+
detached: true,
86+
stdio: 'ignore',
87+
windowsVerbatimArguments: true
88+
})
89+
);
90+
} else {
91+
expect(spawnMock).toHaveBeenCalledWith(
92+
'rm',
93+
expect.arrayContaining(['-rf']),
94+
expect.objectContaining({
95+
detached: true,
96+
stdio: 'ignore'
97+
})
98+
);
99+
}
100+
});
101+
});

libraries/rush-lib/src/logic/Autoinstaller.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import colors from 'colors/safe';
5-
import * as path from 'path';
6-
7-
import { FileSystem, type IPackageJson, JsonFile, LockFile, NewlineKind } from '@rushstack/node-core-library';
4+
import * as path from 'node:path';
5+
6+
import {
7+
FileSystem,
8+
type IPackageJson,
9+
JsonFile,
10+
LockFile,
11+
NewlineKind,
12+
PackageName,
13+
type IParsedPackageNameOrError
14+
} from '@rushstack/node-core-library';
15+
import { Colorize } from '@rushstack/terminal';
16+
17+
import { AsyncRecycler } from '../utilities/AsyncRecycler';
818
import { Utilities } from '../utilities/Utilities';
9-
10-
import { PackageName, type IParsedPackageNameOrError } from '@rushstack/node-core-library';
1119
import type { RushConfiguration } from '../api/RushConfiguration';
1220
import { PackageJsonEditor } from '../api/PackageJsonEditor';
1321
import { InstallHelpers } from './installManager/InstallHelpers';
@@ -17,7 +25,7 @@ import { LastInstallFlag } from '../api/LastInstallFlag';
1725
import { RushCommandLineParser } from '../cli/RushCommandLineParser';
1826
import type { PnpmPackageManager } from '../api/packageManager/PnpmPackageManager';
1927

20-
interface IAutoinstallerOptions {
28+
export interface IAutoinstallerOptions {
2129
autoinstallerName: string;
2230
rushConfiguration: RushConfiguration;
2331
rushGlobalFolder: RushGlobalFolder;
@@ -79,7 +87,7 @@ export class Autoinstaller {
7987
);
8088
}
8189

82-
await InstallHelpers.ensureLocalPackageManager(
90+
await InstallHelpers.ensureLocalPackageManagerAsync(
8391
this._rushConfiguration,
8492
this._rushGlobalFolder,
8593
RushConstants.defaultMaxInstallAttempts,
@@ -94,7 +102,7 @@ export class Autoinstaller {
94102

95103
this._logIfConsoleOutputIsNotRestricted(`Acquiring lock for "${relativePathForLogs}" folder...`);
96104

97-
const lock: LockFile = await LockFile.acquire(autoinstallerFullPath, 'autoinstaller');
105+
const lock: LockFile = await LockFile.acquireAsync(autoinstallerFullPath, 'autoinstaller');
98106

99107
try {
100108
// Example: .../common/autoinstallers/my-task/.rush/temp
@@ -118,30 +126,39 @@ export class Autoinstaller {
118126
// Example: ../common/autoinstallers/my-task/node_modules
119127
const nodeModulesFolder: string = `${autoinstallerFullPath}/${RushConstants.nodeModulesFolderName}`;
120128
const flagPath: string = `${nodeModulesFolder}/rush-autoinstaller.flag`;
121-
const isLastInstallFlagDirty: boolean = !lastInstallFlag.isValid() || !FileSystem.exists(flagPath);
129+
const isLastInstallFlagDirty: boolean =
130+
!(await lastInstallFlag.isValidAsync()) || !FileSystem.exists(flagPath);
122131

123132
if (isLastInstallFlagDirty || lock.dirtyWhenAcquired) {
124133
if (FileSystem.exists(nodeModulesFolder)) {
125134
this._logIfConsoleOutputIsNotRestricted('Deleting old files from ' + nodeModulesFolder);
126-
FileSystem.ensureEmptyFolder(nodeModulesFolder);
135+
const recycler = new AsyncRecycler(
136+
path.join(this._rushConfiguration.commonTempFolder, RushConstants.rushRecyclerFolderName)
137+
);
138+
recycler.moveFolder(nodeModulesFolder);
139+
await recycler.startDeleteAllAsync();
127140
}
128141

129142
// Copy: .../common/autoinstallers/my-task/.npmrc
130-
Utilities.syncNpmrc(this._rushConfiguration.commonRushConfigFolder, autoinstallerFullPath);
143+
Utilities.syncNpmrc({
144+
sourceNpmrcFolder: this._rushConfiguration.commonRushConfigFolder,
145+
targetNpmrcFolder: autoinstallerFullPath,
146+
supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm
147+
});
131148

132149
this._logIfConsoleOutputIsNotRestricted(
133150
`Installing dependencies under ${autoinstallerFullPath}...\n`
134151
);
135152

136-
Utilities.executeCommand({
153+
await Utilities.executeCommandAsync({
137154
command: this._rushConfiguration.packageManagerToolFilename,
138155
args: ['install', '--frozen-lockfile'],
139156
workingDirectory: autoinstallerFullPath,
140157
keepEnvironment: true
141158
});
142159

143160
// Create file: ../common/autoinstallers/my-task/.rush/temp/last-install.flag
144-
lastInstallFlag.create();
161+
await lastInstallFlag.createAsync();
145162

146163
FileSystem.writeFile(
147164
flagPath,
@@ -159,7 +176,7 @@ export class Autoinstaller {
159176
}
160177

161178
public async updateAsync(): Promise<void> {
162-
await InstallHelpers.ensureLocalPackageManager(
179+
await InstallHelpers.ensureLocalPackageManagerAsync(
163180
this._rushConfiguration,
164181
this._rushGlobalFolder,
165182
RushConstants.defaultMaxInstallAttempts,
@@ -182,7 +199,7 @@ export class Autoinstaller {
182199
oldFileContents = FileSystem.readFile(this.shrinkwrapFilePath, { convertLineEndings: NewlineKind.Lf });
183200
this._logIfConsoleOutputIsNotRestricted('Deleting ' + this.shrinkwrapFilePath);
184201
await FileSystem.deleteFileAsync(this.shrinkwrapFilePath);
185-
if (this._rushConfiguration.packageManager === 'pnpm') {
202+
if (this._rushConfiguration.isPnpm) {
186203
// Workaround for https://github.com/pnpm/pnpm/issues/1890
187204
//
188205
// When "rush update-autoinstaller" is run, Rush deletes "common/autoinstallers/my-task/pnpm-lock.yaml"
@@ -198,7 +215,7 @@ export class Autoinstaller {
198215
}
199216

200217
// Detect a common mistake where PNPM prints "Already up-to-date" without creating a shrinkwrap file
201-
const packageJsonEditor: PackageJsonEditor = PackageJsonEditor.load(this.packageJsonPath);
218+
const packageJsonEditor: PackageJsonEditor = await PackageJsonEditor.loadAsync(this.packageJsonPath);
202219
if (packageJsonEditor.dependencyList.length === 0) {
203220
throw new Error(
204221
'You must add at least one dependency to the autoinstaller package' +
@@ -209,9 +226,13 @@ export class Autoinstaller {
209226

210227
this._logIfConsoleOutputIsNotRestricted();
211228

212-
Utilities.syncNpmrc(this._rushConfiguration.commonRushConfigFolder, this.folderFullPath);
229+
Utilities.syncNpmrc({
230+
sourceNpmrcFolder: this._rushConfiguration.commonRushConfigFolder,
231+
targetNpmrcFolder: this.folderFullPath,
232+
supportEnvVarFallbackSyntax: this._rushConfiguration.isPnpm
233+
});
213234

214-
Utilities.executeCommand({
235+
await Utilities.executeCommandAsync({
215236
command: this._rushConfiguration.packageManagerToolFilename,
216237
args: ['install'],
217238
workingDirectory: this.folderFullPath,
@@ -221,8 +242,8 @@ export class Autoinstaller {
221242
this._logIfConsoleOutputIsNotRestricted();
222243

223244
if (this._rushConfiguration.packageManager === 'npm') {
224-
this._logIfConsoleOutputIsNotRestricted(colors.bold('Running "npm shrinkwrap"...'));
225-
Utilities.executeCommand({
245+
this._logIfConsoleOutputIsNotRestricted(Colorize.bold('Running "npm shrinkwrap"...'));
246+
await Utilities.executeCommandAsync({
226247
command: this._rushConfiguration.packageManagerToolFilename,
227248
args: ['shrinkwrap'],
228249
workingDirectory: this.folderFullPath,
@@ -243,11 +264,11 @@ export class Autoinstaller {
243264
});
244265
if (oldFileContents !== newFileContents) {
245266
this._logIfConsoleOutputIsNotRestricted(
246-
colors.green('The shrinkwrap file has been updated.') + ' Please commit the updated file:'
267+
Colorize.green('The shrinkwrap file has been updated.') + ' Please commit the updated file:'
247268
);
248269
this._logIfConsoleOutputIsNotRestricted(`\n ${this.shrinkwrapFilePath}`);
249270
} else {
250-
this._logIfConsoleOutputIsNotRestricted(colors.green('Already up to date.'));
271+
this._logIfConsoleOutputIsNotRestricted(Colorize.green('Already up to date.'));
251272
}
252273
}
253274

0 commit comments

Comments
 (0)