@grafana/create-plugin 7.10.1 → 7.11.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 +20 -0
- package/dist/codemods/additions/additions.js +5 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +275 -0
- package/dist/codemods/context.js +13 -1
- package/dist/codemods/runner.js +2 -1
- package/dist/codemods/utils.goMod.js +45 -0
- package/dist/codemods/utils.js +25 -1
- package/dist/commands/add.command.js +9 -4
- package/dist/commands/generate/print-success-message.js +1 -1
- package/package.json +3 -3
- package/src/codemods/additions/additions.ts +5 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +655 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +431 -0
- package/src/codemods/context.test.ts +7 -1
- package/src/codemods/context.ts +23 -1
- package/src/codemods/runner.ts +3 -1
- package/src/codemods/utils.goMod.test.ts +86 -0
- package/src/codemods/utils.goMod.ts +70 -0
- package/src/codemods/utils.test.ts +49 -0
- package/src/codemods/utils.ts +39 -0
- package/src/commands/add.command.ts +10 -5
- package/src/commands/generate/print-success-message.ts +1 -1
- package/templates/app-sdk/.config/AGENTS/app-sdk.md +87 -0
- package/templates/app-sdk/.config/app-sdk/README.md +60 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +138 -0
- package/templates/app-sdk/.github/workflows/generate-kinds-drift.yml +84 -0
- package/templates/app-sdk/kinds/README.md +3 -0
- package/templates/app-sdk/kinds/config.cue +39 -0
- package/templates/app-sdk/kinds/cue.mod/module.cue +4 -0
- package/templates/app-sdk/kinds/example.cue +29 -0
- package/templates/app-sdk/kinds/manifest.cue +29 -0
- package/templates/app-sdk/pkg/generated/example/v1alpha1/doc.go +11 -0
- package/templates/app-sdk/pkg/generated/manifestdata/doc.go +12 -0
- package/templates/app-sdk/pkg/provider/provider.go +30 -0
- package/templates/common/_package.json +1 -1
- package/vitest.setup.ts +2 -2
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { styleText } from 'node:util';
|
|
3
|
+
import { parseDocument, stringify, YAMLMap, Scalar } from 'yaml';
|
|
4
|
+
import type { Context, ContextMessage } from '../../context.js';
|
|
5
|
+
import { getBackendCmd } from '../../../commands/generate/print-success-message.js';
|
|
6
|
+
import { output } from '../../../utils/utils.console.js';
|
|
7
|
+
import { additionsDebug, renderTemplate } from '../../utils.js';
|
|
8
|
+
import { addRequireToGoMod } from '../../utils.goMod.js';
|
|
9
|
+
import { getTemplateData } from '../../../utils/utils.templates.js';
|
|
10
|
+
|
|
11
|
+
// 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'];
|
|
14
|
+
|
|
15
|
+
// Files copied verbatim from templates/app-sdk, and whether they get the "scaffolded by create-plugin,
|
|
16
|
+
// don't edit" header. Paths are relative to both the template folder and the plugin root.
|
|
17
|
+
//
|
|
18
|
+
// The CUE kinds and the drift-check workflow are meant to be edited (declaring your own kinds is the
|
|
19
|
+
// point, and users may want to tweak the workflow's triggers), so they're excluded. generate-kinds.mjs
|
|
20
|
+
// is a tool, not something devs hand-edit, so it gets the header.
|
|
21
|
+
const TEMPLATE_FILES: Array<[path: string, includeWarning: boolean]> = [
|
|
22
|
+
['.config/app-sdk/generate-kinds.mjs', true],
|
|
23
|
+
['.config/app-sdk/README.md', false],
|
|
24
|
+
['.github/workflows/generate-kinds-drift.yml', false],
|
|
25
|
+
['kinds/config.cue', false],
|
|
26
|
+
['kinds/manifest.cue', false],
|
|
27
|
+
['kinds/example.cue', false],
|
|
28
|
+
['kinds/cue.mod/module.cue', false],
|
|
29
|
+
['kinds/README.md', false],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Points agents at the app-sdk guidance. Only added alongside an existing instructions.md.
|
|
33
|
+
const APP_SDK_MD = '.config/AGENTS/app-sdk.md';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Adds grafana-app-sdk CUE kind code generation to an existing app plugin.
|
|
37
|
+
*
|
|
38
|
+
* Every step is guarded so re-running is a no-op, and user edits to the scaffolded CUE are never
|
|
39
|
+
* overwritten.
|
|
40
|
+
*/
|
|
41
|
+
export default function appSdk(context: Context): Context {
|
|
42
|
+
if (!isAppPlugin(context)) {
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const changesBefore = Object.keys(context.listChanges()).length;
|
|
47
|
+
|
|
48
|
+
addTemplateFiles(context);
|
|
49
|
+
referenceAgentInstructions(context);
|
|
50
|
+
addGenerateScript(context);
|
|
51
|
+
addFeatureToggle(context);
|
|
52
|
+
wireGoBackend(context);
|
|
53
|
+
|
|
54
|
+
// Only guide the user when we actually scaffolded something; a re-run should stay quiet.
|
|
55
|
+
if (Object.keys(context.listChanges()).length > changesBefore) {
|
|
56
|
+
context.setMessage(buildNextStepsMessage(hasGoBackend(context)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return context;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Kinds are served per app, so codegen only applies to app plugins. A Go backend is not required:
|
|
64
|
+
* Grafana can serve kinds from the bundled manifest alone.
|
|
65
|
+
*/
|
|
66
|
+
function isAppPlugin(context: Context): boolean {
|
|
67
|
+
const pluginJsonContent = context.getFile('src/plugin.json');
|
|
68
|
+
|
|
69
|
+
if (!pluginJsonContent) {
|
|
70
|
+
skip('Could not find src/plugin.json.', ['Run this from the root of your plugin.']);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let pluginJson;
|
|
75
|
+
try {
|
|
76
|
+
pluginJson = JSON.parse(pluginJsonContent);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
additionsDebug(`Failed to parse src/plugin.json: ${error}`);
|
|
79
|
+
skip('Could not parse src/plugin.json.');
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (pluginJson.type !== 'app') {
|
|
84
|
+
skip(`grafana-app-sdk codegen needs an app plugin, but this is a ${pluginJson.type} plugin.`, [
|
|
85
|
+
'The app-sdk serves Kubernetes-style resources from an app plugin.',
|
|
86
|
+
]);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Explains why nothing happened. The runner reports success for a no-op codemod, so without this the
|
|
95
|
+
* user is left guessing.
|
|
96
|
+
*/
|
|
97
|
+
function skip(title: string, body: string[] = []) {
|
|
98
|
+
output.warning({ title: `Skipping app-sdk: ${title}`, body });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addTemplateFiles(context: Context) {
|
|
102
|
+
for (const [file, includeWarning] of TEMPLATE_FILES) {
|
|
103
|
+
if (context.doesFileExist(file)) {
|
|
104
|
+
additionsDebug(`${file} already exists. Skipping.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
context.addFile(file, renderTemplate(templatePath(file), includeWarning));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function templatePath(file: string): string {
|
|
113
|
+
return fileURLToPath(new URL(`../../../../templates/app-sdk/${file}`, import.meta.url));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Points the plugin's agent instructions at the app-sdk ones.
|
|
118
|
+
*
|
|
119
|
+
* On a fresh scaffold this line is rendered into instructions.md by the template. Retrofitted plugins
|
|
120
|
+
* have an instructions.md that predates the flag, so add it here — without it, an agent reading the
|
|
121
|
+
* plugin's instructions has no idea kinds or generated code exist.
|
|
122
|
+
*/
|
|
123
|
+
function referenceAgentInstructions(context: Context) {
|
|
124
|
+
const path = '.config/AGENTS/instructions.md';
|
|
125
|
+
const instructions = context.getFile(path);
|
|
126
|
+
|
|
127
|
+
if (!instructions) {
|
|
128
|
+
additionsDebug(`Could not find ${path}. Skipping the app-sdk reference.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!context.doesFileExist(APP_SDK_MD)) {
|
|
133
|
+
context.addFile(APP_SDK_MD, renderTemplate(templatePath(APP_SDK_MD), false));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (instructions.includes(APP_SDK_MD)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
context.updateFile(
|
|
141
|
+
path,
|
|
142
|
+
`${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`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Adds the `generate:kinds` npm script that runs code generation. */
|
|
147
|
+
function addGenerateScript(context: Context) {
|
|
148
|
+
const raw = context.getFile('package.json');
|
|
149
|
+
|
|
150
|
+
if (!raw) {
|
|
151
|
+
additionsDebug('Could not find package.json. Skipping the generate:kinds script.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let packageJson;
|
|
156
|
+
try {
|
|
157
|
+
packageJson = JSON.parse(raw);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
additionsDebug(`Failed to parse package.json: ${error}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (packageJson.scripts?.['generate:kinds']) {
|
|
164
|
+
additionsDebug('A generate:kinds script already exists. Skipping.');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
packageJson.scripts = { ...packageJson.scripts, 'generate:kinds': 'node ./.config/app-sdk/generate-kinds.mjs' };
|
|
169
|
+
context.updateFile('package.json', JSON.stringify(packageJson, null, 2));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Enables the app-sdk manifest feature toggle on the dev server.
|
|
174
|
+
*
|
|
175
|
+
* The toggle goes in the root docker-compose.yaml rather than .config/docker-compose-base.yaml,
|
|
176
|
+
* because the root file is the one users own.
|
|
177
|
+
*/
|
|
178
|
+
function addFeatureToggle(context: Context) {
|
|
179
|
+
const composePath = 'docker-compose.yaml';
|
|
180
|
+
const composeContent = context.getFile(composePath);
|
|
181
|
+
|
|
182
|
+
if (!composeContent) {
|
|
183
|
+
additionsDebug(`Could not find ${composePath}. Skipping the feature toggle.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Already set in the base config, so adding it to the root file would be redundant. Note compose
|
|
188
|
+
// replaces (rather than merges) a scalar value across `extends`, so a root value would also mask
|
|
189
|
+
// any other toggles the base file sets.
|
|
190
|
+
const baseComposeContent = context.getFile('.config/docker-compose-base.yaml');
|
|
191
|
+
if (APP_SDK_FEATURE_TOGGLES.every((toggle) => baseComposeContent?.includes(toggle))) {
|
|
192
|
+
additionsDebug(`${APP_SDK_FEATURE_TOGGLES.join(', ')} are already enabled in the base compose file. Skipping.`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const composeData = parseDocument(composeContent);
|
|
197
|
+
const environment = composeData.getIn(['services', 'grafana', 'environment']);
|
|
198
|
+
|
|
199
|
+
if (environment !== undefined && !(environment instanceof YAMLMap)) {
|
|
200
|
+
additionsDebug(
|
|
201
|
+
`services.grafana.environment in ${composePath} is not a mapping. Add ${APP_SDK_FEATURE_TOGGLES.join(', ')} to GF_FEATURE_TOGGLES_ENABLE manually.`
|
|
202
|
+
);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const grafanaService = composeData.getIn(['services', 'grafana']);
|
|
207
|
+
if (!(grafanaService instanceof YAMLMap)) {
|
|
208
|
+
additionsDebug(`Could not find the grafana service in ${composePath}. Skipping the feature toggle.`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const existing = composeData.getIn(['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], true);
|
|
213
|
+
const existingValue = existing instanceof Scalar ? String(existing.value) : undefined;
|
|
214
|
+
const toggles = existingValue
|
|
215
|
+
? existingValue
|
|
216
|
+
.split(',')
|
|
217
|
+
.map((toggle) => toggle.trim())
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
: [];
|
|
220
|
+
|
|
221
|
+
const missingToggles = APP_SDK_FEATURE_TOGGLES.filter((toggle) => !toggles.includes(toggle));
|
|
222
|
+
|
|
223
|
+
if (missingToggles.length === 0) {
|
|
224
|
+
additionsDebug(`${APP_SDK_FEATURE_TOGGLES.join(', ')} are already enabled. Skipping.`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toggles.push(...missingToggles);
|
|
229
|
+
composeData.setIn(['services', 'grafana', 'environment', 'GF_FEATURE_TOGGLES_ENABLE'], toggles.join(','));
|
|
230
|
+
|
|
231
|
+
context.updateFile(composePath, stringify(composeData, { lineWidth: 120, singleQuote: true }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Wires the generated kinds into the Go backend, for app plugins that have one. Go code generation
|
|
236
|
+
* itself is enabled when kinds/config.cue is first scaffolded, in addTemplateFiles.
|
|
237
|
+
*/
|
|
238
|
+
function wireGoBackend(context: Context) {
|
|
239
|
+
if (!hasGoBackend(context)) {
|
|
240
|
+
additionsDebug('No Go backend found. Skipping main.go wiring.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
addAppProvider(context);
|
|
245
|
+
addGeneratedStubs(context);
|
|
246
|
+
wireMainGo(context);
|
|
247
|
+
addGoModDependency(context);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The go.mod version for grafana-app-sdk and its plugin/ submodule.
|
|
251
|
+
const GRAFANA_APP_SDK_VERSION = 'v0.60.0';
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Adds github.com/grafana/grafana-app-sdk and its plugin/ submodule to go.mod. The generated Go kind
|
|
255
|
+
* types, provider.go, and main.go all import them, but the scaffolded backend's go.mod has no reason
|
|
256
|
+
* to know about either until app-sdk is added.
|
|
257
|
+
*
|
|
258
|
+
* Only adds the require lines — go.sum entries and any transitive requirements (k8s.io/apimachinery,
|
|
259
|
+
* k8s.io/kube-openapi, ...) still need `go mod tidy`. This doesn't run it itself; the codemod runner
|
|
260
|
+
* does, once every change (including the pkg/generated/ stubs below) has been flushed to disk.
|
|
261
|
+
*/
|
|
262
|
+
function addGoModDependency(context: Context) {
|
|
263
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', GRAFANA_APP_SDK_VERSION);
|
|
264
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk/plugin', GRAFANA_APP_SDK_VERSION);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Scaffolds pkg/provider/provider.go: the app.Provider/app.App wiring that plugin.Run needs, built
|
|
269
|
+
* from the generated manifest and the example kind. Named "provider", not "app", so it doesn't
|
|
270
|
+
* collide with the app-sdk's own `app` package and force an import alias everywhere it's used.
|
|
271
|
+
*
|
|
272
|
+
* Not overwritten on a re-run — like kinds/*.cue, it's meant to be edited as the plugin adds
|
|
273
|
+
* validators, mutators, or more kinds.
|
|
274
|
+
*/
|
|
275
|
+
function addAppProvider(context: Context) {
|
|
276
|
+
const path = 'pkg/provider/provider.go';
|
|
277
|
+
|
|
278
|
+
if (context.doesFileExist(path)) {
|
|
279
|
+
additionsDebug(`${path} already exists. Skipping.`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
context.addFile(path, renderTemplate(templatePath(path), false));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Stub doc.go files under pkg/generated/. Both are real packages `generate:kinds` fills in; they
|
|
287
|
+
// exist solely so the `go mod tidy` the codemod runner runs automatically (see runGoModTidy in
|
|
288
|
+
// ../../utils.js) succeeds before code generation has ever run, since it otherwise can't resolve the
|
|
289
|
+
// packages pkg/provider/provider.go imports. `generate:kinds` writes its own, differently-named files
|
|
290
|
+
// into these directories rather than overwriting doc.go, so it's left behind afterwards — but an
|
|
291
|
+
// unused doc.go with no exported symbols compiles fine alongside the generated code.
|
|
292
|
+
const GENERATED_STUB_PATHS = ['pkg/generated/example/v1alpha1/doc.go', 'pkg/generated/manifestdata/doc.go'];
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Scaffolds stub packages under pkg/generated/, so provider.go's imports resolve for `go mod tidy`
|
|
296
|
+
* before `generate:kinds` has run for the first time.
|
|
297
|
+
*/
|
|
298
|
+
function addGeneratedStubs(context: Context) {
|
|
299
|
+
for (const path of GENERATED_STUB_PATHS) {
|
|
300
|
+
if (context.doesFileExist(path)) {
|
|
301
|
+
additionsDebug(`${path} already exists. Skipping.`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
context.addFile(path, renderTemplate(templatePath(path), false));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A Go backend is declared by `backend: true` in src/plugin.json, same as the rest of create-plugin. */
|
|
310
|
+
function hasGoBackend(context: Context): boolean {
|
|
311
|
+
const pluginJsonContent = context.getFile('src/plugin.json');
|
|
312
|
+
|
|
313
|
+
if (!pluginJsonContent) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
return JSON.parse(pluginJsonContent).backend === true;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
additionsDebug(`Failed to parse src/plugin.json: ${error}`);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Matches the backend-app template's `if err := app.Manage(...); err != nil { ... }` statement,
|
|
326
|
+
// capturing the plugin ID, app factory, and error-handling body so they can be preserved verbatim.
|
|
327
|
+
const APP_MANAGE_STATEMENT_REGEX =
|
|
328
|
+
/if err := app\.Manage\((".*?"), (\S+), app\.ManageOpts\{\}\); err != nil \{\n(\t+[\s\S]*?\n)\t\}/;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Wires the app-sdk's plugin.Run helper into main.go, replacing the plain app.Manage call, using the
|
|
332
|
+
* app.Provider scaffolded into pkg/provider by addAppProvider.
|
|
333
|
+
*
|
|
334
|
+
* Bails out rather than guessing if main.go has already diverged from the scaffolded shape this
|
|
335
|
+
* transform expects.
|
|
336
|
+
*/
|
|
337
|
+
function wireMainGo(context: Context) {
|
|
338
|
+
const path = 'pkg/main.go';
|
|
339
|
+
const content = context.getFile(path);
|
|
340
|
+
|
|
341
|
+
if (!content) {
|
|
342
|
+
additionsDebug(`Could not find ${path}. Skipping the app-sdk backend wiring.`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (content.includes('grafana-app-sdk/plugin"')) {
|
|
347
|
+
additionsDebug(`${path} already wires plugin.Run. Skipping.`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const match = content.match(APP_MANAGE_STATEMENT_REGEX);
|
|
352
|
+
|
|
353
|
+
if (!match) {
|
|
354
|
+
skip(`${path} does not match the expected app.Manage(...) call.`, [
|
|
355
|
+
'Wire the grafana-app-sdk plugin.Run helper into main.go yourself:',
|
|
356
|
+
'https://github.com/grafana/grafana-app-sdk/blob/main/plugin/run.go',
|
|
357
|
+
]);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const [fullStatement, pluginId, appFactory, errorBody] = match;
|
|
362
|
+
const moduleMatch = content.match(/"(github\.com\/[^/]+\/[^/]+)\/pkg\/plugin"/);
|
|
363
|
+
const providerImportPath = moduleMatch ? `${moduleMatch[1]}/pkg/provider` : undefined;
|
|
364
|
+
|
|
365
|
+
if (!providerImportPath) {
|
|
366
|
+
skip(`${path} does not import its own pkg/plugin package under a recognisable module path.`, [
|
|
367
|
+
'Wire the grafana-app-sdk plugin.Run helper into main.go yourself:',
|
|
368
|
+
'https://github.com/grafana/grafana-app-sdk/blob/main/plugin/run.go',
|
|
369
|
+
]);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const appImport = '\t"github.com/grafana/grafana-plugin-sdk-go/backend/app"';
|
|
374
|
+
const pluginImport = `"${moduleMatch![1]}/pkg/plugin"`;
|
|
375
|
+
|
|
376
|
+
if (!content.includes(appImport) || !content.includes(pluginImport)) {
|
|
377
|
+
skip(`${path} does not match the expected import shape.`, [
|
|
378
|
+
'Wire the grafana-app-sdk plugin.Run helper into main.go yourself:',
|
|
379
|
+
'https://github.com/grafana/grafana-app-sdk/blob/main/plugin/run.go',
|
|
380
|
+
]);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const updated = content
|
|
385
|
+
.replace(appImport, '\tsdkplugin "github.com/grafana/grafana-app-sdk/plugin"')
|
|
386
|
+
.replace(pluginImport, `${pluginImport}\n\t"${providerImportPath}"`)
|
|
387
|
+
.replace(
|
|
388
|
+
fullStatement,
|
|
389
|
+
`if err := sdkplugin.Run(
|
|
390
|
+
provider.New(),
|
|
391
|
+
sdkplugin.WithPluginID(${pluginId}),
|
|
392
|
+
sdkplugin.WithAppFunc(${appFactory}),
|
|
393
|
+
); err != nil {
|
|
394
|
+
${errorBody}\t}`
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
context.updateFile(path, updated);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Builds the message telling the user what to run next. */
|
|
401
|
+
function buildNextStepsMessage(hasGoBackend: boolean): ContextMessage {
|
|
402
|
+
const { packageManagerName } = getTemplateData();
|
|
403
|
+
const versionBadge = styleText(['reset', 'inverse', 'bold', 'cyan'], ` grafana-app-sdk@${GRAFANA_APP_SDK_VERSION} `);
|
|
404
|
+
|
|
405
|
+
const commands = output.bulletList([
|
|
406
|
+
`${output.formatCode(`${packageManagerName} run generate:kinds`)} ${styleText(['dim'], 'to generate code from the definitions in kinds/')}`,
|
|
407
|
+
...(hasGoBackend
|
|
408
|
+
? [
|
|
409
|
+
// The generated Go code under pkg/generated/ only takes effect once it's compiled into the
|
|
410
|
+
// backend binary.
|
|
411
|
+
`${getBackendCmd()} ${styleText(['dim'], 'to rebuild the plugin backend with the generated code')}`,
|
|
412
|
+
]
|
|
413
|
+
: []),
|
|
414
|
+
// Grafana reads the app manifest from the bundle at startup, so a manifest change (any change to
|
|
415
|
+
// the kinds) isn't picked up by an already-running Grafana instance until it's restarted.
|
|
416
|
+
`${output.formatCode('docker compose restart grafana')} ${styleText(['dim'], 'to pick up changes to kinds/')}`,
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
level: 'success',
|
|
421
|
+
title: 'Successfully added grafana-app-sdk code generation to your plugin.',
|
|
422
|
+
body: [
|
|
423
|
+
`${versionBadge} ${styleText(['cyan', 'bold'], 'Next steps:')}`,
|
|
424
|
+
'',
|
|
425
|
+
'Run the following commands to get started:',
|
|
426
|
+
...commands,
|
|
427
|
+
'',
|
|
428
|
+
`See ${output.formatCode('./.config/app-sdk/README.md')} for the full workflow.`,
|
|
429
|
+
],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -62,10 +62,16 @@ describe('Context', () => {
|
|
|
62
62
|
|
|
63
63
|
describe('updateFile', () => {
|
|
64
64
|
it('should update a file in the context', () => {
|
|
65
|
+
const context = new Context(`${__dirname}/migrations/fixtures`);
|
|
66
|
+
context.updateFile('foo/bar.ts', 'new content');
|
|
67
|
+
expect(context.listChanges()).toEqual({ 'foo/bar.ts': { content: 'new content', changeType: 'update' } });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should preserve the 'add' changeType when updating a file that was added in the current context", () => {
|
|
65
71
|
const context = new Context();
|
|
66
72
|
context.addFile('file.txt', 'content');
|
|
67
73
|
context.updateFile('file.txt', 'new content');
|
|
68
|
-
expect(context.listChanges()).toEqual({ 'file.txt': { content: 'new content', changeType: '
|
|
74
|
+
expect(context.listChanges()).toEqual({ 'file.txt': { content: 'new content', changeType: 'add' } });
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
it('should not update a file if it does not exist', () => {
|
package/src/codemods/context.ts
CHANGED
|
@@ -12,14 +12,33 @@ export type ContextFile = Record<
|
|
|
12
12
|
}
|
|
13
13
|
>;
|
|
14
14
|
|
|
15
|
+
export interface ContextMessage {
|
|
16
|
+
level: 'error' | 'success' | 'warning' | 'log';
|
|
17
|
+
title: string;
|
|
18
|
+
body?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export class Context {
|
|
16
22
|
private files: ContextFile = {};
|
|
23
|
+
private message?: ContextMessage;
|
|
17
24
|
basePath: string;
|
|
18
25
|
|
|
19
26
|
constructor(basePath?: string) {
|
|
20
27
|
this.basePath = basePath || process.cwd();
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Lets a codemod defer printing a message until after the caller has reported success, instead
|
|
32
|
+
* of printing immediately during the codemod's own execution.
|
|
33
|
+
*/
|
|
34
|
+
setMessage(message: ContextMessage) {
|
|
35
|
+
this.message = message;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getMessage() {
|
|
39
|
+
return this.message;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
addFile(filePath: string, content: string) {
|
|
24
43
|
const path = this.normalisePath(filePath);
|
|
25
44
|
if (!this.doesFileExist(path)) {
|
|
@@ -58,7 +77,10 @@ export class Context {
|
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
if (originalContent !== content) {
|
|
61
|
-
this
|
|
80
|
+
// Preserve 'add' so a file added earlier in this same context (its directory may not exist on
|
|
81
|
+
// disk yet) doesn't get downgraded to 'update' and fail to write.
|
|
82
|
+
const changeType = this.files[path]?.changeType === 'add' ? 'add' : 'update';
|
|
83
|
+
this.files[path] = { content, changeType };
|
|
62
84
|
} else {
|
|
63
85
|
codemodsDebug(`Context.updateFile() - no updates for ${filePath}`);
|
|
64
86
|
}
|
package/src/codemods/runner.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context } from './context.js';
|
|
2
|
-
import { formatFiles, flushChanges, installNPMDependencies, printChanges } from './utils.js';
|
|
2
|
+
import { formatFiles, flushChanges, installNPMDependencies, printChanges, runGoModTidy } from './utils.js';
|
|
3
3
|
import { parseAndValidateOptions } from './schema-parser.js';
|
|
4
4
|
import { Codemod } from './types.js';
|
|
5
5
|
|
|
@@ -14,6 +14,7 @@ import { Codemod } from './types.js';
|
|
|
14
14
|
* 5. Flush changes to disk
|
|
15
15
|
* 6. Print summary
|
|
16
16
|
* 7. Install dependencies if needed
|
|
17
|
+
* 8. Run `go mod tidy` if go.mod changed
|
|
17
18
|
*/
|
|
18
19
|
export async function runCodemod(codemod: Codemod, options?: Record<string, any>): Promise<Context> {
|
|
19
20
|
const codemodModule = await import(codemod.scriptPath);
|
|
@@ -38,6 +39,7 @@ export async function runCodemod(codemod: Codemod, options?: Record<string, any>
|
|
|
38
39
|
flushChanges(updatedContext);
|
|
39
40
|
printChanges(updatedContext, codemod.name, codemod.description);
|
|
40
41
|
installNPMDependencies(updatedContext);
|
|
42
|
+
runGoModTidy(updatedContext);
|
|
41
43
|
|
|
42
44
|
return updatedContext;
|
|
43
45
|
} catch (error) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Context } from './context.js';
|
|
2
|
+
import { addRequireToGoMod } from './utils.goMod.js';
|
|
3
|
+
|
|
4
|
+
const GO_MOD = `module github.com/my-org/my-plugin
|
|
5
|
+
|
|
6
|
+
go 1.26.3
|
|
7
|
+
|
|
8
|
+
require github.com/grafana/grafana-plugin-sdk-go v0.285.0
|
|
9
|
+
|
|
10
|
+
require (
|
|
11
|
+
github.com/BurntSushi/toml v1.5.0 // indirect
|
|
12
|
+
)
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
describe('addRequireToGoMod', () => {
|
|
16
|
+
it('inserts a new require line after the last existing one', () => {
|
|
17
|
+
const context = new Context('/virtual');
|
|
18
|
+
context.addFile('go.mod', GO_MOD);
|
|
19
|
+
|
|
20
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
21
|
+
|
|
22
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
23
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.59.1');
|
|
24
|
+
// The existing require survives, and the new one is inserted right after it.
|
|
25
|
+
expect(goMod).toContain(
|
|
26
|
+
'require github.com/grafana/grafana-plugin-sdk-go v0.285.0\nrequire github.com/grafana/grafana-app-sdk v0.59.1'
|
|
27
|
+
);
|
|
28
|
+
// The grouped require (...) block is left untouched.
|
|
29
|
+
expect(goMod).toContain('require (\n\tgithub.com/BurntSushi/toml v1.5.0 // indirect\n)');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('inserts after the go directive when there is no existing require line', () => {
|
|
33
|
+
const context = new Context('/virtual');
|
|
34
|
+
context.addFile('go.mod', 'module github.com/my-org/my-plugin\n\ngo 1.26.3\n');
|
|
35
|
+
|
|
36
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
37
|
+
|
|
38
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
39
|
+
expect(goMod).toBe(
|
|
40
|
+
'module github.com/my-org/my-plugin\n\ngo 1.26.3\nrequire github.com/grafana/grafana-app-sdk v0.59.1\n'
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not duplicate an already-present dependency at the same version', () => {
|
|
45
|
+
const context = new Context('/virtual');
|
|
46
|
+
context.addFile('go.mod', GO_MOD);
|
|
47
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
48
|
+
const afterFirst = context.getFile('go.mod');
|
|
49
|
+
|
|
50
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
51
|
+
|
|
52
|
+
expect(context.getFile('go.mod')).toBe(afterFirst);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('bumps the version when a greater one is requested', () => {
|
|
56
|
+
const context = new Context('/virtual');
|
|
57
|
+
context.addFile('go.mod', GO_MOD);
|
|
58
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
59
|
+
|
|
60
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.60.0');
|
|
61
|
+
|
|
62
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
63
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.60.0');
|
|
64
|
+
expect(goMod).not.toContain('v0.59.1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not downgrade when a lesser version is requested', () => {
|
|
68
|
+
const context = new Context('/virtual');
|
|
69
|
+
context.addFile('go.mod', GO_MOD);
|
|
70
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.60.0');
|
|
71
|
+
|
|
72
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
73
|
+
|
|
74
|
+
const goMod = context.getFile('go.mod') ?? '';
|
|
75
|
+
expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v0.60.0');
|
|
76
|
+
expect(goMod).not.toContain('v0.59.1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does nothing when go.mod does not exist', () => {
|
|
80
|
+
const context = new Context('/virtual');
|
|
81
|
+
|
|
82
|
+
addRequireToGoMod(context, 'github.com/grafana/grafana-app-sdk', 'v0.59.1');
|
|
83
|
+
|
|
84
|
+
expect(context.doesFileExist('go.mod')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Context } from './context.js';
|
|
2
|
+
import { isVersionGreater, migrationsDebug } from './utils.js';
|
|
3
|
+
|
|
4
|
+
// Matches a single-line `require <module> <version>` statement (with an optional trailing
|
|
5
|
+
// `// indirect` comment), the shape every codemod-scaffolded backend's go.mod uses.
|
|
6
|
+
function singleLineRequireRegex(modulePath: string): RegExp {
|
|
7
|
+
const escapedModulePath = modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
return new RegExp(`^require ${escapedModulePath} (\\S+)(.*)$`, 'm');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds a `require <modulePath> <version>` line to go.mod, or bumps its version if it's already
|
|
13
|
+
* present with a lower one (using the same version-comparison logic as addDependenciesToPackageJson).
|
|
14
|
+
*
|
|
15
|
+
* Only understands the single-line `require <module> <version>` form that create-plugin's own
|
|
16
|
+
* templates use — it does not parse grouped `require (...)` blocks, `replace`/`exclude` directives,
|
|
17
|
+
* or anything else that would need a real go.mod grammar. A codemod that needs that should shell out
|
|
18
|
+
* to `go mod edit` against the real file on disk instead (see utils.goSdk.ts), outside of Context.
|
|
19
|
+
*/
|
|
20
|
+
export function addRequireToGoMod(context: Context, modulePath: string, version: string, goModPath = 'go.mod') {
|
|
21
|
+
const content = context.getFile(goModPath);
|
|
22
|
+
|
|
23
|
+
if (!content) {
|
|
24
|
+
migrationsDebug(`Could not find ${goModPath}. Skipping the ${modulePath} dependency.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const requireRegex = singleLineRequireRegex(modulePath);
|
|
29
|
+
const match = content.match(requireRegex);
|
|
30
|
+
|
|
31
|
+
if (match) {
|
|
32
|
+
const [, existingVersion] = match;
|
|
33
|
+
if (!isVersionGreater(version, existingVersion, false)) {
|
|
34
|
+
migrationsDebug(`${goModPath} already requires ${modulePath} at ${existingVersion}. Skipping.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updated = content.replace(requireRegex, `require ${modulePath} ${version}$2`);
|
|
39
|
+
context.updateFile(goModPath, updated);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const insertAt = findRequireInsertionPoint(content);
|
|
44
|
+
const newRequireLine = `require ${modulePath} ${version}\n`;
|
|
45
|
+
|
|
46
|
+
context.updateFile(goModPath, `${content.slice(0, insertAt)}${newRequireLine}${content.slice(insertAt)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Finds where to insert a new top-level `require ...` line: right after the last existing one, else
|
|
51
|
+
* right after the `go 1.x` directive, else at the end of the file.
|
|
52
|
+
*/
|
|
53
|
+
function findRequireInsertionPoint(content: string): number {
|
|
54
|
+
const requireLines = [...content.matchAll(/^require \S+ \S+.*$/gm)];
|
|
55
|
+
const lastRequireLine = requireLines[requireLines.length - 1];
|
|
56
|
+
|
|
57
|
+
if (lastRequireLine) {
|
|
58
|
+
const lineEnd = content.indexOf('\n', lastRequireLine.index! + lastRequireLine[0].length);
|
|
59
|
+
return lineEnd === -1 ? content.length : lineEnd + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const goDirective = content.match(/^go \d+\.\d+(\.\d+)?.*$/m);
|
|
63
|
+
|
|
64
|
+
if (goDirective) {
|
|
65
|
+
const lineEnd = content.indexOf('\n', goDirective.index! + goDirective[0].length);
|
|
66
|
+
return lineEnd === -1 ? content.length : lineEnd + 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return content.length;
|
|
70
|
+
}
|