@grafana/create-plugin 7.10.0 → 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 +27 -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/migrations/migrations.js +6 -0
- package/dist/codemods/migrations/scripts/013-jest-esmodules.js +65 -0
- 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/migrations/migrations.ts +7 -0
- package/src/codemods/migrations/scripts/013-jest-esmodules.test.ts +99 -0
- package/src/codemods/migrations/scripts/013-jest-esmodules.ts +79 -0
- 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/.config/jest/utils.js +2 -0
- package/templates/common/_package.json +1 -1
- package/vitest.setup.ts +2 -2
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
|
+
}
|
|
@@ -8,13 +8,25 @@ import {
|
|
|
8
8
|
readJsonFile,
|
|
9
9
|
isVersionGreater,
|
|
10
10
|
printChanges,
|
|
11
|
+
runGoModTidy,
|
|
11
12
|
} from './utils.js';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
14
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
14
15
|
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import which from 'which';
|
|
15
18
|
import { output } from '../utils/utils.console.js';
|
|
16
19
|
import { vi } from 'vitest';
|
|
17
20
|
|
|
21
|
+
vi.mock(import('node:child_process'), async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal()),
|
|
23
|
+
execSync: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('which', () => ({
|
|
27
|
+
default: { sync: vi.fn() },
|
|
28
|
+
}));
|
|
29
|
+
|
|
18
30
|
describe('utils', () => {
|
|
19
31
|
const tmpObj = dirSync({ unsafeCleanup: true });
|
|
20
32
|
const tmpDir = join(tmpObj.name, 'cp-test-migration');
|
|
@@ -121,6 +133,43 @@ describe('utils', () => {
|
|
|
121
133
|
});
|
|
122
134
|
});
|
|
123
135
|
|
|
136
|
+
describe('runGoModTidy', () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
vi.mocked(execSync).mockClear();
|
|
139
|
+
vi.mocked(which.sync).mockReset().mockReturnValue('/usr/local/bin/go');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('runs `go mod tidy` when go.mod was updated', async () => {
|
|
143
|
+
await writeFile(join(tmpDir, 'go.mod'), 'module example.com/foo\n');
|
|
144
|
+
const context = new Context(tmpDir);
|
|
145
|
+
context.updateFile('go.mod', 'module example.com/foo\n\nrequire example.com/bar v1.0.0\n');
|
|
146
|
+
|
|
147
|
+
runGoModTidy(context);
|
|
148
|
+
|
|
149
|
+
expect(execSync).toHaveBeenCalledWith('go mod tidy', { cwd: tmpDir, stdio: 'inherit' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does nothing when go.mod was not changed', () => {
|
|
153
|
+
const context = new Context(tmpDir);
|
|
154
|
+
context.addFile('other.txt', 'content');
|
|
155
|
+
|
|
156
|
+
runGoModTidy(context);
|
|
157
|
+
|
|
158
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does nothing when there is no `go` binary on PATH', async () => {
|
|
162
|
+
vi.mocked(which.sync).mockReturnValue(null as unknown as string);
|
|
163
|
+
await writeFile(join(tmpDir, 'go.mod'), 'module example.com/foo\n');
|
|
164
|
+
const context = new Context(tmpDir);
|
|
165
|
+
context.updateFile('go.mod', 'module example.com/foo\n\nrequire example.com/baz v2.0.0\n');
|
|
166
|
+
|
|
167
|
+
runGoModTidy(context);
|
|
168
|
+
|
|
169
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
124
173
|
describe('readJsonFile', () => {
|
|
125
174
|
it('should read a json file', () => {
|
|
126
175
|
const context = new Context(tmpDir);
|
package/src/codemods/utils.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { styleText } from 'node:util';
|
|
|
6
6
|
import { output } from '../utils/utils.console.js';
|
|
7
7
|
import { getPackageManagerSilentInstallCmd, getPackageManagerWithFallback } from '../utils/utils.packageManager.js';
|
|
8
8
|
import { execSync } from 'node:child_process';
|
|
9
|
+
import which from 'which';
|
|
9
10
|
import { clean, coerce, gt, gte } from 'semver';
|
|
10
11
|
import { debug } from '../utils/utils.cli.js';
|
|
11
12
|
import { renderHandlebarsTemplate } from '../utils/utils.handlebars.js';
|
|
@@ -133,6 +134,44 @@ export function installNPMDependencies(context: Context) {
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// Cache the go.mod contents to avoid re-running `go mod tidy` if go.mod hasn't changed
|
|
138
|
+
// (This runs for each codemod used in an update)
|
|
139
|
+
let goModTidyCache: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Runs `go mod tidy` when a codemod has updated go.mod, mirroring installNPMDependencies. Skipped
|
|
143
|
+
* when there is no `go` binary on PATH, since a Go backend is optional for plugins that use this repo's
|
|
144
|
+
* tooling from a machine without a Go toolchain.
|
|
145
|
+
*/
|
|
146
|
+
export function runGoModTidy(context: Context) {
|
|
147
|
+
const hasGoModChanges = Object.entries(context.listChanges()).some(
|
|
148
|
+
([filePath, { changeType }]) => filePath === 'go.mod' && changeType === 'update'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (!hasGoModChanges) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const goModContents = context.getFile('go.mod');
|
|
156
|
+
|
|
157
|
+
if (!goModContents) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (goModContents === goModTidyCache) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!which.sync('go', { nothrow: true })) {
|
|
166
|
+
additionsDebug('No `go` binary found on PATH. Skipping `go mod tidy`.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
goModTidyCache = goModContents;
|
|
171
|
+
output.logSingleLine('Running `go mod tidy`...');
|
|
172
|
+
execSync('go mod tidy', { cwd: context.basePath, stdio: 'inherit' });
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
export function readJsonFile<T extends object = any>(context: Context, path: string): T {
|
|
137
176
|
if (!context.doesFileExist(path)) {
|
|
138
177
|
throw new Error(`Cannot find ${path}`);
|
|
@@ -24,11 +24,16 @@ export const add = async (argv: minimist.ParsedArgs) => {
|
|
|
24
24
|
|
|
25
25
|
// filter out minimist internal properties (_ and $0) before passing to codemod
|
|
26
26
|
const { _, $0, ...codemodOptions } = argv;
|
|
27
|
-
await runCodemod(addition, codemodOptions);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const context = await runCodemod(addition, codemodOptions);
|
|
28
|
+
|
|
29
|
+
const message = context.getMessage();
|
|
30
|
+
if (message) {
|
|
31
|
+
output[message.level]({ title: message.title, body: message.body });
|
|
32
|
+
} else {
|
|
33
|
+
output.success({
|
|
34
|
+
title: `Successfully added ${addition.name} to your plugin.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
32
37
|
} catch (error) {
|
|
33
38
|
if (error instanceof Error) {
|
|
34
39
|
output.error({
|
|
@@ -39,7 +39,7 @@ export function printGenerateSuccessMessage(answers: TemplateData) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function getBackendCmd() {
|
|
42
|
+
export function getBackendCmd() {
|
|
43
43
|
const platform = machine();
|
|
44
44
|
if (platform === 'arm64') {
|
|
45
45
|
return output.formatCode('mage -v build:linuxARM64');
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: grafana-app-sdk kind instructions for a grafana plugin
|
|
3
|
+
description: Guides how to work with CUE kinds and generated code in a plugin that uses the grafana-app-sdk
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# grafana-app-sdk kinds
|
|
7
|
+
|
|
8
|
+
This plugin defines its own API resources ("kinds") as [CUE](https://cuelang.org/) schemas using the
|
|
9
|
+
[grafana-app-sdk](https://github.com/grafana/grafana-app-sdk). Code generation turns those schemas into
|
|
10
|
+
TypeScript types and an app manifest that Grafana reads from the plugin bundle.
|
|
11
|
+
|
|
12
|
+
## Critical rules
|
|
13
|
+
|
|
14
|
+
- **`kinds/*.cue` is the source of truth for the API.** To change a resource's shape, edit the CUE and
|
|
15
|
+
regenerate. Never work the other way around.
|
|
16
|
+
- **Never hand-edit generated code.** Everything under the generated directories listed below is
|
|
17
|
+
overwritten on the next run, so edits there are silently lost. They carry a
|
|
18
|
+
`Code generated - EDITING IS FUTILE. DO NOT EDIT.` header.
|
|
19
|
+
- **Regenerate after every change under `kinds/`:**
|
|
20
|
+
```bash
|
|
21
|
+
{{ packageManagerName }} run generate:kinds
|
|
22
|
+
```
|
|
23
|
+
- **Generated code is committed.** Commit the regenerated files alongside the CUE change so schema
|
|
24
|
+
changes are reviewable and a fresh clone builds without running code generation.
|
|
25
|
+
- **This plugin generates no Go code.** `kinds/config.cue` sets `codegen: goEnabled: false`, so
|
|
26
|
+
generation emits only TypeScript and the JSON definitions. Do not add Go output paths or a Go
|
|
27
|
+
backend to work around a generation problem.
|
|
28
|
+
- **Do not set `GRAFANA_APP_SDK_BIN`.** It overrides the pinned CLI with a local build, and is meant
|
|
29
|
+
for people working on the app-sdk itself. Code generated with it can differ from what `VERSION` in
|
|
30
|
+
`.config/app-sdk/generate-kinds.mjs` produces, so committing it would put the repository out of step. Run
|
|
31
|
+
`generate:kinds` without it.
|
|
32
|
+
- **Do not add code generation to the build.** It is a schema-change-time step, not a build step. The
|
|
33
|
+
frontend build must keep working without a Go toolchain.
|
|
34
|
+
|
|
35
|
+
## Layout
|
|
36
|
+
|
|
37
|
+
| Path | What it is |
|
|
38
|
+
| ---- | ---------- |
|
|
39
|
+
| `kinds/manifest.cue` | The app manifest: app name, and the versions and kinds your app serves. |
|
|
40
|
+
| `kinds/*.cue` | One file per kind. Edit these to change a resource's schema. |
|
|
41
|
+
| `kinds/config.cue` | Code generation settings (output paths). Rarely needs changing. |
|
|
42
|
+
| `src/generated/` | Generated TypeScript types. **Do not edit.** Import from here in frontend code. |
|
|
43
|
+
| `src/app-sdk-manifest.json` | Generated app manifest JSON. **Do not edit.** The frontend build's existing JSON copy pattern carries it into the plugin bundle. |
|
|
44
|
+
|
|
45
|
+
## Changing a schema
|
|
46
|
+
|
|
47
|
+
1. Edit the relevant kind in `kinds/`. Add a new kind by creating a file and listing it in the version's
|
|
48
|
+
`kinds` array in `kinds/manifest.cue`.
|
|
49
|
+
2. Run `{{ packageManagerName }} run generate:kinds`.
|
|
50
|
+
3. Update the frontend to match. Because the TypeScript types are generated from the CUE, a schema
|
|
51
|
+
change surfaces as a type error wherever the code is now wrong — fix those rather than casting.
|
|
52
|
+
4. Commit the CUE and the regenerated files together.
|
|
53
|
+
|
|
54
|
+
Common CUE constructs, for reference:
|
|
55
|
+
|
|
56
|
+
```cue
|
|
57
|
+
spec: {
|
|
58
|
+
title: string // required
|
|
59
|
+
owner?: string // optional
|
|
60
|
+
tier: *"gold" | "silver" | "bronze" // enum, defaults to "gold"
|
|
61
|
+
labels: [string]: string // map
|
|
62
|
+
tags: [...string] // list
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For anything beyond this — subtypes, time types, constraints, multiple versions — consult the app-sdk's
|
|
67
|
+
kind authoring reference at
|
|
68
|
+
https://github.com/grafana/grafana-app-sdk/blob/main/docs/custom-kinds/writing-kinds.md rather than
|
|
69
|
+
guessing at CUE syntax.
|
|
70
|
+
|
|
71
|
+
## Serving the kinds
|
|
72
|
+
|
|
73
|
+
Grafana serves storage and CRUD for the kinds from the manifest bundled in the plugin, behind the
|
|
74
|
+
`appplugins.loadAppManifest` and `appplugins.registerAPIServer` feature toggles. The development server
|
|
75
|
+
enables both. Grafana only reads plugin manifests at startup, so restart it after rebuilding.
|
|
76
|
+
|
|
77
|
+
Resources are served under a Kubernetes-style path:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
/apis/<group>/<version>/namespaces/<namespace>/<plural>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The namespace is deployment-dependent — `default` on single-tenant Grafana, `stacks-<id>` on Grafana
|
|
84
|
+
Cloud. Read it from `config.namespace` in `@grafana/runtime`; never hardcode it.
|
|
85
|
+
|
|
86
|
+
This plugin has no Go backend, and does not need one for storage or CRUD. Admission (validation and
|
|
87
|
+
mutation), conversion between versions, and custom routes would require adding one.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Kinds (grafana-app-sdk)
|
|
2
|
+
|
|
3
|
+
The [kinds/](../../kinds) directory declares your app's API as [CUE](https://cuelang.org/) "kinds", which
|
|
4
|
+
[grafana-app-sdk](https://github.com/grafana/grafana-app-sdk) turns into TypeScript types and an app
|
|
5
|
+
manifest.
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
| ---- | ------- |
|
|
9
|
+
| `manifest.cue` | The app manifest: app name, and the versions/kinds your app serves. |
|
|
10
|
+
| `example.cue` | An example kind. Rename it and edit its `spec` to model your own resource. |
|
|
11
|
+
| `config.cue` | Code generation settings (output paths). You rarely need to change this. |
|
|
12
|
+
| `cue.mod/module.cue` | The CUE module definition. |
|
|
13
|
+
|
|
14
|
+
## Generating code
|
|
15
|
+
|
|
16
|
+
After every change under this directory:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
{{ packageManagerName }} run generate:kinds
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That script always runs the `grafana-app-sdk` version set as `VERSION` at the top of
|
|
23
|
+
`.config/app-sdk/generate-kinds.mjs`, reusing a copy in `node_modules/.cache/` or downloading and checksum-verifying one for
|
|
24
|
+
your platform. A `grafana-app-sdk` on your `PATH` is ignored, so everyone on the project generates with
|
|
25
|
+
the same version. To run a local build instead, set `GRAFANA_APP_SDK_BIN` to its path. It writes:
|
|
26
|
+
|
|
27
|
+
| Output | Path |
|
|
28
|
+
| ------ | ---- |
|
|
29
|
+
| TypeScript types | `src/generated/<kind>/<version>/` |
|
|
30
|
+
| App manifest (JSON) | `src/app-sdk-manifest.json` |
|
|
31
|
+
| Go types (backend only) | `pkg/generated/` |
|
|
32
|
+
|
|
33
|
+
Generated code is meant to be committed, so schema changes show up in review and a fresh clone builds
|
|
34
|
+
without running code generation.
|
|
35
|
+
|
|
36
|
+
> **Note:** No Go toolchain is needed to run generate:kinds unless generating Go code.**
|
|
37
|
+
|
|
38
|
+
## How the manifest reaches Grafana
|
|
39
|
+
|
|
40
|
+
The generator writes the manifest straight into `src/app-sdk-manifest.json`, so the frontend build's
|
|
41
|
+
existing `**/*.json` copy pattern (`.config/bundler/copyFiles.ts`) carries it into the plugin bundle as
|
|
42
|
+
`dist/app-sdk-manifest.json` with no dedicated copy step. Grafana reads it when the
|
|
43
|
+
`appplugins.loadAppManifest` and `appplugins.registerAPIServer` feature toggles are enabled — the Docker
|
|
44
|
+
dev server in this repo enables both for you. Note the toggles are experimental and off by default in
|
|
45
|
+
Grafana.
|
|
46
|
+
|
|
47
|
+
With the manifest in place, Grafana serves storage and CRUD for your kinds through its aggregated API
|
|
48
|
+
server, and users can also manage the objects with `kubectl`.
|
|
49
|
+
|
|
50
|
+
## Adding a backend
|
|
51
|
+
|
|
52
|
+
Storage and CRUD come from the manifest alone, so a plugin with no backend still gets them. A backend will
|
|
53
|
+
be required for:
|
|
54
|
+
|
|
55
|
+
- Admission logic (i.e. validation/mutation)
|
|
56
|
+
- Conversion logic (i.e. between API versions)
|
|
57
|
+
- Custom routes and subresource routes
|
|
58
|
+
- Controller logic (informers, watchers, or any other background work)
|
|
59
|
+
|
|
60
|
+
If this plugin has a backend, `pkg/provider/provider.go` is where you wire those up as your app grows.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Runs grafana-app-sdk kind code generation from the CUE kinds in ./kinds.
|
|
2
|
+
//
|
|
3
|
+
// Always runs the CLI at the version pinned in VERSION below, resolved as:
|
|
4
|
+
// 1. a previously obtained copy in node_modules/.cache/grafana-app-sdk/<version>/
|
|
5
|
+
// 2. a fresh copy, downloaded from the official release and verified against its checksum
|
|
6
|
+
//
|
|
7
|
+
// A `grafana-app-sdk` on your PATH is deliberately ignored: generated code has to match the library
|
|
8
|
+
// version it is compiled against, and silently generating with whatever happens to be installed makes
|
|
9
|
+
// that mismatch invisible. To use your own build, set GRAFANA_APP_SDK_BIN to the binary — an explicit,
|
|
10
|
+
// per-invocation override for local development.
|
|
11
|
+
//
|
|
12
|
+
// Downloading the CLI needs no Go toolchain. Generation itself only needs Go when codegen.goEnabled
|
|
13
|
+
// is set in kinds/config.cue: the generator formats Go output with golang.org/x/tools, which shells
|
|
14
|
+
// out to `go`.
|
|
15
|
+
//
|
|
16
|
+
// Output paths are configured in kinds/config.cue. Generated code is intended to be committed.
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
import { arch, platform, tmpdir } from 'node:os';
|
|
23
|
+
|
|
24
|
+
const VERSION = 'v0.60.0';
|
|
25
|
+
const REPO = 'grafana/grafana-app-sdk';
|
|
26
|
+
const BIN = 'grafana-app-sdk';
|
|
27
|
+
|
|
28
|
+
// Escape hatch for developing against a local CLI build, e.g. one you are changing yourself. Unlike a
|
|
29
|
+
// binary that merely happens to be on PATH, setting this is a deliberate act, so it is honoured.
|
|
30
|
+
const BIN_OVERRIDE = 'GRAFANA_APP_SDK_BIN';
|
|
31
|
+
|
|
32
|
+
// node_modules is already gitignored, so nothing extra needs ignoring for this cache.
|
|
33
|
+
const CACHE_DIR = resolve('node_modules', '.cache', 'grafana-app-sdk', VERSION);
|
|
34
|
+
|
|
35
|
+
function run(command, args, options = {}) {
|
|
36
|
+
return spawnSync(command, args, { stdio: 'inherit', ...options });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EXE = platform() === 'win32' ? '.exe' : '';
|
|
40
|
+
|
|
41
|
+
/** Returns the release asset's platform pair, or exits if this platform has no published build. */
|
|
42
|
+
function target() {
|
|
43
|
+
const goos = { linux: 'linux', darwin: 'darwin', win32: 'windows' }[platform()];
|
|
44
|
+
const goarch = { x64: 'amd64', arm64: 'arm64' }[arch()];
|
|
45
|
+
|
|
46
|
+
if (!goos || !goarch) {
|
|
47
|
+
console.error(
|
|
48
|
+
`No grafana-app-sdk release build for ${platform()}/${arch()}.\n` +
|
|
49
|
+
`Install the CLI yourself and re-run: https://github.com/${REPO}/releases`
|
|
50
|
+
);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { goos, goarch };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchOrDie(url, what) {
|
|
58
|
+
const response = await fetch(url);
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
console.error(`Failed to download ${what} (${response.status} ${response.statusText})\n ${url}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
return Buffer.from(await response.arrayBuffer());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Downloads the release archive, verifies it against checksums.txt, and unpacks it into CACHE_DIR. */
|
|
67
|
+
async function download({ goos, goarch }) {
|
|
68
|
+
// Release assets drop the leading "v" from the tag.
|
|
69
|
+
const archive = `${BIN}_${VERSION.replace(/^v/, '')}_${goos}_${goarch}.tar.gz`;
|
|
70
|
+
const base = `https://github.com/${REPO}/releases/download/${VERSION}`;
|
|
71
|
+
|
|
72
|
+
console.log(`Downloading ${BIN} ${VERSION} for ${goos}/${goarch}...`);
|
|
73
|
+
const [tarball, checksums] = await Promise.all([
|
|
74
|
+
fetchOrDie(`${base}/${archive}`, archive),
|
|
75
|
+
fetchOrDie(`${base}/checksums.txt`, 'checksums.txt'),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const expected = checksums
|
|
79
|
+
.toString('utf8')
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((line) => line.trim().split(/\s+/))
|
|
82
|
+
.find(([, name]) => name === archive)?.[0];
|
|
83
|
+
|
|
84
|
+
if (!expected) {
|
|
85
|
+
console.error(`${archive} is not listed in checksums.txt; refusing to run an unverified binary.`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const actual = createHash('sha256').update(tarball).digest('hex');
|
|
90
|
+
if (actual !== expected) {
|
|
91
|
+
console.error(`Checksum mismatch for ${archive}.\n expected ${expected}\n actual ${actual}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Unpack via tar(1): available on macOS/Linux and shipped with Windows 10+. The archive lays the
|
|
96
|
+
// binary flat at its root (alongside LICENSE and README.md), so extract straight into CACHE_DIR.
|
|
97
|
+
const tarPath = join(tmpdir(), `${archive}-${process.pid}`);
|
|
98
|
+
writeFileSync(tarPath, tarball);
|
|
99
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const untar = run('tar', ['-xzf', tarPath, '-C', CACHE_DIR, `${BIN}${EXE}`]);
|
|
102
|
+
rmSync(tarPath, { force: true });
|
|
103
|
+
if (untar.status !== 0) {
|
|
104
|
+
console.error(`Could not unpack ${archive}. Is tar available on your PATH?`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const binary = join(CACHE_DIR, `${BIN}${EXE}`);
|
|
109
|
+
chmodSync(binary, 0o755);
|
|
110
|
+
|
|
111
|
+
return binary;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function resolveBinary() {
|
|
115
|
+
const override = process.env[BIN_OVERRIDE];
|
|
116
|
+
if (override) {
|
|
117
|
+
// Exit rather than falling back to the pinned version: you asked for a specific binary, so
|
|
118
|
+
// silently generating with a different one is the surprise worth avoiding.
|
|
119
|
+
if (!existsSync(override)) {
|
|
120
|
+
console.error(`${BIN_OVERRIDE} is set to ${override}, but no such file exists.`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`Using ${BIN} from ${BIN_OVERRIDE}: ${override}`);
|
|
125
|
+
return resolve(override);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cached = join(CACHE_DIR, `${BIN}${EXE}`);
|
|
129
|
+
if (existsSync(cached)) {
|
|
130
|
+
return cached;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return download(target());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const binary = await resolveBinary();
|
|
137
|
+
const result = run(binary, ['generate', '--source', 'kinds']);
|
|
138
|
+
process.exit(result.status ?? 1);
|