Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Create Plugin updates as migrations #1479

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ playwright/.auth

# Used in CI to pass built packages to the next job
packed-artifacts/
.mise.toml
17 changes: 17 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
],
},
],
},
},
];
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 117 additions & 1 deletion packages/create-plugin/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Context> {
// 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);
});
});
```
8 changes: 5 additions & 3 deletions packages/create-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -86,4 +88,4 @@
"engines": {
"node": ">=20"
}
}
}
108 changes: 33 additions & 75 deletions packages/create-plugin/src/commands/update.command.ts
Original file line number Diff line number Diff line change
@@ -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.)',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition to this feature!

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();
};
31 changes: 31 additions & 0 deletions packages/create-plugin/src/commands/update.migrate.command.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
Loading
Loading