diff --git a/.gitignore b/.gitignore index 6e005206e..974c76357 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ playwright/.auth # Used in CI to pass built packages to the next job packed-artifacts/ +.mise.toml \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index d0db6435c..fd612ef74 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,4 +29,21 @@ module.exports = [ 'react-hooks/rules-of-hooks': 'off', }, }, + { + name: 'create-plugin/overrides', + files: ['packages/create-plugin/src/migrations/scripts/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['fs', 'fs:promises', 'node:fs', 'node:fs/promises'], + message: 'Use the Context passed to the migration script.', + }, + ], + }, + ], + }, + }, ]; diff --git a/package-lock.json b/package-lock.json index 1dd80a8a8..1dc026da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10275,6 +10275,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -35086,9 +35093,11 @@ "@types/marked-terminal": "^6.1.1", "@types/minimist": "^1.2.5", "@types/semver": "^7.5.8", + "@types/tmp": "^0.2.6", "@types/which": "^3.0.4", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^5.1.0" + "eslint-plugin-react-hooks": "^5.1.0", + "tmp": "^0.2.3" }, "engines": { "node": ">=20" diff --git a/packages/create-plugin/CONTRIBUTING.md b/packages/create-plugin/CONTRIBUTING.md index 5a718b4cc..b1cdeb918 100644 --- a/packages/create-plugin/CONTRIBUTING.md +++ b/packages/create-plugin/CONTRIBUTING.md @@ -33,7 +33,8 @@ npm install ├── src // Executable code │ ├── bin // the entrypoint file │ ├── commands // Code that runs commands -│ └── utils // Utilities used by commands +│ ├── utils // Utilities used by commands +│ └── migrations // Migrations for updating create-plugins └── templates // Handlebars templates ├── _partials // Composable parts of a template ├── app // Templates specific to scaffolding an app plugin @@ -81,3 +82,118 @@ _Work in progress._ The templates are used by Handlebars to scaffold Grafana plugins. Whilst they appear to be the intended filetype they are infact treated as markdown by Handlebars when it runs. As such we need to be mindful of syntax and to [escape particular characters](https://handlebarsjs.com/guide/expressions.html#whitespace-control) where necessary. The [github/ci.yml](./templates/github/ci/.github/workflows/ci.yml) file is a good example of this. Note that certain files are intentionally named differently (e.g. npmrc, package.json). This is done due to other tooling preventing the files from being packaged for NPM or breaking other tools during local development. + +### Migrations + +> **Note:** Migrations are currently behind the `--experimental-updates` flag and are not enabled by default. + +Migrations are scripts that update a particular aspect of a project created with create-plugin. When users run `@grafana/create-plugin@latest update`, the command compares their project's version against the running package version and executes any necessary migrations to bring their project up to date. + +```js +└── src/ + ├── migrations/ + │ └── scripts/ // The directory where migration scripts live + │ ├── add-webpack-profile.test.ts // migration script tests + │ └── add-webpack-profile.ts // migration script + ├── context.ts // The context object that is passed to the migration scripts + ├── manager.ts // The manager object that is used to run the migration scripts + ├── migrations.ts // The configuration that registers the migration scripts + └── utils.ts // The utilities used by the migration scripts +``` + +#### How do migrations run? + +The update command follows these steps: + +1. Checks the current project version (`projectCpVersion`) and package version (`packageCpVersion`). +2. If the project version is greater than or equal to the package version, exits early. +3. Identifies all migrations needed between the project version and package version. +4. Executes migrations sequentially. +5. If the `--commit` flag is passed, it will commit changes after each migration. + +#### How to add a migration? + +1. Create a new migration script file with a descriptive name (e.g. `add-webpack-profile.ts`) +2. Register your migration in `migrations.ts`: + + ```typescript + migrations: { + 'add-webpack-profile': { + version: '5.13.0', + description: 'Update build command to use webpack profile flag.', + migrationScript: './scripts/add-webpack-profile.js', + }, + }, + ``` + +3. Write your migration script: + + The migration script makes changes to files in a Grafana plugin to bring updates or improvements to the project. It should be isolated to a single task (e.g. add profile flag to webpack builds) rather than update the entire .config directory and all the projects dependencies. + + > **Note:** The migration script must use the context to access the file system and return the updated context. + + ```typescript + import { Context } from '../context.js'; + + export default async function (context: Context): Promise { + // Your migration logic here. for example: + // update files, delete files, add files, rename files, etc. + // Once done, return the updated context. + return context; + } + ``` + +#### How to test a migration? + +Migrations should be thoroughly tested using the provided testing utilities. Create a test file alongside your migration script (e.g., `add-webpack-profile.test.ts`). + +```typescript +import migrate from './add-webpack-profile.js'; +import { createDefaultContext } from '../test-utils.js'; + +describe('Migration - append profile to webpack', () => { + test('should update the package.json', async () => { + // 1. Set up a test context with some default files. + const context = await createDefaultContext(); + + // 2. Create some file state to test against. + await context.updateFile( + './package.json', + JSON.stringify({ + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + }, + }) + ); + + // 3. Run the migration function and get the updated context. + const updatedContext = await migrate(context); + + // 4. Assert expected changes + expect(await updatedContext.getFile('./package.json')).toMatch( + 'webpack -c ./.config/webpack/webpack.config.ts --profile --env production' + ); + }); +}); +``` + +It is important that migration scripts can run multiple times without making additional changes to files. To make it easy to test this you can make use of the `.toBeIdempotent()` test matcher. + +```typescript +describe('Migration - append profile to webpack', () => { + it('should not make additional changes when run multiple times', async () => { + const context = await createDefaultContext(); + + await context.updateFile( + './package.json', + JSON.stringify({ + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + }, + }) + ); + + await expect(migrate).toBeIdempotent(context); + }); +}); +``` diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json index 412b2a27b..6317e7f37 100644 --- a/packages/create-plugin/package.json +++ b/packages/create-plugin/package.json @@ -37,7 +37,7 @@ "generate-datasource-backend": "tsc && npm run clean-generated && CREATE_PLUGIN_DEV=true node ./dist/bin/run.js --pluginName='Sample datasource' --orgName='sample-org' --pluginType='datasource' --hasBackend", "lint": "eslint --cache ./src", "lint:fix": "npm run lint -- --fix", - "test": "vitest", + "test": "vitest --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -61,9 +61,11 @@ "@types/marked-terminal": "^6.1.1", "@types/minimist": "^1.2.5", "@types/semver": "^7.5.8", + "@types/tmp": "^0.2.6", "@types/which": "^3.0.4", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^5.1.0" + "eslint-plugin-react-hooks": "^5.1.0", + "tmp": "^0.2.3" }, "overrides": { "@types/marked-terminal": { @@ -86,4 +88,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/create-plugin/src/commands/update.command.ts b/packages/create-plugin/src/commands/update.command.ts index eda7db52b..86b0f1a98 100644 --- a/packages/create-plugin/src/commands/update.command.ts +++ b/packages/create-plugin/src/commands/update.command.ts @@ -1,91 +1,49 @@ -import chalk from 'chalk'; import minimist from 'minimist'; -import { UDPATE_CONFIG } from '../constants.js'; -import { printBlueBox, printRedBox } from '../utils/utils.console.js'; -import { getOnlyExistingInCwd, removeFilesInCwd } from '../utils/utils.files.js'; +import { standardUpdate } from './update.standard.command.js'; +import { migrationUpdate } from './update.migrate.command.js'; import { isGitDirectory, isGitDirectoryClean } from '../utils/utils.git.js'; -import { updateGoSdkAndModules } from '../utils/utils.goSdk.js'; -import { updateNpmScripts, updatePackageJson } from '../utils/utils.npm.js'; -import { getPackageManagerFromUserAgent } from '../utils/utils.packageManager.js'; -import { isPluginDirectory, updateDotConfigFolder } from '../utils/utils.plugin.js'; -import { getGrafanaRuntimeVersion, getVersion } from '../utils/utils.version.js'; +import { printRedBox } from '../utils/utils.console.js'; +import chalk from 'chalk'; +import { isPluginDirectory } from '../utils/utils.plugin.js'; export const update = async (argv: minimist.ParsedArgs) => { - const { packageManagerName } = getPackageManagerFromUserAgent(); - try { - if (!(await isGitDirectory()) && !argv.force) { - printRedBox({ - title: 'You are not inside a git directory', - content: `In order to proceed please run "git init" in the root of your project and commit your changes.\n + if (!(await isGitDirectory()) && !argv.force) { + printRedBox({ + title: 'You are not inside a git directory', + content: `In order to proceed please run "git init" in the root of your project and commit your changes.\n (This check is necessary to make sure that the updates are easy to revert and don't mess with any changes you currently have. In case you want to proceed as is please use the ${chalk.bold('--force')} flag.)`, - }); + }); - process.exit(1); - } + process.exit(1); + } - if (!(await isGitDirectoryClean()) && !argv.force) { - printRedBox({ - title: 'Please clean your repository working tree before updating.', - subtitle: '(Commit your changes or stash them.)', - content: `(This check is necessary to make sure that the updates are easy to revert and don't mess with any changes you currently have. + if (!(await isGitDirectoryClean()) && !argv.force) { + printRedBox({ + title: 'Please clean your repository working tree before updating.', + subtitle: '(Commit your changes or stash them.)', + content: `(This check is necessary to make sure that the updates are easy to revert and don't mess with any changes you currently have. In case you want to proceed as is please use the ${chalk.bold('--force')} flag.)`, - }); - - process.exit(1); - } - - if (!isPluginDirectory() && !argv.force) { - printRedBox({ - title: 'Are you inside a plugin directory?', - subtitle: 'We couldn\'t find a "src/plugin.json" file under your current directory.', - content: `(Please make sure to run this command from the root of your plugin folder. In case you want to proceed as is please use the ${chalk.bold( - '--force' - )} flag.)`, - }); + }); - process.exit(1); - } + process.exit(1); + } - // Updating the plugin (.config/, NPM package dependencies, package.json scripts) - // (More info on the why: https://docs.google.com/document/d/15dm4WV9v7Ga9Z_Hp3CJMf2D3meuTyEWqBc3omqiOksQ) - // ------------------- - await updateDotConfigFolder(); - updateNpmScripts(); - updatePackageJson({ - onlyOutdated: true, - ignoreGrafanaDependencies: false, + if (!isPluginDirectory() && !argv.force) { + printRedBox({ + title: 'Are you inside a plugin directory?', + subtitle: 'We couldn\'t find a "src/plugin.json" file under your current directory.', + content: `(Please make sure to run this command from the root of your plugin folder. In case you want to proceed as is please use the ${chalk.bold( + '--force' + )} flag.)`, }); - await updateGoSdkAndModules(process.cwd()); - - const filesToRemove = getOnlyExistingInCwd(UDPATE_CONFIG.filesToRemove); - if (filesToRemove.length) { - removeFilesInCwd(filesToRemove); - } - printBlueBox({ - title: 'Update successful ✔', - content: `${chalk.bold('@grafana/* package version:')} ${getGrafanaRuntimeVersion()} -${chalk.bold('@grafana/create-plugin version:')} ${getVersion()} + process.exit(1); + } -${chalk.bold.underline('Next steps:')} -- 1. Run ${chalk.bold(`${packageManagerName} install`)} to install the package updates -- 2. Check if you encounter any breaking changes - (refer to our migration guide: https://grafana.com/developers/plugin-tools/migration-guides/update-from-grafana-versions/) -${chalk.bold('Do you have questions?')} -Please don't hesitate to reach out in one of the following ways: -- Open an issue in https://github.com/grafana/plugin-tools -- Ask a question in the community forum at https://community.grafana.com/c/plugin-development/30 -- Join our community slack channel at https://slack.grafana.com/`, - }); - } catch (error) { - if (error instanceof Error) { - printRedBox({ - title: 'Something went wrong while updating your plugin.', - content: error.message, - }); - } else { - console.error(error); - } + if (argv.experimentalUpdates) { + return await migrationUpdate(argv); } + + return await standardUpdate(); }; diff --git a/packages/create-plugin/src/commands/update.migrate.command.ts b/packages/create-plugin/src/commands/update.migrate.command.ts new file mode 100644 index 000000000..77147acd6 --- /dev/null +++ b/packages/create-plugin/src/commands/update.migrate.command.ts @@ -0,0 +1,31 @@ +import minimist from 'minimist'; +import { gte } from 'semver'; +import { getMigrationsToRun, runMigrations } from '../migrations/manager.js'; +import { getConfig } from '../utils/utils.config.js'; +import { getVersion } from '../utils/utils.version.js'; +import { printHeader } from '../utils/utils.console.js'; + +export const migrationUpdate = async (argv: minimist.ParsedArgs) => { + try { + const projectCpVersion = getConfig().version; + const packageCpVersion = getVersion(); + + if (gte(projectCpVersion, packageCpVersion)) { + console.warn('Nothing to update, exiting.'); + process.exit(0); + } + + console.log(`Running migrations from ${projectCpVersion} to ${packageCpVersion}.`); + + const commitEachMigration = argv.commit; + const migrations = getMigrationsToRun(projectCpVersion, packageCpVersion); + await runMigrations(migrations, { commitEachMigration }); + printHeader('the update command completed successfully.'); + console.log(''); + } catch (error) { + printHeader('the update command encountered an error.'); + console.log(''); + console.error(error); + process.exit(1); + } +}; diff --git a/packages/create-plugin/src/commands/update.standard.command.ts b/packages/create-plugin/src/commands/update.standard.command.ts new file mode 100644 index 000000000..30393abd5 --- /dev/null +++ b/packages/create-plugin/src/commands/update.standard.command.ts @@ -0,0 +1,55 @@ +import chalk from 'chalk'; +import { UDPATE_CONFIG } from '../constants.js'; +import { printBlueBox, printRedBox } from '../utils/utils.console.js'; +import { getOnlyExistingInCwd, removeFilesInCwd } from '../utils/utils.files.js'; +import { updateGoSdkAndModules } from '../utils/utils.goSdk.js'; +import { updateNpmScripts, updatePackageJson } from '../utils/utils.npm.js'; +import { getPackageManagerFromUserAgent } from '../utils/utils.packageManager.js'; +import { updateDotConfigFolder } from '../utils/utils.plugin.js'; +import { getGrafanaRuntimeVersion, getVersion } from '../utils/utils.version.js'; + +export const standardUpdate = async () => { + const { packageManagerName } = getPackageManagerFromUserAgent(); + try { + // Updating the plugin (.config/, NPM package dependencies, package.json scripts) + // (More info on the why: https://docs.google.com/document/d/15dm4WV9v7Ga9Z_Hp3CJMf2D3meuTyEWqBc3omqiOksQ) + // ------------------- + await updateDotConfigFolder(); + updateNpmScripts(); + updatePackageJson({ + onlyOutdated: true, + ignoreGrafanaDependencies: false, + }); + await updateGoSdkAndModules(process.cwd()); + + const filesToRemove = getOnlyExistingInCwd(UDPATE_CONFIG.filesToRemove); + if (filesToRemove.length) { + removeFilesInCwd(filesToRemove); + } + + printBlueBox({ + title: 'Update successful ✔', + content: `${chalk.bold('@grafana/* package version:')} ${getGrafanaRuntimeVersion()} +${chalk.bold('@grafana/create-plugin version:')} ${getVersion()} + +${chalk.bold.underline('Next steps:')} +- 1. Run ${chalk.bold(`${packageManagerName} install`)} to install the package updates +- 2. Check if you encounter any breaking changes + (refer to our migration guide: https://grafana.com/developers/plugin-tools/migration-guides/update-from-grafana-versions/) +${chalk.bold('Do you have questions?')} +Please don't hesitate to reach out in one of the following ways: +- Open an issue in https://github.com/grafana/plugin-tools +- Ask a question in the community forum at https://community.grafana.com/c/plugin-development/30 +- Join our community slack channel at https://slack.grafana.com/`, + }); + } catch (error) { + if (error instanceof Error) { + printRedBox({ + title: 'Something went wrong while updating your plugin.', + content: error.message, + }); + } else { + console.error(error); + } + } +}; diff --git a/packages/create-plugin/src/migrations/context.test.ts b/packages/create-plugin/src/migrations/context.test.ts new file mode 100644 index 000000000..a8336d4f6 --- /dev/null +++ b/packages/create-plugin/src/migrations/context.test.ts @@ -0,0 +1,148 @@ +import { Context } from './context.js'; + +describe('Context', () => { + describe('getFile', () => { + it('should read a file from the file system', () => { + const context = new Context(`${__dirname}/fixtures`); + const content = context.getFile('foo/bar.ts'); + expect(content).toEqual("console.log('foo/bar.ts');\n"); + }); + + it('should get a file that was just added to the context', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + const content = context.getFile('file.txt'); + expect(content).toEqual('content'); + }); + + it('should get a file that was updated in the current context', () => { + const context = new Context(`${__dirname}/fixtures`); + context.updateFile('foo/bar.ts', 'content'); + const content = context.getFile('foo/bar.ts'); + expect(content).toEqual('content'); + }); + + it('should not return a file that was marked for deletion', () => { + const context = new Context(`${__dirname}/fixtures`); + context.deleteFile('foo/bar.ts'); + const content = context.getFile('foo/bar.ts'); + expect(content).toEqual(undefined); + }); + }); + + describe('addFile', () => { + it('should add a file to the context', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + expect(context.listChanges()).toEqual({ 'file.txt': { content: 'content', changeType: 'add' } }); + }); + + it('should not add a file if it already exists', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + + expect(() => context.addFile('file.txt', 'new content')).toThrowError('File file.txt already exists'); + }); + }); + + describe('deleteFile', () => { + it('should delete a file from the context', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + context.deleteFile('file.txt'); + expect(context.listChanges()).toEqual({}); + }); + + it('should not delete a file if it does not exist', () => { + const context = new Context(); + + expect(() => context.deleteFile('file.txt')).toThrowError('File file.txt does not exist'); + }); + }); + + describe('updateFile', () => { + it('should update a file in the context', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + context.updateFile('file.txt', 'new content'); + expect(context.listChanges()).toEqual({ 'file.txt': { content: 'new content', changeType: 'update' } }); + }); + + it('should not update a file if it does not exist', () => { + const context = new Context(); + + expect(() => context.updateFile('file.txt', 'new content')).toThrowError('File file.txt does not exist'); + }); + }); + + describe('renameFile', () => { + it('should rename a file', () => { + const context = new Context(`${__dirname}/fixtures`); + context.renameFile('foo/bar.ts', 'new-file.txt'); + expect(context.listChanges()).toEqual({ + 'new-file.txt': { content: "console.log('foo/bar.ts');\n", changeType: 'add' }, + 'foo/bar.ts': { changeType: 'delete' }, + }); + }); + + it('should not rename a file if it does not exist', () => { + const context = new Context(); + + expect(() => context.renameFile('file.txt', 'new-file.txt')).toThrowError('File file.txt does not exist'); + }); + + it('should not rename a file if the new name already exists', () => { + const context = new Context(); + context.addFile('file.txt', 'content'); + context.addFile('new-file.txt', 'content'); + + expect(() => context.renameFile('file.txt', 'new-file.txt')).toThrowError('File new-file.txt already exists'); + }); + }); + + describe('readDir', () => { + it('should read the directory', () => { + const context = new Context(`${__dirname}/fixtures`); + const files = context.readDir('foo'); + expect(files).toEqual(['foo/bar.ts', 'foo/baz.ts']); + }); + + it('should filter out deleted files', () => { + const context = new Context(`${__dirname}/fixtures`); + context.deleteFile('foo/bar.ts'); + const files = context.readDir('foo'); + expect(files).toEqual(['foo/baz.ts']); + }); + + it('should include files that are only added to the context', () => { + const context = new Context(`${__dirname}/fixtures`); + context.addFile('foo/foo.txt', ''); + const files = context.readDir('foo'); + expect(files).toEqual(['foo/bar.ts', 'foo/baz.ts', 'foo/foo.txt']); + }); + }); + + describe('normalisePath', () => { + it('should normalise the path', () => { + const context = new Context(`${__dirname}/fixtures`); + expect(context.normalisePath('foo/bar.ts')).toEqual('foo/bar.ts'); + expect(context.normalisePath('./foo/bar.ts')).toEqual('foo/bar.ts'); + expect(context.normalisePath('/foo/bar.ts')).toEqual('foo/bar.ts'); + }); + }); + + describe('hasChanges', () => { + it('should return FALSE if the context has no changes', () => { + const context = new Context(`${__dirname}/fixtures`); + expect(context.hasChanges()).toEqual(false); + }); + + it('should return TRUE if the context has changes', () => { + const context = new Context(`${__dirname}/fixtures`); + + context.addFile('foo.ts', ''); + + expect(context.hasChanges()).toEqual(true); + }); + }); +}); diff --git a/packages/create-plugin/src/migrations/context.ts b/packages/create-plugin/src/migrations/context.ts new file mode 100644 index 000000000..82bf881d5 --- /dev/null +++ b/packages/create-plugin/src/migrations/context.ts @@ -0,0 +1,155 @@ +import { constants, accessSync, readFileSync, readdirSync } from 'node:fs'; +import { relative, normalize, join, dirname } from 'node:path'; +import { migrationsDebug } from './utils.js'; + +export type ContextFile = Record< + string, + { + content?: string; + changeType: 'add' | 'delete' | 'update'; + } +>; + +export class Context { + private files: ContextFile = {}; + basePath: string; + + constructor(basePath?: string) { + this.basePath = basePath || process.cwd(); + } + + addFile(filePath: string, content: string) { + const path = this.normalisePath(filePath); + if (!this.doesFileExist(path)) { + this.files[path] = { content, changeType: 'add' }; + } else { + throw new Error(`File ${path} already exists`); + } + } + + deleteFile(filePath: string) { + const path = this.normalisePath(filePath); + + // Delete a file that was added to the current context + if (this.files[path] && this.files[path].changeType === 'add') { + delete this.files[path]; + return; + } + // Delete a file from the disk + else if (this.doesFileExistOnDisk(path)) { + this.files[path] = { ...this.files[path], changeType: 'delete' }; + } + // Delete a file that was updated in the current context + else if (this.files[path] && this.files[path].changeType === 'update') { + throw new Error(`File ${path} was marked as updated already`); + } else { + throw new Error(`File ${path} does not exist`); + } + } + + updateFile(filePath: string, content: string) { + const path = this.normalisePath(filePath); + const originalContent = this.getFile(path); + + if (originalContent === undefined) { + throw new Error(`File ${path} does not exist`); + } + + if (originalContent !== content) { + this.files[path] = { content, changeType: 'update' }; + } else { + migrationsDebug(`Context.updateFile() - no updates for ${filePath}`); + } + } + + doesFileExist(filePath: string) { + const path = this.normalisePath(filePath); + + // Added / updated in this context + if (this.files[path] && this.files[path].changeType !== 'delete') { + return true; + } + + return this.doesFileExistOnDisk(path); + } + + doesFileExistOnDisk(filePath: string) { + const path = join(this.basePath, this.normalisePath(filePath)); + + try { + accessSync(path, constants.R_OK | constants.W_OK); + return true; + } catch { + return false; + } + } + + getFile(filePath: string) { + const path = this.normalisePath(filePath); + + // Deleted in this context + if (this.files[path] && this.files[path].changeType === 'delete') { + return undefined; + } + + // Added / updated in this context + if (this.files[path]) { + return this.files[path].content; + } + + if (this.doesFileExistOnDisk(path)) { + return readFileSync(join(this.basePath, path), 'utf-8'); + } + + return undefined; + } + + listChanges() { + return this.files; + } + + hasChanges() { + return Object.keys(this.files).length > 0; + } + + renameFile(from: string, to: string) { + const normalisedTo = this.normalisePath(to); + const contents = this.getFile(from); + + if (contents === undefined) { + throw new Error(`File ${from} does not exist`); + } + // File was already touched in this context + else if (this.files[normalisedTo]) { + throw new Error(`File ${to} already exists`); + } else { + this.deleteFile(from); + this.addFile(to, contents); + } + } + + readDir(folderPath: string): string[] { + const path = this.normalisePath(folderPath); + const childrenOnDisk = this.readDirFromDisk(folderPath) + .map((child) => join(path, child)) + .filter((child) => !this.files[child] || this.files[child].changeType !== 'delete'); + const childrenAddedInContext = Object.keys(this.files).filter( + (p) => dirname(p) === path && this.files[p].changeType === 'add' + ); + return [...childrenOnDisk, ...childrenAddedInContext]; + } + + readDirFromDisk(folderPath: string): string[] { + const path = this.normalisePath(folderPath); + + try { + return readdirSync(join(this.basePath, path)); + } catch (error) { + return []; + } + } + + normalisePath(filePath: string) { + return normalize(relative(this.basePath, join(this.basePath, filePath))); + } +} diff --git a/packages/create-plugin/src/migrations/fixtures/foo/bar.ts b/packages/create-plugin/src/migrations/fixtures/foo/bar.ts new file mode 100644 index 000000000..216c1f8cc --- /dev/null +++ b/packages/create-plugin/src/migrations/fixtures/foo/bar.ts @@ -0,0 +1 @@ +console.log('foo/bar.ts'); diff --git a/packages/create-plugin/src/migrations/fixtures/foo/baz.ts b/packages/create-plugin/src/migrations/fixtures/foo/baz.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/create-plugin/src/migrations/fixtures/migrations.ts b/packages/create-plugin/src/migrations/fixtures/migrations.ts new file mode 100644 index 000000000..3f52ebdb8 --- /dev/null +++ b/packages/create-plugin/src/migrations/fixtures/migrations.ts @@ -0,0 +1,19 @@ +export default { + migrations: { + 'migration-key1': { + version: '5.0.0', + description: 'Update project to use new cache directory', + migrationScript: './5-0-0-cache-directory.js', + }, + 'migration-key2': { + version: '5.4.0', + description: 'Update project to use new cache directory', + migrationScript: './5-4-0-cache-directory.js', + }, + 'migration-key3': { + version: '6.0.0', + description: 'Update project to use new cache directory', + migrationScript: './5-4-0-cache-directory.js', + }, + }, +}; diff --git a/packages/create-plugin/src/migrations/manager.test.ts b/packages/create-plugin/src/migrations/manager.test.ts new file mode 100644 index 000000000..eecc0f83b --- /dev/null +++ b/packages/create-plugin/src/migrations/manager.test.ts @@ -0,0 +1,217 @@ +import { vi } from 'vitest'; +import { getMigrationsToRun, runMigration, runMigrations } from './manager.js'; +import migrationFixtures from './fixtures/migrations.js'; +import { Context } from './context.js'; +import { gitCommitNoVerify } from '../utils/utils.git.js'; +import { flushChanges, printChanges } from './utils.js'; +import { setRootConfig } from '../utils/utils.config.js'; +import { MigrationMeta } from './migrations.js'; + +vi.mock('./utils.js', () => ({ + flushChanges: vi.fn(), + printChanges: vi.fn(), + migrationsDebug: vi.fn(), +})); + +vi.mock('../utils/utils.config.js', () => ({ + setRootConfig: vi.fn(), +})); +vi.mock('../utils/utils.git.js', () => ({ + gitCommitNoVerify: vi.fn(), +})); + +describe('Migrations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getMigrationsToRun', () => { + it('should return the migrations that need to be run', () => { + const fromVersion = '3.0.0'; + const toVersion = '5.0.0'; + const migrations = getMigrationsToRun(fromVersion, toVersion, migrationFixtures.migrations); + expect(migrations).toEqual({ + 'migration-key1': { + version: '5.0.0', + description: 'Update project to use new cache directory', + migrationScript: './5-0-0-cache-directory.js', + }, + }); + + const fromVersion2 = '5.0.0'; + const toVersion2 = '5.5.0'; + const migrations2 = getMigrationsToRun(fromVersion2, toVersion2, migrationFixtures.migrations); + expect(migrations2).toEqual({ + 'migration-key1': { + version: '5.0.0', + description: 'Update project to use new cache directory', + migrationScript: './5-0-0-cache-directory.js', + }, + 'migration-key2': { + version: '5.4.0', + description: 'Update project to use new cache directory', + migrationScript: './5-4-0-cache-directory.js', + }, + }); + + const fromVersion3 = '5.5.0'; + const toVersion3 = '6.0.0'; + const migrations3 = getMigrationsToRun(fromVersion3, toVersion3, migrationFixtures.migrations); + expect(migrations3).toEqual({ + 'migration-key3': { + version: '6.0.0', + description: 'Update project to use new cache directory', + migrationScript: './5-4-0-cache-directory.js', + }, + }); + }); + + it('should sort migrations by version', () => { + const fromVersion = '2.0.0'; + const toVersion = '6.0.0'; + const migrations = getMigrationsToRun(fromVersion, toVersion, { + 'migration-key1': { + version: '5.3.0', + description: 'Update project to use new cache directory', + migrationScript: './5.3.0-migration.js', + }, + 'migration-key2': { + version: '2.3.0', + description: 'Update project to use new cache directory', + migrationScript: './2.3.0-migration.js', + }, + 'migration-key3': { + version: '2.0.0', + description: 'Update project to use new cache directory', + migrationScript: './2.0.0-migration.js', + }, + 'migration-key4': { + version: '2.0.0', + description: 'Update project to use new cache directory', + migrationScript: './2.0.0-migration.js', + }, + }); + + expect(Object.keys(migrations)).toEqual(['migration-key3', 'migration-key4', 'migration-key2', 'migration-key1']); + }); + }); + + describe('runMigration', () => { + it('should pass a context to the migration script', async () => { + const mockContext = new Context('/virtual'); + const migrationFn = vi.fn().mockResolvedValue(mockContext); + + vi.doMock('./test-migration.js', () => ({ + default: migrationFn, + })); + + const migration: MigrationMeta = { + version: '1.0.0', + description: 'test migration', + migrationScript: './test-migration.js', + }; + + const result = await runMigration(migration, mockContext); + + expect(migrationFn).toHaveBeenCalledWith(mockContext); + expect(result).toBe(mockContext); + }); + }); + + describe('runMigrations', () => { + const migrationOneFn = vi.fn(); + const migrationTwoFn = vi.fn(); + const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + vi.doMock('./migration-one.js', () => ({ + default: migrationOneFn, + })); + vi.doMock('./migration-two.js', () => ({ + default: migrationTwoFn, + })); + + const migrations: Record = { + 'migration-one': { + version: '1.0.0', + description: '...', + migrationScript: './migration-one.js', + }, + 'migration-two': { + version: '1.2.0', + description: '...', + migrationScript: './migration-two.js', + }, + }; + + beforeEach(() => { + migrationOneFn.mockImplementation(async (context: Context) => { + await context.addFile('one.ts', ''); + + return context; + }); + migrationTwoFn.mockImplementation(async (context: Context) => { + await context.addFile('two.ts', ''); + + return context; + }); + }); + + afterAll(() => { + consoleMock.mockReset(); + }); + + it('should flush the changes for each migration', async () => { + await runMigrations(migrations); + + expect(flushChanges).toHaveBeenCalledTimes(2); + }); + + it('should print the changes for each migration', async () => { + await runMigrations(migrations); + + expect(printChanges).toHaveBeenCalledTimes(2); + }); + + it('should not commit the changes for each migration by default', async () => { + await runMigrations(migrations); + + expect(gitCommitNoVerify).toHaveBeenCalledTimes(0); + }); + + it('should commit the changes for each migration if the CLI arg is present', async () => { + await runMigrations(migrations, { commitEachMigration: true }); + + expect(gitCommitNoVerify).toHaveBeenCalledTimes(2); + }); + + it('should not create a commit for a migration that has no changes', async () => { + migrationTwoFn.mockImplementation(async (context: Context) => context); + + await runMigrations(migrations, { commitEachMigration: true }); + + expect(gitCommitNoVerify).toHaveBeenCalledTimes(1); + }); + + it('should update version in ".config/.cprc.json" on a successful update', async () => { + await runMigrations(migrations); + + expect(setRootConfig).toHaveBeenCalledTimes(1); + + // The latest version in the migrations + // (For `runMigrations()` this means the last key in the object according to `getMigrationsToRun()`) + expect(setRootConfig).toHaveBeenCalledWith({ version: '1.2.0' }); + }); + + it('should NOT update version in ".config/.cprc.json" if any of the migrations fail', async () => { + migrationTwoFn.mockImplementation(async () => { + throw new Error('Unknown error.'); + }); + + await expect(async () => { + await runMigrations(migrations); + }).rejects.toThrow(); + + expect(setRootConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/create-plugin/src/migrations/manager.ts b/packages/create-plugin/src/migrations/manager.ts new file mode 100644 index 000000000..d3988aa2d --- /dev/null +++ b/packages/create-plugin/src/migrations/manager.ts @@ -0,0 +1,70 @@ +import { satisfies, gte } from 'semver'; +import { Context } from './context.js'; +import defaultMigrations, { MigrationMeta } from './migrations.js'; +import { flushChanges, printChanges, migrationsDebug } from './utils.js'; +import { gitCommitNoVerify } from '../utils/utils.git.js'; +import { setRootConfig } from '../utils/utils.config.js'; + +export type MigrationFn = (context: Context) => Context | Promise; + +export function getMigrationsToRun( + fromVersion: string, + toVersion: string, + migrations: Record = defaultMigrations.migrations +): Record { + const semverRange = `${fromVersion} - ${toVersion}`; + + const migrationsToRun = Object.entries(migrations) + .sort((a, b) => { + return gte(a[1].version, b[1].version) ? 1 : -1; + }) + .reduce>((acc, [key, meta]) => { + if (satisfies(meta.version, semverRange)) { + acc[key] = meta; + } + return acc; + }, {}); + + return migrationsToRun; +} + +type RunMigrationsOptions = { + commitEachMigration?: boolean; +}; + +export async function runMigrations(migrations: Record, options: RunMigrationsOptions = {}) { + const basePath = process.cwd(); + + console.log(''); + console.log('Running the following migrations:'); + Object.entries(migrations).map(([key, migrationMeta]) => console.log(`- ${key} (${migrationMeta.description})`)); + console.log(''); + + for (const [key, migration] of Object.entries(migrations)) { + try { + const context = await runMigration(migration, new Context(basePath)); + const shouldCommit = options.commitEachMigration && context.hasChanges(); + + migrationsDebug(`context for "${key} (${migration.migrationScript})":`); + migrationsDebug('%O', context.listChanges()); + + flushChanges(context); + printChanges(context, key, migration); + + if (shouldCommit) { + await gitCommitNoVerify(`chore: run create-plugin migration - ${key} (${migration.migrationScript})`); + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error running migration "${key} (${migration.migrationScript})": ${error.message}`); + } + } + } + setRootConfig({ version: Object.values(migrations).at(-1)!.version }); +} + +export async function runMigration(migration: MigrationMeta, context: Context): Promise { + const module: { default: MigrationFn } = await import(migration.migrationScript); + + return module.default(context); +} diff --git a/packages/create-plugin/src/migrations/migrations.test.ts b/packages/create-plugin/src/migrations/migrations.test.ts new file mode 100644 index 000000000..fd374a09b --- /dev/null +++ b/packages/create-plugin/src/migrations/migrations.test.ts @@ -0,0 +1,12 @@ +import defaultMigrations from './migrations.js'; + +describe('migrations json', () => { + // As migration scripts are imported dynamically when update is run we assert the path is valid + Object.entries(defaultMigrations.migrations).forEach(([key, migration]) => { + it(`should have a valid migration script path for ${key}`, () => { + expect(async () => { + await import(migration.migrationScript); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/create-plugin/src/migrations/migrations.ts b/packages/create-plugin/src/migrations/migrations.ts new file mode 100644 index 000000000..e1fe10c75 --- /dev/null +++ b/packages/create-plugin/src/migrations/migrations.ts @@ -0,0 +1,20 @@ +export type MigrationMeta = { + version: string; + description: string; + migrationScript: string; +}; + +type Migrations = { + migrations: Record; +}; + +export default { + migrations: { + // Example migration entry (DO NOT UNCOMMENT!) + // 'example-migration': { + // version: '5.13.0', + // description: 'Update build command to use webpack profile flag.', + // migrationScript: './scripts/example-migration.js', + // }, + }, +} as Migrations; diff --git a/packages/create-plugin/src/migrations/scripts/example-migration.test.ts b/packages/create-plugin/src/migrations/scripts/example-migration.test.ts new file mode 100644 index 000000000..c43e72ae5 --- /dev/null +++ b/packages/create-plugin/src/migrations/scripts/example-migration.test.ts @@ -0,0 +1,43 @@ +// ONLY FOR DRAFT - DELETE BEFORE MERGE +// ------------------------------------- + +import migrate from './example-migration.js'; +import { createDefaultContext } from '../test-utils.js'; + +describe('Migration - append profile to webpack', () => { + test('should update the package.json', async () => { + const context = createDefaultContext(); + + context.updateFile( + './package.json', + JSON.stringify({ + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + }, + }) + ); + + const updatedContext = await migrate(context); + + expect(updatedContext.getFile('./package.json')).toMatch( + 'webpack -c ./.config/webpack/webpack.config.ts --profile --env production' + ); + + expect(updatedContext.readDir('./src')).toEqual(['src/FOO.md', 'src/foo.json']); + }); + + it('should not make additional changes when run multiple times', async () => { + const context = await createDefaultContext(); + + await context.updateFile( + './package.json', + JSON.stringify({ + scripts: { + build: 'webpack -c ./.config/webpack/webpack.config.ts --env production', + }, + }) + ); + + await expect(migrate).toBeIdempotent(context); + }); +}); diff --git a/packages/create-plugin/src/migrations/scripts/example-migration.ts b/packages/create-plugin/src/migrations/scripts/example-migration.ts new file mode 100644 index 000000000..12a09b4ff --- /dev/null +++ b/packages/create-plugin/src/migrations/scripts/example-migration.ts @@ -0,0 +1,34 @@ +import type { Context } from '../context.js'; + +export default function migrate(context: Context): Context { + const rawPkgJson = context.getFile('./package.json') ?? '{}'; + const packageJson = JSON.parse(rawPkgJson); + + if (packageJson.scripts && packageJson.scripts.build) { + const buildScript = packageJson.scripts.build; + + const pattern = /(webpack.+-c\s.+\.ts)\s(.+)/; + + if (pattern.test(buildScript) && !buildScript.includes('--profile')) { + packageJson.scripts.build = buildScript.replace(pattern, `$1 --profile $2`); + } + + context.updateFile('./package.json', JSON.stringify(packageJson, null, 2)); + } + + if (context.doesFileExist('./src/README.md')) { + context.deleteFile('./src/README.md'); + } + + if (!context.doesFileExist('./src/foo.json')) { + context.addFile('./src/foo.json', JSON.stringify({ foo: 'bar' })); + } + + if (context.doesFileExist('.eslintrc')) { + context.renameFile('.eslintrc', '.eslint.config.json'); + } + + context.readDir('./src'); + + return context; +} diff --git a/packages/create-plugin/src/migrations/test-utils.ts b/packages/create-plugin/src/migrations/test-utils.ts new file mode 100644 index 000000000..dda2d2a3a --- /dev/null +++ b/packages/create-plugin/src/migrations/test-utils.ts @@ -0,0 +1,12 @@ +import { Context } from './context.js'; + +export function createDefaultContext() { + const context = new Context('/virtual'); + + context.addFile('.eslintrc', '{}'); + context.addFile('./package.json', '{}'); + context.addFile('./src/README.md', ''); + context.addFile('./src/FOO.md', ''); + + return context; +} diff --git a/packages/create-plugin/src/migrations/utils.test.ts b/packages/create-plugin/src/migrations/utils.test.ts new file mode 100644 index 000000000..a19b8d5b7 --- /dev/null +++ b/packages/create-plugin/src/migrations/utils.test.ts @@ -0,0 +1,81 @@ +import { dirSync } from 'tmp'; +import { Context } from './context.js'; +import { flushChanges, printChanges } from './utils.js'; +import { join } from 'node:path'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; + +describe('utils', () => { + const tmpObj = dirSync({ unsafeCleanup: true }); + const tmpDir = join(tmpObj.name, 'cp-test-migration'); + + beforeEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + await mkdir(tmpDir, { recursive: true }); + + await writeFile(join(tmpDir, 'bar.ts'), 'content'); + await writeFile(join(tmpDir, 'baz.ts'), 'content'); + }); + + afterAll(() => { + tmpObj.removeCallback(); + }); + + describe('flushChanges', () => { + it('should write files to disk', async () => { + const context = new Context(tmpDir); + await context.addFile('file.txt', 'content'); + await context.addFile('deeper/path/to/file.txt', 'content'); + flushChanges(context); + expect(readFileSync(join(tmpDir, 'file.txt'), 'utf-8')).toBe('content'); + expect(readFileSync(join(tmpDir, 'deeper/path/to/file.txt'), 'utf-8')).toBe('content'); + }); + + it('should update files on disk', async () => { + const context = new Context(tmpDir); + await context.updateFile('bar.ts', 'new content'); + flushChanges(context); + expect(readFileSync(join(tmpDir, 'bar.ts'), 'utf-8')).toBe('new content'); + }); + + it('should delete files from disk', async () => { + const context = new Context(tmpDir); + await context.deleteFile('bar.ts'); + flushChanges(context); + expect(() => readFileSync(join(tmpDir, 'bar.ts'), 'utf-8')).toThrowError(); + }); + }); + + describe('printChanges', () => { + const originalConsoleLog = console.log; + + beforeEach(() => { + vitest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('should print changes', async () => { + const context = new Context(tmpDir); + await context.addFile('file.txt', 'content'); + await context.updateFile('baz.ts', 'new content'); + await context.deleteFile('bar.ts'); + + printChanges(context, 'key', { migrationScript: 'test', description: 'test', version: '1.0.0' }); + + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/ADD.+file\.txt/)); + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/UPDATE.+baz\.ts/)); + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/DELETE.+bar\.ts/)); + }); + + it('should print no changes', async () => { + const context = new Context(tmpDir); + + printChanges(context, 'key', { migrationScript: 'test', description: 'test', version: '1.0.0' }); + + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/No changes were made/)); + }); + }); +}); diff --git a/packages/create-plugin/src/migrations/utils.ts b/packages/create-plugin/src/migrations/utils.ts new file mode 100644 index 000000000..3aa83e784 --- /dev/null +++ b/packages/create-plugin/src/migrations/utils.ts @@ -0,0 +1,50 @@ +import { dirname, join } from 'node:path'; +import { Context } from './context.js'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { debug } from '../utils/utils.cli.js'; +import chalk from 'chalk'; +import { MigrationMeta } from './migrations.js'; + +export function printChanges(context: Context, key: string, migration: MigrationMeta) { + const changes = context.listChanges(); + const lines = []; + + for (const [filePath, { changeType }] of Object.entries(changes)) { + if (changeType === 'add') { + lines.push(`${chalk.green('ADD')} ${filePath}`); + } else if (changeType === 'update') { + lines.push(`${chalk.yellow('UPDATE')} ${filePath}`); + } else if (changeType === 'delete') { + lines.push(`${chalk.red('DELETE')} ${filePath}`); + } + } + + console.log('--------------------------------'); + console.log('Running migration:', key, chalk.bold(migration.migrationScript)); + + if (lines.length === 0) { + console.log('No changes were made'); + } else { + console.log(`${chalk.bold('Changes:')}\n ${lines.join('\n ')}`); + } + console.log(''); +} + +export function flushChanges(context: Context) { + const basePath = context.basePath; + const changes = context.listChanges(); + + for (const [filePath, { changeType, content }] of Object.entries(changes)) { + const resolvedPath = join(basePath, filePath); + if (changeType === 'add') { + mkdirSync(dirname(resolvedPath), { recursive: true }); + writeFileSync(resolvedPath, content!); + } else if (changeType === 'update') { + writeFileSync(resolvedPath, content!); + } else if (changeType === 'delete') { + rmSync(resolvedPath); + } + } +} + +export const migrationsDebug = debug.extend('migrations'); diff --git a/packages/create-plugin/src/utils/utils.cli.ts b/packages/create-plugin/src/utils/utils.cli.ts index fb46aba51..d58a9fcba 100644 --- a/packages/create-plugin/src/utils/utils.cli.ts +++ b/packages/create-plugin/src/utils/utils.cli.ts @@ -1,4 +1,7 @@ import minimist from 'minimist'; +import createDebug from 'debug'; + +export const debug = createDebug('create-plugin'); export const args = process.argv.slice(2); @@ -10,6 +13,8 @@ export const argv = minimist(args, { hasBackend: 'backend', pluginName: 'plugin-name', orgName: 'org-name', + // temporary flag whilst we work on the migration updates + experimentalUpdates: 'experimental-updates', }, }); diff --git a/packages/create-plugin/src/utils/utils.config.ts b/packages/create-plugin/src/utils/utils.config.ts index 4954e103a..37d3eac9a 100644 --- a/packages/create-plugin/src/utils/utils.config.ts +++ b/packages/create-plugin/src/utils/utils.config.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { writeFile } from 'node:fs/promises'; import path from 'node:path'; import { getVersion } from './utils.version.js'; import { argv, commandName } from './utils.cli.js'; @@ -91,7 +92,7 @@ function readRCFileSync(path: string): CreatePluginConfig | undefined { // This function creates feature flags based on the defaults for generate command else flags read from config. // In all cases it will override the flags with the featureFlag cli arg values. function createFeatureFlags(flags?: FeatureFlags): FeatureFlags { - const featureFlags = commandName === 'generate' ? DEFAULT_FEATURE_FLAGS : flags ?? {}; + const featureFlags = commandName === 'generate' ? DEFAULT_FEATURE_FLAGS : (flags ?? {}); const cliArgFlags = parseFeatureFlagsFromCliArgs(); return { ...featureFlags, ...cliArgFlags }; } @@ -117,3 +118,13 @@ function parseFeatureFlagsFromCliArgs() { return { ...acc, [flag]: true }; }, {} as FeatureFlags); } + +export async function setRootConfig(configOverride: Partial = {}): Promise { + const rootConfig = getRootConfig(); + const rootConfigPath = path.resolve(process.cwd(), '.config/.cprc.json'); + const updatedConfig = { ...rootConfig, ...configOverride }; + + await writeFile(rootConfigPath, JSON.stringify(updatedConfig, null, 2)); + + return updatedConfig; +} diff --git a/packages/create-plugin/src/utils/utils.console.ts b/packages/create-plugin/src/utils/utils.console.ts index 78b075d98..a3ead8080 100644 --- a/packages/create-plugin/src/utils/utils.console.ts +++ b/packages/create-plugin/src/utils/utils.console.ts @@ -13,6 +13,13 @@ marked.use( }) as MarkedExtension ); +export function printHeader(message: string, status: 'success' | 'info' | 'error' = 'success') { + const color = status === 'success' ? 'green' : status === 'info' ? 'blue' : 'red'; + let prefix = chalk.reset.inverse.bold[color](` CREATE PLUGIN `); + let txt = chalk[color](message); + console.log(`${prefix} ${txt}`); +} + export function displayAsMarkdown(msg: string) { return marked(msg); } diff --git a/packages/create-plugin/src/utils/utils.git.ts b/packages/create-plugin/src/utils/utils.git.ts index e9b432cd0..7d4a4ae52 100644 --- a/packages/create-plugin/src/utils/utils.git.ts +++ b/packages/create-plugin/src/utils/utils.git.ts @@ -27,3 +27,17 @@ export async function isGitDirectoryClean() { return false; } } + +export async function gitCommitNoVerify(commitMsg: string) { + try { + let addAllCommand = 'git add -A'; + let commitCommand = `git commit --no-verify -m ${commitMsg}`; + + await exec(addAllCommand); + await exec(commitCommand); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error committing changes:\n${error.message}`); + } + } +} diff --git a/packages/create-plugin/src/utils/utils.goSdk.ts b/packages/create-plugin/src/utils/utils.goSdk.ts index c32b93643..201d45948 100644 --- a/packages/create-plugin/src/utils/utils.goSdk.ts +++ b/packages/create-plugin/src/utils/utils.goSdk.ts @@ -1,11 +1,11 @@ import which from 'which'; import fs from 'node:fs'; -import createDebug from 'debug'; import { exec } from 'node:child_process'; +import { debug } from './utils.cli.js'; const SDK_GO_MODULE = 'github.com/grafana/grafana-plugin-sdk-go'; -const debug = createDebug('create-plugin:update-go'); +const updateGoDebugger = debug.extend('update-go'); export async function updateGoSdkAndModules(exportPath: string) { // check if there is a go.mod file in exportPath @@ -40,7 +40,7 @@ function updateSdk(exportPath: string): Promise { const command = `go get ${SDK_GO_MODULE}`; exec(command, { cwd: exportPath }, (error) => { if (error) { - debug(error); + updateGoDebugger(error); reject(); } resolve(); @@ -54,7 +54,7 @@ function updateGoMod(exportPath: string): Promise { const command = `go mod tidy`; exec(command, { cwd: exportPath }, (error) => { if (error) { - debug(error); + updateGoDebugger(error); reject(); } resolve(); @@ -68,7 +68,7 @@ function getLatestSdkVersion(exportPath: string): Promise { const command = `go list -m -json ${SDK_GO_MODULE}@latest`; exec(command, { cwd: exportPath }, (error, stdout) => { if (error) { - debug(error); + updateGoDebugger(error); reject(); } @@ -76,7 +76,7 @@ function getLatestSdkVersion(exportPath: string): Promise { const version = JSON.parse(stdout).Version; resolve(version); } catch (e) { - debug(e); + updateGoDebugger(e); reject(); } }); diff --git a/packages/create-plugin/src/utils/utils.templates.ts b/packages/create-plugin/src/utils/utils.templates.ts index c62f5387a..7e4fee1f3 100644 --- a/packages/create-plugin/src/utils/utils.templates.ts +++ b/packages/create-plugin/src/utils/utils.templates.ts @@ -2,10 +2,10 @@ import { lt as semverLt } from 'semver'; import { glob } from 'glob'; import path from 'node:path'; import fs from 'node:fs'; -import createDebug from 'debug'; import { filterOutCommonFiles, isFile, isFileStartingWith } from './utils.files.js'; import { normalizeId, renderHandlebarsTemplate } from './utils.handlebars.js'; import { getPluginJson } from './utils.plugin.js'; +import { debug } from './utils.cli.js'; import { TEMPLATE_PATHS, EXPORT_PATH_PREFIX, @@ -23,7 +23,7 @@ import { getExportFileName } from '../utils/utils.files.js'; import { getGrafanaRuntimeVersion, getVersion } from './utils.version.js'; import { getConfig } from './utils.config.js'; -const debug = createDebug('create-plugin:templates'); +const templatesDebugger = debug.extend('templates'); /** * @@ -161,7 +161,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData { }; } - debug('\nTemplate data:\n' + JSON.stringify(templateData, null, 2)); + templatesDebugger('\nTemplate data:\n' + JSON.stringify(templateData, null, 2)); return templateData; } diff --git a/packages/create-plugin/tsconfig.json b/packages/create-plugin/tsconfig.json index cd4cab52d..c88fe31f8 100644 --- a/packages/create-plugin/tsconfig.json +++ b/packages/create-plugin/tsconfig.json @@ -5,5 +5,5 @@ }, "exclude": ["node_modules", "templates"], "extends": "../../tsconfig.base.json", - "include": ["src"] + "include": ["src", "vitest.d.ts"] } diff --git a/packages/create-plugin/vitest.config.ts b/packages/create-plugin/vitest.config.ts index c01dc7406..29db7394d 100644 --- a/packages/create-plugin/vitest.config.ts +++ b/packages/create-plugin/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( defineProject({ test: { root: resolve(__dirname), + setupFiles: ['./vitest.setup.ts'], }, }) ); diff --git a/packages/create-plugin/vitest.d.ts b/packages/create-plugin/vitest.d.ts new file mode 100644 index 000000000..404c0eb32 --- /dev/null +++ b/packages/create-plugin/vitest.d.ts @@ -0,0 +1,11 @@ +import 'vitest'; +import { Context } from './src/migrations/context'; + +interface CustomMatchers { + toBeIdempotent(context: Context): Promise; +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/packages/create-plugin/vitest.setup.ts b/packages/create-plugin/vitest.setup.ts new file mode 100644 index 000000000..489cb7daa --- /dev/null +++ b/packages/create-plugin/vitest.setup.ts @@ -0,0 +1,53 @@ +import { expect } from 'vitest'; +import type { Context } from './src/migrations/context'; +import { inspect } from 'node:util'; + +type ClonedContext = { + files: Record; +}; + +async function compareContexts(firstRun: ClonedContext, secondRun: ClonedContext) { + for (const file of Object.keys(firstRun.files)) { + const firstRunContent = firstRun.files[file].content; + const secondRunContent = secondRun.files[file].content; + if (firstRunContent !== secondRunContent) { + return { + pass: false, + file, + firstRunContent, + secondRunContent, + }; + } + } + + return { pass: true }; +} + +const parseContent = (content?: string) => (content ? JSON.parse(content) : ''); + +expect.extend({ + async toBeIdempotent(migrate: (context: Context) => Promise, context: Context) { + const firstRun = await migrate(context); + const firstRunDeepCopy = structuredClone(firstRun) as unknown as ClonedContext; + + const secondRun = await migrate(firstRun); + const secondRunDeepCopy = structuredClone(secondRun) as unknown as ClonedContext; + + const result = await compareContexts(firstRunDeepCopy, secondRunDeepCopy); + + if (result.pass) { + return { + pass: true, + message: () => 'Expected migration not to be idempotent', + }; + } + + return { + pass: false, + message: () => + `Migration is not idempotent. File ${result.file} changed on second run.\n` + + `First run content: ${inspect(parseContent(result.firstRunContent), { colors: true })}\n` + + `Second run content: ${inspect(parseContent(result.secondRunContent), { colors: true })}`, + }; + }, +});