Skip to content

Commit

Permalink
feat: add "clean" command (react-native-community#1582)
Browse files Browse the repository at this point in the history
* feat: add "clean" command

* prompt when --include is omitted

* uncheck npm and yarn by default

* replace execute with execa

* add some tests

* don't show cocoapods on Windows

* show hints for each group

* don't stop when encountering an unknown group

* dim the group description

* add chalk to dependencies
  • Loading branch information
tido64 authored Apr 7, 2022
1 parent e044e7b commit ada9516
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 17 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@react-native-community/eslint-config": "^2.0.0",
"@types/glob": "^7.1.1",
"@types/jest": "^26.0.15",
"@types/node": "^10.0.0",
"@types/node": "^12.0.0",
"@types/node-fetch": "^2.3.7",
"babel-jest": "^26.6.2",
"babel-plugin-module-resolver": "^3.2.0",
Expand All @@ -55,6 +55,6 @@
"typescript": "^3.8.0"
},
"resolutions": {
"@types/node": "^10.0.0"
"@types/node": "^12.0.0"
}
}
32 changes: 32 additions & 0 deletions packages/cli-clean/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@react-native-community/cli-clean",
"version": "8.0.0-alpha.0",
"license": "MIT",
"main": "build/index.js",
"publishConfig": {
"access": "public"
},
"types": "build/index.d.ts",
"dependencies": {
"@react-native-community/cli-tools": "^8.0.0-alpha.0",
"chalk": "^4.1.2",
"execa": "^1.0.0",
"prompts": "^2.4.0"
},
"files": [
"build",
"!*.d.ts",
"!*.map"
],
"devDependencies": {
"@react-native-community/cli-types": "^8.0.0-alpha.0",
"@types/execa": "^0.9.0",
"@types/prompts": "^2.0.9"
},
"homepage": "https://github.com/react-native-community/cli/tree/master/packages/cli-clean",
"repository": {
"type": "git",
"url": "https://github.com/react-native-community/cli.git",
"directory": "packages/cli-clean"
}
}
47 changes: 47 additions & 0 deletions packages/cli-clean/src/__tests__/clean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import execa from 'execa';
import os from 'os';
import prompts from 'prompts';
import {clean} from '../clean';

jest.mock('execa', () => jest.fn());
jest.mock('prompts', () => jest.fn());

describe('clean', () => {
const mockConfig: any = {};

afterEach(() => {
jest.resetAllMocks();
});

it('throws if project root is not set', () => {
expect(clean([], mockConfig, mockConfig)).rejects.toThrow();
});

it('prompts if `--include` is omitted', async () => {
prompts.mockReturnValue({cache: []});

await clean([], mockConfig, {include: '', projectRoot: process.cwd()});

expect(execa).not.toBeCalled();
expect(prompts).toBeCalled();
});

it('stops Watchman and clears out caches', async () => {
await clean([], mockConfig, {
include: 'watchman',
projectRoot: process.cwd(),
});

expect(prompts).not.toBeCalled();
expect(execa).toBeCalledWith(
os.platform() === 'win32' ? 'tskill' : 'killall',
['watchman'],
expect.anything(),
);
expect(execa).toBeCalledWith(
'watchman',
['watch-del-all'],
expect.anything(),
);
});
});
242 changes: 242 additions & 0 deletions packages/cli-clean/src/clean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {getLoader} from '@react-native-community/cli-tools';
import type {Config as CLIConfig} from '@react-native-community/cli-types';
import chalk from 'chalk';
import execa from 'execa';
import {existsSync as fileExists, rmdir} from 'fs';
import os from 'os';
import path from 'path';
import prompts from 'prompts';
import {promisify} from 'util';

type Args = {
include?: string;
projectRoot: string;
verifyCache?: boolean;
};

type Task = {
label: string;
action: () => Promise<void>;
};

type CleanGroups = {
[key: string]: {
description: string;
tasks: Task[];
};
};

const DEFAULT_GROUPS = ['metro', 'watchman'];

const rmdirAsync = promisify(rmdir);

function cleanDir(directory: string): Promise<void> {
if (!fileExists(directory)) {
return Promise.resolve();
}

return rmdirAsync(directory, {maxRetries: 3, recursive: true});
}

function findPath(startPath: string, files: string[]): string | undefined {
// TODO: Find project files via `@react-native-community/cli`
for (const file of files) {
const filename = path.resolve(startPath, file);
if (fileExists(filename)) {
return filename;
}
}

return undefined;
}

async function promptForCaches(
groups: CleanGroups,
): Promise<string[] | undefined> {
const {caches} = await prompts({
type: 'multiselect',
name: 'caches',
message: 'Select all caches to clean',
choices: Object.entries(groups).map(([cmd, group]) => ({
title: `${cmd} ${chalk.dim(`(${group.description})`)}`,
value: cmd,
selected: DEFAULT_GROUPS.includes(cmd),
})),
min: 1,
});
return caches;
}

export async function clean(
_argv: string[],
_config: CLIConfig,
cleanOptions: Args,
): Promise<void> {
const {include, projectRoot, verifyCache} = cleanOptions;
if (!fileExists(projectRoot)) {
throw new Error(`Invalid path provided! ${projectRoot}`);
}

const COMMANDS: CleanGroups = {
android: {
description: 'Android build caches, e.g. Gradle',
tasks: [
{
label: 'Clean Gradle cache',
action: async () => {
const candidates =
os.platform() === 'win32'
? ['android/gradlew.bat', 'gradlew.bat']
: ['android/gradlew', 'gradlew'];
const gradlew = findPath(projectRoot, candidates);
if (gradlew) {
const script = path.basename(gradlew);
await execa(
os.platform() === 'win32' ? script : `./${script}`,
['clean'],
{cwd: path.dirname(gradlew)},
);
}
},
},
],
},
...(os.platform() === 'darwin'
? {
cocoapods: {
description: 'CocoaPods cache',
tasks: [
{
label: 'Clean CocoaPods cache',
action: async () => {
await execa('pod', ['cache', 'clean', '--all'], {
cwd: projectRoot,
});
},
},
],
},
}
: undefined),
metro: {
description: 'Metro, haste-map caches',
tasks: [
{
label: 'Clean Metro cache',
action: () => cleanDir(`${os.tmpdir()}/metro-*`),
},
{
label: 'Clean Haste cache',
action: () => cleanDir(`${os.tmpdir()}/haste-map-*`),
},
{
label: 'Clean React Native cache',
action: () => cleanDir(`${os.tmpdir()}/react-*`),
},
],
},
npm: {
description:
'`node_modules` folder in the current package, and optionally verify npm cache',
tasks: [
{
label: 'Remove node_modules',
action: () => cleanDir(`${projectRoot}/node_modules`),
},
...(verifyCache
? [
{
label: 'Verify npm cache',
action: async () => {
await execa('npm', ['cache', 'verify'], {cwd: projectRoot});
},
},
]
: []),
],
},
watchman: {
description: 'Stop Watchman and delete its cache',
tasks: [
{
label: 'Stop Watchman',
action: async () => {
await execa(
os.platform() === 'win32' ? 'tskill' : 'killall',
['watchman'],
{cwd: projectRoot},
);
},
},
{
label: 'Delete Watchman cache',
action: async () => {
await execa('watchman', ['watch-del-all'], {cwd: projectRoot});
},
},
],
},
yarn: {
description: 'Yarn cache',
tasks: [
{
label: 'Clean Yarn cache',
action: async () => {
await execa('yarn', ['cache', 'clean'], {cwd: projectRoot});
},
},
],
},
};

const groups = include ? include.split(',') : await promptForCaches(COMMANDS);
if (!groups || groups.length === 0) {
return;
}

const spinner = getLoader();
for (const group of groups) {
const commands = COMMANDS[group];
if (!commands) {
spinner.warn(`Unknown group: ${group}`);
continue;
}

for (const {action, label} of commands.tasks) {
spinner.start(label);
await action()
.then(() => {
spinner.succeed();
})
.catch((e) => {
spinner.fail(`${label} » ${e}`);
});
}
}
}

export default {
func: clean,
name: 'clean',
description:
'Cleans your project by removing React Native related caches and modules.',
options: [
{
name: '--include <string>',
description:
'Comma-separated flag of caches to clear e.g. `npm,yarn`. If omitted, an interactive prompt will appear.',
},
{
name: '--project-root <string>',
description:
'Root path to your React Native project. When not specified, defaults to current working directory.',
default: process.cwd(),
},
{
name: '--verify-cache',
description:
'Whether to verify the cache. Currently only applies to npm cache.',
default: false,
},
],
};
3 changes: 3 additions & 0 deletions packages/cli-clean/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {default as clean} from './clean';

export const commands = {clean};
12 changes: 12 additions & 0 deletions packages/cli-clean/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "build"
},
"references": [
{"path": "../tools"},
{"path": "../cli-types"},
{"path": "../cli-config"}
]
}
1 change: 0 additions & 1 deletion packages/cli-doctor/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ const doctorCommand = (async (_, options) => {
removeKeyPressListener();

process.exit(0);
return;
}

if (
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-doctor/src/tools/windows/androidWinHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ export const installComponent = (component: string, androidSdkRoot: string) => {
const child = executeCommand(command);
let stderr = '';

child.stdout.on('data', (data) => {
child.stdout?.on('data', (data) => {
if (data.includes('(y/N)')) {
child.stdin.write('y\n');
child.stdin?.write('y\n');
}
});

child.stderr.on('data', (data) => {
child.stderr?.on('data', (data) => {
stderr += data.toString('utf-8');
});

Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"testEnvironment": "node"
},
"dependencies": {
"@react-native-community/cli-clean": "^8.0.0-alpha.0",
"@react-native-community/cli-config": "^8.0.0-alpha.0",
"@react-native-community/cli-debugger-ui": "^8.0.0-alpha.0",
"@react-native-community/cli-doctor": "^8.0.0-alpha.0",
Expand Down
Loading

0 comments on commit ada9516

Please sign in to comment.