Skip to content

Commit e39e8db

Browse files
authored
feat: clean multiple projects (#5726)
1 parent ab7bb1d commit e39e8db

29 files changed

+8693
-8223
lines changed

lib/base-package-manager.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ export abstract class BasePackageManager implements INodePackageManager {
130130
protected getFlagsString(config: any, asArray: boolean): any {
131131
const array: Array<string> = [];
132132
for (const flag in config) {
133-
if (flag === "global" && this.packageManager !== "yarn" && this.packageManager !== "yarn2") {
133+
if (
134+
flag === "global" &&
135+
this.packageManager !== "yarn" &&
136+
this.packageManager !== "yarn2"
137+
) {
134138
array.push(`--${flag}`);
135139
array.push(`${config[flag]}`);
136140
} else if (config[flag]) {
@@ -145,7 +149,6 @@ export abstract class BasePackageManager implements INodePackageManager {
145149
array.push(`--fields ${flag}`);
146150
} else {
147151
array.push(` ${flag}`);
148-
149152
}
150153
continue;
151154
}

lib/commands/clean.ts

+325-4
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,108 @@ import { ICommand, ICommandParameter } from "../common/definitions/commands";
22
import { injector } from "../common/yok";
33
import * as constants from "../constants";
44
import {
5+
IProjectCleanupResult,
56
IProjectCleanupService,
67
IProjectConfigService,
8+
IProjectService,
79
} from "../definitions/project";
810

11+
import type { PromptObject } from "prompts";
12+
import { IOptions } from "../declarations";
13+
import {
14+
ITerminalSpinner,
15+
ITerminalSpinnerService,
16+
} from "../definitions/terminal-spinner-service";
17+
import { IChildProcess } from "../common/declarations";
18+
import * as os from "os";
19+
20+
import { resolve } from "path";
21+
import { readdir } from "fs/promises";
22+
import { isInteractive } from "../common/helpers";
23+
24+
const CLIPath = resolve(__dirname, "..", "..", "bin", "nativescript.js");
25+
26+
function bytesToHumanReadable(bytes: number): string {
27+
const units = ["B", "KB", "MB", "GB", "TB"];
28+
let unit = 0;
29+
while (bytes >= 1024) {
30+
bytes /= 1024;
31+
unit++;
32+
}
33+
return `${bytes.toFixed(2)} ${units[unit]}`;
34+
}
35+
36+
/**
37+
* A helper function to map an array of values to promises with a concurrency limit.
38+
* The mapper function should return a promise. It will be called for each value in the values array.
39+
* The concurrency limit is the number of promises that can be running at the same time.
40+
*
41+
* This function will return a promise that resolves when all values have been mapped.
42+
*
43+
* @param values A static array of values to map to promises
44+
* @param mapper A function that maps a value to a promise
45+
* @param concurrency The number of promises that can be running at the same time
46+
* @returns Promise<void>
47+
*/
48+
function promiseMap<T>(
49+
values: T[],
50+
mapper: (value: T) => Promise<void>,
51+
concurrency = 10
52+
) {
53+
let index = 0;
54+
let pending = 0;
55+
let done = false;
56+
57+
return new Promise<void>((resolve, reject) => {
58+
const next = () => {
59+
done = index === values.length;
60+
61+
if (done && pending === 0) {
62+
return resolve();
63+
}
64+
65+
while (pending < concurrency && index < values.length) {
66+
const value = values[index++];
67+
pending++;
68+
mapper(value)
69+
.then(() => {
70+
pending--;
71+
next();
72+
})
73+
.catch();
74+
}
75+
};
76+
77+
next();
78+
});
79+
}
80+
981
export class CleanCommand implements ICommand {
1082
public allowedParameters: ICommandParameter[] = [];
1183

1284
constructor(
1385
private $projectCleanupService: IProjectCleanupService,
1486
private $projectConfigService: IProjectConfigService,
15-
private $terminalSpinnerService: ITerminalSpinnerService
87+
private $terminalSpinnerService: ITerminalSpinnerService,
88+
private $projectService: IProjectService,
89+
private $prompter: IPrompter,
90+
private $logger: ILogger,
91+
private $options: IOptions,
92+
private $childProcess: IChildProcess
1693
) {}
1794

1895
public async execute(args: string[]): Promise<void> {
19-
const spinner = this.$terminalSpinnerService.createSpinner();
96+
const isDryRun = this.$options.dryRun ?? false;
97+
const isJSON = this.$options.json ?? false;
98+
99+
const spinner = this.$terminalSpinnerService.createSpinner({
100+
isSilent: isJSON,
101+
});
102+
103+
if (!this.$projectService.isValidNativeScriptProject()) {
104+
return this.cleanMultipleProjects(spinner);
105+
}
106+
20107
spinner.start("Cleaning project...\n");
21108

22109
let pathsToClean = [
@@ -46,14 +133,248 @@ export class CleanCommand implements ICommand {
46133
// ignore
47134
}
48135

49-
const success = await this.$projectCleanupService.clean(pathsToClean);
136+
const res = await this.$projectCleanupService.clean(pathsToClean, {
137+
dryRun: isDryRun,
138+
silent: isJSON,
139+
stats: isJSON,
140+
});
141+
142+
if (res.stats && isJSON) {
143+
console.log(
144+
JSON.stringify(
145+
{
146+
ok: res.ok,
147+
dryRun: isDryRun,
148+
stats: Object.fromEntries(res.stats.entries()),
149+
},
150+
null,
151+
2
152+
)
153+
);
154+
155+
return;
156+
}
50157

51-
if (success) {
158+
if (res.ok) {
52159
spinner.succeed("Project successfully cleaned.");
53160
} else {
54161
spinner.fail(`${"Project unsuccessfully cleaned.".red}`);
55162
}
56163
}
164+
165+
private async cleanMultipleProjects(spinner: ITerminalSpinner) {
166+
if (!isInteractive() || this.$options.json) {
167+
// interactive terminal is required, and we can't output json in an interactive command.
168+
this.$logger.warn("No project found in the current directory.");
169+
return;
170+
}
171+
172+
const shouldScan = await this.$prompter.confirm(
173+
"No project found in the current directory. Would you like to scan for all projects in sub-directories instead?"
174+
);
175+
176+
if (!shouldScan) {
177+
return;
178+
}
179+
180+
spinner.start("Scanning for projects... Please wait.");
181+
const paths = await this.getNSProjectPathsInDirectory();
182+
spinner.succeed(`Found ${paths.length} projects.`);
183+
184+
let computed = 0;
185+
const updateProgress = () => {
186+
const current = `${computed}/${paths.length}`.grey;
187+
spinner.start(
188+
`Gathering cleanable sizes. This may take a while... ${current}`
189+
);
190+
};
191+
192+
// update the progress initially
193+
updateProgress();
194+
195+
const projects = new Map<string, number>();
196+
197+
await promiseMap(
198+
paths,
199+
(p) => {
200+
return this.$childProcess
201+
.exec(`node ${CLIPath} clean --dry-run --json --disable-analytics`, {
202+
cwd: p,
203+
})
204+
.then((res) => {
205+
const paths: Record<string, number> = JSON.parse(res).stats;
206+
return Object.values(paths).reduce((a, b) => a + b, 0);
207+
})
208+
.catch((err) => {
209+
this.$logger.trace(
210+
"Failed to get project size for %s, Error is:",
211+
p,
212+
err
213+
);
214+
return -1;
215+
})
216+
.then((size) => {
217+
if (size > 0 || size === -1) {
218+
// only store size if it's larger than 0 or -1 (error while getting size)
219+
projects.set(p, size);
220+
}
221+
// update the progress after each processed project
222+
computed++;
223+
updateProgress();
224+
});
225+
},
226+
os.cpus().length
227+
);
228+
229+
spinner.clear();
230+
spinner.stop();
231+
232+
this.$logger.clearScreen();
233+
234+
const totalSize = Array.from(projects.values())
235+
.filter((s) => s > 0)
236+
.reduce((a, b) => a + b, 0);
237+
238+
const pathsToClean = await this.$prompter.promptForChoice(
239+
`Found ${projects.size} cleanable project(s) with a total size of: ${
240+
bytesToHumanReadable(totalSize).green
241+
}. Select projects to clean`,
242+
Array.from(projects.keys()).map((p) => {
243+
const size = projects.get(p);
244+
let description;
245+
if (size === -1) {
246+
description = " - could not get size";
247+
} else {
248+
description = ` - ${bytesToHumanReadable(size)}`;
249+
}
250+
251+
return {
252+
title: `${p}${description.grey}`,
253+
value: p,
254+
};
255+
}),
256+
true,
257+
{
258+
optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions
259+
} as Partial<PromptObject>
260+
);
261+
this.$logger.clearScreen();
262+
263+
spinner.warn(
264+
`This will run "${`ns clean`.yellow}" in all the selected projects and ${
265+
"delete files from your system".red.bold
266+
}!`
267+
);
268+
spinner.warn(`This action cannot be undone!`);
269+
270+
let confirmed = await this.$prompter.confirm(
271+
"Are you sure you want to clean the selected projects?"
272+
);
273+
if (!confirmed) {
274+
return;
275+
}
276+
277+
spinner.info("Cleaning... This might take a while...");
278+
279+
let totalSizeCleaned = 0;
280+
for (let i = 0; i < pathsToClean.length; i++) {
281+
const currentPath = pathsToClean[i];
282+
283+
spinner.start(
284+
`Cleaning ${currentPath.cyan}... ${i + 1}/${pathsToClean.length}`
285+
);
286+
287+
const ok = await this.$childProcess
288+
.exec(
289+
`node ${CLIPath} clean ${
290+
this.$options.dryRun ? "--dry-run" : ""
291+
} --json --disable-analytics`,
292+
{
293+
cwd: currentPath,
294+
}
295+
)
296+
.then((res) => {
297+
const cleanupRes = JSON.parse(res) as IProjectCleanupResult;
298+
return cleanupRes.ok;
299+
})
300+
.catch((err) => {
301+
this.$logger.trace('Failed to clean project "%s"', currentPath, err);
302+
return false;
303+
});
304+
305+
if (ok) {
306+
const cleanedSize = projects.get(currentPath);
307+
const cleanedSizeStr = `- ${bytesToHumanReadable(cleanedSize)}`.grey;
308+
spinner.succeed(`Cleaned ${currentPath.cyan} ${cleanedSizeStr}`);
309+
totalSizeCleaned += cleanedSize;
310+
} else {
311+
spinner.fail(`Failed to clean ${currentPath.cyan} - skipped`);
312+
}
313+
}
314+
spinner.clear();
315+
spinner.stop();
316+
spinner.succeed(
317+
`Done! We've just freed up ${
318+
bytesToHumanReadable(totalSizeCleaned).green
319+
}! Woohoo! 🎉`
320+
);
321+
322+
if (this.$options.dryRun) {
323+
spinner.info(
324+
'Note: the "--dry-run" flag was used, so no files were actually deleted.'
325+
);
326+
}
327+
}
328+
329+
private async getNSProjectPathsInDirectory(
330+
dir = process.cwd()
331+
): Promise<string[]> {
332+
let nsDirs: string[] = [];
333+
334+
const getFiles = async (dir: string) => {
335+
if (dir.includes("node_modules")) {
336+
// skip traversing node_modules
337+
return;
338+
}
339+
340+
const dirents = await readdir(dir, { withFileTypes: true }).catch(
341+
(err) => {
342+
this.$logger.trace(
343+
'Failed to read directory "%s". Error is:',
344+
dir,
345+
err
346+
);
347+
return [];
348+
}
349+
);
350+
351+
const hasNSConfig = dirents.some(
352+
(ent) =>
353+
ent.name.includes("nativescript.config.ts") ||
354+
ent.name.includes("nativescript.config.js")
355+
);
356+
357+
if (hasNSConfig) {
358+
nsDirs.push(dir);
359+
// found a NativeScript project, stop traversing
360+
return;
361+
}
362+
363+
await Promise.all(
364+
dirents.map((dirent: any) => {
365+
const res = resolve(dir, dirent.name);
366+
367+
if (dirent.isDirectory()) {
368+
return getFiles(res);
369+
}
370+
})
371+
);
372+
};
373+
374+
await getFiles(dir);
375+
376+
return nsDirs;
377+
}
57378
}
58379

59380
injector.registerCommand("clean", CleanCommand);

lib/commands/plugin/create-plugin.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IOptions, INodePackageManager } from "../../declarations";
44
import { ICommand, ICommandParameter } from "../../common/definitions/commands";
55
import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations";
66
import { injector } from "../../common/yok";
7+
import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service";
78

89
export class CreatePluginCommand implements ICommand {
910
public allowedParameters: ICommandParameter[] = [];

0 commit comments

Comments
 (0)