@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/codemods/additions/additions.js +5 -0
  3. package/dist/codemods/additions/scripts/experimental-app-sdk.js +275 -0
  4. package/dist/codemods/context.js +13 -1
  5. package/dist/codemods/runner.js +2 -1
  6. package/dist/codemods/utils.goMod.js +45 -0
  7. package/dist/codemods/utils.js +25 -1
  8. package/dist/commands/add.command.js +9 -4
  9. package/dist/commands/generate/print-success-message.js +1 -1
  10. package/package.json +3 -3
  11. package/src/codemods/additions/additions.ts +5 -0
  12. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +655 -0
  13. package/src/codemods/additions/scripts/experimental-app-sdk.ts +431 -0
  14. package/src/codemods/context.test.ts +7 -1
  15. package/src/codemods/context.ts +23 -1
  16. package/src/codemods/runner.ts +3 -1
  17. package/src/codemods/utils.goMod.test.ts +86 -0
  18. package/src/codemods/utils.goMod.ts +70 -0
  19. package/src/codemods/utils.test.ts +49 -0
  20. package/src/codemods/utils.ts +39 -0
  21. package/src/commands/add.command.ts +10 -5
  22. package/src/commands/generate/print-success-message.ts +1 -1
  23. package/templates/app-sdk/.config/AGENTS/app-sdk.md +87 -0
  24. package/templates/app-sdk/.config/app-sdk/README.md +60 -0
  25. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +138 -0
  26. package/templates/app-sdk/.github/workflows/generate-kinds-drift.yml +84 -0
  27. package/templates/app-sdk/kinds/README.md +3 -0
  28. package/templates/app-sdk/kinds/config.cue +39 -0
  29. package/templates/app-sdk/kinds/cue.mod/module.cue +4 -0
  30. package/templates/app-sdk/kinds/example.cue +29 -0
  31. package/templates/app-sdk/kinds/manifest.cue +29 -0
  32. package/templates/app-sdk/pkg/generated/example/v1alpha1/doc.go +11 -0
  33. package/templates/app-sdk/pkg/generated/manifestdata/doc.go +12 -0
  34. package/templates/app-sdk/pkg/provider/provider.go +30 -0
  35. package/templates/common/_package.json +1 -1
  36. package/vitest.setup.ts +2 -2
@@ -0,0 +1,655 @@
1
+ import { Context } from '../../context.js';
2
+ import { output } from '../../../utils/utils.console.js';
3
+ import appSdk from './experimental-app-sdk.js';
4
+
5
+ // renderTemplate resolves hasBackend (and other plugin state) via getPluginJson, which reads real
6
+ // disk rather than the codemod's in-memory Context. Mirror that value here, kept in sync with each
7
+ // test's createAppContext({ hasBackend }) below, so templates render as they would for the plugin
8
+ // actually under test. Declared via vi.hoisted since the vi.mock factories below are hoisted above
9
+ // ordinary top-level declarations.
10
+ const mockedState = vi.hoisted(() => ({ hasBackend: false }));
11
+
12
+ vi.mock(import('../../../utils/utils.plugin.js'), async (importOriginal) => {
13
+ const originalModule = await importOriginal();
14
+ return {
15
+ ...originalModule,
16
+ getPluginJson: () => ({
17
+ id: 'my-plugin-id',
18
+ name: 'My Plugin',
19
+ info: { author: { name: 'my-author' } },
20
+ backend: mockedState.hasBackend,
21
+ }),
22
+ };
23
+ });
24
+
25
+ vi.mock(import('../../utils.js'), async (importOriginal) => {
26
+ const originalModule = await importOriginal();
27
+ // Disk I/O is slow so render each template once per includeWarning/hasBackend combination, keyed
28
+ // off the requested path.
29
+ const render = (file: string, includeWarning: boolean, hasBackend: boolean) => {
30
+ mockedState.hasBackend = hasBackend;
31
+ return originalModule.renderTemplate(
32
+ new URL(`../../../../templates/app-sdk/${file}`, import.meta.url).pathname,
33
+ includeWarning
34
+ );
35
+ };
36
+ const files = [
37
+ '.config/app-sdk/generate-kinds.mjs',
38
+ '.config/app-sdk/README.md',
39
+ '.config/AGENTS/app-sdk.md',
40
+ '.github/workflows/generate-kinds-drift.yml',
41
+ 'kinds/config.cue',
42
+ 'kinds/manifest.cue',
43
+ 'kinds/example.cue',
44
+ 'kinds/cue.mod/module.cue',
45
+ 'kinds/README.md',
46
+ 'pkg/provider/provider.go',
47
+ 'pkg/generated/example/v1alpha1/doc.go',
48
+ 'pkg/generated/manifestdata/doc.go',
49
+ ];
50
+ const rendered: Record<string, Record<'true' | 'false', { withoutBackend: string; withBackend: string }>> =
51
+ Object.fromEntries(
52
+ files.map((file) => [
53
+ file,
54
+ {
55
+ true: { withoutBackend: render(file, true, false), withBackend: render(file, true, true) },
56
+ false: { withoutBackend: render(file, false, false), withBackend: render(file, false, true) },
57
+ },
58
+ ])
59
+ );
60
+ mockedState.hasBackend = false;
61
+
62
+ return {
63
+ ...originalModule,
64
+ renderTemplate: (templatePath: string, includeWarning = false) => {
65
+ const match = Object.keys(rendered).find((file) => templatePath.endsWith(file));
66
+
67
+ if (!match) {
68
+ return '';
69
+ }
70
+
71
+ const variant = rendered[match][includeWarning ? 'true' : 'false'];
72
+ return mockedState.hasBackend ? variant.withBackend : variant.withoutBackend;
73
+ },
74
+ };
75
+ });
76
+
77
+ const APP_SDK_FILES = [
78
+ '.config/app-sdk/generate-kinds.mjs',
79
+ '.config/app-sdk/README.md',
80
+ '.config/AGENTS/app-sdk.md',
81
+ '.github/workflows/generate-kinds-drift.yml',
82
+ 'kinds/config.cue',
83
+ 'kinds/manifest.cue',
84
+ 'kinds/example.cue',
85
+ 'kinds/cue.mod/module.cue',
86
+ 'kinds/README.md',
87
+ ];
88
+
89
+ const STOCK_COMPOSE = `services:
90
+ grafana:
91
+ extends:
92
+ file: .config/docker-compose-base.yaml
93
+ service: grafana
94
+ `;
95
+
96
+ function createAppContext({
97
+ pluginType = 'app',
98
+ compose = STOCK_COMPOSE,
99
+ instructions = '# Grafana Plugin\n\n## Critical rules\n\n- Existing rule.\n',
100
+ hasBackend = false,
101
+ }: {
102
+ pluginType?: string;
103
+ compose?: string | null;
104
+ instructions?: string | null;
105
+ hasBackend?: boolean;
106
+ } = {}) {
107
+ mockedState.hasBackend = hasBackend;
108
+
109
+ const context = new Context('/virtual');
110
+
111
+ context.addFile('src/plugin.json', JSON.stringify({ type: pluginType, id: 'my-plugin-id', backend: hasBackend }));
112
+ context.addFile('package.json', JSON.stringify({ scripts: { build: 'webpack' } }, null, 2));
113
+ context.addFile('.gitignore', 'node_modules/\ndist/\n');
114
+ context.addFile('.config/bundler/copyFiles.ts', `export const copyFilePatterns = ['**/*.json'];`);
115
+
116
+ if (instructions !== null) {
117
+ context.addFile('.config/AGENTS/instructions.md', instructions);
118
+ }
119
+
120
+ if (compose !== null) {
121
+ context.addFile('docker-compose.yaml', compose);
122
+ }
123
+
124
+ if (hasBackend) {
125
+ context.addFile('pkg/main.go', BACKEND_MAIN_GO);
126
+ context.addFile('go.mod', BACKEND_GO_MOD);
127
+ }
128
+
129
+ return context;
130
+ }
131
+
132
+ const BACKEND_GO_MOD = `module github.com/my-org/my-plugin
133
+
134
+
135
+ go 1.26.3
136
+
137
+ require github.com/grafana/grafana-plugin-sdk-go v0.285.0
138
+
139
+ require (
140
+ github.com/BurntSushi/toml v1.5.0 // indirect
141
+ )
142
+ `;
143
+
144
+ const BACKEND_MAIN_GO = `package main
145
+
146
+ import (
147
+ "os"
148
+
149
+ "github.com/grafana/grafana-plugin-sdk-go/backend/app"
150
+ "github.com/grafana/grafana-plugin-sdk-go/backend/log"
151
+ "github.com/my-org/my-plugin/pkg/plugin"
152
+ )
153
+
154
+ func main() {
155
+ // Start listening to requests sent from Grafana. This call is blocking so
156
+ // it won't finish until Grafana shuts down the process or the plugin choose
157
+ // to exit by itself using os.Exit. Manage automatically manages life cycle
158
+ // of app instances. It accepts app instance factory as first
159
+ // argument. This factory will be automatically called on incoming request
160
+ // from Grafana to create different instances of \`App\` (per plugin
161
+ // ID).
162
+ if err := app.Manage("my-plugin-id", plugin.NewApp, app.ManageOpts{}); err != nil {
163
+ log.DefaultLogger.Error(err.Error())
164
+ os.Exit(1)
165
+ }
166
+ }
167
+ `;
168
+
169
+ describe('experimental-app-sdk addition', () => {
170
+ // Silence terminal output, and let us assert on what the user is told.
171
+ beforeEach(() => {
172
+ vi.spyOn(output, 'log').mockImplementation(() => {});
173
+ vi.spyOn(output, 'warning').mockImplementation(() => {});
174
+ });
175
+
176
+ afterEach(() => {
177
+ vi.restoreAllMocks();
178
+ });
179
+
180
+ describe('preconditions', () => {
181
+ it('makes no changes when there is no plugin.json', () => {
182
+ const context = new Context('/virtual');
183
+
184
+ const result = appSdk(context);
185
+
186
+ expect(result.listChanges()).toEqual({});
187
+ });
188
+
189
+ it('makes no changes for a datasource plugin', () => {
190
+ const context = createAppContext({ pluginType: 'datasource' });
191
+ const changesBefore = Object.keys(context.listChanges()).length;
192
+
193
+ const result = appSdk(context);
194
+
195
+ expect(Object.keys(result.listChanges()).length).toBe(changesBefore);
196
+ expect(result.doesFileExist('kinds/config.cue')).toBe(false);
197
+ });
198
+
199
+ it('makes no changes when plugin.json is malformed', () => {
200
+ const context = new Context('/virtual');
201
+ context.addFile('src/plugin.json', '{ not json');
202
+
203
+ const result = appSdk(context);
204
+
205
+ expect(result.doesFileExist('kinds/config.cue')).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe('scaffolding', () => {
210
+ it('adds the kinds and the generate script', () => {
211
+ const context = createAppContext();
212
+
213
+ const result = appSdk(context);
214
+
215
+ for (const file of APP_SDK_FILES) {
216
+ expect(result.doesFileExist(file), `${file} should exist`).toBe(true);
217
+ }
218
+ expect(result.getFile('.config/app-sdk/generate-kinds.mjs')).toContain('grafana-app-sdk');
219
+ expect(result.getFile('kinds/config.cue')).toContain('tsGenPath');
220
+ });
221
+
222
+ it('marks generate-kinds.mjs as scaffolded but leaves the editable CUE kinds unmarked', () => {
223
+ const context = createAppContext();
224
+
225
+ const result = appSdk(context);
226
+
227
+ expect(result.getFile('.config/app-sdk/generate-kinds.mjs')).toContain('DO NOT EDIT THIS FILE DIRECTLY');
228
+ expect(result.getFile('kinds/config.cue')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
229
+ expect(result.getFile('kinds/manifest.cue')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
230
+ expect(result.getFile('kinds/example.cue')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
231
+ expect(result.getFile('.github/workflows/generate-kinds-drift.yml')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
232
+ });
233
+
234
+ it('adds the generate:kinds npm script', () => {
235
+ const context = createAppContext();
236
+
237
+ const result = appSdk(context);
238
+
239
+ const packageJson = JSON.parse(result.getFile('package.json') ?? '{}');
240
+ expect(packageJson.scripts['generate:kinds']).toBe('node ./.config/app-sdk/generate-kinds.mjs');
241
+ // Existing scripts survive.
242
+ expect(packageJson.scripts.build).toBe('webpack');
243
+ });
244
+
245
+ it('does not clobber an existing generate:kinds script', () => {
246
+ const context = createAppContext();
247
+ context.updateFile('package.json', JSON.stringify({ scripts: { 'generate:kinds': 'my own thing' } }, null, 2));
248
+
249
+ const result = appSdk(context);
250
+
251
+ const packageJson = JSON.parse(result.getFile('package.json') ?? '{}');
252
+ expect(packageJson.scripts['generate:kinds']).toBe('my own thing');
253
+ });
254
+
255
+ it('does not overwrite kinds a user has already edited', () => {
256
+ const context = createAppContext();
257
+ const userManifest = 'package kinds\n\n// my own manifest\n';
258
+ context.addFile('kinds/manifest.cue', userManifest);
259
+
260
+ const result = appSdk(context);
261
+
262
+ expect(result.getFile('kinds/manifest.cue')).toBe(userManifest);
263
+ // ...but still scaffolds the files that were missing.
264
+ expect(result.doesFileExist('kinds/config.cue')).toBe(true);
265
+ });
266
+
267
+ it('points the agent instructions at the app-sdk guidance', () => {
268
+ const context = createAppContext();
269
+
270
+ const result = appSdk(context);
271
+
272
+ const instructions = result.getFile('.config/AGENTS/instructions.md') ?? '';
273
+ expect(instructions).toContain('AGENTS/app-sdk.md');
274
+ // The existing content survives.
275
+ expect(instructions).toContain('- Existing rule.');
276
+ });
277
+
278
+ it('does not duplicate the agent instructions reference', () => {
279
+ const context = createAppContext();
280
+ appSdk(context);
281
+ const afterFirst = context.getFile('.config/AGENTS/instructions.md');
282
+
283
+ appSdk(context);
284
+
285
+ expect(context.getFile('.config/AGENTS/instructions.md')).toBe(afterFirst);
286
+ });
287
+
288
+ it('does not add the app-sdk guidance file when there is no instructions.md', () => {
289
+ const context = createAppContext({ instructions: null });
290
+
291
+ const result = appSdk(context);
292
+
293
+ expect(result.doesFileExist('.config/AGENTS/app-sdk.md')).toBe(false);
294
+ // The rest of the addition still applies.
295
+ expect(result.doesFileExist('kinds/config.cue')).toBe(true);
296
+ });
297
+ });
298
+
299
+ describe('feature toggle', () => {
300
+ it('enables the app-sdk manifest toggle', () => {
301
+ const context = createAppContext();
302
+
303
+ const result = appSdk(context);
304
+
305
+ expect(result.getFile('docker-compose.yaml')).toContain(
306
+ 'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer'
307
+ );
308
+ });
309
+
310
+ it('appends to existing toggles rather than replacing them', () => {
311
+ const context = createAppContext({
312
+ compose: `services:
313
+ grafana:
314
+ extends:
315
+ file: .config/docker-compose-base.yaml
316
+ service: grafana
317
+ environment:
318
+ GF_FEATURE_TOGGLES_ENABLE: someOtherToggle
319
+ `,
320
+ });
321
+
322
+ const result = appSdk(context);
323
+
324
+ const compose = result.getFile('docker-compose.yaml') ?? '';
325
+ expect(compose).toContain('someOtherToggle');
326
+ expect(compose).toContain('appplugins.loadAppManifest');
327
+ expect(compose).toContain('appplugins.registerAPIServer');
328
+ });
329
+
330
+ it('does not duplicate the toggles when they are already set', () => {
331
+ const context = createAppContext({
332
+ compose: `services:
333
+ grafana:
334
+ extends:
335
+ file: .config/docker-compose-base.yaml
336
+ service: grafana
337
+ environment:
338
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
339
+ `,
340
+ });
341
+
342
+ const result = appSdk(context);
343
+
344
+ const compose = result.getFile('docker-compose.yaml') ?? '';
345
+ expect(compose.match(/appplugins\.loadAppManifest/g)).toHaveLength(1);
346
+ expect(compose.match(/appplugins\.registerAPIServer/g)).toHaveLength(1);
347
+ });
348
+
349
+ it('adds only the missing toggle when one is already set', () => {
350
+ const context = createAppContext({
351
+ compose: `services:
352
+ grafana:
353
+ extends:
354
+ file: .config/docker-compose-base.yaml
355
+ service: grafana
356
+ environment:
357
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest
358
+ `,
359
+ });
360
+
361
+ const result = appSdk(context);
362
+
363
+ const compose = result.getFile('docker-compose.yaml') ?? '';
364
+ expect(compose.match(/appplugins\.loadAppManifest/g)).toHaveLength(1);
365
+ expect(compose).toContain('appplugins.registerAPIServer');
366
+ });
367
+
368
+ it('skips the toggles when the base compose file already enables them', () => {
369
+ const context = createAppContext();
370
+ context.addFile(
371
+ '.config/docker-compose-base.yaml',
372
+ `services:
373
+ grafana:
374
+ environment:
375
+ GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
376
+ `
377
+ );
378
+
379
+ const result = appSdk(context);
380
+
381
+ expect(result.getFile('docker-compose.yaml')).toBe(STOCK_COMPOSE);
382
+ });
383
+
384
+ it('leaves a list-style environment block alone', () => {
385
+ const listCompose = `services:
386
+ grafana:
387
+ environment:
388
+ - GF_FEATURE_TOGGLES_ENABLE=someOtherToggle
389
+ `;
390
+ const context = createAppContext({ compose: listCompose });
391
+
392
+ const result = appSdk(context);
393
+
394
+ expect(result.getFile('docker-compose.yaml')).toBe(listCompose);
395
+ // The rest of the addition still applies.
396
+ expect(result.doesFileExist('kinds/config.cue')).toBe(true);
397
+ });
398
+
399
+ it('still scaffolds when there is no docker-compose.yaml', () => {
400
+ const context = createAppContext({ compose: null });
401
+
402
+ const result = appSdk(context);
403
+
404
+ expect(result.doesFileExist('kinds/config.cue')).toBe(true);
405
+ });
406
+ });
407
+
408
+ describe('user messaging', () => {
409
+ it('explains why it skipped an unsupported plugin type', () => {
410
+ const context = createAppContext({ pluginType: 'panel' });
411
+
412
+ appSdk(context);
413
+
414
+ expect(output.warning).toHaveBeenCalledWith(
415
+ expect.objectContaining({ title: expect.stringContaining('needs an app plugin') })
416
+ );
417
+ });
418
+
419
+ it('prints next steps after scaffolding', () => {
420
+ const context = createAppContext();
421
+
422
+ appSdk(context);
423
+
424
+ const body = context.getMessage()?.body ?? [];
425
+ expect(body.some((line) => line.includes('Next steps'))).toBe(true);
426
+ });
427
+
428
+ it('does not set a new message on a re-run', () => {
429
+ const context = createAppContext();
430
+ appSdk(context);
431
+ const messageAfterFirstRun = context.getMessage();
432
+
433
+ appSdk(context);
434
+
435
+ // A re-run makes no changes, so it never calls setMessage again; the message from the first
436
+ // run is untouched rather than replaced or cleared.
437
+ expect(context.getMessage()).toBe(messageAfterFirstRun);
438
+ });
439
+
440
+ });
441
+
442
+ it('is idempotent', async () => {
443
+ const context = createAppContext();
444
+
445
+ await expect(appSdk).toBeIdempotent(context);
446
+ });
447
+
448
+ describe('Go backend wiring', () => {
449
+ it('leaves Go code generation disabled when there is no backend', () => {
450
+ const context = createAppContext({ hasBackend: false });
451
+
452
+ const result = appSdk(context);
453
+
454
+ expect(result.getFile('kinds/config.cue')).toContain('goEnabled: false');
455
+ expect(result.doesFileExist('pkg/main.go')).toBe(false);
456
+ });
457
+
458
+ it('enables Go code generation and sets a Go output path when a backend is present', () => {
459
+ const context = createAppContext({ hasBackend: true });
460
+
461
+ const result = appSdk(context);
462
+
463
+ const config = result.getFile('kinds/config.cue') ?? '';
464
+ expect(config).toContain('goEnabled: true');
465
+ expect(config).toContain('goGenPath: "pkg/generated/"');
466
+ expect(config).not.toContain('goEnabled: false');
467
+ });
468
+
469
+ it('scaffolds pkg/provider/provider.go with the app.Provider wiring', () => {
470
+ const context = createAppContext({ hasBackend: true });
471
+
472
+ const result = appSdk(context);
473
+
474
+ const providerGo = result.getFile('pkg/provider/provider.go') ?? '';
475
+ expect(providerGo).toContain('"github.com/grafana/grafana-app-sdk/app"');
476
+ expect(providerGo).toContain('func New() app.Provider');
477
+ expect(providerGo).toContain('simple.NewAppProvider(manifestdata.LocalManifest(), nil, newApp)');
478
+ });
479
+
480
+ it('does not scaffold pkg/provider/provider.go when there is no Go backend', () => {
481
+ const context = createAppContext({ hasBackend: false });
482
+
483
+ const result = appSdk(context);
484
+
485
+ expect(result.doesFileExist('pkg/provider/provider.go')).toBe(false);
486
+ });
487
+
488
+ it('does not overwrite an existing pkg/provider/provider.go', () => {
489
+ const context = createAppContext({ hasBackend: true });
490
+ const userProviderGo = 'package provider\n\n// my own provider\n';
491
+ context.addFile('pkg/provider/provider.go', userProviderGo);
492
+
493
+ const result = appSdk(context);
494
+
495
+ expect(result.getFile('pkg/provider/provider.go')).toBe(userProviderGo);
496
+ });
497
+
498
+ it('scaffolds pkg/generated stub packages so go mod tidy resolves provider.go imports', () => {
499
+ const context = createAppContext({ hasBackend: true });
500
+
501
+ const result = appSdk(context);
502
+
503
+ expect(result.doesFileExist('pkg/generated/example/v1alpha1/doc.go')).toBe(true);
504
+ expect(result.doesFileExist('pkg/generated/manifestdata/doc.go')).toBe(true);
505
+ });
506
+
507
+ it('does not scaffold pkg/generated stubs when there is no Go backend', () => {
508
+ const context = createAppContext({ hasBackend: false });
509
+
510
+ const result = appSdk(context);
511
+
512
+ expect(result.doesFileExist('pkg/generated/example/v1alpha1/doc.go')).toBe(false);
513
+ expect(result.doesFileExist('pkg/generated/manifestdata/doc.go')).toBe(false);
514
+ });
515
+
516
+ it('does not overwrite existing pkg/generated stubs', () => {
517
+ const context = createAppContext({ hasBackend: true });
518
+ const userStub = 'package v1alpha1\n\n// hand-edited after real codegen\n';
519
+ context.addFile('pkg/generated/example/v1alpha1/doc.go', userStub);
520
+
521
+ const result = appSdk(context);
522
+
523
+ expect(result.getFile('pkg/generated/example/v1alpha1/doc.go')).toBe(userStub);
524
+ });
525
+
526
+ it('wires plugin.Run into main.go', () => {
527
+ const context = createAppContext({ hasBackend: true });
528
+
529
+ const result = appSdk(context);
530
+
531
+ const mainGo = result.getFile('pkg/main.go') ?? '';
532
+ expect(mainGo).toContain('sdkplugin "github.com/grafana/grafana-app-sdk/plugin"');
533
+ expect(mainGo).toContain('"github.com/my-org/my-plugin/pkg/provider"');
534
+ expect(mainGo).toContain('sdkplugin.Run(');
535
+ expect(mainGo).toContain('provider.New()');
536
+ // The original app.Manage call's plugin ID and app factory are preserved as Run options.
537
+ expect(mainGo).toContain('sdkplugin.WithPluginID("my-plugin-id")');
538
+ expect(mainGo).toContain('sdkplugin.WithAppFunc(plugin.NewApp)');
539
+ expect(mainGo).not.toContain('app.Manage(');
540
+ });
541
+
542
+ it('does not modify main.go when there is no Go backend', () => {
543
+ const context = createAppContext({ hasBackend: false });
544
+
545
+ const result = appSdk(context);
546
+
547
+ expect(result.doesFileExist('pkg/main.go')).toBe(false);
548
+ });
549
+
550
+ it('does not duplicate the wiring on a re-run', () => {
551
+ const context = createAppContext({ hasBackend: true });
552
+ appSdk(context);
553
+ const afterFirst = context.getFile('pkg/main.go');
554
+
555
+ appSdk(context);
556
+
557
+ expect(context.getFile('pkg/main.go')).toBe(afterFirst);
558
+ expect((context.getFile('pkg/main.go') ?? '').match(/sdkplugin "github\.com\/grafana\/grafana-app-sdk\/plugin"/g)).toHaveLength(1);
559
+ });
560
+
561
+ it('skips main.go safely when it does not match the expected shape', () => {
562
+ const context = createAppContext({ hasBackend: true });
563
+ const customMainGo = `package main
564
+
565
+ func main() {
566
+ // heavily customized, no app.Manage call left
567
+ }
568
+ `;
569
+ context.updateFile('pkg/main.go', customMainGo);
570
+
571
+ const result = appSdk(context);
572
+
573
+ expect(result.getFile('pkg/main.go')).toBe(customMainGo);
574
+ expect(output.warning).toHaveBeenCalledWith(
575
+ expect.objectContaining({ title: expect.stringContaining('does not match the expected app.Manage') })
576
+ );
577
+ });
578
+
579
+ it('is idempotent with a Go backend present', async () => {
580
+ const context = createAppContext({ hasBackend: true });
581
+
582
+ await expect(appSdk).toBeIdempotent(context);
583
+ });
584
+
585
+ it('adds the grafana-app-sdk dependency to go.mod', () => {
586
+ const context = createAppContext({ hasBackend: true });
587
+
588
+ const result = appSdk(context);
589
+
590
+ const goMod = result.getFile('go.mod') ?? '';
591
+ expect(goMod).toContain('require github.com/grafana/grafana-app-sdk v');
592
+ // The existing require survives.
593
+ expect(goMod).toContain('require github.com/grafana/grafana-plugin-sdk-go v0.285.0');
594
+ });
595
+
596
+ it('does not duplicate the go.mod dependency on a re-run', () => {
597
+ const context = createAppContext({ hasBackend: true });
598
+ appSdk(context);
599
+ const afterFirst = context.getFile('go.mod');
600
+
601
+ appSdk(context);
602
+
603
+ expect(context.getFile('go.mod')).toBe(afterFirst);
604
+ expect((context.getFile('go.mod') ?? '').match(/github\.com\/grafana\/grafana-app-sdk /g)).toHaveLength(1);
605
+ });
606
+
607
+ it('does not tell the user to run go mod tidy, since the codemod runner already ran it', () => {
608
+ const context = createAppContext({ hasBackend: true });
609
+
610
+ appSdk(context);
611
+
612
+ const body = context.getMessage()?.body ?? [];
613
+ expect(body.some((line) => line.includes('go mod tidy'))).toBe(false);
614
+ });
615
+
616
+ it('does not mention go mod tidy without a Go backend', () => {
617
+ const context = createAppContext({ hasBackend: false });
618
+
619
+ appSdk(context);
620
+
621
+ const body = context.getMessage()?.body ?? [];
622
+ expect(body.some((line) => line.includes('go mod tidy'))).toBe(false);
623
+ });
624
+
625
+ it('tells the user to rebuild the backend after generating kinds, when a Go backend is present', () => {
626
+ const context = createAppContext({ hasBackend: true });
627
+
628
+ appSdk(context);
629
+
630
+ const body = context.getMessage()?.body ?? [];
631
+ const generateIndex = body.findIndex((line) => line.includes('generate:kinds'));
632
+ const buildIndex = body.findIndex((line) => line.includes('mage'));
633
+ expect(buildIndex).toBeGreaterThanOrEqual(0);
634
+ expect(buildIndex).toBeGreaterThan(generateIndex);
635
+ });
636
+
637
+ it('does not tell the user to rebuild a Go backend that does not exist', () => {
638
+ const context = createAppContext({ hasBackend: false });
639
+
640
+ appSdk(context);
641
+
642
+ const body = context.getMessage()?.body ?? [];
643
+ expect(body.some((line) => line.includes('mage'))).toBe(false);
644
+ });
645
+
646
+ it('tells the user to restart grafana to pick up the manifest', () => {
647
+ const context = createAppContext();
648
+
649
+ appSdk(context);
650
+
651
+ const body = context.getMessage()?.body ?? [];
652
+ expect(body.some((line) => line.includes('docker compose restart grafana'))).toBe(true);
653
+ });
654
+ });
655
+ });