Initial commit

This commit is contained in:
Aditya Gupta
2026-06-29 19:29:19 +05:30
committed by GitHub
commit aabb8986aa
292 changed files with 17976 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# Module manager
Module manager is a tool to manage modules in a project. It can be used to delete or recover some pages.
## Usage
### On Root
```bash
$ pnpm module-manager
```
OR
```bash
pnpm module-manager -d popup
```
### On Module
```bash
$ pnpm start
```
OR
```bash
pnpm start -d popup
```
> [!NOTE]
> For complete info about CLI support run:
> `pnpm module-manager --help`
> [!IMPORTANT]
> If you want to remove all tests, and something else with one command, you need to set `tests` as first argument like:
> `pnpm module-manager -d tests popup ...`
### Choose a tool
```
? Choose a tool (Use arrow keys)
Delete Feature
Recover Feature
```
## How it works
### Delete Feature
When you select an unused module, Module Manager compresses the contents of that folder, takes a snapshot of it, and removes it.
It also automatically removes anything that needs to be cleared from the manifest.
### Recover Feature
When you select a module that has been deleted, The Module Manager will recover that module from the snapshot and add it back to the manifest.
> [!IMPORTANT]
> SAVE ALL FILES FROM /ARCHIVE, WITHOUT THESE FILES' RECOVERING PROCESS WON'T WORK
> [!IMPORTANT]
> IF YOU DECIDE TO REMOVE ONE OF CONTENT SCRIPTs AFTER EDIT, E.G YOU REMOVE OR ADD ANY MATCHES,
> REMEMBER TO EDIT ALSO [MODULE_CONFIG](./lib/const.ts) TO BE ABLE TO RECOVER IT EASILY IN THE FUTURE.

View File

@@ -0,0 +1,3 @@
import { startManager } from './lib/index.js';
startManager();

View File

@@ -0,0 +1,51 @@
import { CLI_OPTIONS, DEFAULT_CHOICES_VALUES, HELP_EXAMPLES } from '../const.js';
import { checkCliArgsIsValid } from '../helpers/utils.js';
import { excludeValuesFromBaseArray } from '@extension/shared';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import type { ICLIOptions, ModuleNameType } from '../types.js';
import type { WritableDeep } from '@extension/shared';
export const processCLIArgs = (): ICLIOptions | null => {
const argv = yargs(hideBin(process.argv))
.option('delete', CLI_OPTIONS[0])
.option('recover', CLI_OPTIONS[1])
.option('delete-exclude', CLI_OPTIONS[2])
.option('recover-exclude', CLI_OPTIONS[3])
.check(argv => checkCliArgsIsValid(argv))
.strict()
.example(HELP_EXAMPLES as WritableDeep<typeof HELP_EXAMPLES>)
.help()
.showHelpOnFail(true)
.parseSync();
if (argv.delete) {
return {
action: 'delete',
targets: argv.delete as ModuleNameType[],
};
}
if (argv.recover) {
return {
action: 'recover',
targets: argv.recover as ModuleNameType[],
};
}
if (argv.deleteExclude) {
return {
action: 'delete',
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.deleteExclude),
};
}
if (argv.recoverExclude) {
return {
action: 'recover',
targets: excludeValuesFromBaseArray(DEFAULT_CHOICES_VALUES, argv.recoverExclude),
};
}
return null;
};

View File

@@ -0,0 +1,2 @@
export * from './cli-args-processor.js';
export * from './run-module-manager.js';

View File

@@ -0,0 +1,49 @@
import manifest from '../../../../chrome-extension/manifest.js';
import { MANAGER_ACTION_PROMPT_CONFIG } from '../const.js';
import { promptSelection } from '../helpers/utils.js';
import { deleteFeature, recoverFeature } from '../processing/index.js';
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { CliActionType, ModuleNameType } from '../types.js';
import type { ManifestType } from '@extension/shared';
const manifestPath = resolve(import.meta.dirname, '..', '..', '..', '..', 'chrome-extension', 'manifest.ts');
const manifestObject = JSON.parse(JSON.stringify(manifest)) as ManifestType;
const manifestString = readFileSync(manifestPath, 'utf-8');
export const runModuleManager = async (moduleName?: ModuleNameType, action?: CliActionType, isLastLap = true) => {
if (!action) {
action = (await promptSelection(MANAGER_ACTION_PROMPT_CONFIG)) as Awaited<CliActionType>;
}
switch (action) {
case 'delete':
await deleteFeature(manifestObject, moduleName);
break;
case 'recover':
await recoverFeature(manifestObject, moduleName);
}
const updatedManifest = manifestString
.replace(
/const manifest = {[\s\S]*?} satisfies/,
'const manifest = ' + JSON.stringify(manifestObject, null, 2) + ' satisfies',
)
.replace(/ {2}"version": "[\s\S]*?",/, ' version: packageJson.version,');
writeFileSync(manifestPath, updatedManifest);
if (isLastLap) {
execSync('pnpm i', {
stdio: 'inherit',
cwd: resolve('..', '..'),
});
execSync('pnpm -F chrome-extension lint:fix', {
stdio: 'inherit',
cwd: resolve('..', '..'),
});
}
};

View File

@@ -0,0 +1,98 @@
const CHOICE_QUESTION = 'Choose feature to';
export const RECOVER_CHOICE_QUESTION = `${CHOICE_QUESTION} recover`;
export const DELETE_CHOICE_QUESTION = `${CHOICE_QUESTION} delete`;
export const DEFAULT_CHOICES = [
{ name: 'Content Script (Execute JS on Web Page)', value: 'content' },
{ name: 'Content Script UI (Render Custom React Component on Web Page)', value: 'content-ui' },
{ name: 'Content Script Runtime (Inject JS on Specific Actions like Popup Click)', value: 'content-runtime' },
{ name: 'Background Script', value: 'background' },
{ name: 'New Tab Override', value: 'new-tab' },
{ name: 'Popup (On Extension Icon Click)', value: 'popup' },
{ name: 'DevTools (Include DevTools Panel)', value: 'devtools' },
{ name: 'Side Panel', value: 'side-panel' },
{ name: 'Options Page', value: 'options' },
{ name: 'All tests', value: 'tests' },
] as const;
export const DEFAULT_CHOICES_VALUES = DEFAULT_CHOICES.map(item => item.value);
export const HELP_EXAMPLES = [
['-d content-ui content-runtime', 'Delete content-ui and content-runtime'],
['--de content devtools', 'Delete everything exclude content and devtools'],
['-r options side-panel', 'Recover options and side-panel'],
['--re popup new-tab', 'Recover everything exclude popup and new-tab'],
] as const;
export const CLI_OPTIONS = [
{ alias: 'd', type: 'array', description: 'Delete specified features' },
{ alias: 'r', type: 'array', description: 'Recover specified features' },
{ alias: 'de', type: 'array', description: 'Delete all features except specified' },
{ alias: 're', type: 'array', description: 'Recover all features except specified' },
] as const;
export const MANAGER_ACTION_PROMPT_CONFIG = {
message: 'Choose a tool',
choices: [
{ name: 'Delete Feature', value: 'delete' },
{ name: 'Recover Feature', value: 'recover' },
],
} as const;
export const MODULE_CONFIG = {
content: {
content_scripts: [
{
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
js: ['content/all.iife.js'],
},
{
matches: ['https://example.com/*'],
js: ['content/example.iife.js'],
},
],
},
'content-ui': {
content_scripts: [
{
matches: ['http://*/*', 'https://*/*', '<all_urls>'],
js: ['content-ui/all.iife.js'],
},
{
matches: ['https://example.com/*'],
js: ['content-ui/example.iife.js'],
},
],
},
background: {
background: {
service_worker: 'background.js',
type: 'module',
},
},
'new-tab': {
chrome_url_overrides: {
newtab: 'new-tab/index.html',
},
},
popup: {
action: {
default_popup: 'popup/index.html',
default_icon: 'icon-34.png',
},
},
devtools: {
devtools_page: 'devtools/index.html',
},
'side-panel': {
side_panel: {
default_path: 'side-panel/index.html',
},
permissions: ['sidePanel'],
},
options: {
options_page: 'options/index.html',
},
} as const;
export const EXIT_PROMPT_ERROR = 'ExitPromptError';

View File

@@ -0,0 +1,2 @@
export * from './utils.js';
export * from './zip-utils.js';

View File

@@ -0,0 +1,113 @@
import { DEFAULT_CHOICES_VALUES, EXIT_PROMPT_ERROR, MODULE_CONFIG } from '../const.js';
import { colorfulLog } from '@extension/shared';
import { select } from '@inquirer/prompts';
import { readdirSync } from 'node:fs';
import type { DELETE_CHOICE_QUESTION, RECOVER_CHOICE_QUESTION } from '../const.js';
import type {
ChoicesType,
CliEntriesType,
InputConfigType,
ModuleNameType,
WritableModuleConfigValuesType,
} from '../types.js';
import type { ConditionalPickDeep, Entries, ManifestType } from '@extension/shared';
import type { Arguments } from 'yargs';
export const isFolderEmpty = (path: string) => !readdirSync(path).length;
export const promptSelection = async (inputConfig: InputConfigType) =>
select(inputConfig).catch(err => {
if (err.name === EXIT_PROMPT_ERROR) {
process.exit(0);
} else {
colorfulLog(err.message, 'error');
}
}) as Promise<string>;
export const processModuleConfig = (
manifestObject: ManifestType,
moduleName: ModuleNameType,
isRecovering?: boolean,
) => {
if (moduleName === 'content-runtime' || moduleName === 'devtools-panel' || moduleName === 'tests') {
return;
}
const moduleConfigValues = MODULE_CONFIG[moduleName];
const moduleConfigEntriesOfKeys = Object.entries(moduleConfigValues) as Entries<typeof moduleConfigValues>;
if (moduleName === 'content' || moduleName === 'content-ui') {
if (isRecovering) {
(moduleConfigValues as WritableModuleConfigValuesType<typeof moduleName>).content_scripts.map(script =>
manifestObject.content_scripts?.push(script),
);
} else {
const outputFileName = new RegExp(`${moduleName}/+`);
manifestObject.content_scripts = manifestObject.content_scripts?.filter(
script => !outputFileName.test(script.js ? script.js[0] : ''),
);
}
return;
}
moduleConfigEntriesOfKeys.forEach(([key, value]) => {
const manifestValue = manifestObject[key];
if (manifestValue) {
if (manifestValue instanceof Array) {
const arrayValues = Object.values(
moduleConfigValues[key as ConditionalPickDeep<keyof typeof moduleConfigValues, typeof moduleName>],
);
if (isRecovering) {
manifestObject[key] = manifestValue.concat(arrayValues);
} else {
manifestObject[key] = manifestValue.filter((value: string) => !arrayValues.includes(value));
}
} else {
delete manifestObject[key];
}
} else if (isRecovering) {
Object.assign(manifestObject, { [key]: value });
} else {
throw new Error(`Key ${key} not found in manifest.ts`);
}
});
};
export const checkCliArgsIsValid = <T extends Arguments>(argv: T) => {
const [key, values] = Object.entries(argv)[1] as CliEntriesType;
if (Array.isArray(values)) {
for (const value of values) {
if (!DEFAULT_CHOICES_VALUES.some(moduleName => value === moduleName)) {
throw new Error(`All values after --${key} must be names of pages`);
}
}
}
return true;
};
export const processSelection = async (
choices: ChoicesType,
question: typeof RECOVER_CHOICE_QUESTION | typeof DELETE_CHOICE_QUESTION,
moduleName?: ModuleNameType,
) => {
if (!choices.length) {
colorfulLog('No options available', 'warning');
process.exit(0);
}
if (!moduleName) {
const inputConfig = {
message: question,
choices,
} as const;
return (await promptSelection(inputConfig)) as Awaited<ModuleNameType>;
}
return null;
};

View File

@@ -0,0 +1,97 @@
import { streamFileToZip } from '@extension/dev-utils';
import fg from 'fast-glob';
import { unzipSync, Zip } from 'fflate';
import { rimraf } from 'rimraf';
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import type { ModuleNameType } from '../types.js';
const zipAndDeleteModuleTest = async (moduleName: ModuleNameType, archivePath: string, testsPath: string) => {
const moduleTestName = `page-${moduleName}.test.ts`;
const moduleTestsPath = resolve(testsPath, moduleTestName);
await zipFolder(testsPath, resolve(archivePath, `${moduleName}.test.zip`), [moduleTestName]);
void rimraf([moduleTestsPath]);
};
export const zipAndDeleteModule = async (
moduleName: ModuleNameType,
pagesPath: string,
archivePath: string,
testsPath?: string,
) => {
const modulePath = resolve(pagesPath, moduleName);
await zipFolder(resolve(pagesPath, moduleName), resolve(archivePath, `${moduleName}.zip`));
void rimraf([modulePath]);
if (testsPath && existsSync(testsPath)) {
await zipAndDeleteModuleTest(moduleName, archivePath, testsPath);
}
};
export const zipAndDeleteTests = async (testsPath: string, archivePath: string) => {
await zipFolder(testsPath, resolve(archivePath, `tests.zip`));
void rimraf(testsPath);
};
export const unZipAndDeleteModule = (zipFilePath: string, destPath: string) => {
const unzipped = unzipSync(readFileSync(zipFilePath));
mkdirSync(destPath, { recursive: true });
for (const [filePath, fileData] of Object.entries(unzipped)) {
const dir = dirname(filePath);
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, fileData);
}
unlinkSync(zipFilePath);
};
export const zipFolder = async (folderPath: string, out: string, filesToInclude?: string[]) => {
const fileList = await fg(
['!node_modules', '!dist', '!**/*/node_modules', '!**/*/dist'].concat(
filesToInclude?.length ? filesToInclude : ['**/*'],
),
{
cwd: folderPath,
onlyFiles: true,
},
);
const output = createWriteStream(out);
return new Promise<void>((resolvePromise, rejectPromise) => {
const zip = new Zip((err, data, final) => {
if (err) {
rejectPromise(err);
} else {
output.write(data);
if (final) {
output.end();
resolvePromise();
console.log(`Archive created at: ${out} for recovery`);
}
}
});
for (const file of fileList) {
const absPath = resolve(folderPath, file);
console.log(`Archiving file: ${absPath}`);
streamFileToZip(
absPath,
absPath,
zip,
() => {
output.end();
rejectPromise(new Error('Aborted'));
},
rejectPromise,
);
}
zip.end();
});
};

View File

@@ -0,0 +1,21 @@
import { processCLIArgs, runModuleManager } from './base/index.js';
import { colorfulLog, sleep } from '@extension/shared';
const cliOptions = processCLIArgs();
export const startManager = () => {
if (cliOptions) {
const targets = cliOptions.targets;
(async () => {
for (const moduleName of targets) {
colorfulLog(`Processing module: ${moduleName}`, 'info');
const isLastIndex = targets.at(-1) === moduleName;
await runModuleManager(moduleName, cliOptions.action, isLastIndex);
await sleep(500);
}
})();
} else {
void runModuleManager();
}
};

View File

@@ -0,0 +1,6 @@
import { resolve } from 'node:path';
export const pagesPath = resolve(import.meta.dirname, '..', '..', '..', 'pages');
export const testsPath = resolve(pagesPath, '..', 'tests');
export const specsPath = resolve(testsPath, 'e2e', 'specs');
export const archivePath = resolve(import.meta.dirname, '..', 'archive');

View File

@@ -0,0 +1,34 @@
import { deleteModule } from './modules-handler.js';
import { DEFAULT_CHOICES, DELETE_CHOICE_QUESTION } from '../const.js';
import { processSelection } from '../helpers/utils.js';
import { pagesPath, testsPath } from '../paths.js';
import { existsSync, readdirSync } from 'node:fs';
import type { ChoicesType, ModuleNameType } from '../types.ts';
import type { ManifestType } from '@extension/shared';
export const deleteFeature = async (manifestObject: ManifestType, moduleName?: ModuleNameType) => {
const pageFolders = readdirSync(pagesPath);
const choices: ChoicesType = DEFAULT_CHOICES.filter(choice => {
if (choice.value === 'background') {
return !!manifestObject.background;
} else if (choice.value === 'tests') {
return existsSync(testsPath);
}
return pageFolders.includes(choice.value);
});
const processResult = await processSelection(choices, DELETE_CHOICE_QUESTION, moduleName);
if (processResult) {
moduleName = processResult;
}
if (moduleName === 'devtools') {
await deleteModule(manifestObject, moduleName as ModuleNameType);
await deleteModule(manifestObject, 'devtools-panel');
} else {
await deleteModule(manifestObject, moduleName as ModuleNameType);
}
};

View File

@@ -0,0 +1,3 @@
export * from './delete-feature.js';
export * from './recover-feature.js';
export * from './modules-handler.js';

View File

@@ -0,0 +1,48 @@
import { unZipAndDeleteModule, zipAndDeleteModule, zipAndDeleteTests } from '../helpers/index.js';
import { isFolderEmpty, processModuleConfig } from '../helpers/utils.js';
import { archivePath, pagesPath, specsPath, testsPath } from '../paths.js';
import { colorfulLog } from '@extension/shared';
import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ModuleNameType } from '../types.ts';
import type { ManifestType } from '@extension/shared';
export const recoverModule = (manifestObject: ManifestType, moduleName: ModuleNameType) => {
const zipFilePath = resolve(archivePath, `${moduleName}.zip`);
const zipTestFilePath = resolve(archivePath, `${moduleName}.test.zip`);
if (!existsSync(zipFilePath)) {
colorfulLog(`No archive found for ${moduleName}`, 'info');
process.exit(0);
}
processModuleConfig(manifestObject, moduleName, true);
unZipAndDeleteModule(zipFilePath, pagesPath);
if (existsSync(zipTestFilePath)) {
unZipAndDeleteModule(zipTestFilePath, specsPath);
}
colorfulLog(`Recovered: ${moduleName}`, 'info');
if (isFolderEmpty(archivePath)) {
rmdirSync(archivePath);
}
};
export const deleteModule = async (manifestObject: ManifestType, moduleName: ModuleNameType) => {
processModuleConfig(manifestObject, moduleName);
if (!existsSync(archivePath)) {
mkdirSync(archivePath, { recursive: true });
}
if (moduleName === 'tests') {
await zipAndDeleteTests(testsPath, archivePath);
} else {
await zipAndDeleteModule(moduleName, pagesPath, archivePath, specsPath);
}
colorfulLog(`Deleted: ${moduleName}`, 'info');
};

View File

@@ -0,0 +1,41 @@
import { recoverModule } from './modules-handler.js';
import { DEFAULT_CHOICES, RECOVER_CHOICE_QUESTION } from '../const.js';
import { processSelection } from '../helpers/utils.js';
import { archivePath } from 'lib/paths.js';
import { rimraf } from 'rimraf';
import { existsSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ChoicesType, ModuleNameType } from '../types.ts';
import type { ManifestType } from '@extension/shared';
export const recoverFeature = async (manifestObject: ManifestType, moduleName?: ModuleNameType) => {
const archiveFiles = existsSync(archivePath) ? readdirSync(archivePath) : [];
const choices: ChoicesType = DEFAULT_CHOICES.filter(choice => {
if (choice.value === 'background') {
return !manifestObject.background;
}
return archiveFiles.includes(`${choice.value}.zip`);
});
const processResult = await processSelection(choices, RECOVER_CHOICE_QUESTION, moduleName);
if (processResult) {
moduleName = processResult;
}
if (moduleName === 'tests') {
recoverModule(manifestObject, moduleName as ModuleNameType);
const testZipFilePath = resolve(archivePath, `${moduleName}.test.zip`);
if (existsSync(testZipFilePath)) {
await rimraf(testZipFilePath);
}
} else if (moduleName === 'devtools') {
recoverModule(manifestObject, moduleName as ModuleNameType);
recoverModule(manifestObject, 'devtools-panel');
} else {
recoverModule(manifestObject, moduleName as ModuleNameType);
}
};

View File

@@ -0,0 +1,19 @@
import type { DEFAULT_CHOICES, MODULE_CONFIG } from './const.js';
import type { WritableDeep } from '@extension/shared';
import type { select } from '@inquirer/prompts';
type ModuleConfigType = typeof MODULE_CONFIG;
export type ChoiceType = (typeof DEFAULT_CHOICES)[number];
export type ChoicesType = ChoiceType[];
export type ModuleNameType = ChoiceType['value'] | 'devtools-panel';
export type InputConfigType = Parameters<typeof select>[0];
export type WritableModuleConfigValuesType<T extends keyof ModuleConfigType> = WritableDeep<ModuleConfigType[T]>;
export interface ICLIOptions {
action: 'delete' | 'recover';
targets: ModuleNameType[];
}
export type CliEntriesType = [string, (string | number)[]][];
export type CliActionType = 'delete' | 'recover';

View File

@@ -0,0 +1,23 @@
{
"name": "@extension/module-manager",
"version": "0.5.0",
"description": "chrome extension - module manager",
"type": "module",
"private": true,
"sideEffects": true,
"scripts": {
"start": "tsx index.mts",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier . --write --ignore-path ../../.prettierignore"
},
"devDependencies": {
"@extension/dev-utils": "workspace:*",
"@extension/shared": "workspace:*",
"@extension/tsconfig": "workspace:*",
"@inquirer/prompts": "^7.5.1",
"@types/yargs": "^17.0.33",
"tsx": "^4.19.4",
"yargs": "^17.7.2"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["lib", "index.mts"]
}