vinumcms
v0.7.0
Published
Command line tools for Vinum CMS: init wires it into an existing React Router app, doctor checks an install for the mistakes that fail silently.
Maintainers
Readme
vinumcms
Command line tools for Vinum CMS — a block-based CMS delivered as importable packages that mount inside your own React Router v7 or v8 app.
npx vinumcms init # wire Vinum into an existing React Router app
npx vinumcms doctor # check an install for the mistakes that fail silentlyNo installation and no configuration.
init
Against an app you already have — npx create-react-router@latest makes one,
and reimplementing that means owning every future change to their template.
npx create-react-router@latest my-site
cd my-site && npm install
npx vinumcms init
npm run dev # then sign in at /adminEight steps, four of which fail silently if you do them by hand and miss one:
the Vite ssr.noExternal setting, the Tailwind @source lines,
allowImportingTsExtensions in your tsconfig, and — the one that costs the most
— an import of your vinum.server module from app/entry.server.tsx, without
which registerVinum() never runs and every admin route throws an error naming
a call you already wrote.
It also writes app/vinum.server.ts — wired to a store that survives a
restart, Deno KV under Deno and fsStore under Node — spreads vinumRoutes()
after your own routes, generates a SESSION_SECRET from the CSPRNG, and
gitignores .env and data/, because the store holds user password hashes.
It never overwrites a file that exists. Patches go in by insertion, and where it cannot patch confidently it prints the exact snippet and where it goes rather than guessing. Running it twice does nothing the second time and says so, which includes not regenerating your session secret.
| | |
|---|---|
| --no-install | Patch files but skip npm install |
| --mail | Include @vinumcms/mail |
Exits 1 if a step failed, 2 if the directory is not a React Router app — in
which case it writes nothing and names what is missing.
doctor
Vinum ships TypeScript with no build step. That trade buys a codebase you can read while you debug it, and it costs every install two configuration lines that nothing will ask you for. Get either wrong and the failure does not name itself:
- Missing
ssr.noExternal— the SSR build hands Node raw TypeScript. You get a syntax error deep insidenode_modules, which reads like a published package is broken rather than like a missing line in your own config. - Missing
optimizeDeps— nothing breaks and nothing is logged;devis simply slower on every navigation, because the browser fetches Vinum's source one module at a time instead of a few pre-bundled chunks. Measured on this repo's playground: 43 module requests five levels deep, against 10 requests and three levels with it set. That reads as "this CMS is heavy" rather than as a missing config line, which is why it is a check. - Missing the Tailwind
@sourcelines — Tailwind v4 does not scannode_modules, so none of Vinum's classes are generated and the admin renders completely unstyled. Nothing is logged. Nothing errors.
There is a third setting with the same shape — allowImportingTsExtensions in
your tsconfig.json — which breaks neither dev nor build, because Vite never
typechecks. It fails tsc, so it surfaces late and usually in CI.
And one that is not a setting at all: something has to import your
vinum.server module. registerVinum() is a module side effect, so if nothing
in the server graph imports it the call never runs, and every admin route throws
No Vinum instance registered — an error that names a call you have already
written, which is why it is the most expensive of the four to find.
doctor checks all four, plus two warnings and one more that only applies
once you switch analytics or the consent banner on — see What it checks.
A missing SESSION_SECRET. A warning rather than an error because the server
falls back to a development-only default — right for react-router dev and
dangerous in production, which is exactly the shape that reaches a deploy
unnoticed.
A memoryStore. It forgets everything when the dev server reloads, and a
second process (vinumcms mcp) opens its own empty copy rather than seeing
yours. Also a warning, because it is a legitimate choice in tests and this
command cannot know it is not deliberate. init scaffolded it until fsStore
shipped, so installs created before then are quietly losing content.
vinumcms doctor /Users/you/my-site
✓ Vite SSR config vite.config.ts sets ssr.noExternal to cover @vinumcms/.
✗ Tailwind sources app/app.css does not scan @vinumcms/admin, so those
classes are never generated and the UI renders unstyled.
✓ tsconfig tsconfig.json sets allowImportingTsExtensions.
✗ Registration app/vinum.server.ts calls registerVinum() but nothing
imports it, so it never runs and every admin route will
throw at the first request.
! Store app/vinum.server.ts uses memoryStore, so content is lost
on every restart and `vinumcms mcp` sees an empty site.
! Session secret SESSION_SECRET is not set, so admin sessions are signed
with a development-only default.
@vinumcms/[email protected] @vinumcms/[email protected]
2 errors, 2 warnings.Each failure prints the exact snippet to paste and where it goes.
Options
| | |
|---|---|
| --cwd=<dir> | Directory to check. Defaults to the working directory. |
| --json | Machine-readable output on stdout. |
| --version, -v | Print the version. |
| --help, -h | Print usage. |
Exit code is 1 when a check fails and 0 otherwise; warnings do not fail the
command. 2 means it could not run at all — no command given, or no Vinum
install where it looked.
For agents
Nothing prompts, check order is fixed, and --json gives you the same report as
a structure:
{
"ok": false,
"runnable": true, // false when there was no install to check at all
"root": "/Users/you/my-site",
"install": {
"declared": { "@vinumcms/core": "^0.2.0" },
"installed": { "@vinumcms/core": "0.2.0" }
},
"checks": [
{
"id": "ssr-noexternal", // ssr-noexternal | dev-prebundle |
// tailwind-source | ts-extensions |
// registration | store | session-secret |
// extras
"title": "Vite SSR config",
"status": "ok", // ok | error | warn | unknown | skipped
"detail": "…",
"fix": null // a pasteable snippet when there is something to do
}
]
}status: "unknown" is deliberate and worth handling. doctor reads a host repo
it cannot execute, and a vite config is TypeScript that may import, spread or
compute its options — so every check is a regex over source, and a regex can be
wrong. Where it cannot prove something either way it says so and names the file
to open, rather than guessing confidently in either direction.
What it checks
The build settings above, that something imports your vinum.server module,
your SESSION_SECRET, your store — and, if you switched analytics or the
consent banner on, that any route of your own calling loadCmsPage also renders
CmsPageExtras.
That last one is the newest and the most recently expensive. A host route that owns its own CMS page renders the blocks and nothing else, so measurement is configured, mounted and completely silent. It happened to a real site, and to this repo's own playground, which is why it is a check rather than a paragraph.
mcp
vinumcms mcp hands off to @vinumcms/mcp,
which serves your content model and pages as MCP tools — read-only unless
--write, and every write through the same sanitizers a form post gets.
export and import
The backup round trip, from a terminal. Vinum's leaving guide promises you can take everything and go; this is the version of that promise you can actually run without writing code.
npx vinumcms export # → vinum-backup.json
npx vinumcms export --out=mine.json
npx vinumcms export --force # replace an existing file
npx vinumcms import mine.json --dry-run # what is in it; writes nothing
npx vinumcms import mine.json # merge (default)
npx vinumcms import mine.json --mode=overwrite --yesMerge writes each key and leaves anything not mentioned alone. Overwrite
clears every covered prefix first — which is the one genuinely destructive thing
here, so it refuses to run without --yes and tells you what it would clear.
Import goes through the same sanitizer the admin uses, so a file from somewhere else cannot store a shape the editor could never have made: unknown block types are dropped, URLs are neutralised, and a record's identity comes from its key rather than its body.
Media bytes are not in the JSON. Copy your media directory alongside it.
Exporting from a Deno site
[email protected] VINUM_PASSWORD=… \
npx vinumcms export --url=https://your-site.example--url asks the running site instead of opening its store, and for a Deno host
that is the only way in: its content lives in Deno KV, which only the process
that already has it open can read, and Deno refuses to compile Vinum's
TypeScript out of node_modules so no second process can join. The site has no
such difficulty — it serves this JSON to a signed-in admin already.
Credentials come from the environment, never a flag. A password in a flag is
a password in ps, in your shell history, and in whatever CI log captured the
command line.
It refuses plain http to anything but localhost, because it is sending an
admin password, and it checks the output file before it sends anything — a
mistyped --out should not cost you a round trip with your credentials on it.
Without --url, on a Deno site, init scaffolds a store that picks
Deno KV when the runtime is Deno and a file when it is Node — and the local path
runs under Node, so it would open the wrong database. It detects that and
refuses rather than handing you a backup of a site you do not have. Use --url
above, or /admin/backup in the browser.
Two things worth knowing:
- An empty export is reported loudly, because it usually means the install
uses
memoryStore(). This command is its own process, so it opens its own empty database rather than the one your dev server is holding in RAM. - It needs
tsx, because it loads yourvinum.servermodule and the packages ship TypeScript. It is not a dependency of this CLI — it is resolved when you run it, from yournode_modulesfirst, thennpx. If neither works,npm i -D tsx.
What else is coming
Read-only stays the default throughout. Anything destructive needs an explicit flag and prints what it intends to touch first.
Licence
MIT
