@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.
- package/CHANGELOG.md +24 -0
- package/dist/codemods/additions/additions.js +12 -0
- package/dist/codemods/additions/prompts/docs-content.md +131 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +468 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +40 -7
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/utils/utils.cli.js +3 -2
- package/dist/utils/utils.handlebars.js +9 -2
- package/package.json +1 -1
- package/src/codemods/additions/additions.ts +12 -0
- package/src/codemods/additions/prompts/docs-content.md +131 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +103 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1766 -0
- package/src/codemods/additions/scripts/docs.ts +697 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +23 -0
- package/src/codemods/utils.ts +29 -7
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/utils/utils.cli.ts +2 -1
- package/src/utils/utils.handlebars.ts +9 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/docs/app/docs/AGENTS.md +127 -0
- package/templates/docs/app/docs/CLAUDE.md +1 -0
- package/templates/docs/app/docs/README.md +20 -0
- package/templates/docs/app/docs/configure.md +65 -0
- package/templates/docs/app/docs/dashboards.md +29 -0
- package/templates/docs/app/docs/index.md +70 -0
- package/templates/docs/app/docs/permissions.md +45 -0
- package/templates/docs/app/docs/troubleshooting.md +65 -0
- package/templates/docs/app/feature-page.md +26 -0
- package/templates/docs/app/features-index.md +12 -0
- package/templates/docs/common/workflows/validate-docs.yml +52 -0
- package/templates/docs/datasource/docs/AGENTS.md +123 -0
- package/templates/docs/datasource/docs/CLAUDE.md +1 -0
- package/templates/docs/datasource/docs/README.md +20 -0
- package/templates/docs/datasource/docs/alerting.md +56 -0
- package/templates/docs/datasource/docs/annotations.md +68 -0
- package/templates/docs/datasource/docs/configure.md +114 -0
- package/templates/docs/datasource/docs/dashboards.md +28 -0
- package/templates/docs/datasource/docs/index.md +40 -0
- package/templates/docs/datasource/docs/query-editor.md +72 -0
- package/templates/docs/datasource/docs/template-variables.md +69 -0
- package/templates/docs/datasource/docs/troubleshooting.md +73 -0
- package/templates/docs/panel/docs/AGENTS.md +113 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- 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 = [
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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.
|
|
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' });
|
package/src/codemods/utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|