|
| 1 | +--- |
| 2 | +name: write-codemod |
| 3 | +description: Expert at writing migrations and additions for the create-plugin codemod system in packages/create-plugin/src/codemods/. Use this skill whenever the user wants to create, modify, or debug a codemod, migration, or addition in the plugin-tools repo — including replicating a create-plugin template change onto existing plugins, or adding CLI flags to an addition. Triggers on: "write a migration", "add a codemod", "create a migration for X", "add an addition", "update the codemod", or any request to automate a change that plugin authors need to apply to their plugin projects. |
| 4 | +--- |
| 5 | + |
| 6 | +# Write Codemod |
| 7 | + |
| 8 | +Codemods automate changes to plugin projects scaffolded by `@grafana/create-plugin`. There are two kinds — the first decision is which one to write: |
| 9 | + |
| 10 | +| | Migration | Addition | |
| 11 | +| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------- | |
| 12 | +| Purpose | Bring existing plugins in line with newer create-plugin output | Opt-in feature an author asks for | |
| 13 | +| Runs via | `npx create-plugin update` — automatic, version-gated | `npx create-plugin add <name>` — on demand | |
| 14 | +| Registry entry | `{ name, version, description, scriptPath }` in `migrations/migrations.ts` | `{ name, description, scriptPath }` in `additions/additions.ts` | |
| 15 | +| Naming | `NNN-migration-title` — next number in sequence, ≤3-word kebab-case title | Descriptive kebab-case — the name becomes the CLI subcommand | |
| 16 | + |
| 17 | +If the change should reach every plugin on its next update, write a migration. If it's something an author chooses to apply ("externalize the JSX runtime"), write an addition. |
| 18 | + |
| 19 | +## When in doubt, read the source |
| 20 | + |
| 21 | +- The scripts in `migrations/scripts/` and `additions/scripts/` are the ground truth for how `recast`, `yaml`, `jsonc-parser`, and `valibot` are used in this codebase. Read the closest existing example before writing a new one. |
| 22 | +- For library APIs the examples don't cover, fetch the docs (context7 MCP server if available, otherwise the package's official documentation). Don't guess from training data. |
| 23 | + |
| 24 | +Before writing any code, present a short plan — target files, applicability and idempotency guards, and the tests that prove each behaviour — and get it confirmed. |
| 25 | + |
| 26 | +## How codemods execute (and why the rules exist) |
| 27 | + |
| 28 | +Both kinds run through the same pipeline in `runner.ts`: |
| 29 | + |
| 30 | +1. Import the script's default export; validate CLI flags against its exported valibot `schema`, if any |
| 31 | +2. Call it with a fresh `Context` rooted in the plugin's working directory |
| 32 | +3. Format every changed file with the plugin's local prettier (skipped when prettier isn't installed) |
| 33 | +4. Flush the context's staged changes to disk |
| 34 | +5. Print a summary of changes to the author |
| 35 | +6. Run the package manager's install when `package.json` changed |
| 36 | + |
| 37 | +The pipeline explains the core rules: |
| 38 | + |
| 39 | +- **Use the Context for all file operations, never Node.js `fs`.** The runner owns disk writes; the context is an in-memory staging area, which is also what makes tests hermetic. Always `return context`, even on early returns. |
| 40 | +- **Don't chase perfect output formatting.** Prettier reformats every changed file afterwards using the plugin's own config. |
| 41 | +- **The dependency helpers are enough.** Changing `package.json` through them triggers a real install after the run. |
| 42 | +- **Degrade gracefully — don't throw.** A thrown error aborts the author's entire `update` sequence. When a file is missing, unparseable, or already migrated, log with the debug helper and return the context unchanged. |
| 43 | +- **Stay inside the plugin's working directory.** Codemods run inside a user's project; the context base path is the boundary. |
| 44 | +- **Idempotency is non-negotiable.** Codemods re-run against already-migrated projects; every script must be safe to run repeatedly. |
| 45 | +- **One concern per codemod.** Don't bundle unrelated changes. |
| 46 | + |
| 47 | +Migrations run sequentially in ascending version order. With the `--commit` flag, each migration that changed files gets its own commit with the registry `description` as the body — one more reason descriptions must read well. |
| 48 | + |
| 49 | +### Debug logging |
| 50 | + |
| 51 | +Log every early-return path so a codemod that silently no-ops in the field can be diagnosed with `DEBUG=create-plugin:*`: |
| 52 | + |
| 53 | +```ts |
| 54 | +import { migrationsDebug } from '../../utils.js'; // additionsDebug for additions |
| 55 | + |
| 56 | +if (!parsed.success) { |
| 57 | + migrationsDebug(`Failed to parse webpack.config.ts: ${parsed.error.message}`); |
| 58 | + return context; |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +## Architecture |
| 63 | + |
| 64 | +``` |
| 65 | +packages/create-plugin/src/codemods/ |
| 66 | +├── AGENTS.md # Condensed rules for agents without this skill — keep in sync |
| 67 | +├── context.ts # Context class — ALL file operations go through this |
| 68 | +├── runner.ts # Shared pipeline: validate options → run → format → flush → install |
| 69 | +├── schema-parser.ts # Valibot validation of CLI flags |
| 70 | +├── types.ts # Codemod / CodemodModule types |
| 71 | +├── utils.ts # Helpers: package.json, semver, renderTemplate, debug loggers |
| 72 | +├── utils.ast.ts # recast helpers for TS/JS codemods |
| 73 | +├── test-utils.ts # createDefaultContext() helper for tests |
| 74 | +├── migrations/ |
| 75 | +│ ├── migrations.ts # Migration registry |
| 76 | +│ ├── manager.ts # Version gating + sequential execution |
| 77 | +│ └── scripts/ |
| 78 | +│ ├── NNN-migration-title.ts |
| 79 | +│ └── NNN-migration-title.test.ts |
| 80 | +└── additions/ |
| 81 | + ├── additions.ts # Addition registry |
| 82 | + └── scripts/ |
| 83 | + ├── addition-name.ts |
| 84 | + └── addition-name.test.ts |
| 85 | +``` |
| 86 | + |
| 87 | +## Context API |
| 88 | + |
| 89 | +```ts |
| 90 | +// Check before touching |
| 91 | +context.doesFileExist(filePath: string): boolean // staged view (includes adds/deletes) |
| 92 | +context.doesFileExistOnDisk(filePath: string): boolean |
| 93 | + |
| 94 | +// Read |
| 95 | +context.getFile(filePath: string): string | undefined // returns undefined if deleted |
| 96 | +context.readDir(folderPath: string): string[] // includes context-added files |
| 97 | +context.readDirFromDisk(folderPath: string): string[] |
| 98 | + |
| 99 | +// Write |
| 100 | +context.addFile(filePath: string, content: string) // throws if already exists |
| 101 | +context.updateFile(filePath: string, content: string) // throws if doesn't exist; no-ops if content unchanged |
| 102 | +context.deleteFile(filePath: string) |
| 103 | +context.renameFile(from: string, to: string) // delete old + add new |
| 104 | + |
| 105 | +// Inspect |
| 106 | +context.listChanges(): ContextFile |
| 107 | +context.hasChanges(): boolean |
| 108 | +``` |
| 109 | + |
| 110 | +Always check `doesFileExist` before `getFile` or `updateFile`. |
| 111 | + |
| 112 | +## Utility functions (from `../../utils.js`) |
| 113 | + |
| 114 | +```ts |
| 115 | +// package.json manipulation |
| 116 | +addDependenciesToPackageJson(context, dependencies, devDependencies?, packageJsonPath?) |
| 117 | +removeDependenciesFromPackageJson(context, dependencies, devDependencies?, packageJsonPath?) |
| 118 | +readJsonFile<T>(context, path): T // throws if missing or unparseable |
| 119 | + |
| 120 | +// Semver — handles dist-tags ("latest", "next", "*") and standard ranges |
| 121 | +isVersionGreater(incomingVersion, existingVersion, orEqualTo?): boolean |
| 122 | + |
| 123 | +// Render a file from the scaffold templates (see "Rendering scaffold templates") |
| 124 | +renderTemplate(templatePath, includeWarning?): string |
| 125 | + |
| 126 | +// Debug loggers (namespace create-plugin:*) |
| 127 | +migrationsDebug, additionsDebug |
| 128 | +``` |
| 129 | +
|
| 130 | +## Writing a migration |
| 131 | +
|
| 132 | +```ts |
| 133 | +import type { Context } from '../../context.js'; |
| 134 | + |
| 135 | +export default function migrate(context: Context) { |
| 136 | + // 1. Guard: check the file/condition that makes this migration applicable |
| 137 | + if (!context.doesFileExist('some-file')) { |
| 138 | + return context; |
| 139 | + } |
| 140 | + |
| 141 | + const content = context.getFile('some-file') || ''; |
| 142 | + |
| 143 | + // 2. Guard: skip if already applied (idempotency) |
| 144 | + if (content.includes('already-migrated-marker')) { |
| 145 | + return context; |
| 146 | + } |
| 147 | + |
| 148 | + // 3. Apply the change |
| 149 | + context.updateFile('some-file', content.replace('old', 'new')); |
| 150 | + |
| 151 | + // 4. Always return context |
| 152 | + return context; |
| 153 | +} |
| 154 | +``` |
| 155 | +
|
| 156 | +Async is fine — the runner awaits, so the signature may be `async` and return `Promise<Context>`. |
| 157 | +
|
| 158 | +### Registering a migration |
| 159 | +
|
| 160 | +Add to the end of the `default` array in `migrations/migrations.ts`: |
| 161 | +
|
| 162 | +```ts |
| 163 | +{ |
| 164 | + name: 'NNN-migration-title', |
| 165 | + version: 'X.Y.Z+1', // next patch from the current @grafana/create-plugin version — see below |
| 166 | + description: 'One sentence explaining WHY this migration is needed (the problem/consequence), not just what it does.', |
| 167 | + scriptPath: import.meta.resolve('./scripts/NNN-migration-title.js'), |
| 168 | +}, |
| 169 | +``` |
| 170 | +
|
| 171 | +### `version` field — always the next patch |
| 172 | +
|
| 173 | +A plugin records the create-plugin version it was last updated to in `.config/.cprc.json`. `update` selects every registered migration whose `version` falls inside the range from that recorded version to the current create-plugin version (inclusive), runs them in ascending order, then bumps `.cprc.json`. |
| 174 | +
|
| 175 | +Set `version` to the next **patch** of the current version in `packages/create-plugin/package.json` (e.g. `7.3.0` → `7.3.1`), regardless of the semver bump the change will actually ship in. The registry version only gates which migrations run — it is decoupled from the release version chosen by release-please — and the next patch is the lowest possible next version, so the migration is guaranteed to fire on the next release whatever bump that release turns out to be. |
| 176 | +
|
| 177 | +Do not use `LEGACY_UPDATE_CUTOFF_VERSION` for new migrations — that constant is reserved for the original batch written before the "updates as migrations" model. |
| 178 | +
|
| 179 | +### `description` field |
| 180 | +
|
| 181 | +The `description` is shown to plugin authors during `update` and becomes the commit body with `--commit`. Lead with the problem or consequence (an error code, a deprecation, a breaking change), then briefly note how it's resolved. "Fix X: Y is deprecated causing error Z, replaced with W" beats "Replace Y with W". |
| 182 | +
|
| 183 | +## Writing an addition |
| 184 | +
|
| 185 | +Additions live in `additions/scripts/` as `addition-name.ts` + `addition-name.test.ts` and register in `additions/additions.ts`: |
| 186 | +
|
| 187 | +```ts |
| 188 | +{ |
| 189 | + name: 'externalize-jsx-runtime', // the CLI subcommand: npx create-plugin add externalize-jsx-runtime |
| 190 | + description: 'Externalizes the react JSX runtime to help migrate plugins to React 19', |
| 191 | + scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'), |
| 192 | +}, |
| 193 | +``` |
| 194 | +
|
| 195 | +There is no `version` field — additions aren't gated. They run whenever an author invokes them, so they must handle any plugin state they might meet: fresh scaffold, heavily customised, or already applied. |
| 196 | +
|
| 197 | +### CLI flags via valibot schema |
| 198 | +
|
| 199 | +A codemod can accept CLI flags by exporting a valibot `schema` alongside the default function. The runner validates the flags (types, defaults, coercion) before your code runs and reports failures as per-flag error messages: |
| 200 | +
|
| 201 | +```ts |
| 202 | +import * as v from 'valibot'; |
| 203 | +import type { Context } from '../../context.js'; |
| 204 | + |
| 205 | +export const schema = v.object({ |
| 206 | + featureName: v.pipe(v.string(), v.minLength(3, 'Feature name must be at least 3 characters')), |
| 207 | + enabled: v.optional(v.boolean(), true), |
| 208 | +}); |
| 209 | + |
| 210 | +export default function addFeature(context: Context, options: v.InferOutput<typeof schema>): Context { |
| 211 | + // options are validated and defaulted by the runner |
| 212 | + return context; |
| 213 | +} |
| 214 | +``` |
| 215 | +
|
| 216 | +`additions/scripts/example-addition.ts` is the canonical example. `update` forwards CLI flags to migrations the same way, but migrations should rarely take options — they must work unattended. |
| 217 | +
|
| 218 | +### Rendering scaffold templates |
| 219 | +
|
| 220 | +When a codemod adds a file that also exists in the scaffold templates, render the real template instead of duplicating its content — one source of truth: |
| 221 | +
|
| 222 | +```ts |
| 223 | +import { fileURLToPath } from 'node:url'; |
| 224 | +import { renderTemplate } from '../../utils.js'; |
| 225 | + |
| 226 | +const templatePath = fileURLToPath( |
| 227 | + new URL('../../../../templates/common/.config/bundler/externals.ts', import.meta.url) |
| 228 | +); |
| 229 | +context.addFile('.config/bundler/externals.ts', renderTemplate(templatePath, true)); |
| 230 | +``` |
| 231 | +
|
| 232 | +## Working from a template change |
| 233 | +
|
| 234 | +A common trigger for a migration: the scaffold templates in `packages/create-plugin/templates/` have already been updated, and existing plugins need a codemod that applies the same change. The `update` command only runs migrations — a template change on its own never reaches already-scaffolded plugins. |
| 235 | +
|
| 236 | +1. **Treat the template diff as the specification.** Run `git diff main -- packages/create-plugin/templates/` (or read the relevant PR/commits) and enumerate every changed template file before planning the codemod. |
| 237 | +2. **Triage each changed file.** Some template changes only matter for new scaffolds (README wording, sample data, docs). List which files need a codemod and which don't, and confirm the split with the user. |
| 238 | +3. **Map template paths to scaffolded paths.** The type directory prefix is not part of the scaffolded output path, the `.hbs` extension is stripped, and some filenames are rewritten at scaffold time (see `configFileNamesMap` in `src/utils/utils.files.ts`): `_package.json` → `package.json`, `gitignore` → `.gitignore`, `npmrc` → `.npmrc`, `_eslintrc` → `.eslintrc`, `playwright.config` → `playwright.config.ts`. Templates under `common/` are scaffolded for every plugin type; those under `app/`, `panel/`, `datasource/`, `scenes-app/`, `backend/`, and `backend-app/` only for that type — a change under a type-specific directory needs a matching applicability guard. |
| 239 | +4. **Work with rendered output, not template source.** Templates are Handlebars — `{{ pluginId }}` expressions, `{{#if}}` blocks, and partials from `templates/_partials/` never appear in a scaffolded plugin. The codemod must read and write the rendered form, and a change inside a Handlebars conditional needs the equivalent guard in the codemod. When the change adds a whole new file, prefer `renderTemplate` (above) over inlining the content. |
| 240 | +5. **Target the intent, not the template text.** An existing plugin's file has usually diverged from the pristine scaffold (renamed variables, extra config, reformatting). Anchor the codemod on the structure the change touches — the AST node, JSON key, or YAML path — with guards for when it's missing, rather than string-replacing the template's old text. |
| 241 | +6. **Derive test fixtures from the templates.** Use the old template's rendered output as the happy-path input and assert the result matches what the new template renders. Add cases for divergent user files: the structure moved, the value customized, the file absent. |
| 242 | +
|
| 243 | +## Editing files by type |
| 244 | +
|
| 245 | +Structured files need parser-based edits — string replacement breaks on formatting differences and is never idempotent. Read the matching reference before writing the transformation: |
| 246 | +
|
| 247 | +| Target file type | Read | Approach | |
| 248 | +| --------------------------------------------- | ------------------------------ | ----------------------------------------------------------- | |
| 249 | +| JSON / JSONC (`package.json`, tsconfig, etc.) | `references/json.md` | `JSON.parse`/`stringify`, helpers, `jsonc-parser` for JSONC | |
| 250 | +| TypeScript / JavaScript | `references/typescript-ast.md` | recast AST via the `utils.ast.ts` helpers | |
| 251 | +| YAML (workflows, docker-compose) | `references/yaml.md` | `yaml` package document API | |
| 252 | +| Plain text / markdown | — | string operations with an idempotency guard | |
| 253 | +
|
| 254 | +## Testing requirements |
| 255 | +
|
| 256 | +Every codemod ships with a colocated `.test.ts`. Cover four behaviours: |
| 257 | +
|
| 258 | +1. **Happy path** — the codemod applies the expected change. |
| 259 | +2. **Idempotency** — running twice produces the same result, via the custom matcher (defined in `packages/create-plugin/vitest.setup.ts`; it runs the codemod twice and diffs the files staged after the first run): |
| 260 | + ```ts |
| 261 | + await expect(migrate).toBeIdempotent(context); |
| 262 | + // codemods that take options need a wrapper: |
| 263 | + await expect((ctx) => addFeature(ctx, options)).toBeIdempotent(context); |
| 264 | + ``` |
| 265 | +3. **Already-migrated guard** — no-op when the change is already present. |
| 266 | +4. **Missing file guard** — no-op when the target file doesn't exist (if applicable). |
| 267 | +
|
| 268 | +`test-utils.ts` exports `createDefaultContext()` for a context pre-seeded with a minimal plugin; otherwise seed your own: |
| 269 | +
|
| 270 | +```ts |
| 271 | +import { describe, expect, it } from 'vitest'; |
| 272 | +import migrate from './NNN-migration-title.js'; |
| 273 | +import { Context } from '../../context.js'; |
| 274 | + |
| 275 | +describe('NNN-migration-title', () => { |
| 276 | + it('should <describe the happy path>', () => { |
| 277 | + const context = new Context('/virtual'); |
| 278 | + context.addFile('target-file', 'original content'); |
| 279 | + |
| 280 | + const result = migrate(context); |
| 281 | + |
| 282 | + expect(result.getFile('target-file')).toBe('expected content'); |
| 283 | + }); |
| 284 | + |
| 285 | + it('should be idempotent', async () => { |
| 286 | + const context = new Context('/virtual'); |
| 287 | + context.addFile('target-file', 'original content'); |
| 288 | + |
| 289 | + await expect(migrate).toBeIdempotent(context); |
| 290 | + }); |
| 291 | + |
| 292 | + it('should not modify files if already migrated', () => { |
| 293 | + const context = new Context('/virtual'); |
| 294 | + const content = 'already-migrated content'; |
| 295 | + context.addFile('target-file', content); |
| 296 | + |
| 297 | + const result = migrate(context); |
| 298 | + |
| 299 | + expect(result.getFile('target-file')).toBe(content); |
| 300 | + }); |
| 301 | +}); |
| 302 | +``` |
| 303 | +
|
| 304 | +## Verifying before shipping |
| 305 | +
|
| 306 | +- Run the codemod's tests from the repo root: |
| 307 | + ```bash |
| 308 | + npm run test -w @grafana/create-plugin -- --run src/codemods/migrations/scripts/NNN-migration-title.test.ts |
| 309 | + ``` |
| 310 | +- For anything non-trivial, run the codemod against a real plugin: follow "How to test a migration locally" in `packages/create-plugin/CONTRIBUTING.md` (link the local build, check `.config/.cprc.json` is below the bumped version, run `npx create-plugin update` in a test plugin) and inspect the resulting diff. |
0 commit comments