@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTRIBUTING.md +19 -10
  3. package/dist/codemods/additions/scripts/experimental-app-sdk.js +1 -1
  4. package/dist/codemods/migrations/manager.js +19 -6
  5. package/dist/codemods/migrations/migrations.js +9 -0
  6. package/dist/codemods/migrations/scripts/014-eslint-config-v10.js +35 -0
  7. package/dist/commands/update.command.js +3 -3
  8. package/dist/constants.js +2 -1
  9. package/dist/utils/utils.templates.js +2 -2
  10. package/package.json +1 -1
  11. package/src/codemods/AGENTS.md +9 -1
  12. package/src/codemods/additions/scripts/experimental-app-sdk.ts +1 -1
  13. package/src/codemods/migrations/manager.test.ts +107 -2
  14. package/src/codemods/migrations/manager.ts +31 -5
  15. package/src/codemods/migrations/migrations.test.ts +82 -1
  16. package/src/codemods/migrations/migrations.ts +11 -0
  17. package/src/codemods/migrations/scripts/014-eslint-config-v10.test.ts +94 -0
  18. package/src/codemods/migrations/scripts/014-eslint-config-v10.ts +40 -0
  19. package/src/commands/update.command.ts +3 -3
  20. package/src/constants.ts +6 -0
  21. package/src/utils/utils.templates.ts +2 -2
  22. package/templates/app/README.md +1 -1
  23. package/templates/app/src/plugin.json +1 -1
  24. package/templates/app-sdk/.config/AGENTS/app-sdk.md +16 -4
  25. package/templates/app-sdk/.config/app-sdk/README.md +66 -0
  26. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  27. package/templates/app-sdk/.github/workflows/generate-kinds-drift.yml +2 -2
  28. package/templates/app-sdk/kinds/example.cue +3 -0
  29. package/templates/app-sdk/kinds/manifest.cue +24 -0
  30. package/templates/app-sdk/pkg/provider/provider.go +6 -2
  31. package/templates/common/.config/docker-compose-base.yaml +1 -1
  32. package/templates/common/.config/eslint.config.mjs +1 -1
  33. package/templates/common/.config/jest-setup.js +3 -1
  34. package/templates/common/_package.json +13 -13
  35. package/templates/datasource/src/plugin.json +1 -1
  36. package/templates/panel/src/plugin.json +1 -1
  37. 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 descriptive name (e.g. `add-webpack-profile.ts`)
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
- migrations: {
143
- 'add-webpack-profile': {
144
- version: '5.13.0',
145
- description: 'Update build command to use webpack profile flag.',
146
- migrationScript: './scripts/add-webpack-profile.js',
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
- - Bump the version of create-plugin _(This can be necessary if your plugin was already updated using the latest create-plugin version.)_
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.0";
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, gte } from 'semver';
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.version, semverRange)).sort((a, b) => {
12
- return gte(a.version, b.version) ? 1 : -1;
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, gte } from 'semver';
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 (gte(version, CURRENT_APP_VERSION)) {
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: "^7.0.0",
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: "^7.0.0",
102
+ scenesVersion: "^8.17.0",
103
103
  pluginExecutable: pluginJson.executable,
104
104
  useExperimentalRspack: Boolean(features.useExperimentalRspack),
105
105
  frontendBundler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/create-plugin",
3
- "version": "7.11.0",
3
+ "version": "7.12.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "directory": "packages/create-plugin",
@@ -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 object in @./migrations/migrations.ts
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.0';
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 { gte, satisfies } from 'semver';
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.version, semverRange))
42
+ .filter((meta) => satisfies(resolveVersion(meta, toVersion), semverRange))
18
43
  .sort((a, b) => {
19
- return gte(a.version, b.version) ? 1 : -1;
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
- if (options.commitEachMigration) {
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 { gte, lt } from 'semver';
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 (gte(version, CURRENT_APP_VERSION)) {
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: '^7.0.0',
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: '^7.0.0',
148
+ scenesVersion: '^8.17.0',
149
149
  pluginExecutable: pluginJson.executable,
150
150
  useExperimentalRspack: Boolean(features.useExperimentalRspack),
151
151
  frontendBundler,
@@ -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-jsonplugin-json)
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)
@@ -58,7 +58,7 @@
58
58
  }
59
59
  ],
60
60
  "dependencies": {
61
- "grafanaDependency": ">=12.3.0",
61
+ "grafanaDependency": ">=13.2.0",
62
62
  "plugins": []
63
63
  }
64
64
  }
@@ -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
- - **This plugin generates no Go code.** `kinds/config.cue` sets `codegen: goEnabled: false`, so
26
- generation emits only TypeScript and the JSON definitions. Do not add Go output paths or a Go
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
- This plugin has no Go backend, and does not need one for storage or CRUD. Admission (validation and
87
- mutation), conversion between versions, and custom routes would require adding one.
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.0';
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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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@v4
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: "{{ pluginId }}",
23
- KubeConfig: cfg.KubeConfig,
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.1.0}
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:
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { defineConfig } from 'eslint/config';
9
- import grafanaConfig from '@grafana/eslint-config/flat.js';
9
+ import grafanaConfig from '@grafana/eslint-config';
10
10
 
11
11
  export default defineConfig([
12
12
  ...grafanaConfig,
@@ -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
- Object.assign(global, { TextDecoder, TextEncoder });
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": "^9.0.0",
20
- "@grafana/plugin-e2e": "^3.12.0",
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-ts": "^4.4.0",
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": "^18.3.0",
35
- "@types/react-dom": "^18.3.0",{{#if useExperimentalRspack}}
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.0.0",
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.1.0",
78
- "@grafana/i18n": "13.1.0",
79
- "@grafana/runtime": "13.1.0",
80
- "@grafana/ui": "13.1.0",
81
- "@grafana/schema": "13.1.0",{{#if_eq pluginType "scenesapp" }}
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": "^18.3.0",
84
- "react-dom": "^18.3.0"{{#if isAppType}},
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
  },
@@ -22,7 +22,7 @@
22
22
  "updated": "%TODAY%"
23
23
  },
24
24
  "dependencies": {
25
- "grafanaDependency": ">=12.3.0",
25
+ "grafanaDependency": ">=13.2.0",
26
26
  "plugins": []
27
27
  }
28
28
  }
@@ -19,7 +19,7 @@
19
19
  "updated": "%TODAY%"
20
20
  },
21
21
  "dependencies": {
22
- "grafanaDependency": ">=12.3.0",
22
+ "grafanaDependency": ">=13.2.0",
23
23
  "plugins": []
24
24
  }
25
25
  }
@@ -52,7 +52,7 @@
52
52
  }
53
53
  ],
54
54
  "dependencies": {
55
- "grafanaDependency": ">=12.3.0",
55
+ "grafanaDependency": ">=13.2.0",
56
56
  "plugins": []
57
57
  }
58
58
  }