Skip to content

Commit

Permalink
feat: clean multiple projects (#5726)
Browse files Browse the repository at this point in the history
  • Loading branch information
rigor789 authored Apr 6, 2023
1 parent ab7bb1d commit e39e8db
Show file tree
Hide file tree
Showing 29 changed files with 8,693 additions and 8,223 deletions.
7 changes: 5 additions & 2 deletions lib/base-package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ export abstract class BasePackageManager implements INodePackageManager {
protected getFlagsString(config: any, asArray: boolean): any {
const array: Array<string> = [];
for (const flag in config) {
if (flag === "global" && this.packageManager !== "yarn" && this.packageManager !== "yarn2") {
if (
flag === "global" &&
this.packageManager !== "yarn" &&
this.packageManager !== "yarn2"
) {
array.push(`--${flag}`);
array.push(`${config[flag]}`);
} else if (config[flag]) {
Expand All @@ -145,7 +149,6 @@ export abstract class BasePackageManager implements INodePackageManager {
array.push(`--fields ${flag}`);
} else {
array.push(` ${flag}`);

}
continue;
}
Expand Down
329 changes: 325 additions & 4 deletions lib/commands/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,108 @@ import { ICommand, ICommandParameter } from "../common/definitions/commands";
import { injector } from "../common/yok";
import * as constants from "../constants";
import {
IProjectCleanupResult,
IProjectCleanupService,
IProjectConfigService,
IProjectService,
} from "../definitions/project";

import type { PromptObject } from "prompts";
import { IOptions } from "../declarations";
import {
ITerminalSpinner,
ITerminalSpinnerService,
} from "../definitions/terminal-spinner-service";
import { IChildProcess } from "../common/declarations";
import * as os from "os";

import { resolve } from "path";
import { readdir } from "fs/promises";
import { isInteractive } from "../common/helpers";

const CLIPath = resolve(__dirname, "..", "..", "bin", "nativescript.js");

function bytesToHumanReadable(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let unit = 0;
while (bytes >= 1024) {
bytes /= 1024;
unit++;
}
return `${bytes.toFixed(2)} ${units[unit]}`;
}

/**
* A helper function to map an array of values to promises with a concurrency limit.
* The mapper function should return a promise. It will be called for each value in the values array.
* The concurrency limit is the number of promises that can be running at the same time.
*
* This function will return a promise that resolves when all values have been mapped.
*
* @param values A static array of values to map to promises
* @param mapper A function that maps a value to a promise
* @param concurrency The number of promises that can be running at the same time
* @returns Promise<void>
*/
function promiseMap<T>(
values: T[],
mapper: (value: T) => Promise<void>,
concurrency = 10
) {
let index = 0;
let pending = 0;
let done = false;

return new Promise<void>((resolve, reject) => {
const next = () => {
done = index === values.length;

if (done && pending === 0) {
return resolve();
}

while (pending < concurrency && index < values.length) {
const value = values[index++];
pending++;
mapper(value)
.then(() => {
pending--;
next();
})
.catch();
}
};

next();
});
}

export class CleanCommand implements ICommand {
public allowedParameters: ICommandParameter[] = [];

constructor(
private $projectCleanupService: IProjectCleanupService,
private $projectConfigService: IProjectConfigService,
private $terminalSpinnerService: ITerminalSpinnerService
private $terminalSpinnerService: ITerminalSpinnerService,
private $projectService: IProjectService,
private $prompter: IPrompter,
private $logger: ILogger,
private $options: IOptions,
private $childProcess: IChildProcess
) {}

public async execute(args: string[]): Promise<void> {
const spinner = this.$terminalSpinnerService.createSpinner();
const isDryRun = this.$options.dryRun ?? false;
const isJSON = this.$options.json ?? false;

const spinner = this.$terminalSpinnerService.createSpinner({
isSilent: isJSON,
});

if (!this.$projectService.isValidNativeScriptProject()) {
return this.cleanMultipleProjects(spinner);
}

spinner.start("Cleaning project...\n");

let pathsToClean = [
Expand Down Expand Up @@ -46,14 +133,248 @@ export class CleanCommand implements ICommand {
// ignore
}

const success = await this.$projectCleanupService.clean(pathsToClean);
const res = await this.$projectCleanupService.clean(pathsToClean, {
dryRun: isDryRun,
silent: isJSON,
stats: isJSON,
});

if (res.stats && isJSON) {
console.log(
JSON.stringify(
{
ok: res.ok,
dryRun: isDryRun,
stats: Object.fromEntries(res.stats.entries()),
},
null,
2
)
);

return;
}

if (success) {
if (res.ok) {
spinner.succeed("Project successfully cleaned.");
} else {
spinner.fail(`${"Project unsuccessfully cleaned.".red}`);
}
}

private async cleanMultipleProjects(spinner: ITerminalSpinner) {
if (!isInteractive() || this.$options.json) {
// interactive terminal is required, and we can't output json in an interactive command.
this.$logger.warn("No project found in the current directory.");
return;
}

const shouldScan = await this.$prompter.confirm(
"No project found in the current directory. Would you like to scan for all projects in sub-directories instead?"
);

if (!shouldScan) {
return;
}

spinner.start("Scanning for projects... Please wait.");
const paths = await this.getNSProjectPathsInDirectory();
spinner.succeed(`Found ${paths.length} projects.`);

let computed = 0;
const updateProgress = () => {
const current = `${computed}/${paths.length}`.grey;
spinner.start(
`Gathering cleanable sizes. This may take a while... ${current}`
);
};

// update the progress initially
updateProgress();

const projects = new Map<string, number>();

await promiseMap(
paths,
(p) => {
return this.$childProcess
.exec(`node ${CLIPath} clean --dry-run --json --disable-analytics`, {
cwd: p,
})
.then((res) => {
const paths: Record<string, number> = JSON.parse(res).stats;
return Object.values(paths).reduce((a, b) => a + b, 0);
})
.catch((err) => {
this.$logger.trace(
"Failed to get project size for %s, Error is:",
p,
err
);
return -1;
})
.then((size) => {
if (size > 0 || size === -1) {
// only store size if it's larger than 0 or -1 (error while getting size)
projects.set(p, size);
}
// update the progress after each processed project
computed++;
updateProgress();
});
},
os.cpus().length
);

spinner.clear();
spinner.stop();

this.$logger.clearScreen();

const totalSize = Array.from(projects.values())
.filter((s) => s > 0)
.reduce((a, b) => a + b, 0);

const pathsToClean = await this.$prompter.promptForChoice(
`Found ${projects.size} cleanable project(s) with a total size of: ${
bytesToHumanReadable(totalSize).green
}. Select projects to clean`,
Array.from(projects.keys()).map((p) => {
const size = projects.get(p);
let description;
if (size === -1) {
description = " - could not get size";
} else {
description = ` - ${bytesToHumanReadable(size)}`;
}

return {
title: `${p}${description.grey}`,
value: p,
};
}),
true,
{
optionsPerPage: process.stdout.rows - 6, // 6 lines are taken up by the instructions
} as Partial<PromptObject>
);
this.$logger.clearScreen();

spinner.warn(
`This will run "${`ns clean`.yellow}" in all the selected projects and ${
"delete files from your system".red.bold
}!`
);
spinner.warn(`This action cannot be undone!`);

let confirmed = await this.$prompter.confirm(
"Are you sure you want to clean the selected projects?"
);
if (!confirmed) {
return;
}

spinner.info("Cleaning... This might take a while...");

let totalSizeCleaned = 0;
for (let i = 0; i < pathsToClean.length; i++) {
const currentPath = pathsToClean[i];

spinner.start(
`Cleaning ${currentPath.cyan}... ${i + 1}/${pathsToClean.length}`
);

const ok = await this.$childProcess
.exec(
`node ${CLIPath} clean ${
this.$options.dryRun ? "--dry-run" : ""
} --json --disable-analytics`,
{
cwd: currentPath,
}
)
.then((res) => {
const cleanupRes = JSON.parse(res) as IProjectCleanupResult;
return cleanupRes.ok;
})
.catch((err) => {
this.$logger.trace('Failed to clean project "%s"', currentPath, err);
return false;
});

if (ok) {
const cleanedSize = projects.get(currentPath);
const cleanedSizeStr = `- ${bytesToHumanReadable(cleanedSize)}`.grey;
spinner.succeed(`Cleaned ${currentPath.cyan} ${cleanedSizeStr}`);
totalSizeCleaned += cleanedSize;
} else {
spinner.fail(`Failed to clean ${currentPath.cyan} - skipped`);
}
}
spinner.clear();
spinner.stop();
spinner.succeed(
`Done! We've just freed up ${
bytesToHumanReadable(totalSizeCleaned).green
}! Woohoo! 🎉`
);

if (this.$options.dryRun) {
spinner.info(
'Note: the "--dry-run" flag was used, so no files were actually deleted.'
);
}
}

private async getNSProjectPathsInDirectory(
dir = process.cwd()
): Promise<string[]> {
let nsDirs: string[] = [];

const getFiles = async (dir: string) => {
if (dir.includes("node_modules")) {
// skip traversing node_modules
return;
}

const dirents = await readdir(dir, { withFileTypes: true }).catch(
(err) => {
this.$logger.trace(
'Failed to read directory "%s". Error is:',
dir,
err
);
return [];
}
);

const hasNSConfig = dirents.some(
(ent) =>
ent.name.includes("nativescript.config.ts") ||
ent.name.includes("nativescript.config.js")
);

if (hasNSConfig) {
nsDirs.push(dir);
// found a NativeScript project, stop traversing
return;
}

await Promise.all(
dirents.map((dirent: any) => {
const res = resolve(dir, dirent.name);

if (dirent.isDirectory()) {
return getFiles(res);
}
})
);
};

await getFiles(dir);

return nsDirs;
}
}

injector.registerCommand("clean", CleanCommand);
1 change: 1 addition & 0 deletions lib/commands/plugin/create-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IOptions, INodePackageManager } from "../../declarations";
import { ICommand, ICommandParameter } from "../../common/definitions/commands";
import { IErrors, IFileSystem, IChildProcess } from "../../common/declarations";
import { injector } from "../../common/yok";
import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service";

export class CreatePluginCommand implements ICommand {
public allowedParameters: ICommandParameter[] = [];
Expand Down
Loading

0 comments on commit e39e8db

Please sign in to comment.