@grafana/create-plugin 7.13.0 → 7.14.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 +16 -0
- package/dist/codemods/additions/additions.js +12 -0
- package/dist/codemods/additions/prompts/docs-content.md +119 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +250 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +40 -7
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/utils/utils.cli.js +3 -2
- package/dist/utils/utils.handlebars.js +9 -2
- package/package.json +1 -1
- package/src/codemods/additions/additions.ts +12 -0
- package/src/codemods/additions/prompts/docs-content.md +119 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1037 -0
- package/src/codemods/additions/scripts/docs.ts +353 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +23 -0
- package/src/codemods/utils.ts +29 -7
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/utils/utils.cli.ts +2 -1
- package/src/utils/utils.handlebars.ts +9 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/docs/common/workflows/validate-docs.yml +52 -0
- package/templates/docs/panel/docs/AGENTS.md +107 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- package/templates/docs/panel/docs/troubleshooting.md +35 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
import * as v from 'valibot';
|
|
3
|
+
import type { Context, ContextMessage } from '../../context.js';
|
|
4
|
+
import { TEMPLATE_PATHS, TEMPLATES_DIR } from '../../../constants.js';
|
|
5
|
+
import { output } from '../../../utils/utils.console.js';
|
|
6
|
+
import {
|
|
7
|
+
additionsDebug,
|
|
8
|
+
addDependenciesToPackageJson,
|
|
9
|
+
addTemplateFolder,
|
|
10
|
+
getTemplateDataFromContext,
|
|
11
|
+
readJsonFile,
|
|
12
|
+
renderTemplate,
|
|
13
|
+
} from '../../utils.js';
|
|
14
|
+
import {
|
|
15
|
+
AGENT_INSTRUCTIONS_PATH,
|
|
16
|
+
appendToAgentInstructions,
|
|
17
|
+
ensureConfigAgentFiles,
|
|
18
|
+
ensureRootAgentFiles,
|
|
19
|
+
} from '../../utils.agents.js';
|
|
20
|
+
import { type BumpActionRefsResult, bumpActionRefs } from '../../utils.workflows.js';
|
|
21
|
+
import { getPackageManagerExecCmd } from '../../../utils/utils.packageManager.js';
|
|
22
|
+
|
|
23
|
+
// an allowlist, because the value becomes a path on disk and is embedded unescaped in a YAML workflow
|
|
24
|
+
const DOCS_PATH_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]|\/[a-zA-Z0-9_-])*$/;
|
|
25
|
+
const DOCS_PATH_RULE =
|
|
26
|
+
'a relative path of letters, digits, hyphens, underscores and single "/" separators (for example "catalog-docs" or "docs/plugin")';
|
|
27
|
+
const DEFAULT_DOCS_PATH = 'catalog-docs';
|
|
28
|
+
|
|
29
|
+
export function isValidDocsPath(value: unknown): value is string {
|
|
30
|
+
return typeof value === 'string' && DOCS_PATH_RE.test(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assertValidDocsPath(value: unknown, command: string, nextStep: string): asserts value is string {
|
|
34
|
+
if (!isValidDocsPath(value)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`src/plugin.json has docsPath set to '${value}', which create-plugin ${command} can't use.\n Change it to ${DOCS_PATH_RULE}, then ${nextStep}.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const schema = v.object({
|
|
42
|
+
docsPath: v.optional(
|
|
43
|
+
v.pipe(
|
|
44
|
+
v.string(),
|
|
45
|
+
v.minLength(1, 'docsPath must not be empty.'),
|
|
46
|
+
v.regex(
|
|
47
|
+
DOCS_PATH_RE,
|
|
48
|
+
`docsPath must be ${DOCS_PATH_RULE}, with no leading "/", "./", trailing "/", ".." segment, backslash or quote character.`
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
type Options = v.InferOutput<typeof schema>;
|
|
55
|
+
|
|
56
|
+
// kept current by a Renovate custom manager in .github/renovate.json, which matches this exact line
|
|
57
|
+
const PLUGIN_DOCS_CLI_VERSION = '^0.8.0';
|
|
58
|
+
|
|
59
|
+
// each type needs a template set under `templates/docs/<type>/`
|
|
60
|
+
const SUPPORTED_PLUGIN_TYPES = ['panel'] as const;
|
|
61
|
+
type SupportedPluginType = (typeof SUPPORTED_PLUGIN_TYPES)[number];
|
|
62
|
+
|
|
63
|
+
export default function docs(context: Context, options: Options): Context {
|
|
64
|
+
const pluginType = assertSupportedPluginType(context);
|
|
65
|
+
return setupDocsScaffolding({
|
|
66
|
+
context,
|
|
67
|
+
docsPath: options.docsPath,
|
|
68
|
+
// `templates/docs` is left out of TEMPLATE_PATHS so `generate` ignores it
|
|
69
|
+
templateDir: join(TEMPLATES_DIR, 'docs', pluginType),
|
|
70
|
+
commonTemplateDir: join(TEMPLATES_DIR, 'docs', 'common'),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PluginJson {
|
|
75
|
+
type?: string;
|
|
76
|
+
name?: string;
|
|
77
|
+
id?: string;
|
|
78
|
+
docsPath?: string;
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DocsSetupOptions {
|
|
83
|
+
context: Context;
|
|
84
|
+
/** Falls back to the docsPath in `src/plugin.json`, then to `catalog-docs`. */
|
|
85
|
+
docsPath?: string;
|
|
86
|
+
/** Templates specific to this plugin type, under `templates/docs/<type>/`. */
|
|
87
|
+
templateDir: string;
|
|
88
|
+
/** Templates shared by every plugin type, under `templates/docs/common/`. */
|
|
89
|
+
commonTemplateDir: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function setupDocsScaffolding(opts: DocsSetupOptions): Context {
|
|
93
|
+
const { context, templateDir, commonTemplateDir } = opts;
|
|
94
|
+
|
|
95
|
+
const pluginJson = readJsonFile<PluginJson>(context, 'src/plugin.json');
|
|
96
|
+
const existingDocsPath = pluginJson.docsPath;
|
|
97
|
+
|
|
98
|
+
if (existingDocsPath !== undefined) {
|
|
99
|
+
assertValidDocsPath(existingDocsPath, 'add docs', 'run this again');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts.docsPath !== undefined && existingDocsPath !== undefined && existingDocsPath !== opts.docsPath) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`src/plugin.json already has docsPath set to '${existingDocsPath}'.\n Re-run with the existing path:\n create-plugin add docs --docsPath ${existingDocsPath}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const docsPath = opts.docsPath ?? existingDocsPath ?? DEFAULT_DOCS_PATH;
|
|
109
|
+
|
|
110
|
+
// a folder plugin.json doesn't declare isn't ours. doesFileExistOnDisk also catches an empty directory
|
|
111
|
+
if (existingDocsPath === undefined && context.doesFileExistOnDisk(docsPath)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`A directory already exists at '${docsPath}' but src/plugin.json has no docsPath.\n Point the plugin at it by setting "docsPath": "${docsPath}" in src/plugin.json, or scaffold elsewhere:\n create-plugin add docs --docsPath <alternative-path>`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!pluginJson.name) {
|
|
118
|
+
throw new Error('src/plugin.json has no "name". Add one - it is used as the title throughout the docs.');
|
|
119
|
+
}
|
|
120
|
+
if (!pluginJson.id) {
|
|
121
|
+
throw new Error('src/plugin.json has no "id". Add one - it is used in the catalog links throughout the docs.');
|
|
122
|
+
}
|
|
123
|
+
const pluginId = pluginJson.id;
|
|
124
|
+
|
|
125
|
+
// only rewritten when docsPath changes, so re-runs don't reformat plugin.json
|
|
126
|
+
if (existingDocsPath !== docsPath) {
|
|
127
|
+
context.updateFile('src/plugin.json', JSON.stringify({ ...pluginJson, docsPath }, null, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const templateData = getTemplateDataFromContext(context);
|
|
131
|
+
const { packageManagerName, packageManagerVersion } = templateData;
|
|
132
|
+
const createPluginCmd = `${getPackageManagerExecCmd(packageManagerName, packageManagerVersion)}@latest`;
|
|
133
|
+
const data = {
|
|
134
|
+
...templateData,
|
|
135
|
+
pluginId,
|
|
136
|
+
docsPath,
|
|
137
|
+
createPluginCmd,
|
|
138
|
+
lockFile: LOCK_FILES[packageManagerName],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
addDependenciesToPackageJson(context, {}, { '@grafana/plugin-docs-cli': PLUGIN_DOCS_CLI_VERSION });
|
|
142
|
+
|
|
143
|
+
addDocsScripts(context);
|
|
144
|
+
|
|
145
|
+
// pages are only rendered into a folder without any, so pages the author deleted stay deleted
|
|
146
|
+
const hasPages = hasDocsPages(context, docsPath);
|
|
147
|
+
addTemplateFolder(context, {
|
|
148
|
+
folderPath: join(templateDir, 'docs'),
|
|
149
|
+
targetPath: docsPath,
|
|
150
|
+
data,
|
|
151
|
+
include: (file) => !hasPages || SCAFFOLD_META_FILES.includes(file),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// left alone when it exists, since the author may have customized it
|
|
155
|
+
const workflowPath = '.github/workflows/validate-docs.yml';
|
|
156
|
+
if (!context.doesFileExist(workflowPath)) {
|
|
157
|
+
context.addFile(workflowPath, renderTemplate(join(commonTemplateDir, 'workflows/validate-docs.yml'), false, data));
|
|
158
|
+
} else {
|
|
159
|
+
additionsDebug(`${workflowPath} already exists, skipping`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pluginActions = bumpActionRefs(context, RELEASE_WORKFLOW_PATH, REQUIRED_ACTION_REFS);
|
|
163
|
+
|
|
164
|
+
// a plugin that predates the root and .config/AGENTS files gets them here
|
|
165
|
+
const linkedRootFiles = ensureRootAgentFiles(context, TEMPLATE_PATHS.common);
|
|
166
|
+
ensureConfigAgentFiles(context, TEMPLATE_PATHS.common, CONFIG_AGENT_FILES, data);
|
|
167
|
+
appendDocsPointerToInstructions(context, docsPath);
|
|
168
|
+
|
|
169
|
+
// deferred so it prints after the runner's change list, and only when something changed
|
|
170
|
+
if (context.hasChanges()) {
|
|
171
|
+
context.setMessage(
|
|
172
|
+
buildNextStepsMessage({ docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles })
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return context;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildNextStepsMessage(opts: {
|
|
180
|
+
docsPath: string;
|
|
181
|
+
packageManagerName: string;
|
|
182
|
+
createPluginCmd: string;
|
|
183
|
+
pluginActions: BumpActionRefsResult;
|
|
184
|
+
linkedRootFiles: string[];
|
|
185
|
+
}): ContextMessage {
|
|
186
|
+
const { docsPath, packageManagerName, createPluginCmd, pluginActions, linkedRootFiles } = opts;
|
|
187
|
+
const run = (script: string) => output.formatCode(`${packageManagerName} run ${script}`);
|
|
188
|
+
|
|
189
|
+
const steps: NextStep[] = [
|
|
190
|
+
{
|
|
191
|
+
title: `Read ${docsPath}/README.md`,
|
|
192
|
+
detail: 'It has the commands and a link to the plugin docs guide.',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
title: 'Write the pages, yourself or with an AI agent',
|
|
196
|
+
detail: [
|
|
197
|
+
`Fill in or review the pages under ${docsPath}/ by hand.`,
|
|
198
|
+
'Or commit these changes, then have an agent draft them from your source code and README:',
|
|
199
|
+
output.formatCode(`${createPluginCmd} add docs-content`),
|
|
200
|
+
'Review its work either way.',
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
title: 'Preview the pages',
|
|
205
|
+
detail: `Run ${run('docs:serve')}, which also lists every section left to fill.`,
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const warnings: NextStep[] = [];
|
|
210
|
+
if (pluginActions.found.length === 0) {
|
|
211
|
+
warnings.push({
|
|
212
|
+
title: `No build-plugin or package-plugin step found in ${RELEASE_WORKFLOW_PATH}`,
|
|
213
|
+
detail: `Make sure your release pipeline builds ${docsPath}/ into the plugin archive, or the docs won't publish.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
for (const action of pluginActions.unrecognized) {
|
|
217
|
+
warnings.push({
|
|
218
|
+
title: `${RELEASE_WORKFLOW_PATH} pins ${action} to a branch or commit, so it was left as is`,
|
|
219
|
+
detail: `Docs need ${REQUIRED_ACTION_REFS[action]} or later.`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (const file of linkedRootFiles) {
|
|
223
|
+
warnings.push({
|
|
224
|
+
title: `Added a pointer to ${AGENT_INSTRUCTIONS_PATH} at the end of ${file}`,
|
|
225
|
+
detail: 'Agents that read it now also load the plugin instructions. Check it fits with the rest of the file.',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const body = steps.flatMap((step, i) => formatNextStep(step, ` ${i + 1}. `));
|
|
230
|
+
if (warnings.length > 0) {
|
|
231
|
+
body.push('', 'Needs your attention:', ...warnings.flatMap((warning) => formatNextStep(warning, ' ! ')));
|
|
232
|
+
}
|
|
233
|
+
return { level: 'log', title: 'Next steps', body };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface NextStep {
|
|
237
|
+
title: string;
|
|
238
|
+
detail?: string | string[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatNextStep({ title, detail }: NextStep, marker: string): string[] {
|
|
242
|
+
const indent = ' '.repeat(marker.length);
|
|
243
|
+
return [`${marker}${title}`, ...[detail ?? []].flat().map((line) => `${indent}${line}`)];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function assertSupportedPluginType(context: Context, additionName = 'docs'): SupportedPluginType {
|
|
247
|
+
const { type } = readJsonFile<PluginJson>(context, 'src/plugin.json');
|
|
248
|
+
|
|
249
|
+
if (isSupportedPluginType(type)) {
|
|
250
|
+
return type;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const supported = SUPPORTED_PLUGIN_TYPES.join(', ');
|
|
254
|
+
if (type === 'app' || type === 'datasource') {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`create-plugin add ${additionName} does not support '${type}' plugins yet. Supported so far: ${supported}.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(
|
|
260
|
+
`create-plugin add ${additionName} needs a plugin type of ${supported} in src/plugin.json, but found '${type ?? 'unset'}'.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isSupportedPluginType(type: string | undefined): type is SupportedPluginType {
|
|
265
|
+
return SUPPORTED_PLUGIN_TYPES.includes(type as SupportedPluginType);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// instructions.md imports e2e-testing.md, so the two are created together
|
|
269
|
+
const CONFIG_AGENT_FILES = ['instructions.md', 'e2e-testing.md'];
|
|
270
|
+
// the scaffold's files that aren't pages, so they're added on every run
|
|
271
|
+
const SCAFFOLD_META_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md'];
|
|
272
|
+
// mirrors isMetaFile in @grafana/plugin-docs-cli, so both tools agree on what counts as a page
|
|
273
|
+
const DOCS_CLI_META_FILES = [
|
|
274
|
+
'README.MD',
|
|
275
|
+
'CONTRIBUTING.MD',
|
|
276
|
+
'LICENSE.MD',
|
|
277
|
+
'CODE_OF_CONDUCT.MD',
|
|
278
|
+
'SECURITY.MD',
|
|
279
|
+
'CHANGELOG.MD',
|
|
280
|
+
'AGENTS.MD',
|
|
281
|
+
'CLAUDE.MD',
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
export function hasDocsPages(context: Context, docsPath: string): boolean {
|
|
285
|
+
const addedInContext = Object.entries(context.listChanges())
|
|
286
|
+
.filter(([path, change]) => change.changeType === 'add' && path.startsWith(`${docsPath}/`))
|
|
287
|
+
.map(([path]) => path);
|
|
288
|
+
return [...listFiles(context, docsPath), ...addedInContext].some(
|
|
289
|
+
(file) =>
|
|
290
|
+
file.endsWith('.md') &&
|
|
291
|
+
!DOCS_CLI_META_FILES.includes(basename(file).toUpperCase()) &&
|
|
292
|
+
!isInSkippedFolder(file.slice(docsPath.length + 1))
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// plugin-docs-cli skips these too, and walking node_modules would be slow
|
|
297
|
+
const SKIPPED_FOLDERS = ['node_modules', 'dist'];
|
|
298
|
+
|
|
299
|
+
function isInSkippedFolder(relativePath: string): boolean {
|
|
300
|
+
return relativePath.split('/').some((segment) => SKIPPED_FOLDERS.includes(segment));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// readDir gives nothing for a file, so this walks into every entry
|
|
304
|
+
function listFiles(context: Context, folder: string): string[] {
|
|
305
|
+
return context
|
|
306
|
+
.readDir(folder)
|
|
307
|
+
.flatMap((entry) => (SKIPPED_FOLDERS.includes(basename(entry)) ? [] : [entry, ...listFiles(context, entry)]));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function appendDocsPointerToInstructions(context: Context, docsPath: string): void {
|
|
311
|
+
const agentsPath = `${docsPath}/AGENTS.md`;
|
|
312
|
+
// a link rather than an @-import, so the docs guidance only loads when an agent works in the docs folder
|
|
313
|
+
const section = `## Plugin docs\n\nWhenever you add, remove or change a feature in the plugin, you must update the plugin documentation under \`${docsPath}/\`. Refer to [\`${agentsPath}\`](../../${agentsPath}) for instructions.\n`;
|
|
314
|
+
appendToAgentInstructions(context, agentsPath, section);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// so the docs workflow also runs when the docs CLI is bumped
|
|
318
|
+
const LOCK_FILES: Record<string, string> = {
|
|
319
|
+
npm: 'package-lock.json',
|
|
320
|
+
pnpm: 'pnpm-lock.yaml',
|
|
321
|
+
yarn: 'yarn.lock',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const RELEASE_WORKFLOW_PATH = '.github/workflows/release.yml';
|
|
325
|
+
// the oldest release of each action that builds docs into the plugin archive
|
|
326
|
+
const REQUIRED_ACTION_REFS: Record<string, string> = {
|
|
327
|
+
'grafana/plugin-actions/build-plugin': 'build-plugin/v1.2.0',
|
|
328
|
+
'grafana/plugin-actions/package-plugin': 'package-plugin/v1.3.3',
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const DOCS_SCRIPTS: Record<string, string> = {
|
|
332
|
+
'docs:serve': 'plugin-docs-cli serve --port 3001 --reload',
|
|
333
|
+
'docs:validate': 'plugin-docs-cli validate',
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function addDocsScripts(context: Context): void {
|
|
337
|
+
const packageJson = readJsonFile<Record<string, unknown>>(context, 'package.json');
|
|
338
|
+
const scripts = (packageJson['scripts'] ?? {}) as Record<string, string>;
|
|
339
|
+
const missing = Object.entries(DOCS_SCRIPTS).filter(([name]) => {
|
|
340
|
+
if (scripts[name]) {
|
|
341
|
+
additionsDebug(`${name} already exists in package.json scripts, skipping`);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (missing.length > 0) {
|
|
348
|
+
context.updateFile(
|
|
349
|
+
'package.json',
|
|
350
|
+
JSON.stringify({ ...packageJson, scripts: { ...scripts, ...Object.fromEntries(missing) } }, null, 2)
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -252,7 +252,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
252
252
|
const result = appSdk(context);
|
|
253
253
|
|
|
254
254
|
expect(result.getFile('docker-compose.yaml')).toContain(
|
|
255
|
-
'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer'
|
|
255
|
+
'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware'
|
|
256
256
|
);
|
|
257
257
|
});
|
|
258
258
|
|
|
@@ -274,6 +274,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
274
274
|
expect(compose).toContain('someOtherToggle');
|
|
275
275
|
expect(compose).toContain('appplugins.loadAppManifest');
|
|
276
276
|
expect(compose).toContain('appplugins.registerAPIServer');
|
|
277
|
+
expect(compose).toContain('grafana.useRouterMiddleware');
|
|
277
278
|
});
|
|
278
279
|
|
|
279
280
|
it('does not duplicate the toggles when they are already set', () => {
|
|
@@ -284,7 +285,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
284
285
|
file: .config/docker-compose-base.yaml
|
|
285
286
|
service: grafana
|
|
286
287
|
environment:
|
|
287
|
-
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
|
|
288
|
+
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
|
|
288
289
|
`,
|
|
289
290
|
});
|
|
290
291
|
|
|
@@ -321,7 +322,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
321
322
|
`services:
|
|
322
323
|
grafana:
|
|
323
324
|
environment:
|
|
324
|
-
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
|
|
325
|
+
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
|
|
325
326
|
`
|
|
326
327
|
);
|
|
327
328
|
|
|
@@ -6,11 +6,16 @@ import type { TemplateData } from '../../../types.js';
|
|
|
6
6
|
import { getBackendCmd } from '../../../commands/generate/print-success-message.js';
|
|
7
7
|
import { output } from '../../../utils/utils.console.js';
|
|
8
8
|
import { additionsDebug, getTemplateDataFromContext, renderTemplate } from '../../utils.js';
|
|
9
|
+
import { AGENT_INSTRUCTIONS_PATH, appendToAgentInstructions } from '../../utils.agents.js';
|
|
9
10
|
import { addRequireToGoMod } from '../../utils.goMod.js';
|
|
10
11
|
|
|
11
12
|
// Grafana reads an app-sdk manifest from the plugin bundle, and registers its API server, only when
|
|
12
|
-
// these toggles are enabled.
|
|
13
|
-
const APP_SDK_FEATURE_TOGGLES = [
|
|
13
|
+
// these toggles are enabled. The router middleware lets the app's requests through the router.
|
|
14
|
+
const APP_SDK_FEATURE_TOGGLES = [
|
|
15
|
+
'appplugins.loadAppManifest',
|
|
16
|
+
'appplugins.registerAPIServer',
|
|
17
|
+
'grafana.useRouterMiddleware',
|
|
18
|
+
];
|
|
14
19
|
|
|
15
20
|
// Files copied verbatim from templates/app-sdk, and whether they get the "scaffolded by create-plugin,
|
|
16
21
|
// don't edit" header. Paths are relative to both the template folder and the plugin root.
|
|
@@ -130,11 +135,8 @@ function renderAppSdkTemplate(file: string, data: TemplateData, includeWarning =
|
|
|
130
135
|
* plugin's instructions has no idea kinds or generated code exist.
|
|
131
136
|
*/
|
|
132
137
|
function referenceAgentInstructions(context: Context, data: TemplateData) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (!instructions) {
|
|
137
|
-
additionsDebug(`Could not find ${path}. Skipping the app-sdk reference.`);
|
|
138
|
+
if (!context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
|
|
139
|
+
additionsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}. Skipping the app-sdk reference.`);
|
|
138
140
|
return;
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -142,13 +144,11 @@ function referenceAgentInstructions(context: Context, data: TemplateData) {
|
|
|
142
144
|
context.addFile(APP_SDK_MD, renderAppSdkTemplate(APP_SDK_MD, data));
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
path,
|
|
151
|
-
`${instructions.trimEnd()}\n- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`
|
|
147
|
+
appendToAgentInstructions(
|
|
148
|
+
context,
|
|
149
|
+
APP_SDK_MD,
|
|
150
|
+
`- This plugin defines its API resources as **CUE kinds** under \`kinds/\`, with TypeScript and Go types generated from them. Read @./${APP_SDK_MD} before changing anything under \`kinds/\` or any generated directory. **Never hand-edit generated code.**\n`,
|
|
151
|
+
'\n'
|
|
152
152
|
);
|
|
153
153
|
}
|
|
154
154
|
|
|
@@ -257,7 +257,7 @@ function wireGoBackend(context: Context, data: TemplateData) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
// The go.mod version for grafana-app-sdk and its plugin/ submodule.
|
|
260
|
-
const GRAFANA_APP_SDK_VERSION = 'v0.60.
|
|
260
|
+
const GRAFANA_APP_SDK_VERSION = 'v0.60.16';
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
263
|
* Adds github.com/grafana/grafana-app-sdk and its plugin/ submodule to go.mod. The generated Go kind
|
|
@@ -159,9 +159,7 @@ function addConfigsToMigration(imports: Imports, config: Linter.ConfigOverride)
|
|
|
159
159
|
function generateAST(
|
|
160
160
|
value: Object
|
|
161
161
|
):
|
|
162
|
-
| recast.types.namedTypes.
|
|
163
|
-
| recast.types.namedTypes.ArrayExpression
|
|
164
|
-
| recast.types.namedTypes.Literal {
|
|
162
|
+
recast.types.namedTypes.ObjectExpression | recast.types.namedTypes.ArrayExpression | recast.types.namedTypes.Literal {
|
|
165
163
|
if (Array.isArray(value)) {
|
|
166
164
|
return builders.arrayExpression(value.map((i) => generateAST(i)));
|
|
167
165
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Context } from './context.js';
|
|
4
|
+
import {
|
|
5
|
+
AGENT_INSTRUCTIONS_PATH,
|
|
6
|
+
INSTRUCTIONS_POINTER,
|
|
7
|
+
appendToAgentInstructions,
|
|
8
|
+
ensureConfigAgentFiles,
|
|
9
|
+
ensureRootAgentFiles,
|
|
10
|
+
} from './utils.agents.js';
|
|
11
|
+
|
|
12
|
+
describe('utils.agents', () => {
|
|
13
|
+
describe('ensureRootAgentFiles', () => {
|
|
14
|
+
const templateDir = join(__dirname, '..', '..', 'templates', 'common');
|
|
15
|
+
|
|
16
|
+
it('creates the missing root agent files', () => {
|
|
17
|
+
const context = new Context('/virtual');
|
|
18
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
|
|
19
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
20
|
+
expect(context.getFile(file), file).toContain(AGENT_INSTRUCTIONS_PATH);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('appends a pointer to an existing file that never reaches instructions.md', () => {
|
|
25
|
+
const context = new Context('/virtual');
|
|
26
|
+
context.addFile('CLAUDE.md', '# Mine\n');
|
|
27
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
|
|
28
|
+
expect(context.getFile('CLAUDE.md')).toContain(AGENT_INSTRUCTIONS_PATH);
|
|
29
|
+
expect(context.getFile('CLAUDE.md')).toContain('# Mine');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats a file that imports a linked AGENTS.md as linked', () => {
|
|
33
|
+
const context = new Context('/virtual');
|
|
34
|
+
context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
|
|
35
|
+
context.addFile('CLAUDE.md', '@AGENTS.md\n');
|
|
36
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
|
|
37
|
+
expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])('uses the same pointer as the %s template', (file) => {
|
|
41
|
+
expect(readFileSync(join(templateDir, file), 'utf-8')).toContain(INSTRUCTIONS_POINTER);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
['a plain mention of AGENTS.md', 'Codex reads AGENTS.md, this file is for Claude.\n'],
|
|
46
|
+
['a markdown link to AGENTS.md', 'See [the guide](AGENTS.md).\n'],
|
|
47
|
+
['a plain mention of the instructions', `Don't edit ${AGENT_INSTRUCTIONS_PATH} by hand.\n`],
|
|
48
|
+
])('links a CLAUDE.md with only %s', (_, content) => {
|
|
49
|
+
const context = new Context('/virtual');
|
|
50
|
+
context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
|
|
51
|
+
context.addFile('CLAUDE.md', content);
|
|
52
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
|
|
53
|
+
expect(context.getFile('CLAUDE.md')).toBe(`${content}\n${INSTRUCTIONS_POINTER}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('treats a plain mention of the instructions in AGENTS.md as linked', () => {
|
|
57
|
+
const context = new Context('/virtual');
|
|
58
|
+
context.addFile('AGENTS.md', `Read ${AGENT_INSTRUCTIONS_PATH} first.\n`);
|
|
59
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('links existing files that never reach instructions.md', () => {
|
|
63
|
+
const context = new Context('/virtual');
|
|
64
|
+
context.addFile('AGENTS.md', '# Mine\n');
|
|
65
|
+
context.addFile('CLAUDE.md', '@AGENTS.md\n');
|
|
66
|
+
context.addFile('GEMINI.md', 'See docs/AGENTS.md\n');
|
|
67
|
+
// AGENTS.md gets the pointer first, so CLAUDE.md's @AGENTS.md then resolves without its own edit
|
|
68
|
+
expect(ensureRootAgentFiles(context, templateDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
|
|
69
|
+
expect(context.getFile('AGENTS.md')).toContain(AGENT_INSTRUCTIONS_PATH);
|
|
70
|
+
expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
|
|
71
|
+
expect(context.getFile('GEMINI.md')).toContain(AGENT_INSTRUCTIONS_PATH);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('ensureConfigAgentFiles', () => {
|
|
76
|
+
const templateDir = join(__dirname, '..', '..', 'templates', 'common');
|
|
77
|
+
|
|
78
|
+
it('creates only the given files from the template dir and returns them', () => {
|
|
79
|
+
const context = new Context('/virtual');
|
|
80
|
+
const added = ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
|
|
81
|
+
packageManagerName: 'npm',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(added.sort()).toEqual(['.config/AGENTS/e2e-testing.md', AGENT_INSTRUCTIONS_PATH]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does nothing when instructions.md already exists', () => {
|
|
88
|
+
const context = new Context('/virtual');
|
|
89
|
+
context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
|
|
90
|
+
expect(
|
|
91
|
+
ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
|
|
92
|
+
packageManagerName: 'npm',
|
|
93
|
+
})
|
|
94
|
+
).toEqual([]);
|
|
95
|
+
|
|
96
|
+
expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n');
|
|
97
|
+
expect(context.doesFileExist('.config/AGENTS/e2e-testing.md')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('appendToAgentInstructions', () => {
|
|
102
|
+
it('writes just the text into an empty instructions file', () => {
|
|
103
|
+
const context = new Context('/virtual');
|
|
104
|
+
context.addFile(AGENT_INSTRUCTIONS_PATH, '\n');
|
|
105
|
+
appendToAgentInstructions(context, 'new.md', '## New\n');
|
|
106
|
+
expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('## New\n');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('appends after a blank line', () => {
|
|
110
|
+
const context = new Context('/virtual');
|
|
111
|
+
context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
|
|
112
|
+
appendToAgentInstructions(context, 'new.md', '## New\n');
|
|
113
|
+
|
|
114
|
+
expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n\n## New\n');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('uses the given separator', () => {
|
|
118
|
+
const context = new Context('/virtual');
|
|
119
|
+
context.addFile(AGENT_INSTRUCTIONS_PATH, '- one\n');
|
|
120
|
+
appendToAgentInstructions(context, 'new.md', '- two\n', '\n');
|
|
121
|
+
|
|
122
|
+
expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('- one\n- two\n');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('skips when the marker is already present', () => {
|
|
126
|
+
const context = new Context('/virtual');
|
|
127
|
+
context.addFile(AGENT_INSTRUCTIONS_PATH, 'See new.md\n');
|
|
128
|
+
appendToAgentInstructions(context, 'new.md', '## New\n');
|
|
129
|
+
|
|
130
|
+
expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('See new.md\n');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('skips when there is no instructions file', () => {
|
|
134
|
+
const context = new Context('/virtual');
|
|
135
|
+
appendToAgentInstructions(context, 'new.md', '## New\n');
|
|
136
|
+
|
|
137
|
+
expect(context.doesFileExist(AGENT_INSTRUCTIONS_PATH)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Context } from './context.js';
|
|
4
|
+
import { addTemplateFolder, codemodsDebug } from './utils.js';
|
|
5
|
+
|
|
6
|
+
export const AGENT_INSTRUCTIONS_PATH = '.config/AGENTS/instructions.md';
|
|
7
|
+
|
|
8
|
+
const ROOT_AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
9
|
+
// matches the pointer line in templates/common/{AGENTS,CLAUDE,GEMINI}.md
|
|
10
|
+
export const INSTRUCTIONS_POINTER = `## Project knowledge\nThis repository contains a **Grafana plugin**. You must Read @./${AGENT_INSTRUCTIONS_PATH} before doing changes.\n`;
|
|
11
|
+
const IMPORTS_INSTRUCTIONS = /@(\.\/)?\.config\/AGENTS\/instructions\.md/;
|
|
12
|
+
const IMPORTS_AGENTS_MD = /(^|\s)@(\.\/)?AGENTS\.md\b/m;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates the missing root agent files. Appends a pointer to the instructions to any existing one that
|
|
16
|
+
* doesn't reach them, and returns those files.
|
|
17
|
+
*/
|
|
18
|
+
export function ensureRootAgentFiles(context: Context, templateDir: string): string[] {
|
|
19
|
+
const existing = ROOT_AGENT_FILES.filter((file) => context.doesFileExist(file));
|
|
20
|
+
for (const file of ROOT_AGENT_FILES) {
|
|
21
|
+
if (!existing.includes(file)) {
|
|
22
|
+
context.addFile(file, readFileSync(join(templateDir, file), 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Claude and Gemini only follow `@` imports, while Codex reads AGENTS.md as text, so a mention is enough there
|
|
27
|
+
const reachesInstructions = (file: string): boolean => {
|
|
28
|
+
const content = context.getFile(file) ?? '';
|
|
29
|
+
if (file === 'AGENTS.md') {
|
|
30
|
+
return content.includes(AGENT_INSTRUCTIONS_PATH);
|
|
31
|
+
}
|
|
32
|
+
return IMPORTS_INSTRUCTIONS.test(content) || (IMPORTS_AGENTS_MD.test(content) && reachesInstructions('AGENTS.md'));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const linked: string[] = [];
|
|
36
|
+
for (const file of existing) {
|
|
37
|
+
if (reachesInstructions(file)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const content = context.getFile(file) ?? '';
|
|
41
|
+
context.updateFile(file, `${content.trimEnd()}\n\n${INSTRUCTIONS_POINTER}`);
|
|
42
|
+
linked.push(file);
|
|
43
|
+
}
|
|
44
|
+
return linked;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates the given `.config/AGENTS/` files, only when `instructions.md` is missing. They import each
|
|
49
|
+
* other, so a plugin with its own instructions keeps what it has. Returns the paths it added.
|
|
50
|
+
*/
|
|
51
|
+
export function ensureConfigAgentFiles(
|
|
52
|
+
context: Context,
|
|
53
|
+
templateDir: string,
|
|
54
|
+
files: string[],
|
|
55
|
+
data: Record<string, unknown>
|
|
56
|
+
): string[] {
|
|
57
|
+
if (context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return addTemplateFolder(context, {
|
|
61
|
+
folderPath: join(templateDir, '.config', 'AGENTS'),
|
|
62
|
+
targetPath: '.config/AGENTS',
|
|
63
|
+
data,
|
|
64
|
+
include: (file) => files.includes(file),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Appends `text` to the agent instructions, unless they're missing or already contain `marker`. */
|
|
69
|
+
export function appendToAgentInstructions(context: Context, marker: string, text: string, separator = '\n\n') {
|
|
70
|
+
const existing = context.getFile(AGENT_INSTRUCTIONS_PATH);
|
|
71
|
+
if (existing === undefined) {
|
|
72
|
+
codemodsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}, skipping`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (existing.includes(marker)) {
|
|
76
|
+
codemodsDebug(`${AGENT_INSTRUCTIONS_PATH} already contains ${marker}, skipping`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const kept = existing.trimEnd();
|
|
80
|
+
context.updateFile(AGENT_INSTRUCTIONS_PATH, kept ? `${kept}${separator}${text}` : text);
|
|
81
|
+
}
|