@grafana/create-plugin 7.13.0 → 7.15.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 (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/codemods/additions/additions.js +12 -0
  3. package/dist/codemods/additions/prompts/docs-content.md +131 -0
  4. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  5. package/dist/codemods/additions/scripts/docs.js +468 -0
  6. package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
  7. package/dist/codemods/utils.agents.js +64 -0
  8. package/dist/codemods/utils.js +40 -7
  9. package/dist/codemods/utils.workflows.js +94 -0
  10. package/dist/utils/utils.cli.js +3 -2
  11. package/dist/utils/utils.handlebars.js +9 -2
  12. package/package.json +1 -1
  13. package/src/codemods/additions/additions.ts +12 -0
  14. package/src/codemods/additions/prompts/docs-content.md +131 -0
  15. package/src/codemods/additions/scripts/docs-content.test.ts +103 -0
  16. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  17. package/src/codemods/additions/scripts/docs.test.ts +1766 -0
  18. package/src/codemods/additions/scripts/docs.ts +697 -0
  19. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
  20. package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
  21. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  22. package/src/codemods/utils.agents.test.ts +140 -0
  23. package/src/codemods/utils.agents.ts +81 -0
  24. package/src/codemods/utils.test.ts +23 -0
  25. package/src/codemods/utils.ts +29 -7
  26. package/src/codemods/utils.workflows.test.ts +153 -0
  27. package/src/codemods/utils.workflows.ts +140 -0
  28. package/src/utils/utils.cli.ts +2 -1
  29. package/src/utils/utils.handlebars.ts +9 -0
  30. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  31. package/templates/docs/app/docs/AGENTS.md +127 -0
  32. package/templates/docs/app/docs/CLAUDE.md +1 -0
  33. package/templates/docs/app/docs/README.md +20 -0
  34. package/templates/docs/app/docs/configure.md +65 -0
  35. package/templates/docs/app/docs/dashboards.md +29 -0
  36. package/templates/docs/app/docs/index.md +70 -0
  37. package/templates/docs/app/docs/permissions.md +45 -0
  38. package/templates/docs/app/docs/troubleshooting.md +65 -0
  39. package/templates/docs/app/feature-page.md +26 -0
  40. package/templates/docs/app/features-index.md +12 -0
  41. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  42. package/templates/docs/datasource/docs/AGENTS.md +123 -0
  43. package/templates/docs/datasource/docs/CLAUDE.md +1 -0
  44. package/templates/docs/datasource/docs/README.md +20 -0
  45. package/templates/docs/datasource/docs/alerting.md +56 -0
  46. package/templates/docs/datasource/docs/annotations.md +68 -0
  47. package/templates/docs/datasource/docs/configure.md +114 -0
  48. package/templates/docs/datasource/docs/dashboards.md +28 -0
  49. package/templates/docs/datasource/docs/index.md +40 -0
  50. package/templates/docs/datasource/docs/query-editor.md +72 -0
  51. package/templates/docs/datasource/docs/template-variables.md +69 -0
  52. package/templates/docs/datasource/docs/troubleshooting.md +73 -0
  53. package/templates/docs/panel/docs/AGENTS.md +113 -0
  54. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  55. package/templates/docs/panel/docs/README.md +20 -0
  56. package/templates/docs/panel/docs/data-formats.md +27 -0
  57. package/templates/docs/panel/docs/examples.md +35 -0
  58. package/templates/docs/panel/docs/index.md +26 -0
  59. package/templates/docs/panel/docs/options.md +53 -0
  60. package/templates/docs/panel/docs/troubleshooting.md +35 -0
@@ -252,7 +252,7 @@ describe('experimental-app-sdk addition', () => {
252
252
  const result = appSdk(context);
253
253
 
254
254
  expect(result.getFile('docker-compose.yaml')).toContain(
255
- 'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer'
255
+ 'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware'
256
256
  );
257
257
  });
258
258
 
@@ -274,6 +274,7 @@ describe('experimental-app-sdk addition', () => {
274
274
  expect(compose).toContain('someOtherToggle');
275
275
  expect(compose).toContain('appplugins.loadAppManifest');
276
276
  expect(compose).toContain('appplugins.registerAPIServer');
277
+ expect(compose).toContain('grafana.useRouterMiddleware');
277
278
  });
278
279
 
279
280
  it('does not duplicate the toggles when they are already set', () => {
@@ -284,7 +285,7 @@ describe('experimental-app-sdk addition', () => {
284
285
  file: .config/docker-compose-base.yaml
285
286
  service: grafana
286
287
  environment:
287
- GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
288
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
288
289
  `,
289
290
  });
290
291
 
@@ -321,7 +322,7 @@ describe('experimental-app-sdk addition', () => {
321
322
  `services:
322
323
  grafana:
323
324
  environment:
324
- GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
325
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
325
326
  `
326
327
  );
327
328
 
@@ -6,11 +6,16 @@ import type { TemplateData } from '../../../types.js';
6
6
  import { getBackendCmd } from '../../../commands/generate/print-success-message.js';
7
7
  import { output } from '../../../utils/utils.console.js';
8
8
  import { additionsDebug, getTemplateDataFromContext, renderTemplate } from '../../utils.js';
9
+ import { AGENT_INSTRUCTIONS_PATH, appendToAgentInstructions } from '../../utils.agents.js';
9
10
  import { addRequireToGoMod } from '../../utils.goMod.js';
10
11
 
11
12
  // Grafana reads an app-sdk manifest from the plugin bundle, and registers its API server, only when
12
- // these toggles are enabled.
13
- const APP_SDK_FEATURE_TOGGLES = ['appplugins.loadAppManifest', 'appplugins.registerAPIServer'];
13
+ // these toggles are enabled. The router middleware lets the app's requests through the router.
14
+ const APP_SDK_FEATURE_TOGGLES = [
15
+ 'appplugins.loadAppManifest',
16
+ 'appplugins.registerAPIServer',
17
+ 'grafana.useRouterMiddleware',
18
+ ];
14
19
 
15
20
  // Files copied verbatim from templates/app-sdk, and whether they get the "scaffolded by create-plugin,
16
21
  // don't edit" header. Paths are relative to both the template folder and the plugin root.
@@ -130,11 +135,8 @@ function renderAppSdkTemplate(file: string, data: TemplateData, includeWarning =
130
135
  * plugin's instructions has no idea kinds or generated code exist.
131
136
  */
132
137
  function referenceAgentInstructions(context: Context, data: TemplateData) {
133
- const path = '.config/AGENTS/instructions.md';
134
- const instructions = context.getFile(path);
135
-
136
- if (!instructions) {
137
- additionsDebug(`Could not find ${path}. Skipping the app-sdk reference.`);
138
+ if (!context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
139
+ additionsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}. Skipping the app-sdk reference.`);
138
140
  return;
139
141
  }
140
142
 
@@ -142,13 +144,11 @@ function referenceAgentInstructions(context: Context, data: TemplateData) {
142
144
  context.addFile(APP_SDK_MD, renderAppSdkTemplate(APP_SDK_MD, data));
143
145
  }
144
146
 
145
- if (instructions.includes(APP_SDK_MD)) {
146
- return;
147
- }
148
-
149
- context.updateFile(
150
- path,
151
- `${instructions.trimEnd()}\n- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`
147
+ appendToAgentInstructions(
148
+ context,
149
+ APP_SDK_MD,
150
+ `- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`,
151
+ '\n'
152
152
  );
153
153
  }
154
154
 
@@ -257,7 +257,7 @@ function wireGoBackend(context: Context, data: TemplateData) {
257
257
  }
258
258
 
259
259
  // The go.mod version for grafana-app-sdk and its plugin/ submodule.
260
- const GRAFANA_APP_SDK_VERSION = 'v0.60.3';
260
+ const GRAFANA_APP_SDK_VERSION = 'v0.60.16';
261
261
 
262
262
  /**
263
263
  * Adds github.com/grafana/grafana-app-sdk and its plugin/ submodule to go.mod. The generated Go kind
@@ -159,9 +159,7 @@ function addConfigsToMigration(imports: Imports, config: Linter.ConfigOverride)
159
159
  function generateAST(
160
160
  value: Object
161
161
  ):
162
- | recast.types.namedTypes.ObjectExpression
163
- | recast.types.namedTypes.ArrayExpression
164
- | recast.types.namedTypes.Literal {
162
+ recast.types.namedTypes.ObjectExpression | recast.types.namedTypes.ArrayExpression | recast.types.namedTypes.Literal {
165
163
  if (Array.isArray(value)) {
166
164
  return builders.arrayExpression(value.map((i) => generateAST(i)));
167
165
  }
@@ -0,0 +1,140 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Context } from './context.js';
4
+ import {
5
+ AGENT_INSTRUCTIONS_PATH,
6
+ INSTRUCTIONS_POINTER,
7
+ appendToAgentInstructions,
8
+ ensureConfigAgentFiles,
9
+ ensureRootAgentFiles,
10
+ } from './utils.agents.js';
11
+
12
+ describe('utils.agents', () => {
13
+ describe('ensureRootAgentFiles', () => {
14
+ const templateDir = join(__dirname, '..', '..', 'templates', 'common');
15
+
16
+ it('creates the missing root agent files', () => {
17
+ const context = new Context('/virtual');
18
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
19
+ for (const file of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
20
+ expect(context.getFile(file), file).toContain(AGENT_INSTRUCTIONS_PATH);
21
+ }
22
+ });
23
+
24
+ it('appends a pointer to an existing file that never reaches instructions.md', () => {
25
+ const context = new Context('/virtual');
26
+ context.addFile('CLAUDE.md', '# Mine\n');
27
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
28
+ expect(context.getFile('CLAUDE.md')).toContain(AGENT_INSTRUCTIONS_PATH);
29
+ expect(context.getFile('CLAUDE.md')).toContain('# Mine');
30
+ });
31
+
32
+ it('treats a file that imports a linked AGENTS.md as linked', () => {
33
+ const context = new Context('/virtual');
34
+ context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
35
+ context.addFile('CLAUDE.md', '@AGENTS.md\n');
36
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
37
+ expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
38
+ });
39
+
40
+ it.each(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])('uses the same pointer as the %s template', (file) => {
41
+ expect(readFileSync(join(templateDir, file), 'utf-8')).toContain(INSTRUCTIONS_POINTER);
42
+ });
43
+
44
+ it.each([
45
+ ['a plain mention of AGENTS.md', 'Codex reads AGENTS.md, this file is for Claude.\n'],
46
+ ['a markdown link to AGENTS.md', 'See [the guide](AGENTS.md).\n'],
47
+ ['a plain mention of the instructions', `Don't edit ${AGENT_INSTRUCTIONS_PATH} by hand.\n`],
48
+ ])('links a CLAUDE.md with only %s', (_, content) => {
49
+ const context = new Context('/virtual');
50
+ context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
51
+ context.addFile('CLAUDE.md', content);
52
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
53
+ expect(context.getFile('CLAUDE.md')).toBe(`${content}\n${INSTRUCTIONS_POINTER}`);
54
+ });
55
+
56
+ it('treats a plain mention of the instructions in AGENTS.md as linked', () => {
57
+ const context = new Context('/virtual');
58
+ context.addFile('AGENTS.md', `Read ${AGENT_INSTRUCTIONS_PATH} first.\n`);
59
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
60
+ });
61
+
62
+ it('links existing files that never reach instructions.md', () => {
63
+ const context = new Context('/virtual');
64
+ context.addFile('AGENTS.md', '# Mine\n');
65
+ context.addFile('CLAUDE.md', '@AGENTS.md\n');
66
+ context.addFile('GEMINI.md', 'See docs/AGENTS.md\n');
67
+ // AGENTS.md gets the pointer first, so CLAUDE.md's @AGENTS.md then resolves without its own edit
68
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
69
+ expect(context.getFile('AGENTS.md')).toContain(AGENT_INSTRUCTIONS_PATH);
70
+ expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
71
+ expect(context.getFile('GEMINI.md')).toContain(AGENT_INSTRUCTIONS_PATH);
72
+ });
73
+ });
74
+
75
+ describe('ensureConfigAgentFiles', () => {
76
+ const templateDir = join(__dirname, '..', '..', 'templates', 'common');
77
+
78
+ it('creates only the given files from the template dir and returns them', () => {
79
+ const context = new Context('/virtual');
80
+ const added = ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
81
+ packageManagerName: 'npm',
82
+ });
83
+
84
+ expect(added.sort()).toEqual(['.config/AGENTS/e2e-testing.md', AGENT_INSTRUCTIONS_PATH]);
85
+ });
86
+
87
+ it('does nothing when instructions.md already exists', () => {
88
+ const context = new Context('/virtual');
89
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
90
+ expect(
91
+ ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
92
+ packageManagerName: 'npm',
93
+ })
94
+ ).toEqual([]);
95
+
96
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n');
97
+ expect(context.doesFileExist('.config/AGENTS/e2e-testing.md')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('appendToAgentInstructions', () => {
102
+ it('writes just the text into an empty instructions file', () => {
103
+ const context = new Context('/virtual');
104
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '\n');
105
+ appendToAgentInstructions(context, 'new.md', '## New\n');
106
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('## New\n');
107
+ });
108
+
109
+ it('appends after a blank line', () => {
110
+ const context = new Context('/virtual');
111
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
112
+ appendToAgentInstructions(context, 'new.md', '## New\n');
113
+
114
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n\n## New\n');
115
+ });
116
+
117
+ it('uses the given separator', () => {
118
+ const context = new Context('/virtual');
119
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '- one\n');
120
+ appendToAgentInstructions(context, 'new.md', '- two\n', '\n');
121
+
122
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('- one\n- two\n');
123
+ });
124
+
125
+ it('skips when the marker is already present', () => {
126
+ const context = new Context('/virtual');
127
+ context.addFile(AGENT_INSTRUCTIONS_PATH, 'See new.md\n');
128
+ appendToAgentInstructions(context, 'new.md', '## New\n');
129
+
130
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('See new.md\n');
131
+ });
132
+
133
+ it('skips when there is no instructions file', () => {
134
+ const context = new Context('/virtual');
135
+ appendToAgentInstructions(context, 'new.md', '## New\n');
136
+
137
+ expect(context.doesFileExist(AGENT_INSTRUCTIONS_PATH)).toBe(false);
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Context } from './context.js';
4
+ import { addTemplateFolder, codemodsDebug } from './utils.js';
5
+
6
+ export const AGENT_INSTRUCTIONS_PATH = '.config/AGENTS/instructions.md';
7
+
8
+ const ROOT_AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
+ // matches the pointer line in templates/common/{AGENTS,CLAUDE,GEMINI}.md
10
+ export const INSTRUCTIONS_POINTER = `## Project knowledge\nThis repository contains a **Grafana plugin**. You must Read @./${AGENT_INSTRUCTIONS_PATH} before doing changes.\n`;
11
+ const IMPORTS_INSTRUCTIONS = /@(\.\/)?\.config\/AGENTS\/instructions\.md/;
12
+ const IMPORTS_AGENTS_MD = /(^|\s)@(\.\/)?AGENTS\.md\b/m;
13
+
14
+ /**
15
+ * Creates the missing root agent files. Appends a pointer to the instructions to any existing one that
16
+ * doesn't reach them, and returns those files.
17
+ */
18
+ export function ensureRootAgentFiles(context: Context, templateDir: string): string[] {
19
+ const existing = ROOT_AGENT_FILES.filter((file) => context.doesFileExist(file));
20
+ for (const file of ROOT_AGENT_FILES) {
21
+ if (!existing.includes(file)) {
22
+ context.addFile(file, readFileSync(join(templateDir, file), 'utf-8'));
23
+ }
24
+ }
25
+
26
+ // Claude and Gemini only follow `@` imports, while Codex reads AGENTS.md as text, so a mention is enough there
27
+ const reachesInstructions = (file: string): boolean => {
28
+ const content = context.getFile(file) ?? '';
29
+ if (file === 'AGENTS.md') {
30
+ return content.includes(AGENT_INSTRUCTIONS_PATH);
31
+ }
32
+ return IMPORTS_INSTRUCTIONS.test(content) || (IMPORTS_AGENTS_MD.test(content) && reachesInstructions('AGENTS.md'));
33
+ };
34
+
35
+ const linked: string[] = [];
36
+ for (const file of existing) {
37
+ if (reachesInstructions(file)) {
38
+ continue;
39
+ }
40
+ const content = context.getFile(file) ?? '';
41
+ context.updateFile(file, `${content.trimEnd()}\n\n${INSTRUCTIONS_POINTER}`);
42
+ linked.push(file);
43
+ }
44
+ return linked;
45
+ }
46
+
47
+ /**
48
+ * Creates the given `.config/AGENTS/` files, only when `instructions.md` is missing. They import each
49
+ * other, so a plugin with its own instructions keeps what it has. Returns the paths it added.
50
+ */
51
+ export function ensureConfigAgentFiles(
52
+ context: Context,
53
+ templateDir: string,
54
+ files: string[],
55
+ data: Record<string, unknown>
56
+ ): string[] {
57
+ if (context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
58
+ return [];
59
+ }
60
+ return addTemplateFolder(context, {
61
+ folderPath: join(templateDir, '.config', 'AGENTS'),
62
+ targetPath: '.config/AGENTS',
63
+ data,
64
+ include: (file) => files.includes(file),
65
+ });
66
+ }
67
+
68
+ /** Appends `text` to the agent instructions, unless they're missing or already contain `marker`. */
69
+ export function appendToAgentInstructions(context: Context, marker: string, text: string, separator = '\n\n') {
70
+ const existing = context.getFile(AGENT_INSTRUCTIONS_PATH);
71
+ if (existing === undefined) {
72
+ codemodsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}, skipping`);
73
+ return;
74
+ }
75
+ if (existing.includes(marker)) {
76
+ codemodsDebug(`${AGENT_INSTRUCTIONS_PATH} already contains ${marker}, skipping`);
77
+ return;
78
+ }
79
+ const kept = existing.trimEnd();
80
+ context.updateFile(AGENT_INSTRUCTIONS_PATH, kept ? `${kept}${separator}${text}` : text);
81
+ }
@@ -196,6 +196,12 @@ describe('utils', () => {
196
196
  const context = new Context(tmpDir);
197
197
  expect(() => readJsonFile(context, 'file.json')).toThrowError();
198
198
  });
199
+
200
+ it.each(['[]', 'null', '"text"'])('should throw when the file contains %s instead of an object', (content) => {
201
+ const context = new Context(tmpDir);
202
+ context.addFile('file.json', content);
203
+ expect(() => readJsonFile(context, 'file.json')).toThrowError('does not contain a JSON object');
204
+ });
199
205
  });
200
206
 
201
207
  describe('getTemplateDataFromContext', () => {
@@ -263,6 +269,23 @@ describe('utils', () => {
263
269
  expect(context.getFile('docs/nested/setup.md')).toBe('pnpm some-name\n');
264
270
  });
265
271
 
272
+ it('skips files that include rejects, matching on the output path relative to the target', () => {
273
+ const context = new Context(tmpDir);
274
+ const folderPath = templateFolder({ 'README.md': 'readme\n', 'nested/page.md.hbs': 'page\n', npmrc: 'x' });
275
+ const seen: string[] = [];
276
+ const added = addTemplateFolder(context, {
277
+ folderPath,
278
+ targetPath: 'docs',
279
+ data,
280
+ include: (file) => {
281
+ seen.push(file);
282
+ return file === 'README.md';
283
+ },
284
+ });
285
+ expect(seen.sort()).toEqual(['.npmrc', 'README.md', 'nested/page.md']);
286
+ expect(added).toEqual(['docs/README.md']);
287
+ });
288
+
266
289
  it('strips .hbs and maps config file names like generate does', () => {
267
290
  const context = new Context(tmpDir);
268
291
  const folderPath = templateFolder({ 'page.md.hbs': 'x', npmrc: 'y' });
@@ -1,4 +1,4 @@
1
- import { dirname, join } from 'node:path';
1
+ import { dirname, join, posix } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  import { Context } from './context.js';
4
4
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -180,11 +180,16 @@ export function readJsonFile<T extends object = any>(context: Context, path: str
180
180
  if (!context.doesFileExist(path)) {
181
181
  throw new Error(`Cannot find ${path}`);
182
182
  }
183
+ let parsed: unknown;
183
184
  try {
184
- return JSON.parse(context.getFile(path) || '');
185
+ parsed = JSON.parse(context.getFile(path) || '');
185
186
  } catch (e) {
186
187
  throw new Error(`Cannot parse ${path}: ${e}`);
187
188
  }
189
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
190
+ throw new Error(`${path} does not contain a JSON object.`);
191
+ }
192
+ return parsed as T;
188
193
  }
189
194
 
190
195
  export function addDependenciesToPackageJson(
@@ -346,6 +351,8 @@ function sortObjectByKeys<T extends Record<string, any>>(obj: T): T {
346
351
 
347
352
  export const migrationsDebug = debug.extend('migrations');
348
353
  export const additionsDebug = debug.extend('additions');
354
+ // for helpers that migrations and additions share
355
+ export const codemodsDebug = debug.extend('codemods');
349
356
 
350
357
  export function renderTemplate(
351
358
  templatePath: string,
@@ -374,12 +381,23 @@ export function getTemplateDataFromContext(context: Context): TemplateData {
374
381
 
375
382
  /**
376
383
  * Renders every file under `folderPath` into `targetPath`, the way `generate` scaffolds a template folder:
377
- * `.hbs` is stripped and config file names are mapped. Files that already exist are left alone.
384
+ * `.hbs` is stripped and config file names are mapped. Files that already exist are left alone, and so are
385
+ * files `include` rejects, given each file's output path relative to `targetPath` with `/` separators.
378
386
  * Returns the paths it added.
379
387
  */
380
388
  export function addTemplateFolder(
381
389
  context: Context,
382
- { folderPath, targetPath, data }: { folderPath: string; targetPath: string; data: Record<string, unknown> }
390
+ {
391
+ folderPath,
392
+ targetPath,
393
+ data,
394
+ include = () => true,
395
+ }: {
396
+ folderPath: string;
397
+ targetPath: string;
398
+ data: Record<string, unknown>;
399
+ include?: (relativeFile: string) => boolean;
400
+ }
383
401
  ): string[] {
384
402
  if (!existsSync(folderPath)) {
385
403
  throw new Error(
@@ -389,11 +407,15 @@ export function addTemplateFolder(
389
407
 
390
408
  const added: string[] = [];
391
409
  // relative to `cwd`, so glob characters in the install path (such as Windows backslashes) can't break the pattern
392
- for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true })) {
410
+ for (const relativeFile of glob.sync('**', { cwd: folderPath, dot: true, nodir: true, posix: true })) {
393
411
  const file = join(folderPath, relativeFile);
394
- const filePath = join(targetPath, dirname(relativeFile), getExportFileName(file));
412
+ const outputFile = posix.join(posix.dirname(relativeFile), getExportFileName(file));
413
+ if (!include(outputFile)) {
414
+ continue;
415
+ }
416
+ const filePath = join(targetPath, outputFile);
395
417
  if (context.doesFileExist(filePath)) {
396
- additionsDebug(`${filePath} already exists, skipping`);
418
+ codemodsDebug(`${filePath} already exists, skipping`);
397
419
  continue;
398
420
  }
399
421
  context.addFile(filePath, renderHandlebarsTemplate(readFileSync(file, 'utf-8'), data));
@@ -0,0 +1,153 @@
1
+ import { Context } from './context.js';
2
+ import { bumpActionRefs } from './utils.workflows.js';
3
+
4
+ const WORKFLOW = '.github/workflows/release.yml';
5
+ const BUILD_PLUGIN = 'grafana/plugin-actions/build-plugin';
6
+ const PACKAGE_PLUGIN = 'grafana/plugin-actions/package-plugin';
7
+ const MINIMUMS = { [BUILD_PLUGIN]: 'build-plugin/v1.2.0', [PACKAGE_PLUGIN]: 'package-plugin/v1.3.3' };
8
+
9
+ // a release workflow with one step per `uses` value
10
+ function workflowWith(...uses: string[]): string {
11
+ return `name: Release\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n${uses.map((u) => ` - uses: ${u}\n`).join('')}`;
12
+ }
13
+
14
+ function bump(workflow: string, minimums: Record<string, string> = MINIMUMS) {
15
+ const context = new Context('/virtual');
16
+ context.addFile(WORKFLOW, workflow);
17
+ const result = bumpActionRefs(context, WORKFLOW, minimums);
18
+ return { result, content: context.getFile(WORKFLOW) };
19
+ }
20
+
21
+ describe('bumpActionRefs', () => {
22
+ it.each([
23
+ ['a prefixed tag', 'build-plugin/v1.0.2'],
24
+ ['a bare tag', 'v1.0.0'],
25
+ ])('raises %s to the minimum', (_, ref) => {
26
+ const { content, result } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
27
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`));
28
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
29
+ });
30
+
31
+ it.each([
32
+ ['a floating minor tag', 'build-plugin/v1.1', 'build-plugin/v1.2'],
33
+ ['a bare floating minor tag', 'v1.0', 'build-plugin/v1.2'],
34
+ ['a floating major tag', 'v0', 'build-plugin/v1'],
35
+ ])('raises %s and keeps it floating', (_, ref, raised) => {
36
+ const { content } = bump(workflowWith(`${BUILD_PLUGIN}@${ref}`));
37
+ expect(content).toBe(workflowWith(`${BUILD_PLUGIN}@${raised}`));
38
+ });
39
+
40
+ it.each(['build-plugin/v1.2.0', 'build-plugin/v2.0.0', 'build-plugin/v1.2', 'build-plugin/v1', 'v1', 'v2', 'main'])(
41
+ 'leaves %s alone',
42
+ (ref) => {
43
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
44
+ const { content, result } = bump(workflow);
45
+ expect(content).toBe(workflow);
46
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [] });
47
+ }
48
+ );
49
+
50
+ it.each([
51
+ ['a branch', 'some-feature-branch'],
52
+ ['a commit SHA', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
53
+ ["another action's tag", 'package-plugin/v1.0.0'],
54
+ ['a tag with a longer prefix', 'foo/build-plugin/v1.0.0'],
55
+ ])('leaves %s alone and reports it', (_, ref) => {
56
+ const workflow = workflowWith(`${BUILD_PLUGIN}@${ref}`);
57
+ const { content, result } = bump(workflow);
58
+ expect(content).toBe(workflow);
59
+ expect(result).toEqual({ found: [BUILD_PLUGIN], unrecognized: [BUILD_PLUGIN] });
60
+ });
61
+
62
+ it('bumps each use independently, to its own action minimum', () => {
63
+ const { content, result } = bump(
64
+ workflowWith(
65
+ `${BUILD_PLUGIN}@v1.0.0`,
66
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
67
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.2`
68
+ )
69
+ );
70
+ expect(content).toBe(
71
+ workflowWith(
72
+ `${BUILD_PLUGIN}@build-plugin/v1.2.0`,
73
+ `${BUILD_PLUGIN}@build-plugin/v2.0.0`,
74
+ `${PACKAGE_PLUGIN}@package-plugin/v1.3.3`
75
+ )
76
+ );
77
+ expect(result.found).toEqual([BUILD_PLUGIN, PACKAGE_PLUGIN]);
78
+ });
79
+
80
+ it('bumps a quoted ref and keeps the quotes and any trailing comment', () => {
81
+ const { content } = bump(workflowWith(`'${BUILD_PLUGIN}@v1.0.0' # pinned`));
82
+ expect(content).toBe(workflowWith(`'${BUILD_PLUGIN}@build-plugin/v1.2.0' # pinned`));
83
+ });
84
+
85
+ it('only bumps `uses` values, not incidental mentions', () => {
86
+ const mention = `# see ${BUILD_PLUGIN}@v1.0.0 for reference\nenv:\n NOTE: ${BUILD_PLUGIN}@v1.0.0\n`;
87
+ const { content } = bump(`${mention}${workflowWith(`${BUILD_PLUGIN}@v1.0.0`)}`);
88
+ expect(content).toBe(`${mention}${workflowWith(`${BUILD_PLUGIN}@build-plugin/v1.2.0`)}`);
89
+ });
90
+
91
+ it('keeps comments, blank lines and the rest of the workflow unchanged', () => {
92
+ const before = [
93
+ '# release on tag',
94
+ 'name: Release',
95
+ '',
96
+ 'on:',
97
+ ' push:',
98
+ " tags: ['v*']",
99
+ '',
100
+ 'jobs:',
101
+ ' release:',
102
+ ' runs-on: ubuntu-latest',
103
+ ' steps:',
104
+ ' - uses: actions/checkout@v6 # keep me',
105
+ ` - uses: ${BUILD_PLUGIN}@v1.0.0`,
106
+ ' with:',
107
+ ' policy_token: ${{ secrets.TOKEN }}',
108
+ '',
109
+ ].join('\n');
110
+ const { content } = bump(before);
111
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
112
+ });
113
+
114
+ it('leaves an indented commented-out block exactly as it was', () => {
115
+ const before = workflowWith(
116
+ `${BUILD_PLUGIN}@v1.0.0\n #with:\n # save the token in your repository secrets\n #policy_token: \${{ secrets.TOKEN }}`
117
+ );
118
+ const { content } = bump(before);
119
+ expect(content).toBe(before.replace(`${BUILD_PLUGIN}@v1.0.0`, `${BUILD_PLUGIN}@build-plugin/v1.2.0`));
120
+ });
121
+
122
+ it('leaves a workflow it cannot parse alone', () => {
123
+ const workflow = `jobs: [\n uses: ${BUILD_PLUGIN}@v1.0.0\n`;
124
+ const { content, result } = bump(workflow);
125
+ expect(content).toBe(workflow);
126
+ expect(result).toEqual({ found: [], unrecognized: [] });
127
+ });
128
+
129
+ it('does not match an action whose name only starts with the given one', () => {
130
+ const workflow = workflowWith(`${BUILD_PLUGIN}-extra@v1.0.0`);
131
+ const { content, result } = bump(workflow);
132
+ expect(content).toBe(workflow);
133
+ expect(result.found).toEqual([]);
134
+ });
135
+
136
+ it('reports nothing found when the workflow uses none of the actions', () => {
137
+ const { content, result } = bump('name: Release\n');
138
+ expect(content).toBe('name: Release\n');
139
+ expect(result).toEqual({ found: [], unrecognized: [] });
140
+ });
141
+
142
+ it('reports nothing found when the workflow does not exist', () => {
143
+ const context = new Context('/virtual');
144
+ expect(bumpActionRefs(context, WORKFLOW, MINIMUMS)).toEqual({ found: [], unrecognized: [] });
145
+ expect(context.doesFileExist(WORKFLOW)).toBe(false);
146
+ });
147
+
148
+ it('throws when a minimum is not a version tag', () => {
149
+ expect(() => bump(workflowWith(`${BUILD_PLUGIN}@v1.0.0`), { [BUILD_PLUGIN]: 'main' })).toThrow(
150
+ "The minimum ref 'main' for grafana/plugin-actions/build-plugin is not a version tag."
151
+ );
152
+ });
153
+ });