@grafana/create-plugin 7.15.1 → 7.17.0

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 (30) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/codemods/additions/prompts/docs-content.md +1 -1
  3. package/dist/codemods/additions/scripts/experimental-app-sdk.js +40 -0
  4. package/dist/codemods/migrations/migrations.js +7 -1
  5. package/dist/codemods/migrations/scripts/019-remove-glob-dependency.js +93 -0
  6. package/dist/codemods/utils.ast.js +44 -1
  7. package/package.json +2 -1
  8. package/src/codemods/additions/prompts/docs-content.md +1 -1
  9. package/src/codemods/additions/scripts/docs.test.ts +39 -3
  10. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +59 -2
  11. package/src/codemods/additions/scripts/experimental-app-sdk.ts +55 -0
  12. package/src/codemods/migrations/migrations.ts +7 -1
  13. package/src/codemods/migrations/scripts/019-remove-glob-dependency.test.ts +146 -0
  14. package/src/codemods/migrations/scripts/019-remove-glob-dependency.ts +121 -0
  15. package/src/codemods/utils.ast.test.ts +151 -0
  16. package/src/codemods/utils.ast.ts +60 -0
  17. package/templates/common/.config/bundler/utils.ts +10 -9
  18. package/templates/common/_package.json +1 -2
  19. package/templates/docs/app/docs/AGENTS.md +2 -0
  20. package/templates/docs/app/docs/configure.md +4 -0
  21. package/templates/docs/app/docs/dashboards.md +4 -0
  22. package/templates/docs/app/docs/index.md +5 -1
  23. package/templates/docs/app/docs/troubleshooting.md +5 -1
  24. package/templates/docs/datasource/docs/AGENTS.md +2 -0
  25. package/templates/docs/datasource/docs/configure.md +4 -0
  26. package/templates/docs/datasource/docs/dashboards.md +4 -0
  27. package/templates/docs/datasource/docs/troubleshooting.md +5 -1
  28. package/templates/docs/panel/docs/AGENTS.md +2 -0
  29. package/templates/docs/panel/docs/data-formats.md +4 -0
  30. package/templates/docs/panel/docs/examples.md +4 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.17.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.16.0...@grafana/create-plugin@7.17.0) (2026-10-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * remove dependency on glob ([#2655](https://github.com/grafana/plugin-tools/issues/2655)) ([9db0649](https://github.com/grafana/plugin-tools/commit/9db0649fd4dee9b5745a248ba421998cd9b86764))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **templates:** Update dependency @grafana/plugin-e2e to v3.15.2 ([#3022](https://github.com/grafana/plugin-tools/issues/3022)) ([2115b27](https://github.com/grafana/plugin-tools/commit/2115b27767554a555640300c7b4dc9c9e1842909))
14
+
15
+ ## [7.16.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.15.1...@grafana/create-plugin@7.16.0) (2026-10-22)
16
+
17
+
18
+ ### Features
19
+
20
+ * **create-plugin:** Override the default Grafana version in the experimental-app-sdk addition ([#3014](https://github.com/grafana/plugin-tools/issues/3014)) ([8d35cd5](https://github.com/grafana/plugin-tools/commit/8d35cd55d2213b454f8e465ec7b9e01cc3039010))
21
+ * **create-plugin:** scaffold docs snippets with fallback text ([#3021](https://github.com/grafana/plugin-tools/issues/3021)) ([7ef65e4](https://github.com/grafana/plugin-tools/commit/7ef65e418a9ed5951d62184954511297196aa0ee))
22
+
3
23
  ## [7.15.1](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.15.0...@grafana/create-plugin@7.15.1) (2026-10-21)
4
24
 
5
25
 
@@ -20,7 +20,7 @@ This usually runs once, after `add docs`. Running it again is safe, because it o
20
20
 
21
21
  ## Check first
22
22
 
23
- Search the docs folder for `section-brief` comments. A section that still has its brief is yours to write. A section without one has been written by the author, so never rewrite it.
23
+ Search the docs folder for `section-brief` comments. A section that still has its brief is yours to write. A section without one has been written by the author, so never rewrite it. Text between `<!-- snippet:... -->` and `<!-- /snippet:... -->` markers is maintained by Grafana, so never edit inside a snippet.
24
24
 
25
25
  If no briefs remain, the pages are already written. Do steps 1 and 3, adding any README or linked-docs content that isn't on the pages yet without rewriting what is there, then steps 7 to 10. If nothing is left to migrate, say so in the summary and change nothing.
26
26
 
@@ -36,6 +36,7 @@ function appSdk(context) {
36
36
  referenceAgentInstructions(context, data);
37
37
  addGenerateScript(context);
38
38
  addFeatureToggle(context);
39
+ addGrafanaVersionOverride(context);
39
40
  wireGoBackend(context, data);
40
41
  if (Object.keys(context.listChanges()).length > changesBefore) {
41
42
  context.setMessage(buildNextStepsMessage(hasGoBackend(context), data.packageManagerName));
@@ -155,6 +156,45 @@ function addFeatureToggle(context) {
155
156
  composeData.setIn(["services", "grafana", "environment", "GF_FEATURE_TOGGLES_ENABLE"], toggles.join(","));
156
157
  context.updateFile(composePath, stringify(composeData, { lineWidth: 120, singleQuote: true }));
157
158
  }
159
+ const GRAFANA_BUILD_ARGS = {
160
+ grafana_image: "${GRAFANA_IMAGE:-grafana}",
161
+ grafana_version: "${GRAFANA_VERSION:-nightly}"
162
+ };
163
+ function addGrafanaVersionOverride(context) {
164
+ const composePath = "docker-compose.yaml";
165
+ const composeContent = context.getFile(composePath);
166
+ if (!composeContent) {
167
+ additionsDebug(`Could not find ${composePath}. Skipping the Grafana version override.`);
168
+ return;
169
+ }
170
+ const composeData = parseDocument(composeContent);
171
+ if (!(composeData.getIn(["services", "grafana"]) instanceof YAMLMap)) {
172
+ additionsDebug(`Could not find the grafana service in ${composePath}. Skipping the Grafana version override.`);
173
+ return;
174
+ }
175
+ const build = composeData.getIn(["services", "grafana", "build"]);
176
+ if (build !== void 0 && !(build instanceof YAMLMap)) {
177
+ additionsDebug(`services.grafana.build in ${composePath} is not a mapping. Skipping the Grafana version override.`);
178
+ return;
179
+ }
180
+ const args = composeData.getIn(["services", "grafana", "build", "args"]);
181
+ if (args !== void 0 && !(args instanceof YAMLMap)) {
182
+ additionsDebug(
183
+ `services.grafana.build.args in ${composePath} is not a mapping. Skipping the Grafana version override.`
184
+ );
185
+ return;
186
+ }
187
+ let changed = false;
188
+ for (const [name, value] of Object.entries(GRAFANA_BUILD_ARGS)) {
189
+ if (!composeData.hasIn(["services", "grafana", "build", "args", name])) {
190
+ composeData.setIn(["services", "grafana", "build", "args", name], value);
191
+ changed = true;
192
+ }
193
+ }
194
+ if (changed) {
195
+ context.updateFile(composePath, stringify(composeData, { lineWidth: 120, singleQuote: true }));
196
+ }
197
+ }
158
198
  function wireGoBackend(context, data) {
159
199
  if (!hasGoBackend(context)) {
160
200
  additionsDebug("No Go backend found. Skipping main.go wiring.");
@@ -108,11 +108,17 @@ var defaultMigrations = [
108
108
  version: "7.13.0",
109
109
  description: "Replace eslint-webpack-plugin with diagnostics-webpack-plugin in the webpack config. eslint-webpack-plugin depends on micromatch, which pulls in the braces dependency flagged by vulnerability scanners.",
110
110
  scriptPath: import.meta.resolve("./scripts/018-replace-eslint-webpack-plugin.js")
111
+ },
112
+ {
113
+ name: "019-remove-glob-dependency",
114
+ version: "7.17.0",
115
+ description: "Replace the glob package with the Node.js built-in fs.glob in bundler utils.",
116
+ scriptPath: import.meta.resolve("./scripts/019-remove-glob-dependency.js")
111
117
  }
112
118
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
113
119
  // for those written before the switch to updates as migrations.
114
120
  //
115
- // New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
121
+ // New migrations should never specify a version: copy the unreleased version line from ../AGENTS.md exactly.
116
122
  // Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
117
123
  ];
118
124
 
@@ -0,0 +1,93 @@
1
+ import { parseAsTypescript, findImports, findNamedImport, findFunctionDeclaration, callsFunction, insertImports, createImport, printAST } from '../../utils.ast.js';
2
+ import { removeDependenciesFromPackageJson, migrationsDebug } from '../../utils.js';
3
+
4
+ const OLD_PACKAGE_NAME = "glob";
5
+ const NEW_PACKAGE_NAME = "node:fs/promises";
6
+ const UTILS_PATHS = [".config/webpack/utils.ts", ".config/bundler/utils.ts"];
7
+ const GET_ENTRIES_SOURCE = `export async function getEntries() {
8
+ const plugins: string[][] = [];
9
+ for await (const pluginJson of glob('**/src/**/plugin.json')) {
10
+ const folder = path.dirname(pluginJson);
11
+ const modules: string[] = [];
12
+ for await (const module of glob(\`\${folder}/module.{ts,tsx,js,jsx}\`)) {
13
+ modules.push(path.resolve(module));
14
+ }
15
+ plugins.push(modules);
16
+ }
17
+
18
+ return plugins.reduce<Record<string, string>>((result, modules) => {
19
+ return modules.reduce((innerResult, module) => {
20
+ const pluginPath = path.dirname(module);
21
+ const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\\/?/i, '');
22
+ const entryName = pluginName === '' ? 'module' : \`\${pluginName}/module\`;
23
+
24
+ innerResult[entryName] = module;
25
+ return innerResult;
26
+ }, result);
27
+ }, {});
28
+ }`;
29
+ function migrate(context) {
30
+ const updatedPaths = UTILS_PATHS.filter((filePath) => replaceGlobWithFsGlob(context, filePath));
31
+ if (updatedPaths.length === 0) {
32
+ return context;
33
+ }
34
+ const isStillUsed = UTILS_PATHS.some((filePath) => {
35
+ const source = context.getFile(filePath);
36
+ if (!source) {
37
+ return false;
38
+ }
39
+ const parsed = parseAsTypescript(source);
40
+ return !parsed.success || findImports(parsed.ast, OLD_PACKAGE_NAME).length > 0;
41
+ });
42
+ if (context.doesFileExist("package.json") && !isStillUsed) {
43
+ removeDependenciesFromPackageJson(context, [OLD_PACKAGE_NAME], [OLD_PACKAGE_NAME]);
44
+ }
45
+ return context;
46
+ }
47
+ function replaceGlobWithFsGlob(context, utilsPath) {
48
+ if (!context.doesFileExist(utilsPath)) {
49
+ return false;
50
+ }
51
+ const source = context.getFile(utilsPath);
52
+ if (!source || !source.includes(OLD_PACKAGE_NAME)) {
53
+ return false;
54
+ }
55
+ const parsed = parseAsTypescript(source);
56
+ if (!parsed.success) {
57
+ migrationsDebug(`Failed to parse ${utilsPath}. Error: ${parsed.error.message}`);
58
+ return false;
59
+ }
60
+ const globImport = findNamedImport(parsed.ast, OLD_PACKAGE_NAME, "glob");
61
+ if (!globImport) {
62
+ return false;
63
+ }
64
+ const getEntries = findFunctionDeclaration(parsed.ast, "getEntries");
65
+ if (!getEntries) {
66
+ return false;
67
+ }
68
+ if (!callsFunction(getEntries.body, "glob")) {
69
+ return false;
70
+ }
71
+ const { declaration, specifier } = globImport;
72
+ declaration.specifiers = (declaration.specifiers ?? []).filter((candidate) => candidate !== specifier);
73
+ if (declaration.specifiers.length === 0) {
74
+ parsed.ast.program.body.splice(parsed.ast.program.body.indexOf(declaration), 1);
75
+ }
76
+ insertImports(parsed.ast, [createImport({ named: [{ name: "glob" }] }, NEW_PACKAGE_NAME)]);
77
+ getEntries.body = getFunctionBody(GET_ENTRIES_SOURCE);
78
+ context.updateFile(utilsPath, printAST(parsed.ast));
79
+ return true;
80
+ }
81
+ function getFunctionBody(source) {
82
+ const parsed = parseAsTypescript(source);
83
+ if (!parsed.success) {
84
+ throw parsed.error;
85
+ }
86
+ const [statement] = parsed.ast.program.body;
87
+ if (statement?.type !== "ExportNamedDeclaration" || statement.declaration?.type !== "FunctionDeclaration") {
88
+ throw new Error("Failed to parse target function body");
89
+ }
90
+ return statement.declaration.body;
91
+ }
92
+
93
+ export { migrate as default };
@@ -34,6 +34,33 @@ function findVariableDeclaration(ast, variableName) {
34
34
  });
35
35
  return found;
36
36
  }
37
+ function findFunctionDeclaration(ast, functionName) {
38
+ let found = null;
39
+ recast.types.visit(ast, {
40
+ visitFunctionDeclaration(path) {
41
+ if (path.node.id?.name === functionName) {
42
+ found = path.node;
43
+ return false;
44
+ }
45
+ return this.traverse(path);
46
+ }
47
+ });
48
+ return found;
49
+ }
50
+ function callsFunction(ast, functionName) {
51
+ let isCalled = false;
52
+ recast.types.visit(ast, {
53
+ visitCallExpression(path) {
54
+ const { callee } = path.node;
55
+ if (callee.type === "Identifier" && callee.name === functionName) {
56
+ isCalled = true;
57
+ return false;
58
+ }
59
+ return this.traverse(path);
60
+ }
61
+ });
62
+ return isCalled;
63
+ }
37
64
  function isProperty(node) {
38
65
  return node.type === "Property" || node.type === "ObjectProperty";
39
66
  }
@@ -71,5 +98,21 @@ function insertImports(ast, imports) {
71
98
  ast.program.body.unshift(...imports);
72
99
  }
73
100
  }
101
+ function findImports(ast, packageName) {
102
+ return ast.program.body.filter(
103
+ (node) => node.type === "ImportDeclaration" && node.source.value === packageName
104
+ );
105
+ }
106
+ function findNamedImport(ast, packageName, importedName) {
107
+ for (const declaration of findImports(ast, packageName)) {
108
+ const specifier = declaration.specifiers?.find(
109
+ (candidate) => candidate.type === "ImportSpecifier" && candidate.imported.name === importedName
110
+ );
111
+ if (specifier) {
112
+ return { declaration, specifier };
113
+ }
114
+ }
115
+ return void 0;
116
+ }
74
117
 
75
- export { createImport, findObjectProperty, findVariableDeclaration, insertImports, isProperty, parseAsTypescript, printAST };
118
+ export { callsFunction, createImport, findFunctionDeclaration, findImports, findNamedImport, findObjectProperty, findVariableDeclaration, insertImports, isProperty, parseAsTypescript, printAST };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/create-plugin",
3
- "version": "7.15.1",
3
+ "version": "7.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "directory": "packages/create-plugin",
@@ -43,6 +43,7 @@
43
43
  "yaml": "2.9.1"
44
44
  },
45
45
  "devDependencies": {
46
+ "@grafana/plugin-docs-parser": "0.6.0",
46
47
  "@libs/find-up": "1.0.0",
47
48
  "@libs/output": "1.0.3",
48
49
  "@libs/version": "1.0.2",
@@ -20,7 +20,7 @@ This usually runs once, after `add docs`. Running it again is safe, because it o
20
20
 
21
21
  ## Check first
22
22
 
23
- Search the docs folder for `section-brief` comments. A section that still has its brief is yours to write. A section without one has been written by the author, so never rewrite it.
23
+ Search the docs folder for `section-brief` comments. A section that still has its brief is yours to write. A section without one has been written by the author, so never rewrite it. Text between `<!-- snippet:... -->` and `<!-- /snippet:... -->` markers is maintained by Grafana, so never edit inside a snippet.
24
24
 
25
25
  If no briefs remain, the pages are already written. Do steps 1 and 3, adding any README or linked-docs content that isn't on the pages yet without rewriting what is there, then steps 7 to 10. If nothing is left to migrate, say so in the summary and change nothing.
26
26
 
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { stripVTControlCharacters } from 'node:util';
5
+ import { readSnippets, renderSnippet } from '@grafana/plugin-docs-parser';
5
6
  import * as v from 'valibot';
6
7
  import { parse as parseYaml } from 'yaml';
7
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -77,6 +78,7 @@ const PAGES_BY_TYPE: Record<string, string[]> = {
77
78
  app: ['index', 'configure', 'overview', 'reports', 'dashboards', 'permissions', 'troubleshooting'],
78
79
  };
79
80
  const PLUGIN_TYPES = Object.keys(PAGES_BY_TYPE);
81
+ const SNIPPET_MARKER_RE = /^<!-- \/?snippet:\S+ -->$/gm;
80
82
 
81
83
  // scaffolds once and writes it to disk, so the re-run sees a plugin that is already fully set up
82
84
  function scaffoldThenRerun(
@@ -749,7 +751,7 @@ describe('docs codemod', () => {
749
751
  const content = context.getFile(`docs/${page}.md`) ?? '';
750
752
  const briefs = content.match(/<!-- section-brief\n[\s\S]*?-->/g) ?? [];
751
753
  expect(briefs.length, page).toBeGreaterThan(0);
752
- expect(content.match(/<!--/g)?.length, page).toBe(briefs.length);
754
+ expect(content.replace(SNIPPET_MARKER_RE, '').match(/<!--/g)?.length, page).toBe(briefs.length);
753
755
  expect(content, page).not.toContain('section-brief:');
754
756
  }
755
757
  });
@@ -829,7 +831,7 @@ describe('docs codemod', () => {
829
831
  troubleshooting: [
830
832
  'This page explains how to fix common issues when you configure or use the My Plugin data source. For setup instructions, refer to [Configure the My Plugin data source](./configure.md).',
831
833
  'To see the raw request and response for a panel, open the panel editor and click **Query inspector**.',
832
- 'In self-managed Grafana, you can also find more detail in the Grafana server logs:',
834
+ 'In self-managed Grafana, the Grafana server logs show more detail:',
833
835
  "1. Set the log level to `debug` in the Grafana configuration file:\n\n ```ini\n [log]\n level = debug\n ```\n\n1. Restart Grafana and reproduce the error.\n1. Look for log lines with `logger=plugin.acme-test-datasource`.\n1. Set the log level back to `info` when you're done, to keep the logs small.",
834
836
  "If this page doesn't solve your problem:",
835
837
  '- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
@@ -867,7 +869,7 @@ describe('docs codemod', () => {
867
869
  troubleshooting: [
868
870
  'This page explains how to fix common issues when you set up or use My Plugin. For setup instructions, refer to the [Introduction](./index.md).',
869
871
  'For errors in the app, open the developer tools in your browser and check the console.',
870
- 'In self-managed Grafana, you can also find more detail in the Grafana server logs:',
872
+ 'In self-managed Grafana, the Grafana server logs show more detail:',
871
873
  "1. Set the log level to `debug` in the Grafana configuration file:\n\n ```ini\n [log]\n level = debug\n ```\n\n1. Restart Grafana and reproduce the error.\n1. Look for log lines with `logger=plugin.acme-test-app`.\n1. Set the log level back to `info` when you're done, to keep the logs small.",
872
874
  "If this page doesn't solve your problem:",
873
875
  '- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
@@ -886,6 +888,7 @@ describe('docs codemod', () => {
886
888
  let left = (context.getFile(`docs/${page}.md`) ?? '')
887
889
  .replace(/^---\n[\s\S]*?\n---\n/, '')
888
890
  .replace(/<!-- section-brief\n[\s\S]*?-->/g, '')
891
+ .replace(SNIPPET_MARKER_RE, '')
889
892
  .replace(/^#{1,6} .*$/gm, '');
890
893
  for (const text of FIXED_TEXT[type][page] ?? []) {
891
894
  expect(left, page).toContain(text);
@@ -896,6 +899,39 @@ describe('docs codemod', () => {
896
899
  }
897
900
  );
898
901
 
902
+ // when the parser's text for a snippet changes, the fallback in the template must follow
903
+ it.each(PLUGIN_TYPES)('scaffolds %s snippets whose fallback is the parser current text', (type) => {
904
+ const pluginJson = FULL_PLUGIN_JSON[type];
905
+ const context = makeContext(pluginJson);
906
+ docs(context, { docsPath: 'docs' });
907
+ const values = { pluginName: String(pluginJson.name), pluginId: String(pluginJson.id) };
908
+ const collapse = (text: string) => text.replace(/\s+/g, ' ').trim();
909
+ const names: string[] = [];
910
+
911
+ for (const page of PAGES_BY_TYPE[type]) {
912
+ const content = context.getFile(`docs/${page}.md`) ?? '';
913
+ const { snippets, problems } = readSnippets(content);
914
+ expect(problems, page).toEqual([]);
915
+ const lines = content.split('\n');
916
+ for (const { name, startLine, endLine } of snippets) {
917
+ names.push(name);
918
+ expect(collapse(lines.slice(startLine, endLine - 1).join('\n')), `${page} ${name}`).toBe(
919
+ collapse(renderSnippet(name, values) ?? '')
920
+ );
921
+ }
922
+ }
923
+
924
+ expect(names.length).toBeGreaterThan(0);
925
+ });
926
+
927
+ it.each(['datasource', 'app'])('leaves the server logs snippet out of a %s without a backend', (type) => {
928
+ const context = makeContext({ ...FULL_PLUGIN_JSON[type], backend: false });
929
+ docs(context, { docsPath: 'docs' });
930
+ const content = context.getFile('docs/troubleshooting.md') ?? '';
931
+ expect(content).not.toContain('snippet:server-debug-logs');
932
+ expect(readSnippets(content)).toEqual({ snippets: [], problems: [] });
933
+ });
934
+
899
935
  it('interpolates pluginName into the index page', () => {
900
936
  const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
901
937
  docs(context, { docsPath: 'docs' });
@@ -245,6 +245,62 @@ describe('experimental-app-sdk addition', () => {
245
245
  });
246
246
  });
247
247
 
248
+ describe('grafana version override', () => {
249
+ it('adds the image and version build args', () => {
250
+ const result = appSdk(createAppContext());
251
+
252
+ const compose = result.getFile('docker-compose.yaml') ?? '';
253
+ expect(compose).toContain('grafana_image: ${GRAFANA_IMAGE:-grafana}');
254
+ expect(compose).toContain('grafana_version: ${GRAFANA_VERSION:-nightly}');
255
+ });
256
+
257
+ it('adds the build args even when the base compose file already enables the toggles', () => {
258
+ const context = createAppContext();
259
+ context.addFile(
260
+ '.config/docker-compose-base.yaml',
261
+ `services:
262
+ grafana:
263
+ environment:
264
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
265
+ `
266
+ );
267
+
268
+ const result = appSdk(context);
269
+
270
+ expect(result.getFile('docker-compose.yaml')).toContain('grafana_version: ${GRAFANA_VERSION:-nightly}');
271
+ });
272
+
273
+ it('keeps build args the user already set', () => {
274
+ const context = createAppContext({
275
+ compose: `services:
276
+ grafana:
277
+ extends:
278
+ file: .config/docker-compose-base.yaml
279
+ service: grafana
280
+ build:
281
+ args:
282
+ grafana_version: 12.0.0
283
+ `,
284
+ });
285
+
286
+ const result = appSdk(context);
287
+
288
+ const compose = result.getFile('docker-compose.yaml') ?? '';
289
+ expect(compose).toContain('grafana_version: 12.0.0');
290
+ expect(compose).not.toContain('GRAFANA_VERSION');
291
+ expect(compose).toContain('grafana_image: ${GRAFANA_IMAGE:-grafana}');
292
+ });
293
+
294
+ it('is idempotent', () => {
295
+ const once = appSdk(createAppContext());
296
+ const compose = once.getFile('docker-compose.yaml');
297
+
298
+ const twice = appSdk(once);
299
+
300
+ expect(twice.getFile('docker-compose.yaml')).toBe(compose);
301
+ });
302
+ });
303
+
248
304
  describe('feature toggle', () => {
249
305
  it('enables the app-sdk manifest toggle', () => {
250
306
  const context = createAppContext();
@@ -328,7 +384,7 @@ describe('experimental-app-sdk addition', () => {
328
384
 
329
385
  const result = appSdk(context);
330
386
 
331
- expect(result.getFile('docker-compose.yaml')).toBe(STOCK_COMPOSE);
387
+ expect(result.getFile('docker-compose.yaml')).not.toContain('GF_FEATURE_TOGGLES_ENABLE');
332
388
  });
333
389
 
334
390
  it('leaves a list-style environment block alone', () => {
@@ -341,7 +397,8 @@ describe('experimental-app-sdk addition', () => {
341
397
 
342
398
  const result = appSdk(context);
343
399
 
344
- expect(result.getFile('docker-compose.yaml')).toBe(listCompose);
400
+ expect(result.getFile('docker-compose.yaml')).toContain('- GF_FEATURE_TOGGLES_ENABLE=someOtherToggle');
401
+ expect(result.getFile('docker-compose.yaml')).not.toContain('appplugins.loadAppManifest');
345
402
  // The rest of the addition still applies.
346
403
  expect(result.doesFileExist('kinds/config.cue')).toBe(true);
347
404
  });
@@ -59,6 +59,7 @@ export default function appSdk(context: Context): Context {
59
59
  referenceAgentInstructions(context, data);
60
60
  addGenerateScript(context);
61
61
  addFeatureToggle(context);
62
+ addGrafanaVersionOverride(context);
62
63
  wireGoBackend(context, data);
63
64
 
64
65
  // Only guide the user when we actually scaffolded something; a re-run should stay quiet.
@@ -240,6 +241,60 @@ function addFeatureToggle(context: Context) {
240
241
  context.updateFile(composePath, stringify(composeData, { lineWidth: 120, singleQuote: true }));
241
242
  }
242
243
 
244
+ // The app-sdk needs a newer Grafana than the default the plugin was scaffolded with, so the root
245
+ // compose file overrides the build args, while still letting GRAFANA_IMAGE/GRAFANA_VERSION win.
246
+ const GRAFANA_BUILD_ARGS: Record<string, string> = {
247
+ grafana_image: '${GRAFANA_IMAGE:-grafana}',
248
+ grafana_version: '${GRAFANA_VERSION:-nightly}',
249
+ };
250
+
251
+ /**
252
+ * Overrides the default Grafana image and version in the root docker-compose.yaml. Build args the
253
+ * user has already set are left alone.
254
+ */
255
+ function addGrafanaVersionOverride(context: Context) {
256
+ const composePath = 'docker-compose.yaml';
257
+ const composeContent = context.getFile(composePath);
258
+
259
+ if (!composeContent) {
260
+ additionsDebug(`Could not find ${composePath}. Skipping the Grafana version override.`);
261
+ return;
262
+ }
263
+
264
+ const composeData = parseDocument(composeContent);
265
+
266
+ if (!(composeData.getIn(['services', 'grafana']) instanceof YAMLMap)) {
267
+ additionsDebug(`Could not find the grafana service in ${composePath}. Skipping the Grafana version override.`);
268
+ return;
269
+ }
270
+
271
+ const build = composeData.getIn(['services', 'grafana', 'build']);
272
+ if (build !== undefined && !(build instanceof YAMLMap)) {
273
+ additionsDebug(`services.grafana.build in ${composePath} is not a mapping. Skipping the Grafana version override.`);
274
+ return;
275
+ }
276
+
277
+ const args = composeData.getIn(['services', 'grafana', 'build', 'args']);
278
+ if (args !== undefined && !(args instanceof YAMLMap)) {
279
+ additionsDebug(
280
+ `services.grafana.build.args in ${composePath} is not a mapping. Skipping the Grafana version override.`
281
+ );
282
+ return;
283
+ }
284
+
285
+ let changed = false;
286
+ for (const [name, value] of Object.entries(GRAFANA_BUILD_ARGS)) {
287
+ if (!composeData.hasIn(['services', 'grafana', 'build', 'args', name])) {
288
+ composeData.setIn(['services', 'grafana', 'build', 'args', name], value);
289
+ changed = true;
290
+ }
291
+ }
292
+
293
+ if (changed) {
294
+ context.updateFile(composePath, stringify(composeData, { lineWidth: 120, singleQuote: true }));
295
+ }
296
+ }
297
+
243
298
  /**
244
299
  * Wires the generated kinds into the Go backend, for app plugins that have one. Go code generation
245
300
  * itself is enabled when kinds/config.cue is first scaffolded, in addTemplateFiles.
@@ -127,9 +127,15 @@ export default [
127
127
  'Replace eslint-webpack-plugin with diagnostics-webpack-plugin in the webpack config. eslint-webpack-plugin depends on micromatch, which pulls in the braces dependency flagged by vulnerability scanners.',
128
128
  scriptPath: import.meta.resolve('./scripts/018-replace-eslint-webpack-plugin.js'),
129
129
  },
130
+ {
131
+ name: '019-remove-glob-dependency',
132
+ version: '7.17.0',
133
+ description: 'Replace the glob package with the Node.js built-in fs.glob in bundler utils.',
134
+ scriptPath: import.meta.resolve('./scripts/019-remove-glob-dependency.js'),
135
+ },
130
136
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
131
137
  // for those written before the switch to updates as migrations.
132
138
  //
133
- // New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
139
+ // New migrations should never specify a version: copy the unreleased version line from ../AGENTS.md exactly.
134
140
  // Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
135
141
  ] satisfies Migration[];