pad.libre-service.eu-etherpad/bin/plugins/checkPlugin.ts

480 lines
18 KiB
TypeScript
Raw Normal View History

/*
* Usage -- see README.md
*
* Normal usage: node bin/plugins/checkPlugin.js ep_whatever
* Auto fix the things it can: node bin/plugins/checkPlugin.js ep_whatever autofix
* Auto fix and commit: node bin/plugins/checkPlugin.js ep_whatever autocommit
* Auto fix, commit, push and publish to npm (highly dangerous):
* node bin/plugins/checkPlugin.js ep_whatever autopush
*/
2024-03-15 20:59:28 +01:00
import process from 'node:process';
2022-02-20 23:44:06 +01:00
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err) => { throw err; });
2024-03-13 20:31:29 +01:00
import {strict as assert} from 'assert';
2024-03-15 20:59:28 +01:00
import fs from 'node:fs';
const fsp = fs.promises;
2024-03-15 20:59:28 +01:00
import childProcess from 'node:child_process';
2024-03-13 20:31:29 +01:00
import log4js from 'log4js';
2024-03-15 20:59:28 +01:00
import path from 'node:path';
import semver from "semver";
2022-02-21 20:35:44 +01:00
const logger = log4js.getLogger('checkPlugin');
log4js.configure({
appenders: { console: { type: "console" } },
categories: { default: { appenders: ["console"], level: "info" } },
});
(async () => {
// get plugin name & path from user input
const pluginName = process.argv[2];
if (!pluginName) throw new Error('no plugin name specified');
2022-02-21 20:35:44 +01:00
logger.info(`Checking the plugin: ${pluginName}`);
const epRootDir = await fsp.realpath(path.join(await fsp.realpath(__dirname), '../..'));
2022-02-21 20:35:44 +01:00
logger.info(`Etherpad root directory: ${epRootDir}`);
2022-02-20 23:44:06 +01:00
process.chdir(epRootDir);
2024-03-16 20:38:00 +01:00
const pluginPath = await fsp.realpath(`../${pluginName}`);
2022-02-21 20:35:44 +01:00
logger.info(`Plugin directory: ${pluginPath}`);
const epSrcDir = await fsp.realpath(path.join(epRootDir, 'src'));
2022-02-20 23:44:06 +01:00
const optArgs = process.argv.slice(3);
const autoPush = optArgs.includes('autopush');
const autoCommit = autoPush || optArgs.includes('autocommit');
const autoFix = autoCommit || optArgs.includes('autofix');
2024-03-13 20:31:29 +01:00
const execSync = (cmd:string, opts = {}) => (childProcess.execSync(cmd, {
cwd: `${pluginPath}/`,
...opts,
}) || '').toString().replace(/\n+$/, '');
2024-03-13 20:31:29 +01:00
const writePackageJson = async (obj: object) => {
console.log("writing package.json",obj)
let s = JSON.stringify(obj, null, 2);
if (s.length && s.slice(s.length - 1) !== '\n') s += '\n';
return await fsp.writeFile(`${pluginPath}/package.json`, s);
};
2024-03-13 20:31:29 +01:00
const checkEntries = (got: any, want:any) => {
let changed = false;
for (const [key, val] of Object.entries(want)) {
try {
assert.deepEqual(got[key], val);
2024-03-13 20:31:29 +01:00
} catch (err:any) {
2022-02-21 20:35:44 +01:00
logger.warn(`${key} possibly outdated.`);
logger.warn(err.message);
if (autoFix) {
got[key] = val;
changed = true;
}
}
}
return changed;
};
2024-03-13 20:31:29 +01:00
const updateDeps = async (parsedPackageJson: any, key: string, wantDeps: {
[key: string]: string | {ver?: string, overwrite?: boolean}|null
}|string) => {
const {[key]: deps = {}} = parsedPackageJson;
let changed = false;
if (typeof wantDeps === 'string') {
if (deps !== wantDeps) {
logger.warn(`Dependency mismatch in ${key}: '${wantDeps}' (current: ${deps})`);
if (autoFix) {
parsedPackageJson[key] = wantDeps;
await writePackageJson(parsedPackageJson);
}
}
return;
}
for (const [pkg, verInfo] of Object.entries(wantDeps)) {
const {ver, overwrite = true} =
typeof verInfo === 'string' || verInfo == null ? {ver: verInfo} : verInfo;
if (deps[pkg] === ver || (deps[pkg] == null && ver == null)) continue;
if (deps[pkg] == null) {
2022-02-21 20:35:44 +01:00
logger.warn(`Missing dependency in ${key}: '${pkg}': '${ver}'`);
} else {
if (!overwrite) continue;
2022-02-21 20:35:44 +01:00
logger.warn(`Dependency mismatch in ${key}: '${pkg}': '${ver}' (current: ${deps[pkg]})`);
}
if (autoFix) {
if (ver == null) delete deps[pkg];
else deps[pkg] = ver;
changed = true;
}
}
if (changed) {
parsedPackageJson[key] = deps;
await writePackageJson(parsedPackageJson);
}
};
const prepareRepo = () => {
const modified = execSync('git diff-files --name-status');
if (modified !== '') {
logger.warn('working directory has modifications');
if (autoFix)
execSync('git stash', {stdio: 'inherit'})
//throw new Error(`working directory has modifications:\n${modified}`);
}
const untracked = execSync('git ls-files -o --exclude-standard');
if (untracked !== '') throw new Error(`working directory has untracked files:\n${untracked}`);
const indexStatus = execSync('git diff-index --cached --name-status HEAD');
if (indexStatus !== '') throw new Error(`uncommitted staged changes to files:\n${indexStatus}`);
let br;
if (autoCommit) {
br = execSync('git symbolic-ref HEAD');
if (!br.startsWith('refs/heads/')) throw new Error('detached HEAD');
br = br.replace(/^refs\/heads\//, '');
execSync('git rev-parse --verify -q HEAD || ' +
`{ echo "Error: no commits on ${br}" >&2; exit 1; }`);
execSync('git config --get user.name');
execSync('git config --get user.email');
}
if (autoPush) {
2024-03-13 20:31:29 +01:00
if (!['master', 'main'].includes(br!)) throw new Error('master/main not checked out');
execSync('git rev-parse --verify @{u}');
execSync('git pull --ff-only', {stdio: 'inherit'});
if (execSync('git rev-list @{u}...') !== '') throw new Error('repo contains unpushed commits');
}
};
2024-03-13 20:31:29 +01:00
const checkFile = async (srcFn: string, dstFn:string, overwrite = true) => {
const outFn = path.join(pluginPath, dstFn);
const wantContents = await fsp.readFile(srcFn, {encoding: 'utf8'});
let gotContents = null;
try {
gotContents = await fsp.readFile(outFn, {encoding: 'utf8'});
} catch (err) { /* treat as if the file doesn't exist */ }
try {
assert.equal(gotContents, wantContents);
2024-03-13 20:31:29 +01:00
} catch (err:any) {
2022-02-21 20:35:44 +01:00
logger.warn(`File ${dstFn} does not match the default`);
logger.warn(err.message);
if (!overwrite && gotContents != null) {
2022-02-21 20:35:44 +01:00
logger.warn('Leaving existing contents alone.');
return;
}
if (autoFix) {
await fsp.mkdir(path.dirname(outFn), {recursive: true});
await fsp.writeFile(outFn, wantContents);
}
}
};
2021-12-10 02:39:40 +01:00
if (autoPush) {
2022-02-21 20:35:44 +01:00
logger.warn('Auto push is enabled, I hope you know what you are doing...');
2021-12-10 02:39:40 +01:00
}
const files = await fsp.readdir(pluginPath);
// some files we need to know the actual file name. Not compulsory but might help in the future.
const readMeFileName = files.filter((f) => f === 'README' || f === 'README.md')[0];
if (!files.includes('.git')) throw new Error('No .git folder, aborting');
prepareRepo();
const workflows = ['backend-tests.yml', 'frontend-tests.yml', 'npmpublish.yml', 'test-and-release.yml'];
await Promise.all(workflows.map(async (fn) => {
await checkFile(`bin/plugins/lib/${fn}`, `.github/workflows/${fn}`);
}));
await checkFile('bin/plugins/lib/dependabot.yml', '.github/dependabot.yml');
if (!files.includes('package.json')) {
2022-02-21 20:35:44 +01:00
logger.warn('no package.json, please create');
} else {
const packageJSON =
await fsp.readFile(`${pluginPath}/package.json`, {encoding: 'utf8', flag: 'r'});
const parsedPackageJSON = JSON.parse(packageJSON);
await updateDeps(parsedPackageJSON, 'devDependencies', {
2024-03-16 20:38:00 +01:00
'eslint': '^8.57.0',
'eslint-config-etherpad': '^4.0.4',
2022-03-01 23:41:34 +01:00
// Changing the TypeScript version can break plugin code, so leave it alone if present.
'typescript': {ver: '^5.4.2', overwrite: true},
2022-02-21 00:05:57 +01:00
// These were moved to eslint-config-etherpad's dependencies so they can be removed:
'@typescript-eslint/eslint-plugin': null,
'@typescript-eslint/parser': null,
'eslint-import-resolver-typescript': null,
'eslint-plugin-cypress': null,
'eslint-plugin-eslint-comments': null,
'eslint-plugin-import': null,
'eslint-plugin-mocha': null,
'eslint-plugin-node': null,
'eslint-plugin-prefer-arrow': null,
'eslint-plugin-promise': null,
'eslint-plugin-you-dont-need-lodash-underscore': null,
});
const currentVersion = semver.parse(parsedPackageJSON.version)!;
const newVersion = currentVersion.inc('patch');
await updateDeps(parsedPackageJSON, 'version', newVersion.version)
await updateDeps(parsedPackageJSON, 'peerDependencies', {
// These were moved to eslint-config-etherpad's dependencies so they can be removed:
'ep_etherpad-lite': null,
});
2024-03-13 19:11:19 +01:00
/*await updateDeps(parsedPackageJSON, 'peerDependencies', {
// Some plugins require a newer version of Etherpad so don't overwrite if already set.
'ep_etherpad-lite': {ver: '>=1.8.6', overwrite: false},
2024-03-13 19:11:19 +01:00
});*/
delete parsedPackageJSON.peerDependencies;
await updateDeps(parsedPackageJSON, 'engines', {
2024-03-13 19:11:19 +01:00
node: '>=18.0.0',
});
if (parsedPackageJSON.eslintConfig != null && autoFix) {
delete parsedPackageJSON.eslintConfig;
await writePackageJson(parsedPackageJSON);
}
if (files.includes('.eslintrc.js')) {
const [from, to] = [`${pluginPath}/.eslintrc.js`, `${pluginPath}/.eslintrc.cjs`];
if (!files.includes('.eslintrc.cjs')) {
if (autoFix) {
await fsp.rename(from, to);
} else {
2022-02-21 20:35:44 +01:00
logger.warn(`please rename ${from} to ${to}`);
}
} else {
2022-02-21 20:35:44 +01:00
logger.error(`both ${from} and ${to} exist; delete ${from}`);
}
} else {
2024-03-16 20:38:00 +01:00
await checkFile('bin/plugins/lib/eslintrc.cjs', '.eslintrc.cjs', false);
}
if (checkEntries(parsedPackageJSON, {
funding: {
type: 'individual',
url: 'https://etherpad.org/',
},
})) await writePackageJson(parsedPackageJSON);
if (parsedPackageJSON.scripts == null) parsedPackageJSON.scripts = {};
if (checkEntries(parsedPackageJSON.scripts, {
'lint': 'eslint .',
'lint:fix': 'eslint --fix .',
}))
await writePackageJson(parsedPackageJSON);
}
if (!files.includes('pnpm-lock.yaml')) {
logger.warn('pnpm-lock.yaml not found');
if (!autoFix) {
2024-03-16 20:40:16 +01:00
logger.warn('Run pnpm install in the plugin folder and commit the package-lock.json file.');
2024-03-16 20:38:00 +01:00
} else {
logger.info('Autofixing missing package-lock.json file');
try {
fs.statfsSync(`${pluginPath}/package-lock.json`)
fs.rmSync(`${pluginPath}/package-lock.json`)
} catch (e) {
// Nothing to do
}
2024-03-16 20:38:00 +01:00
execSync('pnpm install', {
cwd: `${pluginPath}/`,
stdio: 'inherit',
});
}
}
2024-03-13 20:31:29 +01:00
const fillTemplate = async (templateFilename: string, outputFilename: string) => {
const contents = (await fsp.readFile(templateFilename, 'utf8'))
.replace(/\[name of copyright owner\]/g, execSync('git config user.name'))
.replace(/\[plugin_name\]/g, pluginName)
2024-03-13 20:31:29 +01:00
.replace(/\[yyyy\]/g, new Date().getFullYear().toString());
await fsp.writeFile(outputFilename, contents);
};
if (!readMeFileName) {
2022-02-21 20:35:44 +01:00
logger.warn('README.md file not found, please create');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing missing README.md file');
logger.info('please edit the README.md file further to include plugin specific details.');
await fillTemplate('bin/plugins/lib/README.md', `${pluginPath}/README.md`);
}
}
if (!files.includes('CONTRIBUTING') && !files.includes('CONTRIBUTING.md')) {
2022-02-21 20:35:44 +01:00
logger.warn('CONTRIBUTING.md file not found, please create');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing missing CONTRIBUTING.md file, please edit the CONTRIBUTING.md ' +
'file further to include plugin specific details.');
await fillTemplate('bin/plugins/lib/CONTRIBUTING.md', `${pluginPath}/CONTRIBUTING.md`);
}
}
if (readMeFileName) {
let readme =
await fsp.readFile(`${pluginPath}/${readMeFileName}`, {encoding: 'utf8', flag: 'r'});
if (!readme.toLowerCase().includes('license')) {
2022-02-21 20:35:44 +01:00
logger.warn('No license section in README');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.warn('Please add License section to README manually.');
}
}
// eslint-disable-next-line max-len
const publishBadge = `![Publish Status](https://github.com/ether/${pluginName}/workflows/Node.js%20Package/badge.svg)`;
// eslint-disable-next-line max-len
const testBadge = `![Backend Tests Status](https://github.com/ether/${pluginName}/workflows/Backend%20tests/badge.svg)`;
if (readme.toLowerCase().includes('travis')) {
2022-02-21 20:35:44 +01:00
logger.warn('Remove Travis badges');
}
if (!readme.includes('workflows/Node.js%20Package/badge.svg')) {
2022-02-21 20:35:44 +01:00
logger.warn('No Github workflow badge detected');
if (autoFix) {
readme = `${publishBadge} ${testBadge}\n\n${readme}`;
// write readme to file system
await fsp.writeFile(`${pluginPath}/${readMeFileName}`, readme);
2022-02-21 20:35:44 +01:00
logger.info('Wrote Github workflow badges to README');
}
}
}
if (!files.includes('LICENSE') && !files.includes('LICENSE.md')) {
2022-02-21 20:35:44 +01:00
logger.warn('LICENSE file not found, please create');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing missing LICENSE file (Apache 2.0).');
await fsp.copyFile('bin/plugins/lib/LICENSE', `${pluginPath}/LICENSE`);
}
}
if (!files.includes('.gitignore')) {
2022-02-21 20:35:44 +01:00
logger.warn('.gitignore file not found, please create. .gitignore files are useful to ' +
"ensure files aren't incorrectly commited to a repository.");
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing missing .gitignore file');
const gitignore =
await fsp.readFile('bin/plugins/lib/gitignore', {encoding: 'utf8', flag: 'r'});
await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);
}
2020-12-16 22:51:43 +01:00
} else {
let gitignore =
await fsp.readFile(`${pluginPath}/.gitignore`, {encoding: 'utf8', flag: 'r'});
if (!gitignore.includes('node_modules/')) {
2022-02-21 20:35:44 +01:00
logger.warn('node_modules/ missing from .gitignore');
2020-12-16 22:51:43 +01:00
if (autoFix) {
gitignore += 'node_modules/';
await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);
2020-12-16 22:51:43 +01:00
}
}
}
// if we include templates but don't have translations...
if (files.includes('templates') && !files.includes('locales')) {
2022-02-21 20:35:44 +01:00
logger.warn('Translations not found, please create. ' +
'Translation files help with Etherpad accessibility.');
}
if (files.includes('.ep_initialized')) {
2022-02-21 20:35:44 +01:00
logger.warn(
'.ep_initialized found, please remove. .ep_initialized should never be commited to git ' +
'and should only exist once the plugin has been executed one time.');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing incorrectly existing .ep_initialized file');
await fsp.unlink(`${pluginPath}/.ep_initialized`);
}
}
if (files.includes('npm-debug.log')) {
2022-02-21 20:35:44 +01:00
logger.warn('npm-debug.log found, please remove. npm-debug.log should never be commited to ' +
'your repository.');
if (autoFix) {
2022-02-21 20:35:44 +01:00
logger.info('Autofixing incorrectly existing npm-debug.log file');
await fsp.unlink(`${pluginPath}/npm-debug.log`);
}
}
if (files.includes('static')) {
const staticFiles = await fsp.readdir(`${pluginPath}/static`);
if (!staticFiles.includes('tests')) {
2022-02-21 20:35:44 +01:00
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
}
} else {
2022-02-21 20:35:44 +01:00
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
}
// Install dependencies so we can run ESLint. This should also create or update package-lock.json
// if autoFix is enabled.
const npmInstall = `pnpm install`;
execSync(npmInstall, {stdio: 'inherit'});
// Create the ep_etherpad-lite symlink if necessary. This must be done after running `npm install`
// because that command nukes the symlink.
/*try {
const d = await fsp.realpath(path.join(pluginPath, 'node_modules/ep_etherpad-lite'));
assert.equal(d, epSrcDir);
} catch (err) {
execSync(`${npmInstall} --no-save ep_etherpad-lite@file:${epSrcDir}`, {stdio: 'inherit'});
}*/
// linting begins
try {
2022-02-21 20:35:44 +01:00
logger.info('Linting...');
const lintCmd = autoFix ? 'pnpm exec eslint --fix .' : 'npx eslint';
execSync(lintCmd, {stdio: 'inherit'});
} catch (e) {
// it is gonna throw an error anyway
2024-03-16 20:40:16 +01:00
logger.info('Manual linting probably required, check with: pnpm run lint');
}
// linting ends.
if (autoFix) {
/*const unchanged = JSON.parse(execSync(
'untracked=$(git ls-files -o --exclude-standard) || exit 1; ' +
'git diff-files --quiet && [ -z "$untracked" ] && echo true || echo false'));*/
if (true) {
// Display a diff of changes. Git doesn't diff untracked files, so they must be added to the
// index. Use a temporary index file to avoid modifying Git's default index file.
execSync('git read-tree HEAD', {
env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},
stdio: 'inherit',
});
execSync('git add -A', {
env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},
stdio: 'inherit',
});
execSync('git diff-index -p --cached HEAD', {
env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},
stdio: 'inherit',
});
await fsp.unlink(`${pluginPath}/.git/checkPlugin.index`);
const commitCmd = [
'git add -A',
'git commit -m "autofixes from Etherpad checkPlugin.js"',
]
if (autoCommit) {
2022-02-21 20:35:44 +01:00
logger.info('Committing changes...');
execSync(commitCmd[0], {stdio: 'inherit'});
execSync(commitCmd[1], {stdio: 'inherit'});
} else {
2022-02-21 20:35:44 +01:00
logger.info('Fixes applied. Check the above git diff then run the following command:');
logger.info(`(cd node_modules/${pluginName} && ${commitCmd.join(' && ')})`);
}
const pushCmd = 'git push';
if (autoPush) {
2022-02-21 20:35:44 +01:00
logger.info('Pushing new commit...');
execSync(pushCmd, {stdio: 'inherit'});
} else {
2022-02-21 20:35:44 +01:00
logger.info('Changes committed. To push, run the following command:');
logger.info(`(cd node_modules/${pluginName} && ${pushCmd})`);
}
} else {
2022-02-21 20:35:44 +01:00
logger.info('No changes.');
}
}
2022-02-21 20:35:44 +01:00
logger.info('Finished');
2024-08-08 21:23:10 +02:00
process.exit(0)
})();