Package codemods
September 16, 2026 · View on GitHub
Kody users own saved packages: source in Artifacts git repos, published through repo checks into KV bundle artifacts. A package codemod is a versioned, pure, deterministic, idempotent transform over a package's published file tree. Codemods migrate user package source when the platform's package API changes — the user-package analogue of D1 schema migrations, applied fleet-wide with audit and revert support.
User-authored package contracts live in
packages-and-manifests.md. Repo-backed source
and publish paths are covered in
architecture/data-storage.md.
What codemods are and are not
Codemods are:
- Transforms over the published source snapshot (the same tree
runRepoChecksvalidates), not live Artifacts working copies. - Versioned in-repo platform code, registered once, run many times across users and packages.
- Pure —
detectandtransformreceive an in-memory file map and return findings or a new file map; no I/O, no ambient request context. - Deterministic and idempotent — the same input tree always yields the same
output; running
transformtwice on the result must not change files again. - Conservative — when a pattern match is ambiguous, emit a
needsManualfinding instead of guessing.
Codemods are not:
- D1 migrations. Platform schema changes use SQL migrations under
packages/worker/migrations/. Codemods change user-owned package source in Artifacts/KV, scoped per saved package and per user. - Repo-session edits. Codemods do not patch arbitrary git working trees; they operate on the published snapshot the checks pipeline already built.
- Community listing publishes. A successful apply updates the owning user's saved package only. Pinned community listings keep serving the pinned commit; listing snapshots are not advanced by codemod apply or revert.
- Personal codemods. The built-in system is for platform-authored
migrations: the transform ships in this repo, reviewed and fixture-tested in
CI, because its correctness is pinned to a platform version and it runs
fleet-wide over other users' source. A user transforming their own
packages with their own transform needs no primitive — every required
power (repo sessions,
repoRunChecksagainst a staged tree, gated publish, git history) already exists as user capabilities. That userland pattern is packaged as the@kentcdodds/codemod-runnerpublic package: same contract vocabulary (detect/transform, dry-run-before-apply, drift skips, idempotency verification, revert snapshots), with the codemod authored as a package export the user owns. As with any public package, inspect the source and fork (which pins your own copy) before running it against your packages. Do not grow the built-in engine to execute user-authored transforms; the dividing line is who authored the transform.
Codemod contract
Each codemod lives at
packages/worker/src/package-codemods/codemods/NNNN-kebab-name.ts (for example
0001-ambient-storage-to-package-storage.ts) and is registered in
packages/worker/src/package-codemods/registry.ts.
Types in packages/worker/src/package-codemods/types.ts:
type PackageCodemodFinding = {
path: string | null
message: string
}
type PackageCodemodTransformResult = {
files: Record<string, string>
changed: boolean
changedPaths: Array<string>
needsManual: Array<PackageCodemodFinding>
}
type PackageCodemod = {
id: string
description: string
detect(files: Record<string, string>): Array<PackageCodemodFinding>
transform(files: Record<string, string>): PackageCodemodTransformResult
}
detect(files)— read-only scan. Returns{ path, message }findings without mutating the tree. Used for fleet discovery and reporting.transform(files)— returns a new tree plus metadata. When a hunk cannot be migrated confidently, leave the file unchanged and append aneedsManualfinding rather than applying a risky rewrite. Partial transforms are intentional: whenchanged: trueandneedsManualare both set, dry-run and apply still proceed with the partial tree (per-file conservatism with per-package progress). Findings are recorded on the ledger item and returned to the operator; the no-new-failures check gate still runs on that partial tree. A codemod that must be all-or-nothing should return the original tree withchanged: falseand only findings.
Implementations must stay pure: no fetch, D1, KV, secrets, or reads of the
calling user. The engine supplies the published file map; the codemod returns a
transformed map.
Authoring guide
- Add the module under
packages/worker/src/package-codemods/codemods/NNNN-kebab-name.ts. Use the next sequential id; ids are stable compatibility contracts. - Register the export in
packages/worker/src/package-codemods/registry.tsso the engine and operator surfaces can resolve it by id. - Prefer mechanical rewrites with clear before/after fixtures. Cover edge
cases (already migrated imports, commented code, string literals that look
like patterns but are not) with fixture tests beside the codemod
(
*.node.test.tsor*.workers.test.ts), using small in-memory file trees rather than full publish integration unless the behavior requires it. - Emit
needsManualwhen:- multiple interpretations exist,
- the pattern spans generated or minified output,
- a required symbol cannot be resolved from static analysis alone, or
- the codemod would delete user logic to satisfy the migration.
- Never put source contents in findings. Finding
messagevalues must be fixed, codemod-authored strings;pathidentifies the file. Interpolating file contents, matched snippets, identifiers from user code, or manifest values into a finding would surface private package source to the operator running the fleet scan, breaking the privacy policy's codemod disclosure ("codemods are forbidden from embedding file contents in their findings"). Shipped codemods use constant messages; keep it that way.
0001-ambient-storage-to-package-storage
This permanent repair codemod converts ambient storage imports rejected by
package publish checks to packageStorage() at call sites:
- Rewrites member uses (
storage.get(...)→packageStorage().get(...)) via AST range replacement; it does not insert a module-scopeconst storage = packageStorage()binding. - Adjusts the
kody:runtimeimport: renamestorage→packageStorage, or drop thestoragespecifier whenpackageStorageis already imported. - Emits
needsManualfor aliased imports, non-member uses (value-passing), re-exports, multiple runtime imports, binding sites, and post-rewrite verification failures. - Emits
needsManualfor parse failures on scannable module files that mentionkody:runtimeandstorage. - Manifest gate: when
package.json#kodydeclares any non-emptyapp,jobs,subscriptions,webhooks, orretrieverssurface, every ambient-storage candidate file getsneedsManual— ambientstorageandpackageStorage()use different bucket identities on those execution surfaces, so automatic rewrite risks silent data repointing.
0002-static-first-invocation
This permanent repair codemod brings package source into the static-first
two-rule contract enforced at publish time (see
architecture/invocation-overhead-guardrails.md):
- Rewrites
packages.invokeChecked(...)member calls (includingpackages?.invokeChecked) topackages.invoke(...)via AST range replacement, then composes the0006-invoke-object-to-specifierrepair over those files so safe object inputs become scoped string-first calls in the same run. - Emits
needsManualwhen that second-stage object repair cannot safely derive the owner/target. That second stage is all-or-nothing: any manual finding restores the original tree and returnschanged: false, forcing the engine'sneeds_manualstop instead of applying a partial rewrite to removed API. - Emits
needsManualforpackages.check(...)(its contract return value has no mechanical equivalent —invokechecks internally) and for literal dynamicimport("kody:@...")(namespace semantics andkody.dependenciesmanifest changes need a human), naming the replacement in each finding. - Emits
needsManualfor parse failures on scannable module files that reference unsupported forms, and verifies post-rewrite that no detectablepackages.invokeCheckedmember expressions remain. - Detection reuses the publish-check collector
(
package-runtime/deprecated-invocation-usage.ts), so parseddetectfindings and failing publish lint results stay in lockstep. Unparseable files produce codemod-onlyneedsManualfindings because publish lint cannot classify them.
0003-heykody-domains-to-kody-codes
This origin-migration codemod rewrites published package references from the legacy web origins to the canonical app origin:
- Rewrites
https://heykody.appandhttps://heykody.dev(hostname-boundary safe, including a sentence-final period) tohttps://kody.codes. - Does not rewrite subdomains (
status.heykody.dev,inbox.heykody.app), lookalike hosts, emails, orLEGACY_*configuration values. - Emits
needsManualfor every remainingheykody.app/heykody.devmention after the origin rewrite (bare hostnames, emails, nested labels).
0004-kodyapps-dev-to-kody-run
This origin-migration codemod rewrites published package references from the legacy package-app origin to the canonical hosted-app origin:
- Rewrites
https://kodyapps.devandhttps://{user}.kodyapps.dev(one DNS label, hostname-boundary safe) tohttps://kody.run/https://{user}.kody.run. - Does not rewrite
kody.codes,heykody.app,heykody.dev, inbox hosts, MCP paths,status.heykody.dev, nested labels (https://a.b.kodyapps.dev), lookalike hosts, orPACKAGE_APP_LEGACY_*configuration values. - Emits
needsManualfor every remainingkodyapps.devmention after the origin rewrite (bare hostnames, nested labels, lookalikes).
0005-kody-dependencies-to-wildcard-map
This one-shot manifest-format codemod rewrote package.json#kody.dependencies
from the legacy array of scoped names to a name-to-* map. New publishes reject
arrays; the codemod remains for any leftover published trees:
- Rewrites
["@scope/pkg"]to{ "@scope/pkg": "*" }, including an empty array to{}. - Rewrites the agent-common
latestalias to*. Already-migrated*maps are left unchanged. - Emits
needsManualfor unsupported versions (semver ranges, commit SHAs), unscoped names, and missing or invalidpackage.json. - Does not change resolution:
*is latest-at-publish, captured when the dependent republishes. It is not a live pin.
0006-invoke-object-to-specifier
This permanent repair codemod converts the publish-blocked object-only dynamic package API to an explicit owner-scoped specifier:
- Rewrites
packages.invoke({ kodyId: "target", exportName, params, idempotencyKey, topic })topackages.invoke("kody:@owner/target", { exportName, params, idempotencyKey, topic }). The owner scope comes from the invoking package'spackage.json.name, which preserves the old API's caller-owned lookup. - Handles direct and optional
packages?.invokecalls and preserves option expressions and keyless/exactly-once behavior. - Rewrites complete examples in JavaScript/TypeScript Markdown fences and inline
code spans. Untyped/unsupported fences, partial snippets, and matching prose
remain unchanged with
needsManual. - Applies those safe Markdown rewrites to platform-owned package documentation,
using the package's explicit platform scope. Documentation for
@kody/notify,@kody/stash, and@kody/personal-captureremains manual because examples must use the installed user-fork owner to preservepackageStorage()semantics. - Leaves already string-first calls unchanged and is idempotent.
- Emits
needsManualfor immutablepackageIdtargets, dynamic or indirect input objects, calls withoutexportName, computed properties, spreads, comments in the removed field, parse failures, and manifests without a valid scoped package name. - Emits file-level
needsManualfindings for platform-owned runtime source: its old bare-id lookup follows the runtime caller, which cannot be replaced by the source package's platform scope without changing behavior. - Remains registered because publish checks permanently reject object-only
packages.invokein JavaScript and TypeScript. Authors and operators can use this codemod as the mechanical repair path for source that predates or bypasses those checks.
0007-prefix-packages-invoke-specifiers
This permanent migration codemod moves string-first dynamic invocation to the preferred explicit scheme:
- Rewrites literal
packages.invoke("@owner/package[/export]", options)calls topackages.invoke("kody:@owner/package[/export]", options). - Rewrites parseable non-object dynamic first arguments (identifiers, member and
helper-call expressions, conditionals, and interpolated templates) through a
marked inline normalizer. The original expression is passed into the IIFE and
therefore evaluated exactly once. At runtime the normalizer trims strings only
to detect a leading
@, prefixes that trimmed prefixless value, and passes already-prefixed strings, other strings, and non-strings through unchanged so the existing runtime parser still canonicalizes or rejects them. - Preserves the complete options argument byte-for-byte, including
exportName, so an export subpath keeps its existing precedence. - Handles JavaScript and TypeScript modules plus parseable JS/TS fenced and inline examples in Markdown and MDX. TypeScript uses a return assertion on the generated normalizer; JavaScript and untyped inline examples use a JSDoc expression assertion, so generated JavaScript contains no TypeScript syntax.
- Leaves already-prefixed calls and marked generated normalizers unchanged and is deterministic and idempotent.
- Emits fixed, privacy-safe
needsManualmessages for ambiguous bindings, spread/missing arguments, genuinely call-shaped examples outside parseable JS/TS Markdown ranges, and parse failures. Unparseable source has no textual rewrite fallback: without an AST, the codemod cannot prove either binding or argument boundaries safely. Markdown prose that merely namespackages.invokewithout a following call is ignored. Messages never interpolate a specifier, package, export, parameter, or source value. - Leaves object-only calls unchanged. Their migration remains the permanent
0006-invoke-object-to-specifiercodemod's responsibility.
Prefixless calls remain publishable during this measurement phase; 0007 is a migration aid, not a publish-rejection rule.
0008-packages-invoke-to-static-import
This permanent migration codemod removes author-facing packages.invoke
(decision 0037):
- Rewrites literal
packages.invoke("kody:@owner/package/export", { params })calls to a staticimport export from "kody:@owner/package/export"plusexport(params), and adds the package name topackage.json#kody.dependencieswhen the rewrite is in a JS/TS module. Repo checks only count those static imports, so Markdown examples are rewritten without declaring unused dependencies. - Rewrites computed first arguments to
(await import(specifier)).default(...). That is the name-as-data path; do not use this rewrite when the name is known at write time. - Handles JavaScript and TypeScript modules plus parseable JS/TS fenced and inline examples in Markdown and MDX.
- Emits
needsManualfor keyed invokes (idempotencyKey— use workflows), ambiguous options, and leftover prose that still namespackages.invoke. - Leaves already-migrated static imports unchanged and is idempotent.
Fleet scan 803e3045 found zero executable-source findings, drift, or errors.
Its three remaining findings are private README-only documentation debt: those
files cannot execute and therefore do not block later runtime/type prefix
removal once the telemetry gate passes. Keep the debt tracked as an aggregate
owner-action count without publishing private package ids or owners. Codemod
0007 and the local prefixless teaching error remain the repair path for those
documents.
0009-snake-case-kody-members
This one-shot cleanup recases leftover snake_case builtin kody members to
camelCase JavaScript identifiers:
- Rewrites
kody.package_get(...)andkody["package_get"](...)tokody.packageGet(...)in JavaScript and TypeScript modules, including leftover ambientkodycalls that never importedkody:runtime. - Rewrites
capability:package_getentity refs in those modules and in Markdown / MDX. - Leaves
kody.mcp["server"].tool_name(...)unchanged. MCP-synthesized tools keep their upstream names. - Emits
needsManualfor computedkody[id]where the property is data, and for files that mention a snake_case member but cannot be parsed.
Builtin capability and domain ids are camelCase identifiers (emailSend,
mcpServers). Input field names, usage-metric event types, Codex TOML
[mcp_servers.kody], and SQL cf_agents_mcp_servers stay snake_case.
Engine
The engine entry point is runPackageCodemodStep in
packages/worker/src/package-codemods/engine.ts. Long runs are paged: each
call processes up to limit packages (or revert items) and returns nextCursor
plus a per-step summary count by item status. Continue with the same runId
and nextCursor until nextCursor is null.
The admin UI walks those pages as separate HTTP requests. MCP execute and
inline workflow sandboxes do not: dry-run, apply, and revert are check-heavy
enough that a second page in the same sandbox typically exceeds the workflow
observer (~270s, under the Cloudflare Workflow step timeout). Those modes take
one page per execute or workflow sandbox. When nextCursor is set, start a new
execute or workflows.create with that runId and cursor. Omitted filters
inherit from the stored run. Capability results include a nextStep string that
restates this. Scan pages are cheaper and can often continue in the same
sandbox.
Step limits
| Mode | Default limit | Max limit |
|---|---|---|
scan | 20 | 50 |
dry-run, apply, revert | 5 | 10 |
Fleet scan mode may scan up to five D1 pages of 50 saved packages per step while
applying filters, and can return a progress nextCursor even when the current
step matched zero packages.
Modes
| Mode | Behavior |
|---|---|
scan | Run detect only; record findings per package. |
dry-run | Run transform in memory, then run the full publish check suite (runRepoChecks) on both the original and transformed trees. Pass only when transformed checks introduce no new failures compared to the original. Also verifies mechanical idempotency by transforming twice and requiring an unchanged second result. |
apply | Same gates as dry-run. On success: snapshot the original published tree to KV for revert, commit and push via syncArtifactSourceSnapshot with commit message codemod(<id>): ..., refresh the saved-package projection, and dispatch subscription events (see below). Re-checks drift immediately before the write. |
revert | For each applied item on a prior apply run: load the KV revert snapshot, verify Artifacts HEAD still matches that item's afterCommit, write the snapshot tree back, mark the source apply item reverted, and dispatch package.codemod.reverted. |
Locked packages (saved_packages.locked_at set) still run apply and revert. The
engine commits and pushes HEAD and does not advance published_commit. The
owner reviews that HEAD commit later at
/@username/:kodyId/approve-publish?commit=…. Fleet apply does not skip locked
packages.
Per-package failures are isolated; one package error does not abort sibling items in the same run step.
Run lifecycle
Runs are created as running and end completed only when a caller pages until
nextCursor is null. The other transitions keep the ledger honest when paging
stops early:
- Heartbeat — every step re-asserts
runningand bumps the run'supdated_at, soupdated_atdoubles as a liveness signal. Continuing afailedorabandonedrun reopens it. failed— written when a step throws at the run level (paging, ledger writes); per-item failures never fail the run.abandoned— written when the admin UI stops a run (stop button, step ceiling, stuck cursor) viaPOST /admin/codemods/run/stop.json, or by lazy reconciliation: loading the admin history marksrunningruns whose heartbeat is older than one hour. There is no scheduled sweeper; abandoned callers (closed tabs, agents that stopped paging) are caught on the next history load.
Abandoned and failed apply runs can still hold applied items; revert
accepts them (the admin UI only blocks revert while a run is running).
Safety rails
skipped_unpublished— packages with no published commit are skipped.skipped_drift— when Artifacts default-branch HEAD does not matchentity_sources.published_commit, the engine skips and never overwrites. Apply re-checks drift after transform gates pass and before KV snapshot / publish. Revert compares HEAD to the prior apply item'safterCommit(post-codemod published commit); drift skips revert for that item.- Apply snapshots — before publish, apply writes the pre-codemod published
tree to
BUNDLE_ARTIFACTS_KVatpackage-codemod-revert:{userId}:{itemId}with a 90-day TTL and stores that key on the ledger item asrevert_snapshot_key. - Check gate — apply and dry-run both require the transformed tree to pass
runRepoCheckswithout regressions versus the original tree.
Item statuses
Each per-package row in a run records one of:
detected, clean, dry_run_ok, dry_run_new_failures, needs_manual,
skipped_drift, skipped_unpublished, applied, reverted, failed.
Ledger
Every run and per-package item is stored in D1 (tables defined in
packages/worker/migrations/0001-squashed-init.sql). Pagination cursors live on
step responses, not in the ledger tables.
package_codemod_runs: id, codemod_id, mode, scope_user_id (NULL
for fleet runs), initiated_by_user_id, filters_json, status (running |
completed | failed | abandoned), revert_of_run_id, created_at,
updated_at.
package_codemod_run_items: id, run_id, user_id, package_id,
kody_id, status, before_commit, after_commit, changed_paths_json,
findings_json, check_summary_json, error, revert_snapshot_key,
created_at, updated_at.
Ledger writes bound large text columns (error, check_summary_json,
findings_json, changed_paths_json) to restorable UTF-8 byte limits; findings
cap at 50 entries and changed paths at 200, with truncation notices when
overflowing.
The ledger makes runs resumable (page forward with nextCursor),
auditable, and revertible (revert reads KV snapshots keyed by
revert_snapshot_key). Revert is only possible while the KV snapshot remains
(90-day TTL). All rows are scoped by the owning user's saved package identity;
cross-user reads are a bug.
Operator surfaces
Admin UI
/admin/codemods supports fleet scan, dry-run, apply, and
revert for a selected codemod. Filters include userIds, packageIds, and
limit so operators can canary a subset before a full fleet apply.
User-owned packages
Users transforming their own packages use the
@kentcdodds/codemod-runner
public package (same detect/transform contract, dry-run-before-apply, and
revert snapshots). The built-in engine stays reserved for platform-authored
fleet transforms; see
What codemods are and are not.
MCP — fleet (admin domain)
Admin-gated operator fleet runs:
adminPackageCodemodScanadminPackageCodemodDryRunadminPackageCodemodApplyadminPackageCodemodRevert
Admin capabilities require requiredRole: 'admin' and follow the RBAC boundary
in Authorization.
Rollout doctrine
Platform package API changes that break existing user source follow this sequence (formalizing existing practice):
- Land the platform change with deprecation shims and warnings so old patterns still publish.
- Fleet scan — run codemod
detectacross packages; review findings andneeds_manualvolume. - Fleet dry-run — review diffs and dry-run reports; fix codemod gaps before apply.
- Canary apply — use admin filters (
userIds/packageIds) for a small cohort; monitor checks, projections, and subscriber notifiers. - Fleet apply — page through the full population.
- Land enforcement — add or tighten publish-time lint/checks so new publishes cannot use the deprecated pattern.
Skipping dry-run or canary apply risks mass check failures; skipping step 6 allows new packages to reintroduce debt. After enforcement lands, drop the deprecation shims when no remaining packages need them — or open a GitHub issue if that drop must wait. See Cleanup after migrations.
Revert
Apply persists the pre-codemod published tree to KV (revert_snapshot_key,
90-day TTL) before republishing the transformed tree. Revert mode creates a
new run with revert_of_run_id pointing at the prior apply run, pages through
source items with status applied, loads each KV snapshot, and republishes via
syncArtifactSourceSnapshot with commit message revert codemod(<id>). On
success it marks the source apply item reverted, refreshes projections,
and dispatches package.codemod.reverted.
Revert requires published HEAD to still equal the source item's afterCommit.
Missing or expired KV snapshots fail the revert item. Revert does not restore
Artifacts working-copy edits made after apply.
Runs are single-pass in every mode: items that fail or are drift-skipped are
recorded on the run but not retried within it. Because failed or skipped source
items keep their applied status, retrying is starting a new revert run
against the same apply run — it picks up exactly the items that were not
reverted.
Steps do not take a per-package lock. Publishes serialize in the repo session Durable Object and transforms are deterministic, so overlapping steps of the same codemod are benign, but do not fleet-apply two different codemods concurrently — the second may transform stale published source.
Subscription events
After each successful apply or revert, the host dispatches
package.codemod.applied or package.codemod.reverted to packages saved by the
owning user that declare the topic — the same delivery pattern as
run.error.recorded. Payload shape and handler guidance live in
Package subscriptions.
Related
- Packages and manifests
- Adding capabilities — MCP capability registration
- Package subscriptions — event payloads
- Data storage — published source and KV