npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@heartidentity/datoeditsync

v0.6.0

Published

Mirror a DatoCMS project as local text files so LLMs can edit content with plain file tools — with diff review before writing back.

Readme

@heartidentity/datoeditsync

Mirror a DatoCMS project as local text files so LLMs (Claude Code / Cowork) can edit content with normal file tools — with a diff review before anything is written back, three-way conflict detection like git, and drafts-only writes.

Editing DatoCMS content through the API/MCP is slow and unreviewed: every read and write is a round-trip, changes go live immediately, and there's no full-text search over the content. datoeditsync pulls the content down as JSON files. The LLM edits files (Read / Edit / Grep), the tool shows a diff before pushing, checks for conflicts, and writes back only after confirmation — always as drafts.

Quick start

npx @heartidentity/datoeditsync init

This prompts for a DatoCMS Content Management API token, validates it, pulls the schema and content, generates a Claude skill + CLAUDE.md tailored to the project, and offers to git init (recommended — content edits are hard to undo without it). Then just edit files under content/ and sync back.

Commands

| Command | What it does | |---|---| | init | Interactive setup: token, schema + content pull, skill/CLAUDE.md, .gitignore. Flags: --token, --environment, --dir, --git, --hook, --media. | | pull [record] | Remote → local. Refreshes content/, schema/ and the base snapshot; merges concurrent remote edits into the working copy. --model <api_key>, --force (scoped to [record] when given), --prune (drop records that no longer exist remotely), --media (also mirror the media library into uploads/, and remember the choice). | | status | Local changes vs base: new / modified / deleted, per record with affected fields — plus records that were deleted in DatoCMS and can only be discarded, and (with the media mirror on) changed assets and the ones no record references. | | diff [target] | Detailed field diff (coloured in a TTY, plain with --no-color). | | validate | Check local files against the schema: required fields, enums, allowed block types, item limits, tree placement. Runs automatically before every push. | | push | Dry-run by default — shows exactly what would change and checks conflicts. --apply executes. Writes drafts only. --record <id> (repeatable), --model, --delete <id> (repeatable), --continue (keep going after a record fails). | | publish | Separate, explicit step. Dry-run by default; --apply publishes. Select with --record <id> (repeatable), --records-from <file>, --status draft\|updated or --all; --locale <locale> publishes single locales, --recursive publishes unpublished parents too. | | discard <id...> | Throw away local changes to a record or an asset: back to the last synced state, or remove the file when it is gone from DatoCMS. Dry-run by default; --apply executes. | | new <api_key> | Create a local record template (id: "$new") from the schema. For a block model the template is printed instead of written. | | regen | Rewrite the generated files (skill, CLAUDE.md, schema overview) from local state — offline and idempotent. --check (CI), --diff, --force. | | changelog | What changed in datoeditsync since this project was last generated. --since <version>, --all. |

Every command accepts --json and answers with a single JSON object on stdout; ok is the field to branch on. Exit codes: 0 ok · 1 error · 2 conflict (or a publish blocked by a draft parent) · 3 validation failure · 4 not a project · 5 generated files out of date.

| Command | --json payload | |---|---| | status | { ok, generatedDrift, changes[], deletedRemotely[], uploads?, orphanedBase[] } — uploads is { changes[], unreferenced[] } | | diff | { ok, changes[] } — before/after per field | | push (dry-run) | { ok, uploads[], media[], planned[] }; each plan entry { model, id, action, changedFields[], reason?, merged?, fromRemote?, conflict? }, action create\|update\|delete\|sync\|skip. media[] is the same for assets, action update\|replace\|delete\|sync\|skip. ok is false only for a real collision — a record skipped because it is gone remotely is still ok. | | push --apply | { ok, applied, merged, skipped, remaining, failed[], uploads, media } — applied is what was really written | | publish (dry-run) | { ok, wouldPublish[], blocked[] } — a blocked entry names the draft parent in blockedBy | | publish --apply | { ok, published[], blocked[], failed[] } | | pull | { ok, pulled, merged, skippedLocal, deleted, conflicts[], uploads?, generated } | | validate | { ok, errors[], warnings[] } | | regen | { ok, toolVersion, written[], skipped[] } | | discard | { ok, reverted, dropped, discarded[] } |

Invocation

Nothing is installed: the generated SKILL.md and CLAUDE.md spell every command as npx @heartidentity/datoeditsync@latest …. A documented command therefore works on any machine that has npx, and the tool that runs is always the one the instructions were written for — there is no installed copy that can drift.

init also offers a SessionStart hook (--hook / --no-hook) which runs pull whenever a Claude Code session starts in the project, so an editor never has to remember to sync. It is opt-in: the harness then runs that command unattended, and it needs network access.

Staying current

init sets a project up; pull and regen keep it up to date. The generated files carry a provenance stamp (<!-- generated by datoeditsync X.Y.Z -->) and .datoeditsync/config.json records the hash of each one as it was written.

  • pull regenerates them whenever the tool or the schema has moved on — but only files that still match what it wrote. Anything edited by hand is left alone and reported.
  • status and push warn when they are out of date, so drift surfaces where you already are.
  • regen --check exits 5 on drift, for CI.

This matters more than ordinary documentation rot: SKILL.md is the contract an agent acts on. When push changed from "conflict → skip" to "merge → MERGE", an agent on the old skill would have kept reporting conflicts that no longer exist — confidently.

How it works

Three-way model like git: base (snapshot from the last sync), local (your working copy), remote (DatoCMS).

  • Push compares local vs base to find changes, then merges them against the current remote field by field — and for localized fields locale by locale. Only the fields you actually changed are written (with the record's current_version as an optimistic lock), so a colleague editing other fields of the same record at the same time loses nothing; such a record is reported as MERGE rather than UPDATE.
  • A conflict is the same field — same locale — changed on both sides. Those records are skipped; push --json reports base, local and remote for each colliding field, so a human or an LLM can resolve it semantically. Take the remote version for one record with pull --force <id>.
  • Not merged automatically: two edits inside one string, one locale of a block-bearing field (rich/structured text), or one plain object field (SEO, file references). Those are real conflicts and are shown.
  • Pull uses the same merge: it folds remote changes into records you have edited locally instead of refusing, and reports only genuine collisions.
  • New records use id: "$new" → created remotely, with the real id written back and the file renamed.
  • Deletions are explicit: set "_delete": true in the file or use push --delete <id>. Deleting a file on disk does nothing.
  • Blocks are stored inline as { "id", "type", "fields" }. Keep the id to update, use "$new" to add, remove the object to delete. In a localized block field every locale holds its own block tree with its own ids — filling a second locale means cloning the structure with every "id" set to "$new", never reusing the other locale's ids.
  • Tree models keep their placement in the file: parent_id (the parent record's id, or null at the root) and position sit next to id, outside fields. Editing them moves the record on the next push; they are merged and conflict-checked like any other value. Deletion ignores them, since DatoCMS renumbers position whenever a sibling moves.
  • A push is record by record, not one transaction: DatoCMS has no transaction spanning records. If one record fails, the ones written before it stay written, and the summary says exactly how many went through, which record stopped it and how many were never attempted. --continue finishes the rest and lists every failure at the end.
  • Publishing works on sets: publish --all (or --status updated, --records-from <file>, repeated --record) goes through the CMA bulk endpoint, 200 records per request and one request per tree level — parents before children, so nothing races its own parent. A record whose parent is still a draft is reported by the dry-run instead of failing the apply.
  • Locales are separate publications: publish --locale en --locale fr releases those locales and leaves the others untouched.
  • A record deleted in DatoCMS while the local copy carries changes is not a modification — it is listed under deleted remotely in status and cleared with discard <id> or pull --prune.
  • Assets: existing assets are referenced by upload id. New files go into media/ and are referenced as { "$path": "media/<file>", "alt": "…" }; push --apply uploads them (deduplicated by content hash, tracked in .datoeditsync/uploads.json) and rewrites the reference to the real upload_id. The alt and title given there are written to the new asset's own metadata as well, in the primary locale.

The media library

Alt text and title of an image live in DatoCMS at the upload, not at the record that uses it — localized, one value per locale. Anything built on the mirror is therefore blind to them: a translation pass over content/ never sees a single caption, and the first sign that it missed them is German alt text on the English site.

pull --media mirrors the library into uploads/<id>__<basename>.json — metadata only, never the binaries — and records the choice in config.json, so every later pull, status, diff and push includes it. It is opt-in per project because a mid-sized library is a few thousand files.

{
  "id": "OY7kQfWsQ4-7CiDxVqcwVQ",
  "filename": "gymnasium.jpg",
  "mime_type": "image/jpeg",
  "is_image": true,
  "width": 2000, "height": 1333,
  "basename": "gymnasium",
  "tags": [],
  "fields": {
    "alt":   { "de": "Gymnasium in Uetikon", "en": "Grammar school in Uetikon" },
    "title": { "de": null, "en": null },
    "custom_data": { "de": {}, "en": {} },
    "focal_point": null,
    "poster_time": null
  }
}

fields is the upload's default_field_metadata, and it deliberately looks like any other record's field map: alt, title and custom_data are localized, so the same diff, the same per-locale merge and the same conflict rules apply — and any tooling that already walks content/ walks this without a special case.

  • Editable: fields.*, basename (part of the asset URL, so SEO-relevant), tags, author, copyright, notes, upload_collection. A push writes only the metadata keys that changed; the rest of the asset's metadata is left alone.
  • Read-only mirror: filename, mime_type, is_image, width, height, size, url, md5, smart_tags, meta.
  • Choosing an asset is grep, not an API call: search uploads/ by file name, alt text, tag, mime type or dimensions to get an upload_id for a record. status also names the assets no record in the mirror references.
  • Replacing an asset's file: put "$path": "media/<file>" next to the existing "id". The id survives, so every record referencing it shows the new file without re-linking; "$replace_strategy": "create_new_url" opts out of trying to keep the URL. ($path without an id still means "create", and that form belongs in a record's file field.)
  • Renaming is changing basename; deleting is "_delete": true — which, unlike a record deletion, takes the asset away from every record still using it.
  • Assets cannot be created from uploads/: a new one enters through media/ plus a $path reference in a record, which is where the binary actually is.

Note on the API: default_field_metadata is keyed by field with a locale map inside ({ "alt": { "de": … } }). The generated CMA TypeScript types claim the opposite nesting; a payload in that shape is rejected with 422 INVALID_FORMAT.

File layout

my-project/
├── .datoeditsync/
│   ├── config.json        # project id, environment, options, generated-file
│   │                      # provenance (never the token)
│   ├── base/<model>/<id>.json   # three-way base snapshot
│   ├── base/_uploads/<id>.json  # …and the same for mirrored assets
│   ├── meta.json          # updated_at, hash and version token per record
│   └── uploads.json       # md5 → upload id (media dedup manifest)
├── content/<model>/<id>__<slug>.json
├── uploads/<id>__<basename>.json  # asset metadata (opt-in: `pull --media`)
├── media/                 # new assets, referenced via { "$path": "media/…" }
├── schema/<model>.json + _overview.md
├── .claude/skills/dato-edit/SKILL.md
├── CLAUDE.md
└── .env                   # DATOCMS_API_TOKEN (gitignored)

Auth

v1 uses a CMA API token (full-access or a custom role), stored in .env as DATOCMS_API_TOKEN and read at runtime. It is never written to config.json and never logged.

Development

npm install
npm run build        # compile to dist/
npm test             # unit tests for the diff / merge / conflict engine
npm run dev -- status  # run the CLI from source
npm run test:integration  # live end-to-end test against a real DatoCMS project

The integration test needs DATOCMS_TEST_TOKEN (a CMA token for a dedicated test project, with schema + environment rights) — set it in the environment or put DATOCMS_TEST_TOKEN=… into a .env in the repo root (gitignored). Without the token it skips cleanly.

Non-goals (v1)

No schema migrations (the schema is read-only), no realtime/watch mode, no multi-user locking — the three-way merge plus DatoCMS' current_version optimistic lock is the answer to concurrent editing. The binaries themselves are not mirrored either: uploads/ holds metadata and references, and media/ stays what it is, a drop-zone for files still to be uploaded.

License

MIT