diff --git a/.gitignore b/.gitignore index 556356aec9..fe6161b344 100644 --- a/.gitignore +++ b/.gitignore @@ -1269,6 +1269,9 @@ packages/generator-joplin/generators/app/templates/api/index.js packages/generator-joplin/generators/app/templates/api/noteListType.js packages/generator-joplin/generators/app/templates/api/types.js packages/generator-joplin/generators/app/templates/api_index.js +packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js +packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js +packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js packages/generator-joplin/generators/app/templates/src/index.js packages/generator-joplin/tools/updateCategories.js packages/htmlpack/index.test.js diff --git a/.ignore.eslint b/.ignore.eslint index 0d4da7e7dc..17cd21163d 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -1295,6 +1295,9 @@ packages/generator-joplin/generators/app/templates/api/index.js packages/generator-joplin/generators/app/templates/api/noteListType.js packages/generator-joplin/generators/app/templates/api/types.js packages/generator-joplin/generators/app/templates/api_index.js +packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js +packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js +packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js packages/generator-joplin/generators/app/templates/src/index.js packages/generator-joplin/tools/updateCategories.js packages/htmlpack/index.test.js diff --git a/packages/generator-joplin/generators/app/index.js b/packages/generator-joplin/generators/app/index.js index 8ed51a5ce4..8e5381c5ac 100644 --- a/packages/generator-joplin/generators/app/index.js +++ b/packages/generator-joplin/generators/app/index.js @@ -176,6 +176,11 @@ module.exports = class extends Generator { this.templatePath('api'), this.destinationPath('api'), ); + + this.fs.copy( + this.templatePath('script'), + this.destinationPath('script'), + ); } }; diff --git a/packages/generator-joplin/generators/app/templates/package_TEMPLATE.json b/packages/generator-joplin/generators/app/templates/package_TEMPLATE.json index 92fad8d15e..36572cb04d 100644 --- a/packages/generator-joplin/generators/app/templates/package_TEMPLATE.json +++ b/packages/generator-joplin/generators/app/templates/package_TEMPLATE.json @@ -20,6 +20,8 @@ "copy-webpack-plugin": "^11.0.0", "fs-extra": "^10.1.0", "glob": "^8.0.3", + "semver": "^7.5.4", + "@types/semver": "^7.5.4", "tar": "^6.1.11", "ts-loader": "^9.3.1", "typescript": "^4.8.2", diff --git a/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.ts b/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.ts new file mode 100644 index 0000000000..56457c1784 --- /dev/null +++ b/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.ts @@ -0,0 +1,83 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { valid } from 'semver'; +import logger from '../utils/logger'; + +const distCommand = 'npm run dist'; +const execAsync = promisify(exec); + +interface Manifest { + version?: string; + repository_url?: string; +} + +interface PackageJson { + name?: string; +} + +const verifyBuild = async () => { + const metadata = await validateMetadata(); + await build(); + return metadata; +}; + +const validateMetadata = async () => { + logger.info('Validating metadata...'); + + // process.cwd() is the plugin root dir when run via `npm run publish` + const manifestPath = join(process.cwd(), 'src/manifest.json'); + const packageJsonPath = join(process.cwd(), 'package.json'); + + let manifest: Manifest; + let packageJson: PackageJson; + try { + manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + } catch (error: unknown) { + if (error instanceof Error) { + error.message = `manifest.json or package.json contains invalid JSON: ${error.message}`; + } + throw error; + } + + const { version } = manifest; + const { name } = packageJson; + const repositoryUrl = manifest.repository_url; + + if (!name || !name.startsWith('joplin-plugin-')) { + throw new Error('Plugin name must start with \'joplin-plugin-\' in package.json'); + } + + if (!version || !valid(version)) { + throw new Error(`Invalid plugin version: '${version}'. Must follow semver format.`); + } + + const cleanUrl = typeof repositoryUrl === 'string' + ? repositoryUrl.trim().replace(/\.git$/, '').replace(/\/$/, '') + : ''; + const githubPattern = /^https:\/\/github\.com\/[^/]+\/[^/]+$/; + + if (!cleanUrl || !githubPattern.test(cleanUrl)) { + throw new Error('Repository URL is missing or malformed in manifest.json. Valid format: https://github.com/username/repo'); + } + + logger.success(`Metadata validated: ${name}@${version}`); + return { name, version, repositoryUrl: cleanUrl }; +}; + +const build = async () => { + try { + logger.info(`Running '${distCommand}'...`); + await execAsync(distCommand, { cwd: process.cwd() }); + logger.success('Build verified!'); + } catch (error: unknown) { + if (error instanceof Error) { + error.message = `Build failed. Fix the errors above before publishing: ${error.message}`; + } + throw error; + } +}; + +export default verifyBuild; diff --git a/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.ts b/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.ts new file mode 100644 index 0000000000..7a51e7212a --- /dev/null +++ b/packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.ts @@ -0,0 +1,70 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import logger from '../utils/logger'; + +const execAsync = promisify(exec); + +// Verifies if the user has pushed the local code which he is going to publish on github or not +const verifyGitState = async () => { + + const runGit = async (command: string, errorMessage: string) => { + try { + const { stdout } = await execAsync(command, { encoding: 'utf8', cwd: process.cwd() }); + return stdout.trim(); + } catch (error: unknown) { + if (error instanceof Error) { + error.message = `${errorMessage}: ${error.message}`; + } + throw error; + } + }; + + // Checks if the current folder is a git repository or not + await runGit('git rev-parse --is-inside-work-tree', 'The current directory is not a git repository or git is not installed.'); + + // Checks if there is any uncommitted changes + const status = await runGit('git status --porcelain', 'Failed to check git status. Ensure git is installed and configured.'); + if (status !== '') { + throw new Error('You have uncommitted changes. Please commit or stash them before publishing.'); + } + logger.success('Working tree is clean.'); + + // Gets the current latest local commit hash + const commitHash = await runGit('git rev-parse HEAD', 'Could not get commit hash. Ensure you are in a valid Git repository with at least one commit.'); + if (commitHash.length !== 40) { + throw new Error('Failed to extract a valid commit hash. Ensure that git is properly initialized and you have made at least one local commit (git commit) before publishing.'); + } + logger.success(`Commit hash extracted: ${commitHash}`); + + // check if the local project is linked to github + await runGit('git remote get-url origin', 'No remote named \'origin\' found. Make sure your plugin repository is hosted on GitHub.'); + + const currentBranch = await runGit('git rev-parse --abbrev-ref HEAD', 'Failed to retrieve current branch name. Ensure git is configured correctly.'); + if (currentBranch === 'HEAD') { + throw new Error('You are in a detached HEAD state. Checkout a branch (e.g. git checkout main) and push before publishing.'); + } + + const remoteHeadLine = await runGit(`git ls-remote origin ${currentBranch}`, 'Could not retrieve remote HEAD. Make sure you have pushed your changes and have an internet connection.'); + if (!remoteHeadLine) { + throw new Error('Remote HEAD is empty. Make sure you have pushed your changes.'); + } + + const parts = remoteHeadLine.split('\n')[0].split(/\s+/); + if (parts.length < 2) { + throw new Error(`Unexpected git ls-remote output: "${remoteHeadLine}". Make sure your git remote and branch are configured correctly.`); + } + + const remoteHash = parts[0]; + if (remoteHash.length !== 40) { + throw new Error('Failed to extract a valid remote commit hash.'); + } + + if (remoteHash !== commitHash) { + throw new Error('Your local commit has not been pushed to GitHub. Run git push then try publishing again.'); + } + logger.success('Local commit is synced with remote.'); + + return commitHash; +}; + +export default verifyGitState; diff --git a/packages/generator-joplin/generators/app/templates/script/publish/utils/logger.ts b/packages/generator-joplin/generators/app/templates/script/publish/utils/logger.ts new file mode 100644 index 0000000000..4edd9a824c --- /dev/null +++ b/packages/generator-joplin/generators/app/templates/script/publish/utils/logger.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-console */ +import { green, yellow, red } from 'chalk'; + +const logger = { + info: (msg: string) => console.log(msg), + success: (msg: string) => console.log(green(msg)), + warn: (msg: string) => console.log(yellow(msg)), + error: (msg: string) => console.log(red(msg)), +}; + +export default logger;