@grafana/create-plugin 7.11.0 → 7.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +19 -10
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +1 -1
- package/dist/codemods/migrations/manager.js +19 -6
- package/dist/codemods/migrations/migrations.js +9 -0
- package/dist/codemods/migrations/scripts/014-eslint-config-v10.js +35 -0
- package/dist/commands/update.command.js +3 -3
- package/dist/constants.js +2 -1
- package/dist/utils/utils.templates.js +2 -2
- package/package.json +1 -1
- package/src/codemods/AGENTS.md +9 -1
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +1 -1
- package/src/codemods/migrations/manager.test.ts +107 -2
- package/src/codemods/migrations/manager.ts +31 -5
- package/src/codemods/migrations/migrations.test.ts +82 -1
- package/src/codemods/migrations/migrations.ts +11 -0
- package/src/codemods/migrations/scripts/014-eslint-config-v10.test.ts +94 -0
- package/src/codemods/migrations/scripts/014-eslint-config-v10.ts +40 -0
- package/src/commands/update.command.ts +3 -3
- package/src/constants.ts +6 -0
- package/src/utils/utils.templates.ts +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/src/plugin.json +1 -1
- package/templates/app-sdk/.config/AGENTS/app-sdk.md +16 -4
- package/templates/app-sdk/.config/app-sdk/README.md +66 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/app-sdk/.github/workflows/generate-kinds-drift.yml +2 -2
- package/templates/app-sdk/kinds/example.cue +3 -0
- package/templates/app-sdk/kinds/manifest.cue +24 -0
- package/templates/app-sdk/pkg/provider/provider.go +6 -2
- package/templates/common/.config/docker-compose-base.yaml +1 -1
- package/templates/common/.config/eslint.config.mjs +1 -1
- package/templates/common/.config/jest-setup.js +3 -1
- package/templates/common/_package.json +13 -13
- package/templates/datasource/src/plugin.json +1 -1
- package/templates/panel/src/plugin.json +1 -1
- package/templates/scenes-app/src/plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.12.1](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.12.0...@grafana/create-plugin@7.12.1) (2026-10-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **create-plugin:** bump grafana-app-sdk to v0.60.3 in experimental-app-sdk ([#2899](https://github.com/grafana/plugin-tools/issues/2899)) ([f57e8a8](https://github.com/grafana/plugin-tools/commit/f57e8a837d36c1b5e566ad81cf8fe426337aca88))
|
|
9
|
+
* **docs:** redirect dead plugin-tools urls and fix two broken links ([#2931](https://github.com/grafana/plugin-tools/issues/2931)) ([d26d21c](https://github.com/grafana/plugin-tools/commit/d26d21c6d3be60ba7db7a4576f794e2fcddeb324))
|
|
10
|
+
* **templates:** Update dependency @grafana/plugin-e2e to v3.14.0 ([#2897](https://github.com/grafana/plugin-tools/issues/2897)) ([690dcf8](https://github.com/grafana/plugin-tools/commit/690dcf8f935c3ed741c6bb57ea82df83e5b29fa1))
|
|
11
|
+
|
|
12
|
+
## [7.12.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.11.0...@grafana/create-plugin@7.12.0) (2026-09-22)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **deps:** bump grafana libs to latest in templates in create plugin ([#2893](https://github.com/grafana/plugin-tools/issues/2893)) ([d3c3f4b](https://github.com/grafana/plugin-tools/commit/d3c3f4b2a775d9bfcb0a9209a0c62a2ef16bbd58))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **create-plugin:** Improve experimental-app-sdk templates with RBAC guidance. ([#2892](https://github.com/grafana/plugin-tools/issues/2892)) ([ec59abe](https://github.com/grafana/plugin-tools/commit/ec59abe7f7508e6dc206a24a0658d121d64b3f1f))
|
|
23
|
+
* **create-plugin:** support @grafana/eslint-config v10 in scaffold ([#2873](https://github.com/grafana/plugin-tools/issues/2873)) ([67f6fec](https://github.com/grafana/plugin-tools/commit/67f6fec1321943ae8062e130745b37f3e45c1d72))
|
|
24
|
+
|
|
3
25
|
## [7.11.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.10.1...@grafana/create-plugin@7.11.0) (2026-09-16)
|
|
4
26
|
|
|
5
27
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -111,6 +111,9 @@ Note that certain files are intentionally named differently (e.g. npmrc, package
|
|
|
111
111
|
|
|
112
112
|
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.
|
|
113
113
|
|
|
114
|
+
> [!TIP]
|
|
115
|
+
> If you use Claude Code, the `write-codemod` skill in [`.claude/skills/write-codemod`](../../.claude/skills/write-codemod/SKILL.md) helps you write migrations and additions. Ask Claude to "write a migration" or run `/write-codemod`.
|
|
116
|
+
|
|
114
117
|
```js
|
|
115
118
|
└── src/
|
|
116
119
|
├── migrations/
|
|
@@ -135,19 +138,26 @@ The update command follows these steps:
|
|
|
135
138
|
|
|
136
139
|
#### How to add a migration?
|
|
137
140
|
|
|
138
|
-
1. Create a new migration script file with a
|
|
139
|
-
2. Register your migration in `migrations.ts`:
|
|
141
|
+
1. Create a new migration script file named with the next number in sequence and a short summary (e.g. `015-add-webpack-profile.ts`)
|
|
142
|
+
2. Register your migration at the end of the array in `migrations.ts`:
|
|
140
143
|
|
|
141
144
|
```typescript
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
},
|
|
145
|
+
{
|
|
146
|
+
name: '015-add-webpack-profile',
|
|
147
|
+
version: '0.0.0-unreleased', // x-release-please-version
|
|
148
|
+
description: 'Update build command to use webpack profile flag.',
|
|
149
|
+
scriptPath: import.meta.resolve('./scripts/015-add-webpack-profile.js'),
|
|
148
150
|
},
|
|
149
151
|
```
|
|
150
152
|
|
|
153
|
+
Copy the `version` line exactly, including the comment. Don't choose a version yourself: the release that ships
|
|
154
|
+
your migration isn't known until release-please opens the release pull request.
|
|
155
|
+
- `0.0.0-unreleased` is the `UNRELEASED` constant. It resolves to the running create-plugin version, so your
|
|
156
|
+
migration runs locally, in CI, and in `pkg.pr.new` previews.
|
|
157
|
+
- release-please replaces it with the released version in the release pull request, and the release workflow
|
|
158
|
+
removes the comment.
|
|
159
|
+
- The registry tests in `migrations.test.ts` fail if the line or the comment is missing or changed.
|
|
160
|
+
|
|
151
161
|
3. Write your migration script:
|
|
152
162
|
|
|
153
163
|
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.
|
|
@@ -227,6 +237,5 @@ describe('Migration - append profile to webpack', () => {
|
|
|
227
237
|
|
|
228
238
|
To test a migration locally you'll need a plugin to test on.
|
|
229
239
|
|
|
230
|
-
-
|
|
231
|
-
- Verify that the `.config/.cprc.json` in your plugin has a version that is lower than the bumped `create-plugin` version. `.cprc.json` holds the version of `create-plugin` that was used to scaffold or make the last update of the plugin.
|
|
240
|
+
- Verify that the `.config/.cprc.json` in your plugin has a version no higher than the version in `packages/create-plugin/package.json`. `.cprc.json` holds the version of `create-plugin` that was used to scaffold or make the last update of the plugin. You don't need to bump the create-plugin version: unreleased migrations run even when the plugin is on the current version.
|
|
232
241
|
- Run `npx create-plugin update` in your plugin (see instructions on how to link your create-plugin dev version)
|
|
@@ -158,7 +158,7 @@ function wireGoBackend(context) {
|
|
|
158
158
|
wireMainGo(context);
|
|
159
159
|
addGoModDependency(context);
|
|
160
160
|
}
|
|
161
|
-
const GRAFANA_APP_SDK_VERSION = "v0.60.
|
|
161
|
+
const GRAFANA_APP_SDK_VERSION = "v0.60.3";
|
|
162
162
|
function addGoModDependency(context) {
|
|
163
163
|
addRequireToGoMod(context, "github.com/grafana/grafana-app-sdk", GRAFANA_APP_SDK_VERSION);
|
|
164
164
|
addRequireToGoMod(context, "github.com/grafana/grafana-app-sdk/plugin", GRAFANA_APP_SDK_VERSION);
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import defaultMigrations from './migrations.js';
|
|
2
2
|
import { runCodemod } from '../runner.js';
|
|
3
|
-
import { satisfies,
|
|
3
|
+
import { gt, eq, satisfies, compare } from 'semver';
|
|
4
4
|
import { CURRENT_APP_VERSION } from '../../utils/utils.version.js';
|
|
5
|
-
import { gitCommitNoVerify } from '../../utils/utils.git.js';
|
|
5
|
+
import { gitCommitNoVerify, isGitDirectoryClean } from '../../utils/utils.git.js';
|
|
6
6
|
import { output } from '../../utils/utils.console.js';
|
|
7
7
|
import { setRootConfig } from '../../utils/utils.config.js';
|
|
8
|
+
import { UNRELEASED } from '../../constants.js';
|
|
8
9
|
|
|
10
|
+
function resolveVersion(migration, toVersion) {
|
|
11
|
+
return migration.version === UNRELEASED ? toVersion : migration.version;
|
|
12
|
+
}
|
|
13
|
+
function isUpToDate(fromVersion, toVersion, migrations = defaultMigrations) {
|
|
14
|
+
if (gt(fromVersion, toVersion)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (eq(fromVersion, toVersion)) {
|
|
18
|
+
return !migrations.some((migration) => migration.version === UNRELEASED);
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
9
22
|
function getMigrationsToRun(fromVersion, toVersion, migrations = defaultMigrations) {
|
|
10
23
|
const semverRange = `${fromVersion} - ${toVersion}`;
|
|
11
|
-
return migrations.filter((meta) => satisfies(meta
|
|
12
|
-
return
|
|
24
|
+
return migrations.filter((meta) => satisfies(resolveVersion(meta, toVersion), semverRange)).sort((a, b) => {
|
|
25
|
+
return compare(resolveVersion(a, toVersion), resolveVersion(b, toVersion));
|
|
13
26
|
});
|
|
14
27
|
}
|
|
15
28
|
async function runMigrations(migrations, options = {}) {
|
|
@@ -26,9 +39,9 @@ ${migration.description}`);
|
|
|
26
39
|
}
|
|
27
40
|
}
|
|
28
41
|
await setRootConfig({ version: CURRENT_APP_VERSION });
|
|
29
|
-
if (options.commitEachMigration) {
|
|
42
|
+
if (options.commitEachMigration && !await isGitDirectoryClean()) {
|
|
30
43
|
await gitCommitNoVerify(`chore: update .config/.cprc.json to version ${CURRENT_APP_VERSION}.`);
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
export { getMigrationsToRun, runMigrations };
|
|
47
|
+
export { getMigrationsToRun, isUpToDate, runMigrations };
|
|
@@ -78,9 +78,18 @@ var defaultMigrations = [
|
|
|
78
78
|
version: "7.10.1",
|
|
79
79
|
description: "Support @grafana/ui@13.2.0 in Jest by transforming @react-hookz/web and @ver0/deep-equal ESM dependencies.",
|
|
80
80
|
scriptPath: import.meta.resolve("./scripts/013-jest-esmodules.js")
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "014-eslint-config-v10",
|
|
84
|
+
version: "7.11.1",
|
|
85
|
+
description: "Update @grafana/eslint-config to v10: drop the removed ./flat.js subpath import and swap @stylistic/eslint-plugin-ts for @stylistic/eslint-plugin.",
|
|
86
|
+
scriptPath: import.meta.resolve("./scripts/014-eslint-config-v10.js")
|
|
81
87
|
}
|
|
82
88
|
// Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
|
|
83
89
|
// for those written before the switch to updates as migrations.
|
|
90
|
+
//
|
|
91
|
+
// New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
|
|
92
|
+
// Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
|
|
84
93
|
];
|
|
85
94
|
|
|
86
95
|
export { defaultMigrations as default };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isVersionGreater, addDependenciesToPackageJson, removeDependenciesFromPackageJson } from '../../utils.js';
|
|
2
|
+
|
|
3
|
+
const OLD_IMPORT = "import grafanaConfig from '@grafana/eslint-config/flat.js';";
|
|
4
|
+
const NEW_IMPORT = "import grafanaConfig from '@grafana/eslint-config';";
|
|
5
|
+
const ESLINT_CONFIG_PATH = ".config/eslint.config.mjs";
|
|
6
|
+
function migrate(context) {
|
|
7
|
+
if (!context.doesFileExist("package.json")) {
|
|
8
|
+
return context;
|
|
9
|
+
}
|
|
10
|
+
const packageJson = JSON.parse(context.getFile("package.json") || "{}");
|
|
11
|
+
const currentVersion = packageJson.devDependencies?.["@grafana/eslint-config"];
|
|
12
|
+
if (!currentVersion || !isVersionGreater("^10.0.0", currentVersion)) {
|
|
13
|
+
return context;
|
|
14
|
+
}
|
|
15
|
+
if (context.doesFileExist(ESLINT_CONFIG_PATH)) {
|
|
16
|
+
const content = context.getFile(ESLINT_CONFIG_PATH) || "";
|
|
17
|
+
if (content.includes(OLD_IMPORT)) {
|
|
18
|
+
context.updateFile(ESLINT_CONFIG_PATH, content.replace(OLD_IMPORT, NEW_IMPORT));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
addDependenciesToPackageJson(
|
|
22
|
+
context,
|
|
23
|
+
{},
|
|
24
|
+
{
|
|
25
|
+
"@grafana/eslint-config": "^10.0.0",
|
|
26
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
27
|
+
"eslint-config-prettier": "^10.1.0",
|
|
28
|
+
"eslint-plugin-react-hooks": "^7.1.0"
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
removeDependenciesFromPackageJson(context, [], ["@stylistic/eslint-plugin-ts"]);
|
|
32
|
+
return context;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { migrate as default };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { getMigrationsToRun, runMigrations } from '../codemods/migrations/manager.js';
|
|
1
|
+
import { isUpToDate, getMigrationsToRun, runMigrations } from '../codemods/migrations/manager.js';
|
|
2
2
|
import { getPackageManagerWithFallback, getPackageManagerExecCmd, getPackageManagerSilentInstallCmd } from '../utils/utils.packageManager.js';
|
|
3
|
-
import { lt
|
|
3
|
+
import { lt } from 'semver';
|
|
4
4
|
import { performPreCodemodChecks } from '../utils/utils.checks.js';
|
|
5
5
|
import { CURRENT_APP_VERSION } from '../utils/utils.version.js';
|
|
6
6
|
import { LEGACY_UPDATE_CUTOFF_VERSION } from '../constants.js';
|
|
@@ -15,7 +15,7 @@ const update = async (argv) => {
|
|
|
15
15
|
preparePluginForMigrations(argv);
|
|
16
16
|
}
|
|
17
17
|
try {
|
|
18
|
-
if (
|
|
18
|
+
if (isUpToDate(version, CURRENT_APP_VERSION)) {
|
|
19
19
|
output.log({
|
|
20
20
|
title: "Nothing to update, exiting."
|
|
21
21
|
});
|
package/dist/constants.js
CHANGED
|
@@ -26,6 +26,7 @@ var PLUGIN_TYPES = /* @__PURE__ */ ((PLUGIN_TYPES2) => {
|
|
|
26
26
|
return PLUGIN_TYPES2;
|
|
27
27
|
})(PLUGIN_TYPES || {});
|
|
28
28
|
const LEGACY_UPDATE_CUTOFF_VERSION = "5.27.1";
|
|
29
|
+
const UNRELEASED = "0.0.0-unreleased";
|
|
29
30
|
const EXTRA_TEMPLATE_VARIABLES = {
|
|
30
31
|
grafanaImage: "grafana-enterprise"
|
|
31
32
|
};
|
|
@@ -59,4 +60,4 @@ const MIGRATION_CONFIG = {
|
|
|
59
60
|
devNpmDependenciesToRemove: ["@grafana/toolkit", "@grafana/runtime", "@grafana/data", "@grafana/ui"]
|
|
60
61
|
};
|
|
61
62
|
|
|
62
|
-
export { DEFAULT_FEATURE_FLAGS, EXPORT_PATH_PREFIX, EXTRA_TEMPLATE_VARIABLES, GRAFANA_FE_PACKAGES, IS_DEV, LEGACY_UPDATE_CUTOFF_VERSION, MIGRATION_CONFIG, PARTIALS_DIR, PLUGIN_TYPES, TEMPLATES_DIR, TEMPLATE_PATHS };
|
|
63
|
+
export { DEFAULT_FEATURE_FLAGS, EXPORT_PATH_PREFIX, EXTRA_TEMPLATE_VARIABLES, GRAFANA_FE_PACKAGES, IS_DEV, LEGACY_UPDATE_CUTOFF_VERSION, MIGRATION_CONFIG, PARTIALS_DIR, PLUGIN_TYPES, TEMPLATES_DIR, TEMPLATE_PATHS, UNRELEASED };
|
|
@@ -78,7 +78,7 @@ function getTemplateData(cliArgs) {
|
|
|
78
78
|
isNPM: isNPM(packageManagerName),
|
|
79
79
|
version: currentVersion,
|
|
80
80
|
bundleGrafanaUI,
|
|
81
|
-
scenesVersion: "^
|
|
81
|
+
scenesVersion: "^8.17.0",
|
|
82
82
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
83
83
|
frontendBundler
|
|
84
84
|
};
|
|
@@ -99,7 +99,7 @@ function getTemplateData(cliArgs) {
|
|
|
99
99
|
isNPM: isNPM(packageManagerName),
|
|
100
100
|
version: currentVersion,
|
|
101
101
|
bundleGrafanaUI,
|
|
102
|
-
scenesVersion: "^
|
|
102
|
+
scenesVersion: "^8.17.0",
|
|
103
103
|
pluginExecutable: pluginJson.executable,
|
|
104
104
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
105
105
|
frontendBundler
|
package/package.json
CHANGED
package/src/codemods/AGENTS.md
CHANGED
|
@@ -5,7 +5,15 @@ This guide provides specific instructions for working with migrations and additi
|
|
|
5
5
|
## Agent Behavior
|
|
6
6
|
|
|
7
7
|
- Refer to current migrations and additions typescript files found in @./additions/scripts and @./migrations/scripts
|
|
8
|
-
- When creating a new migration add it to the exported migrations
|
|
8
|
+
- When creating a new migration add it to the end of the exported migrations array in @./migrations/migrations.ts
|
|
9
|
+
- Never choose a version for a new migration. Use this `version` line exactly, including the comment, and never paste it into a comment in @./migrations/migrations.ts:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
version: '0.0.0-unreleased', // x-release-please-version
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
release-please replaces it with the released version. The registry tests in @./migrations/migrations.test.ts enforce this
|
|
16
|
+
|
|
9
17
|
- Always refer to @./context.ts to know what methods are available on the context class
|
|
10
18
|
- Always check for file existence using the @./context.ts class before attempting to do anything with it
|
|
11
19
|
- Never write files with any 3rd party npm library. Use the context for all file operations
|
|
@@ -248,7 +248,7 @@ function wireGoBackend(context: Context) {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// The go.mod version for grafana-app-sdk and its plugin/ submodule.
|
|
251
|
-
const GRAFANA_APP_SDK_VERSION = 'v0.60.
|
|
251
|
+
const GRAFANA_APP_SDK_VERSION = 'v0.60.3';
|
|
252
252
|
|
|
253
253
|
/**
|
|
254
254
|
* Adds github.com/grafana/grafana-app-sdk and its plugin/ submodule to go.mod. The generated Go kind
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { flushChanges, formatFiles, printChanges } from '../utils.js';
|
|
2
|
-
import { getMigrationsToRun, runMigrations } from './manager.js';
|
|
2
|
+
import { getMigrationsToRun, isUpToDate, runMigrations } from './manager.js';
|
|
3
3
|
|
|
4
4
|
import { Context } from '../context.js';
|
|
5
|
+
import { UNRELEASED } from '../../constants.js';
|
|
5
6
|
import { Migration } from './migrations.js';
|
|
6
|
-
import { gitCommitNoVerify } from '../../utils/utils.git.js';
|
|
7
|
+
import { gitCommitNoVerify, isGitDirectoryClean } from '../../utils/utils.git.js';
|
|
7
8
|
import migrationFixtures from './fixtures/migrations.js';
|
|
8
9
|
import { setRootConfig } from '../../utils/utils.config.js';
|
|
9
10
|
import { vi } from 'vitest';
|
|
@@ -34,6 +35,7 @@ vi.mock('../../utils/utils.config.js', () => ({
|
|
|
34
35
|
}));
|
|
35
36
|
vi.mock('../../utils/utils.git.js', () => ({
|
|
36
37
|
gitCommitNoVerify: vi.fn(),
|
|
38
|
+
isGitDirectoryClean: vi.fn(),
|
|
37
39
|
}));
|
|
38
40
|
|
|
39
41
|
vi.mock('@libs/version', () => ({
|
|
@@ -43,6 +45,40 @@ vi.mock('@libs/version', () => ({
|
|
|
43
45
|
describe('Migrations', () => {
|
|
44
46
|
beforeEach(() => {
|
|
45
47
|
vi.clearAllMocks();
|
|
48
|
+
vi.mocked(isGitDirectoryClean).mockResolvedValue(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('isUpToDate', () => {
|
|
52
|
+
const released: Migration = {
|
|
53
|
+
name: '001-released',
|
|
54
|
+
version: '5.0.0',
|
|
55
|
+
description: 'Shipped in 5.0.0',
|
|
56
|
+
scriptPath: './001-released.js',
|
|
57
|
+
};
|
|
58
|
+
const unreleased: Migration = {
|
|
59
|
+
name: '002-unreleased',
|
|
60
|
+
version: UNRELEASED,
|
|
61
|
+
description: 'Not yet shipped',
|
|
62
|
+
scriptPath: './002-unreleased.js',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
it('should be up to date when the plugin is on the running version', () => {
|
|
66
|
+
expect(isUpToDate('5.0.0', '5.0.0', [released])).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not be up to date when the plugin is behind the running version', () => {
|
|
70
|
+
expect(isUpToDate('4.0.0', '5.0.0', [released])).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should be up to date when the plugin is ahead of the running version', () => {
|
|
74
|
+
expect(isUpToDate('6.0.0', '5.0.0', [released, unreleased])).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should not be up to date on the running version when there are unreleased migrations', () => {
|
|
78
|
+
// Local and preview builds report the latest release version, so plugins already on it must still get
|
|
79
|
+
// unreleased migrations.
|
|
80
|
+
expect(isUpToDate('5.0.0', '5.0.0', [released, unreleased])).toBe(false);
|
|
81
|
+
});
|
|
46
82
|
});
|
|
47
83
|
|
|
48
84
|
describe('getMigrationsToRun', () => {
|
|
@@ -127,6 +163,64 @@ describe('Migrations', () => {
|
|
|
127
163
|
'migration-key1',
|
|
128
164
|
]);
|
|
129
165
|
});
|
|
166
|
+
|
|
167
|
+
it('should always include unreleased migrations', () => {
|
|
168
|
+
const migrations = getMigrationsToRun('5.1.0', '5.5.0', [
|
|
169
|
+
{
|
|
170
|
+
name: 'released',
|
|
171
|
+
version: '5.0.0',
|
|
172
|
+
description: 'Already applied to plugins at 5.1.0',
|
|
173
|
+
scriptPath: './released.js',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'unreleased',
|
|
177
|
+
version: UNRELEASED,
|
|
178
|
+
description: 'Not yet shipped',
|
|
179
|
+
scriptPath: './unreleased.js',
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
expect(migrations.map((m) => m.name)).toEqual(['unreleased']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should run unreleased migrations after released ones', () => {
|
|
187
|
+
const migrations = getMigrationsToRun('2.0.0', '6.0.0', [
|
|
188
|
+
{
|
|
189
|
+
name: 'unreleased',
|
|
190
|
+
version: UNRELEASED,
|
|
191
|
+
description: 'Not yet shipped',
|
|
192
|
+
scriptPath: './unreleased.js',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: 'released',
|
|
196
|
+
version: '5.3.0',
|
|
197
|
+
description: 'Shipped in 5.3.0',
|
|
198
|
+
scriptPath: './released.js',
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
expect(migrations.map((m) => m.name)).toEqual(['released', 'unreleased']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should keep declaration order for migrations with the same version', () => {
|
|
206
|
+
const names = ['012-a', '013-b', '014-c', '015-d', '016-e', '017-f'];
|
|
207
|
+
const unreleased = names.map((name) => ({
|
|
208
|
+
name,
|
|
209
|
+
version: UNRELEASED,
|
|
210
|
+
description: 'Not yet shipped',
|
|
211
|
+
scriptPath: `./${name}.js`,
|
|
212
|
+
}));
|
|
213
|
+
const released = {
|
|
214
|
+
name: '011-released',
|
|
215
|
+
version: '5.3.0',
|
|
216
|
+
description: 'Shipped in 5.3.0',
|
|
217
|
+
scriptPath: './011-released.js',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const migrations = getMigrationsToRun('2.0.0', '6.0.0', [released, ...unreleased]);
|
|
221
|
+
|
|
222
|
+
expect(migrations.map((m) => m.name)).toEqual(['011-released', ...names]);
|
|
223
|
+
});
|
|
130
224
|
});
|
|
131
225
|
|
|
132
226
|
describe('runMigrations', () => {
|
|
@@ -213,6 +307,17 @@ describe('Migrations', () => {
|
|
|
213
307
|
expect(gitCommitNoVerify).toHaveBeenCalledTimes(2);
|
|
214
308
|
});
|
|
215
309
|
|
|
310
|
+
it('should not create a version update commit when nothing changed', async () => {
|
|
311
|
+
// Re-running unreleased migrations on a plugin already on the running version leaves .cprc.json as it was.
|
|
312
|
+
migrationOneFn.mockImplementation(async (context: Context) => context);
|
|
313
|
+
migrationTwoFn.mockImplementation(async (context: Context) => context);
|
|
314
|
+
vi.mocked(isGitDirectoryClean).mockResolvedValue(true);
|
|
315
|
+
|
|
316
|
+
await runMigrations(migrations, { commitEachMigration: true });
|
|
317
|
+
|
|
318
|
+
expect(gitCommitNoVerify).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
216
321
|
it('should update version in ".config/.cprc.json" on a successful update', async () => {
|
|
217
322
|
await runMigrations(migrations);
|
|
218
323
|
|
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import defaultMigrations, { Migration } from './migrations.js';
|
|
2
2
|
import { runCodemod } from '../runner.js';
|
|
3
|
-
import {
|
|
3
|
+
import { compare, eq, gt, satisfies } from 'semver';
|
|
4
4
|
import { CURRENT_APP_VERSION } from '../../utils/utils.version.js';
|
|
5
|
-
import { gitCommitNoVerify } from '../../utils/utils.git.js';
|
|
5
|
+
import { gitCommitNoVerify, isGitDirectoryClean } from '../../utils/utils.git.js';
|
|
6
6
|
import { output } from '../../utils/utils.console.js';
|
|
7
7
|
import { setRootConfig } from '../../utils/utils.config.js';
|
|
8
|
+
import { UNRELEASED } from '../../constants.js';
|
|
9
|
+
|
|
10
|
+
// An unreleased migration has not shipped in any version a plugin can be on yet, so it resolves to the
|
|
11
|
+
// version being updated to. That keeps it inside the range, and running it last.
|
|
12
|
+
function resolveVersion(migration: Migration, toVersion: string): string {
|
|
13
|
+
return migration.version === UNRELEASED ? toVersion : migration.version;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Local and preview builds report the latest release version, so a plugin already on it still needs its unreleased
|
|
17
|
+
// migrations.
|
|
18
|
+
export function isUpToDate(
|
|
19
|
+
fromVersion: string,
|
|
20
|
+
toVersion: string,
|
|
21
|
+
migrations: Migration[] = defaultMigrations
|
|
22
|
+
): boolean {
|
|
23
|
+
if (gt(fromVersion, toVersion)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (eq(fromVersion, toVersion)) {
|
|
28
|
+
return !migrations.some((migration) => migration.version === UNRELEASED);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
8
33
|
|
|
9
34
|
export function getMigrationsToRun(
|
|
10
35
|
fromVersion: string,
|
|
@@ -14,9 +39,9 @@ export function getMigrationsToRun(
|
|
|
14
39
|
const semverRange = `${fromVersion} - ${toVersion}`;
|
|
15
40
|
|
|
16
41
|
return migrations
|
|
17
|
-
.filter((meta) => satisfies(meta
|
|
42
|
+
.filter((meta) => satisfies(resolveVersion(meta, toVersion), semverRange))
|
|
18
43
|
.sort((a, b) => {
|
|
19
|
-
return
|
|
44
|
+
return compare(resolveVersion(a, toVersion), resolveVersion(b, toVersion));
|
|
20
45
|
});
|
|
21
46
|
}
|
|
22
47
|
|
|
@@ -45,7 +70,8 @@ export async function runMigrations(migrations: Migration[], options: RunMigrati
|
|
|
45
70
|
|
|
46
71
|
await setRootConfig({ version: CURRENT_APP_VERSION });
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
// Nothing to commit when only unreleased migrations re-ran on a plugin already on this version.
|
|
74
|
+
if (options.commitEachMigration && !(await isGitDirectoryClean())) {
|
|
49
75
|
await gitCommitNoVerify(`chore: update .config/.cprc.json to version ${CURRENT_APP_VERSION}.`);
|
|
50
76
|
}
|
|
51
77
|
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { lte, valid } from 'semver';
|
|
3
5
|
import defaultMigrations from './migrations.js';
|
|
6
|
+
import { UNRELEASED } from '../../constants.js';
|
|
7
|
+
|
|
8
|
+
const RELEASE_PLEASE_ANNOTATION = 'x-release-please-version';
|
|
9
|
+
|
|
10
|
+
const packageVersion: string = JSON.parse(
|
|
11
|
+
readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8')
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
// release-please rewrites the registry as text, so some rules can only be checked against the source.
|
|
15
|
+
const registrySource = readFileSync(new URL('./migrations.ts', import.meta.url), 'utf-8');
|
|
16
|
+
const versionLines = registrySource
|
|
17
|
+
.slice(registrySource.indexOf('export default ['))
|
|
18
|
+
.split('\n')
|
|
19
|
+
.filter((line) => /^\s*version:/.test(line));
|
|
20
|
+
|
|
21
|
+
const releasedMigrations = defaultMigrations.filter((migration) => migration.version !== UNRELEASED);
|
|
4
22
|
|
|
5
23
|
describe('migrations json', () => {
|
|
6
24
|
// As migration scripts are imported dynamically when update is run we assert the path is valid
|
|
@@ -14,4 +32,67 @@ describe('migrations json', () => {
|
|
|
14
32
|
expect(existsSync(sourceFilePath)).toBe(true);
|
|
15
33
|
});
|
|
16
34
|
});
|
|
35
|
+
|
|
36
|
+
it('should only use released versions that exist', () => {
|
|
37
|
+
// An invalid version never satisfies a range, so the migration would silently never run. A version above
|
|
38
|
+
// package.json has not been released yet, so the migration would stay dormant until it is.
|
|
39
|
+
releasedMigrations.forEach((migration) => {
|
|
40
|
+
expect(valid(migration.version), migration.name).not.toBeNull();
|
|
41
|
+
expect(lte(migration.version, packageVersion), `${migration.name} (${migration.version})`).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should order released versions ascending and list unreleased migrations last', () => {
|
|
46
|
+
releasedMigrations.slice(1).forEach((migration, index) => {
|
|
47
|
+
expect(lte(releasedMigrations[index].version, migration.version), migration.name).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const firstUnreleasedIndex = defaultMigrations.findIndex((migration) => migration.version === UNRELEASED);
|
|
51
|
+
const isUnreleasedLast =
|
|
52
|
+
firstUnreleasedIndex === -1 ||
|
|
53
|
+
defaultMigrations.slice(firstUnreleasedIndex).every((migration) => migration.version === UNRELEASED);
|
|
54
|
+
expect(isUnreleasedLast).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should number migrations sequentially and match their script names', () => {
|
|
58
|
+
defaultMigrations.forEach((migration, index) => {
|
|
59
|
+
const expectedPrefix = String(index + 1).padStart(3, '0');
|
|
60
|
+
expect(migration.name.startsWith(`${expectedPrefix}-`), migration.name).toBe(true);
|
|
61
|
+
expect(basename(fileURLToPath(migration.scriptPath), '.js')).toBe(migration.name);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const names = defaultMigrations.map((migration) => migration.name);
|
|
65
|
+
expect(new Set(names).size).toBe(names.length);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('release-please annotations', () => {
|
|
69
|
+
it('should find one version line per migration', () => {
|
|
70
|
+
expect(versionLines).toHaveLength(defaultMigrations.length);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should write unreleased versions as a literal release-please can rewrite', () => {
|
|
74
|
+
// release-please matches digits in the source text, so `version: UNRELEASED` would never be replaced.
|
|
75
|
+
versionLines.forEach((line) => {
|
|
76
|
+
expect(line).not.toMatch(/version:\s*UNRELEASED\b/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should only use the annotation on migration version lines', () => {
|
|
81
|
+
// release-please rewrites any semver on an annotated line, including one inside a comment.
|
|
82
|
+
const annotatedLines = registrySource.split('\n').filter((line) => line.includes(RELEASE_PLEASE_ANNOTATION));
|
|
83
|
+
annotatedLines.forEach((line) => {
|
|
84
|
+
expect(line, line.trim()).toMatch(/^\s*version:/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should annotate unreleased migrations and only those', () => {
|
|
89
|
+
// Without the annotation an unreleased migration is never frozen and re-runs on every release.
|
|
90
|
+
// A left-over annotation on a released migration would be re-stamped with the next release version.
|
|
91
|
+
versionLines.forEach((line, index) => {
|
|
92
|
+
const isUnreleased = line.includes(`'${UNRELEASED}'`);
|
|
93
|
+
const isAnnotated = line.includes(RELEASE_PLEASE_ANNOTATION);
|
|
94
|
+
expect(isAnnotated, `${defaultMigrations[index].name}: ${line.trim()}`).toBe(isUnreleased);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
17
98
|
});
|
|
@@ -2,6 +2,7 @@ import { LEGACY_UPDATE_CUTOFF_VERSION } from '../../constants.js';
|
|
|
2
2
|
import { Codemod } from '../types.js';
|
|
3
3
|
|
|
4
4
|
export interface Migration extends Codemod {
|
|
5
|
+
/** The create-plugin release that shipped this migration, or UNRELEASED if it has not shipped yet. */
|
|
5
6
|
version: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
@@ -91,6 +92,16 @@ export default [
|
|
|
91
92
|
'Support @grafana/ui@13.2.0 in Jest by transforming @react-hookz/web and @ver0/deep-equal ESM dependencies.',
|
|
92
93
|
scriptPath: import.meta.resolve('./scripts/013-jest-esmodules.js'),
|
|
93
94
|
},
|
|
95
|
+
{
|
|
96
|
+
name: '014-eslint-config-v10',
|
|
97
|
+
version: '7.11.1',
|
|
98
|
+
description:
|
|
99
|
+
'Update @grafana/eslint-config to v10: drop the removed ./flat.js subpath import and swap @stylistic/eslint-plugin-ts for @stylistic/eslint-plugin.',
|
|
100
|
+
scriptPath: import.meta.resolve('./scripts/014-eslint-config-v10.js'),
|
|
101
|
+
},
|
|
94
102
|
// Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
|
|
95
103
|
// for those written before the switch to updates as migrations.
|
|
104
|
+
//
|
|
105
|
+
// New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
|
|
106
|
+
// Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
|
|
96
107
|
] satisfies Migration[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import migrate from './014-eslint-config-v10.js';
|
|
3
|
+
import { Context } from '../../context.js';
|
|
4
|
+
|
|
5
|
+
const SCAFFOLD_ESLINT_CONFIG = `/*
|
|
6
|
+
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY \`@grafana/create-plugin\`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineConfig } from 'eslint/config';
|
|
10
|
+
import grafanaConfig from '@grafana/eslint-config/flat.js';
|
|
11
|
+
|
|
12
|
+
export default defineConfig([...grafanaConfig]);
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function addPackageJson(context: Context, eslintConfigVersion = '^9.0.0') {
|
|
16
|
+
context.addFile(
|
|
17
|
+
'package.json',
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
devDependencies: {
|
|
20
|
+
'@grafana/eslint-config': eslintConfigVersion,
|
|
21
|
+
'@stylistic/eslint-plugin-ts': '^4.4.0',
|
|
22
|
+
'eslint-config-prettier': '^8.8.0',
|
|
23
|
+
'eslint-plugin-react-hooks': '^7.0.0',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('014-eslint-config-v10', () => {
|
|
30
|
+
it('should replace the removed flat.js subpath import', () => {
|
|
31
|
+
const context = new Context('/virtual');
|
|
32
|
+
context.addFile('.config/eslint.config.mjs', SCAFFOLD_ESLINT_CONFIG);
|
|
33
|
+
addPackageJson(context);
|
|
34
|
+
|
|
35
|
+
const result = migrate(context);
|
|
36
|
+
const content = result.getFile('.config/eslint.config.mjs') || '';
|
|
37
|
+
|
|
38
|
+
expect(content).toContain("import grafanaConfig from '@grafana/eslint-config';");
|
|
39
|
+
expect(content).not.toContain('@grafana/eslint-config/flat.js');
|
|
40
|
+
expect(content).toContain('DO NOT EDIT THIS FILE DIRECTLY');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should update package.json dependencies for eslint-config v10', () => {
|
|
44
|
+
const context = new Context('/virtual');
|
|
45
|
+
context.addFile('.config/eslint.config.mjs', SCAFFOLD_ESLINT_CONFIG);
|
|
46
|
+
addPackageJson(context);
|
|
47
|
+
|
|
48
|
+
const result = migrate(context);
|
|
49
|
+
const packageJson = JSON.parse(result.getFile('package.json') || '{}');
|
|
50
|
+
|
|
51
|
+
expect(packageJson.devDependencies['@grafana/eslint-config']).toBe('^10.0.0');
|
|
52
|
+
expect(packageJson.devDependencies['@stylistic/eslint-plugin']).toBe('^5.10.0');
|
|
53
|
+
expect(packageJson.devDependencies['@stylistic/eslint-plugin-ts']).toBeUndefined();
|
|
54
|
+
expect(packageJson.devDependencies['eslint-config-prettier']).toBe('^10.1.0');
|
|
55
|
+
expect(packageJson.devDependencies['eslint-plugin-react-hooks']).toBe('^7.1.0');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should not modify anything when package.json does not exist', () => {
|
|
59
|
+
const context = new Context('/virtual');
|
|
60
|
+
|
|
61
|
+
const result = migrate(context);
|
|
62
|
+
|
|
63
|
+
expect(result.hasChanges()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should not modify anything when @grafana/eslint-config is already on v10', () => {
|
|
67
|
+
const context = new Context('/virtual');
|
|
68
|
+
context.addFile('.config/eslint.config.mjs', SCAFFOLD_ESLINT_CONFIG.replace('/flat.js', ''));
|
|
69
|
+
addPackageJson(context, '^10.0.0');
|
|
70
|
+
|
|
71
|
+
const initialChanges = context.listChanges();
|
|
72
|
+
migrate(context);
|
|
73
|
+
|
|
74
|
+
expect(context.listChanges()).toEqual(initialChanges);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should not modify anything when there are no devDependencies', () => {
|
|
78
|
+
const context = new Context('/virtual');
|
|
79
|
+
context.addFile('package.json', JSON.stringify({}));
|
|
80
|
+
|
|
81
|
+
const initialChanges = context.listChanges();
|
|
82
|
+
migrate(context);
|
|
83
|
+
|
|
84
|
+
expect(context.listChanges()).toEqual(initialChanges);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should be idempotent', async () => {
|
|
88
|
+
const context = new Context('/virtual');
|
|
89
|
+
context.addFile('.config/eslint.config.mjs', SCAFFOLD_ESLINT_CONFIG);
|
|
90
|
+
addPackageJson(context);
|
|
91
|
+
|
|
92
|
+
await expect(migrate).toBeIdempotent(context);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Context } from '../../context.js';
|
|
2
|
+
import { addDependenciesToPackageJson, isVersionGreater, removeDependenciesFromPackageJson } from '../../utils.js';
|
|
3
|
+
|
|
4
|
+
const OLD_IMPORT = "import grafanaConfig from '@grafana/eslint-config/flat.js';";
|
|
5
|
+
const NEW_IMPORT = "import grafanaConfig from '@grafana/eslint-config';";
|
|
6
|
+
const ESLINT_CONFIG_PATH = '.config/eslint.config.mjs';
|
|
7
|
+
|
|
8
|
+
export default function migrate(context: Context) {
|
|
9
|
+
if (!context.doesFileExist('package.json')) {
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const packageJson = JSON.parse(context.getFile('package.json') || '{}');
|
|
14
|
+
const currentVersion = packageJson.devDependencies?.['@grafana/eslint-config'];
|
|
15
|
+
|
|
16
|
+
if (!currentVersion || !isVersionGreater('^10.0.0', currentVersion)) {
|
|
17
|
+
return context;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (context.doesFileExist(ESLINT_CONFIG_PATH)) {
|
|
21
|
+
const content = context.getFile(ESLINT_CONFIG_PATH) || '';
|
|
22
|
+
if (content.includes(OLD_IMPORT)) {
|
|
23
|
+
context.updateFile(ESLINT_CONFIG_PATH, content.replace(OLD_IMPORT, NEW_IMPORT));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addDependenciesToPackageJson(
|
|
28
|
+
context,
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
'@grafana/eslint-config': '^10.0.0',
|
|
32
|
+
'@stylistic/eslint-plugin': '^5.10.0',
|
|
33
|
+
'eslint-config-prettier': '^10.1.0',
|
|
34
|
+
'eslint-plugin-react-hooks': '^7.1.0',
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
removeDependenciesFromPackageJson(context, [], ['@stylistic/eslint-plugin-ts']);
|
|
38
|
+
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { getMigrationsToRun, runMigrations } from '../codemods/migrations/manager.js';
|
|
1
|
+
import { getMigrationsToRun, isUpToDate, runMigrations } from '../codemods/migrations/manager.js';
|
|
2
2
|
import {
|
|
3
3
|
getPackageManagerExecCmd,
|
|
4
4
|
getPackageManagerSilentInstallCmd,
|
|
5
5
|
getPackageManagerWithFallback,
|
|
6
6
|
} from '../utils/utils.packageManager.js';
|
|
7
|
-
import {
|
|
7
|
+
import { lt } from 'semver';
|
|
8
8
|
import { performPreCodemodChecks } from '../utils/utils.checks.js';
|
|
9
9
|
|
|
10
10
|
import { CURRENT_APP_VERSION } from '../utils/utils.version.js';
|
|
@@ -23,7 +23,7 @@ export const update = async (argv: minimist.ParsedArgs) => {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
|
-
if (
|
|
26
|
+
if (isUpToDate(version, CURRENT_APP_VERSION)) {
|
|
27
27
|
output.log({
|
|
28
28
|
title: 'Nothing to update, exiting.',
|
|
29
29
|
});
|
package/src/constants.ts
CHANGED
|
@@ -42,6 +42,12 @@ export enum PLUGIN_TYPES {
|
|
|
42
42
|
// Plugins >= 5.27.1 use the new migration-based update system.
|
|
43
43
|
export const LEGACY_UPDATE_CUTOFF_VERSION = '5.27.1';
|
|
44
44
|
|
|
45
|
+
// Version placeholder for a migration that has not been released yet. It resolves to the running
|
|
46
|
+
// create-plugin version, so the migration runs in local dev, CI and preview builds. release-please
|
|
47
|
+
// replaces it with the released version (see `extra-files` in release-please-config.json).
|
|
48
|
+
// Must stay semver-shaped: release-please only rewrites strings that look like a version.
|
|
49
|
+
export const UNRELEASED = '0.0.0-unreleased';
|
|
50
|
+
|
|
45
51
|
// This gets merged into variables coming from user prompts (when scaffolding) or any other dynamic variables,
|
|
46
52
|
// and will be available to use in the templates.
|
|
47
53
|
export const EXTRA_TEMPLATE_VARIABLES = {
|
|
@@ -121,7 +121,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData {
|
|
|
121
121
|
isNPM: isNPM(packageManagerName),
|
|
122
122
|
version: currentVersion,
|
|
123
123
|
bundleGrafanaUI,
|
|
124
|
-
scenesVersion: '^
|
|
124
|
+
scenesVersion: '^8.17.0',
|
|
125
125
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
126
126
|
frontendBundler,
|
|
127
127
|
};
|
|
@@ -145,7 +145,7 @@ export function getTemplateData(cliArgs?: GenerateCliArgs): TemplateData {
|
|
|
145
145
|
isNPM: isNPM(packageManagerName),
|
|
146
146
|
version: currentVersion,
|
|
147
147
|
bundleGrafanaUI,
|
|
148
|
-
scenesVersion: '^
|
|
148
|
+
scenesVersion: '^8.17.0',
|
|
149
149
|
pluginExecutable: pluginJson.executable,
|
|
150
150
|
useExperimentalRspack: Boolean(features.useExperimentalRspack),
|
|
151
151
|
frontendBundler,
|
package/templates/app/README.md
CHANGED
|
@@ -18,5 +18,5 @@ App plugins can let you create a custom out-of-the-box monitoring experience by
|
|
|
18
18
|
Below you can find source code for existing app plugins and other related documentation.
|
|
19
19
|
|
|
20
20
|
- [Basic app plugin example](https://github.com/grafana/grafana-plugin-examples/tree/master/examples/app-basic#readme)
|
|
21
|
-
- [`plugin.json` documentation](https://grafana.com/developers/plugin-tools/reference/plugin-
|
|
21
|
+
- [`plugin.json` documentation](https://grafana.com/developers/plugin-tools/reference/plugin-json)
|
|
22
22
|
- [Sign a plugin](https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin)
|
|
@@ -22,8 +22,8 @@ TypeScript types and an app manifest that Grafana reads from the plugin bundle.
|
|
|
22
22
|
```
|
|
23
23
|
- **Generated code is committed.** Commit the regenerated files alongside the CUE change so schema
|
|
24
24
|
changes are reviewable and a fresh clone builds without running code generation.
|
|
25
|
-
- **
|
|
26
|
-
generation emits only TypeScript and the JSON definitions
|
|
25
|
+
- **Go code generation follows `kinds/config.cue`'s `codegen.goEnabled` setting.** If it's `false`,
|
|
26
|
+
generation emits only TypeScript and the JSON definitions — don't add Go output paths or a Go
|
|
27
27
|
backend to work around a generation problem.
|
|
28
28
|
- **Do not set `GRAFANA_APP_SDK_BIN`.** It overrides the pinned CLI with a local build, and is meant
|
|
29
29
|
for people working on the app-sdk itself. Code generated with it can differ from what `VERSION` in
|
|
@@ -31,6 +31,18 @@ TypeScript types and an app manifest that Grafana reads from the plugin bundle.
|
|
|
31
31
|
`generate:kinds` without it.
|
|
32
32
|
- **Do not add code generation to the build.** It is a schema-change-time step, not a build step. The
|
|
33
33
|
frontend build must keep working without a Go toolchain.
|
|
34
|
+
- **Never write authorization/permission checks in app code.** Access is declared in the manifest and
|
|
35
|
+
enforced by Grafana's API server, not by this plugin.
|
|
36
|
+
- **A `Namespaced` kind is folder-scoped by default:** callers need the Stack Role *and* folder access,
|
|
37
|
+
and every write must set the `grafana.app/folder` annotation to a real folder the caller can access —
|
|
38
|
+
this applies to anything your backend writes on a user's behalf too. Set `folderScoped: false` on the
|
|
39
|
+
kind if it shouldn't be gated by folders (writes then skip the annotation).
|
|
40
|
+
- **Every role needs a `roleBindings` entry to be granted to anyone.** Defining a role in
|
|
41
|
+
`kinds/manifest.cue` without binding it to a basic role (`viewer`/`editor`/`admin`) leaves it unused.
|
|
42
|
+
- **Set `appDisplayName` in `kinds/manifest.cue`** so the app's roles show a readable name in Grafana's
|
|
43
|
+
role picker, instead of the raw `appName`.
|
|
44
|
+
- **`admin` currently grants the same actions as `editor`.** Don't design a feature assuming admin has
|
|
45
|
+
extra permissions today.
|
|
34
46
|
|
|
35
47
|
## Layout
|
|
36
48
|
|
|
@@ -83,5 +95,5 @@ Resources are served under a Kubernetes-style path:
|
|
|
83
95
|
The namespace is deployment-dependent — `default` on single-tenant Grafana, `stacks-<id>` on Grafana
|
|
84
96
|
Cloud. Read it from `config.namespace` in `@grafana/runtime`; never hardcode it.
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
mutation), conversion between versions, and custom routes
|
|
98
|
+
No backend is needed for storage or CRUD — that comes from the manifest alone. Admission (validation
|
|
99
|
+
and mutation), conversion between versions, and custom routes require a backend.
|
|
@@ -58,3 +58,69 @@ be required for:
|
|
|
58
58
|
- Controller logic (informers, watchers, or any other background work)
|
|
59
59
|
|
|
60
60
|
If this plugin has a backend, `pkg/provider/provider.go` is where you wire those up as your app grows.
|
|
61
|
+
|
|
62
|
+
## Authorization & RBAC
|
|
63
|
+
|
|
64
|
+
You don't write permission checks in code. You declare roles and scoping in the manifest, and Grafana's
|
|
65
|
+
aggregated API server enforces them for every caller (kubectl, Terraform, generated clients, your
|
|
66
|
+
frontend). This only applies to requests from users (UI, Service Account tokens) — requests from a
|
|
67
|
+
Service Identity (another internal operator) are less strict.
|
|
68
|
+
|
|
69
|
+
### Folder scoping
|
|
70
|
+
|
|
71
|
+
Namespaced kinds (set in `kinds/*.cue`, see `example.cue`) are **folder-scoped by default**. Access
|
|
72
|
+
requires *both* the right Stack Role (see below) *and* folder access on the resource's
|
|
73
|
+
`grafana.app/folder`. If your kind's data doesn't belong in folders, opt out with
|
|
74
|
+
`folderScoped: false` — access then comes down to the Stack Role alone, and adding a folder annotation
|
|
75
|
+
to a request is rejected.
|
|
76
|
+
|
|
77
|
+
```cue
|
|
78
|
+
examplev1alpha1: exampleKind & {
|
|
79
|
+
// Opt this kind out of folder-scoped access.
|
|
80
|
+
folderScoped: false
|
|
81
|
+
schema: {
|
|
82
|
+
spec: {
|
|
83
|
+
title: string
|
|
84
|
+
description: string
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Roles and role bindings
|
|
91
|
+
|
|
92
|
+
Declare custom roles in `kinds/manifest.cue`. A role name follows `<app>:<role>` and grants one of three
|
|
93
|
+
tiers — `viewer` (read), `editor` (read + write), or `admin` (currently identical to `editor` — don't
|
|
94
|
+
design around admin having extra powers yet). Role bindings attach your app's roles to Grafana's basic
|
|
95
|
+
roles (`viewer`, `editor`, `admin`), so every Grafana user inherits the matching app role automatically.
|
|
96
|
+
Set `appDisplayName` so your app shows up with a readable name in the Grafana UI's role picker, instead
|
|
97
|
+
of the raw `appName`.
|
|
98
|
+
|
|
99
|
+
```cue
|
|
100
|
+
manifest: {
|
|
101
|
+
appName: "{{ pluginId }}"
|
|
102
|
+
appDisplayName: "{{ pluginId }}"
|
|
103
|
+
versions: {
|
|
104
|
+
"v1alpha1": v1alpha1
|
|
105
|
+
}
|
|
106
|
+
extraPermissions: {
|
|
107
|
+
accessKinds: []
|
|
108
|
+
}
|
|
109
|
+
// roles your app grants, and which Grafana basic role gets each one by default.
|
|
110
|
+
roles: [
|
|
111
|
+
{
|
|
112
|
+
name: "{{ pluginId }}:editor"
|
|
113
|
+
permissionSet: "editor"
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
roleBindings: [
|
|
117
|
+
{
|
|
118
|
+
roleName: "{{ pluginId }}:editor"
|
|
119
|
+
basicRole: "editor"
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Without a `roleBindings` entry, a role you define is never granted to anyone — add one for every role you
|
|
126
|
+
want users to actually have.
|
|
@@ -21,7 +21,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs
|
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
22
|
import { arch, platform, tmpdir } from 'node:os';
|
|
23
23
|
|
|
24
|
-
const VERSION = 'v0.60.
|
|
24
|
+
const VERSION = 'v0.60.3';
|
|
25
25
|
const REPO = 'grafana/grafana-app-sdk';
|
|
26
26
|
const BIN = 'grafana-app-sdk';
|
|
27
27
|
|
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
{{#if_eq packageManagerName "pnpm"}}
|
|
37
37
|
# pnpm action uses the packageManager field in package.json to
|
|
38
38
|
# understand which version to install.
|
|
39
|
-
- uses: pnpm/action-setup@
|
|
39
|
+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
|
40
40
|
{{/if_eq}}
|
|
41
41
|
- name: Setup Node.js environment
|
|
42
42
|
uses: actions/setup-node@v6
|
|
@@ -62,7 +62,7 @@ jobs:
|
|
|
62
62
|
run: {{ packageManagerInstallCmd }}
|
|
63
63
|
|
|
64
64
|
- name: Cache grafana-app-sdk CLI
|
|
65
|
-
uses: actions/cache@
|
|
65
|
+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
|
66
66
|
with:
|
|
67
67
|
path: node_modules/.cache/grafana-app-sdk
|
|
68
68
|
key: grafana-app-sdk-cli-${{ '{{' }} hashFiles('.config/app-sdk/generate-kinds.mjs') {{ '}}' }}
|
|
@@ -7,6 +7,9 @@ exampleKind: {
|
|
|
7
7
|
pluralName: "Examples"
|
|
8
8
|
// Namespaced resources are created per-tenant. Use "Cluster" for global resources.
|
|
9
9
|
scope: "Namespaced"
|
|
10
|
+
// Namespaced kinds are folder-scoped by default: callers need both the Stack Role (see roles in
|
|
11
|
+
// manifest.cue) and folder access. Uncomment to opt this kind out of folder scoping.
|
|
12
|
+
// folderScoped: false
|
|
10
13
|
codegen: {
|
|
11
14
|
ts: {
|
|
12
15
|
enabled: true
|
|
@@ -7,6 +7,10 @@ manifest: {
|
|
|
7
7
|
// different group.
|
|
8
8
|
appName: "{{ pluginId }}"
|
|
9
9
|
|
|
10
|
+
// appDisplayName is the human-readable name shown for your app's roles in Grafana's role picker.
|
|
11
|
+
// Set it to something more readable than appName.
|
|
12
|
+
appDisplayName: "{{ pluginId }}"
|
|
13
|
+
|
|
10
14
|
// versions maps each version your app serves to the kinds it exposes. Version names follow the
|
|
11
15
|
// format "v<integer>" or "v<integer>(alpha|beta)<integer>".
|
|
12
16
|
versions: {
|
|
@@ -18,6 +22,26 @@ manifest: {
|
|
|
18
22
|
extraPermissions: {
|
|
19
23
|
accessKinds: []
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
// roles declares custom RBAC roles for your app. Each role name follows "<app>:<role>" and grants
|
|
27
|
+
// one of three tiers: "viewer" (read), "editor" (read + write), or "admin" (currently identical
|
|
28
|
+
// to "editor"). A role you define here is never granted to anyone without a matching entry in
|
|
29
|
+
// roleBindings below.
|
|
30
|
+
// roles: [
|
|
31
|
+
// {
|
|
32
|
+
// name: "{{ pluginId }}:editor"
|
|
33
|
+
// permissionSet: "editor"
|
|
34
|
+
// },
|
|
35
|
+
// ]
|
|
36
|
+
|
|
37
|
+
// roleBindings attaches your app's roles to Grafana's basic roles (viewer/editor/admin), so every
|
|
38
|
+
// Grafana user with that basic role automatically inherits the matching app role.
|
|
39
|
+
// roleBindings: [
|
|
40
|
+
// {
|
|
41
|
+
// roleName: "{{ pluginId }}:editor"
|
|
42
|
+
// basicRole: "editor"
|
|
43
|
+
// },
|
|
44
|
+
// ]
|
|
21
45
|
}
|
|
22
46
|
|
|
23
47
|
// v1alpha1 is the v1alpha1 version of the app's API.
|
|
@@ -2,6 +2,7 @@ package provider
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"github.com/grafana/grafana-app-sdk/app"
|
|
5
|
+
"github.com/grafana/grafana-app-sdk/plugin"
|
|
5
6
|
"github.com/grafana/grafana-app-sdk/simple"
|
|
6
7
|
|
|
7
8
|
examplev1alpha1 "github.com/{{ kebabCase orgName }}/{{ kebabCase pluginName }}/pkg/generated/example/v1alpha1"
|
|
@@ -18,9 +19,12 @@ func New() app.Provider {
|
|
|
18
19
|
// config. Attach a Validator, Mutator, or Reconciler per kind as you need one — see
|
|
19
20
|
// simple.AppManagedKind and ./.config/AGENTS/app-sdk.md.
|
|
20
21
|
func newApp(cfg app.Config) (app.App, error) {
|
|
22
|
+
// The client generator creates clients for interacting with resources.
|
|
23
|
+
clientGenerator := plugin.BuildClientGenerator(cfg.KubeConfig)
|
|
24
|
+
|
|
21
25
|
return simple.NewApp(simple.AppConfig{
|
|
22
|
-
Name:
|
|
23
|
-
|
|
26
|
+
Name: "{{ pluginId }}",
|
|
27
|
+
ClientGenerator: clientGenerator,
|
|
24
28
|
ManagedKinds: []simple.AppManagedKind{
|
|
25
29
|
{
|
|
26
30
|
Kind: examplev1alpha1.Kind(),
|
|
@@ -7,7 +7,7 @@ services:
|
|
|
7
7
|
context: .
|
|
8
8
|
args:
|
|
9
9
|
grafana_image: ${GRAFANA_IMAGE:-{{~grafanaImage~}} }
|
|
10
|
-
grafana_version: ${GRAFANA_VERSION:-13.
|
|
10
|
+
grafana_version: ${GRAFANA_VERSION:-13.2.2}
|
|
11
11
|
development: ${DEVELOPMENT:-false}
|
|
12
12
|
anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true}
|
|
13
13
|
ports:
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import '@testing-library/jest-dom';
|
|
9
9
|
import { TextEncoder, TextDecoder } from 'util';
|
|
10
|
+
import { MessageChannel, MessagePort } from 'worker_threads';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// React 19's scheduler relies on MessageChannel, which jsdom does not implement.
|
|
13
|
+
Object.assign(global, { TextDecoder, TextEncoder, MessageChannel, MessagePort });
|
|
12
14
|
|
|
13
15
|
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
|
|
14
16
|
Object.defineProperty(global, 'matchMedia', {
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
"author": "{{ sentenceCase orgName }}",
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@grafana/eslint-config": "^
|
|
20
|
-
"@grafana/plugin-e2e": "^3.
|
|
19
|
+
"@grafana/eslint-config": "^10.0.0",
|
|
20
|
+
"@grafana/plugin-e2e": "^3.14.0",
|
|
21
21
|
"@grafana/sign-plugin": "^3.3.3",
|
|
22
22
|
"@grafana/tsconfig": "^2.2.0",
|
|
23
23
|
"@playwright/test": "^1.62.1",{{#if useExperimentalRspack}}
|
|
24
24
|
"@rspack/core": "^1.6.0",
|
|
25
25
|
"@rspack/cli": "^1.6.0",{{/if}}
|
|
26
|
-
"@stylistic/eslint-plugin
|
|
26
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
27
27
|
"@swc/core": "^1.15.0",
|
|
28
28
|
"@swc/helpers": "^0.5.0",
|
|
29
29
|
"@swc/jest": "^0.2.0",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@testing-library/react": "^16.3.0",
|
|
32
32
|
"@types/jest": "^29.5.0",
|
|
33
33
|
"@types/node": "^24.0.0",
|
|
34
|
-
"@types/react": "^
|
|
35
|
-
"@types/react-dom": "^
|
|
34
|
+
"@types/react": "^19.0.0",
|
|
35
|
+
"@types/react-dom": "^19.0.0",{{#if useExperimentalRspack}}
|
|
36
36
|
"@types/ws": "^8.18.1",{{/if}}
|
|
37
37
|
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
|
38
38
|
"@typescript-eslint/parser": "^8.38.0",{{#unless useExperimentalRspack}}
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"eslint-config-prettier": "^10.1.0",
|
|
43
43
|
"eslint-plugin-jsdoc": "^52.0.0",
|
|
44
44
|
"eslint-plugin-react": "^7.37.0",
|
|
45
|
-
"eslint-plugin-react-hooks": "^7.
|
|
45
|
+
"eslint-plugin-react-hooks": "^7.1.0",
|
|
46
46
|
"eslint-webpack-plugin": "^5.0.0",{{#unless useExperimentalRspack}}
|
|
47
47
|
"fork-ts-checker-webpack-plugin": "^9.1.0",{{/unless}}
|
|
48
48
|
"glob": "^11.1.0",
|
|
@@ -74,14 +74,14 @@
|
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@emotion/css": "11.10.6",
|
|
77
|
-
"@grafana/data": "13.
|
|
78
|
-
"@grafana/i18n": "13.
|
|
79
|
-
"@grafana/runtime": "13.
|
|
80
|
-
"@grafana/ui": "13.
|
|
81
|
-
"@grafana/schema": "13.
|
|
77
|
+
"@grafana/data": "13.2.2",
|
|
78
|
+
"@grafana/i18n": "13.2.2",
|
|
79
|
+
"@grafana/runtime": "13.2.2",
|
|
80
|
+
"@grafana/ui": "13.2.2",
|
|
81
|
+
"@grafana/schema": "13.2.2",{{#if_eq pluginType "scenesapp" }}
|
|
82
82
|
"@grafana/scenes": "{{ scenesVersion }}",{{/if_eq}}
|
|
83
|
-
"react": "^
|
|
84
|
-
"react-dom": "^
|
|
83
|
+
"react": "^19.0.0",
|
|
84
|
+
"react-dom": "^19.0.0"{{#if isAppType}},
|
|
85
85
|
"react-router-dom": "^6.22.0",
|
|
86
86
|
"rxjs": "7.8.2"{{/if}}
|
|
87
87
|
},
|