@grafana/create-plugin 7.12.3 → 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 +31 -0
- package/dist/codemods/additions/additions.js +20 -7
- package/dist/codemods/additions/prompts/docs-content.md +119 -0
- package/dist/codemods/additions/prompts/example-prompt.md +56 -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 +39 -33
- package/dist/codemods/agentic/definitions.js +31 -0
- package/dist/codemods/agentic/detect.js +57 -0
- package/dist/codemods/agentic/handoff.js +35 -0
- package/dist/codemods/agentic/index.js +143 -0
- package/dist/codemods/agentic/prompts.js +115 -0
- package/dist/codemods/agentic/resolve.js +120 -0
- package/dist/codemods/agentic/runner.js +79 -0
- package/dist/codemods/agentic/verify.js +55 -0
- package/dist/codemods/migrations/migrations.js +12 -0
- package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
- package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +51 -8
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/commands/add.command.js +74 -12
- package/dist/utils/utils.cli.js +7 -2
- package/dist/utils/utils.console.js +10 -1
- package/dist/utils/utils.git.js +10 -1
- package/dist/utils/utils.handlebars.js +9 -2
- package/dist/utils/utils.packageManager.js +2 -2
- package/dist/utils/utils.templates.js +38 -24
- package/package.json +1 -1
- package/src/codemods/AGENTS.md +25 -0
- package/src/codemods/additions/additions.test.ts +69 -4
- package/src/codemods/additions/additions.ts +50 -8
- package/src/codemods/additions/prompts/docs-content.md +119 -0
- package/src/codemods/additions/prompts/example-prompt.md +56 -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 +40 -80
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
- package/src/codemods/agentic/definitions.test.ts +67 -0
- package/src/codemods/agentic/definitions.ts +33 -0
- package/src/codemods/agentic/detect.test.ts +136 -0
- package/src/codemods/agentic/detect.ts +75 -0
- package/src/codemods/agentic/handoff.test.ts +102 -0
- package/src/codemods/agentic/handoff.ts +45 -0
- package/src/codemods/agentic/index.test.ts +342 -0
- package/src/codemods/agentic/index.ts +221 -0
- package/src/codemods/agentic/prompts.test.ts +233 -0
- package/src/codemods/agentic/prompts.ts +166 -0
- package/src/codemods/agentic/resolve.test.ts +220 -0
- package/src/codemods/agentic/resolve.ts +165 -0
- package/src/codemods/agentic/runner.test.ts +146 -0
- package/src/codemods/agentic/runner.ts +103 -0
- package/src/codemods/agentic/types.ts +50 -0
- package/src/codemods/agentic/verify.test.ts +106 -0
- package/src/codemods/agentic/verify.ts +93 -0
- package/src/codemods/migrations/migrations.ts +14 -0
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
- package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
- package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +131 -1
- package/src/codemods/utils.ts +79 -8
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/commands/add.command.ts +92 -9
- package/src/utils/utils.cli.ts +5 -0
- package/src/utils/utils.git.test.ts +57 -0
- package/src/utils/utils.git.ts +25 -0
- package/src/utils/utils.handlebars.ts +9 -0
- package/src/utils/utils.packageManager.test.ts +23 -0
- package/src/utils/utils.packageManager.ts +3 -2
- package/src/utils/utils.templates.ts +50 -25
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/common/.config/webpack/webpack.config.ts +4 -5
- package/templates/common/_package.json +9 -8
- 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
- package/templates/github/workflows/bundle-stats.yml +1 -1
- package/templates/github/workflows/ci.yml +7 -7
- package/templates/github/workflows/is-compatible.yml +3 -3
- package/templates/github/workflows/release.yml +1 -1
|
@@ -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
|
+
}
|
|
@@ -2,77 +2,10 @@ import { Context } from '../../context.js';
|
|
|
2
2
|
import { output } from '../../../utils/utils.console.js';
|
|
3
3
|
import appSdk from './experimental-app-sdk.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
});
|
|
5
|
+
vi.mock(import('../../../utils/utils.packageManager.js'), async (importOriginal) => ({
|
|
6
|
+
...(await importOriginal()),
|
|
7
|
+
getPackageManagerWithFallback: () => ({ packageManagerName: 'npm', packageManagerVersion: '10.9.0' }),
|
|
8
|
+
}));
|
|
76
9
|
|
|
77
10
|
const APP_SDK_FILES = [
|
|
78
11
|
'.config/app-sdk/generate-kinds.mjs',
|
|
@@ -104,11 +37,18 @@ function createAppContext({
|
|
|
104
37
|
instructions?: string | null;
|
|
105
38
|
hasBackend?: boolean;
|
|
106
39
|
} = {}) {
|
|
107
|
-
mockedState.hasBackend = hasBackend;
|
|
108
|
-
|
|
109
40
|
const context = new Context('/virtual');
|
|
110
41
|
|
|
111
|
-
context.addFile(
|
|
42
|
+
context.addFile(
|
|
43
|
+
'src/plugin.json',
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
type: pluginType,
|
|
46
|
+
id: 'my-plugin-id',
|
|
47
|
+
name: 'My Plugin',
|
|
48
|
+
info: { author: { name: 'my-author' } },
|
|
49
|
+
backend: hasBackend,
|
|
50
|
+
})
|
|
51
|
+
);
|
|
112
52
|
context.addFile('package.json', JSON.stringify({ scripts: { build: 'webpack' } }, null, 2));
|
|
113
53
|
context.addFile('.gitignore', 'node_modules/\ndist/\n');
|
|
114
54
|
context.addFile('.config/bundler/copyFiles.ts', `export const copyFilePatterns = ['**/*.json'];`);
|
|
@@ -196,6 +136,13 @@ describe('experimental-app-sdk addition', () => {
|
|
|
196
136
|
expect(result.doesFileExist('kinds/config.cue')).toBe(false);
|
|
197
137
|
});
|
|
198
138
|
|
|
139
|
+
it('explains a missing author name instead of failing while rendering', () => {
|
|
140
|
+
const context = createAppContext();
|
|
141
|
+
context.updateFile('src/plugin.json', JSON.stringify({ type: 'app', id: 'my-plugin-id', name: 'My Plugin' }));
|
|
142
|
+
|
|
143
|
+
expect(() => appSdk(context)).toThrow('src/plugin.json has no info.author.name');
|
|
144
|
+
});
|
|
145
|
+
|
|
199
146
|
it('makes no changes when plugin.json is malformed', () => {
|
|
200
147
|
const context = new Context('/virtual');
|
|
201
148
|
context.addFile('src/plugin.json', '{ not json');
|
|
@@ -228,7 +175,9 @@ describe('experimental-app-sdk addition', () => {
|
|
|
228
175
|
expect(result.getFile('kinds/config.cue')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
|
|
229
176
|
expect(result.getFile('kinds/manifest.cue')).not.toContain('DO NOT EDIT THIS FILE DIRECTLY');
|
|
230
177
|
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(
|
|
178
|
+
expect(result.getFile('.github/workflows/generate-kinds-drift.yml')).not.toContain(
|
|
179
|
+
'DO NOT EDIT THIS FILE DIRECTLY'
|
|
180
|
+
);
|
|
232
181
|
});
|
|
233
182
|
|
|
234
183
|
it('adds the generate:kinds npm script', () => {
|
|
@@ -303,7 +252,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
303
252
|
const result = appSdk(context);
|
|
304
253
|
|
|
305
254
|
expect(result.getFile('docker-compose.yaml')).toContain(
|
|
306
|
-
'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer'
|
|
255
|
+
'GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware'
|
|
307
256
|
);
|
|
308
257
|
});
|
|
309
258
|
|
|
@@ -325,6 +274,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
325
274
|
expect(compose).toContain('someOtherToggle');
|
|
326
275
|
expect(compose).toContain('appplugins.loadAppManifest');
|
|
327
276
|
expect(compose).toContain('appplugins.registerAPIServer');
|
|
277
|
+
expect(compose).toContain('grafana.useRouterMiddleware');
|
|
328
278
|
});
|
|
329
279
|
|
|
330
280
|
it('does not duplicate the toggles when they are already set', () => {
|
|
@@ -335,7 +285,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
335
285
|
file: .config/docker-compose-base.yaml
|
|
336
286
|
service: grafana
|
|
337
287
|
environment:
|
|
338
|
-
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
|
|
288
|
+
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
|
|
339
289
|
`,
|
|
340
290
|
});
|
|
341
291
|
|
|
@@ -372,7 +322,7 @@ describe('experimental-app-sdk addition', () => {
|
|
|
372
322
|
`services:
|
|
373
323
|
grafana:
|
|
374
324
|
environment:
|
|
375
|
-
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer
|
|
325
|
+
GF_FEATURE_TOGGLES_ENABLE: appplugins.loadAppManifest,appplugins.registerAPIServer,grafana.useRouterMiddleware
|
|
376
326
|
`
|
|
377
327
|
);
|
|
378
328
|
|
|
@@ -403,6 +353,15 @@ describe('experimental-app-sdk addition', () => {
|
|
|
403
353
|
|
|
404
354
|
expect(result.doesFileExist('kinds/config.cue')).toBe(true);
|
|
405
355
|
});
|
|
356
|
+
|
|
357
|
+
it('still scaffolds the kinds when there is no package.json', () => {
|
|
358
|
+
const context = createAppContext();
|
|
359
|
+
context.deleteFile('package.json');
|
|
360
|
+
|
|
361
|
+
const result = appSdk(context);
|
|
362
|
+
|
|
363
|
+
expect(result.doesFileExist('kinds/config.cue')).toBe(true);
|
|
364
|
+
});
|
|
406
365
|
});
|
|
407
366
|
|
|
408
367
|
describe('user messaging', () => {
|
|
@@ -436,7 +395,6 @@ describe('experimental-app-sdk addition', () => {
|
|
|
436
395
|
// run is untouched rather than replaced or cleared.
|
|
437
396
|
expect(context.getMessage()).toBe(messageAfterFirstRun);
|
|
438
397
|
});
|
|
439
|
-
|
|
440
398
|
});
|
|
441
399
|
|
|
442
400
|
it('is idempotent', async () => {
|
|
@@ -555,7 +513,9 @@ describe('experimental-app-sdk addition', () => {
|
|
|
555
513
|
appSdk(context);
|
|
556
514
|
|
|
557
515
|
expect(context.getFile('pkg/main.go')).toBe(afterFirst);
|
|
558
|
-
expect(
|
|
516
|
+
expect(
|
|
517
|
+
(context.getFile('pkg/main.go') ?? '').match(/sdkplugin "github\.com\/grafana\/grafana-app-sdk\/plugin"/g)
|
|
518
|
+
).toHaveLength(1);
|
|
559
519
|
});
|
|
560
520
|
|
|
561
521
|
it('skips main.go safely when it does not match the expected shape', () => {
|