Type to search pages and sections.

Skip to content
Ginko

Platform and CLI

What owners do on the platform, the commands of the ginko CLI, and why schemas only grow.

What owners do on the platform

The platform is a hosted web app, run by Lupinum and open by invitation. It turns your bricks into forms and stores the content. The site fetches the content from it as a bundle. Without the platform, a site reads all content from files.

  • Sign-in. People sign in with a code that arrives by email. An account exists only by invitation.
  • Roles. An owner (owner) may do everything on their site and invites staff. A staff member (staff) may only do the tasks that the owner grants. An admin may do everything on all sites.
  • Editing. Owners change entries in forms. Tasks from the bricks are shortcuts for frequent chores. A preview shows the real site with the changed content.
  • Save and publish. Saving puts nothing online: a new entry is a draft, and changes to an entry or single that is online wait as unpublished changes. "Publish …" puts them online now or at a time, "Discard" goes back to the version on the website. An entry of a collection can go back to a draft (offline, without deleting it); a single is always online. A task on Today publishes with one press on "Publish". Moves in the navigation tree go online at once.
  • Safety. If two people change the same field, the platform detects the conflict. A change can be undone. Deleted entries can be restored for 30 days.
  • Approval. If you want, a task or an agent key needs a yes before a change goes live. The reviewer sees the change and approves it, approves it for a time, or rejects it with a sentence; the author sees the reason. Approval is off until someone turns it on.
  • Schedule. A draft or unpublished changes go live at a set time. A release publishes several changes together.
  • History. The history shows every change, with "Show changes" and "Back to this version".
  • Translations. If someone changes a text in the main locale, the translation counts as outdated. The site keeps showing it until someone confirms or rewrites it; the Studio lists it.
  • Refresh now. The site reloads content at most every 60 seconds. An owner can have a site reload at once, for example to take an entry off the site. The site needs a token for this (see Deploy).

You, the developer, define the bricks. The owners fill them.

The CLI

ginko is a command in the package @lupinum/ginko. It has three commands: check, release and export. The commands load your ginko.config.ts (default: ./ginko.config.ts). Use --config <file> to name another file. The messages of the CLI are in English. In a project, you start it with pnpm exec ginko <command> or from a script in package.json.

pnpm exec ginko check

ginko --help lists the commands, ginko --version prints the version.

The CLI loads ginko.config.ts with the type stripping of Node, not with the loader of Nuxt. So relative imports in the config need the .ts suffix (import { menu } from './bricks/menu.ts'), and aliases such as ~/ do not work. nuxt build accepts both, so a config that is split into files can build and still fail in ginko check.

Environment variables

These are all GINKO_* variables that the CLI reads. The CLI does not read .env files: set the variables in the shell or as CI secrets. (The Nuxt site reads .env, so a value only in .env reaches the site but not the CLI.)

VariableCommandsValue
GINKO_PLATFORMcheck (platform bricks), release, exportThe address of the platform. If it is not set, the CLI reads NUXT_PUBLIC_GINKO_PLATFORM, so one CI secret serves the site and the CLI.
GINKO_KEYcheck (platform bricks), release, exportA push key of the site, in the form ginko_<site>_<32 characters>. It has scopes: release, check, export, and pull. The platform stores only its hash. Owners and admins create it in the Studio under Team: “Publish and back up” gives all four scopes, “Back up only” gives only export.
GINKO_COMMITreleaseThe commit that the release builds. If it is not set, the CLI reads GITHUB_SHA (set by GitHub Actions).
GINKO_BRANCHreleaseThe branch whose newest commit may release. Default: main.
GINKO_REMOTEreleaseThe git remote of that branch. Default: origin.

A site with only file bricks needs none of them for ginko check. release and export always need GINKO_PLATFORM and GINKO_KEY. Two more GINKO_* names exist, but not for sites: GINKO_LOCAL belongs to the local platform of the Ginko monorepo, and GINKO_REGISTRY_URL to the build of the Ginko component registry (see Deploy).

Output and exit codes

Every run prints exactly one JSON line on stdout. Messages for people go to stderr. Only --help and --version print plain text. --json is accepted for symmetry with ginko-i18n.

On success, the line starts with "ok":true, followed by the fields of the command:

CommandFields after "ok":true
checkcompared: true if the schema was compared with production
releasedeploy; with true also schemaRev and contentRev, with false also reason ("superseded")
exportdir, entries, files

On an error, the line is {"ok":false,"error":{"code":"…","message":"…"}}, and the exit code says what to do:

Exit codeMeaningWhat to do
0Success. A superseded release also ends with 0.Read deploy before you deploy.
1Invalid or rejected: configuration, schema, content, or links.Fix the code. Running again does not help.
2Wrong call: unknown command or option, a missing or invalid environment variable, for example a GINKO_KEY in the wrong format or a GINKO_PLATFORM where no Ginko platform answers.Fix the call or the CI settings.
3The platform is not reachable, does not answer in time, sends an invalid answer, or the outcome is unknown.Run the same commit again. This is safe.
4The platform refuses the key: unknown, revoked, another site's, or missing the scope. Or something in front of the platform (deployment protection, a proxy) refuses the request: error.code is then HTTP_401 or HTTP_403.Fix GINKO_KEY, or check GINKO_PLATFORM and the protection in front of it.

error.code is USAGE, INVALID, UNAVAILABLE, UNEXPECTED (an error of the CLI itself, for example a folder it may not write), or the code of the platform's answer, for example SCHEMA_REJECTED, CHANGED_SINCE, UNAUTHORIZED or FORBIDDEN. Decide by the exit code and use error.code for logs: new codes can appear.

ginko check

Terminal
ginko check
ginko check --offline

ginko check writes nothing. It checks three things:

  1. Your ginko.config.ts, with the same check as nuxt build. If the configuration is invalid, it lists the problems and stops.
  2. The content files in content/, their links, and that seo.image (a path) exists in public/.
  3. For a site with platform bricks: your schema against production. It shows the allowed changes, new migrations, and the size of the content. It reports every rejection with a hint on how to solve it. This step needs GINKO_PLATFORM and GINKO_KEY.

With --offline, it runs steps 1 and 2 only and prints Skipped the comparison with production (--offline). Use this where the key is missing, for example for a pull request from a fork: GitHub does not pass secrets to it.

A site with only file bricks prints OK: schema and links are fine. when all checks pass. On a problem, ginko check exits with code 1. Use it in CI on every pull request.

ginko check asks external links (http://… and https://…) only if you set linkCheck: { external: 'error' } (or 'warn'). It requests each address once and reports only sure cases: 404, 410, an unknown host, or two timeouts. Answers such as 403 or 429 do not count, because many sites block bots. Ginko remembers good results for seven days in node_modules/.cache/ginko. The same check runs on nuxt build and nuxt generate, never in dev mode. Without the option, Ginko makes no network request.

ginko release

Terminal
ginko release

ginko release brings schema and content to production. You run it in CI on main. It needs git and access to the remote. It does the following:

  1. It checks whether the release commit (GINKO_COMMIT or GITHUB_SHA) is still the newest commit of main on origin (GINKO_BRANCH and GINKO_REMOTE change both). If not, it prints {"ok":true,"deploy":false,"reason":"superseded"} and exits with code 0.
  2. It loads the state of production and checks your schema (see below).
  3. It runs migrations and checks all entries.
  4. It writes everything in one step. If production changed in the meantime, it retries up to five times.

On success, it prints {"ok":true,"deploy":true,"schemaRev":…,"contentRev":…}. Repeating the same release with the same commit is safe.

A superseded release is not an error, so it exits with code 0. Deploy the site only when deploy is true:

Terminal
# GitHub Actions runs this with bash -e: the exit codes 1 to 4 stop the job.
result=$(pnpm exec ginko release)
if [ "$(echo "$result" | jq -r .deploy)" = "true" ]; then
  pnpm exec vercel deploy --prod   # or the deploy command of your host
fi

ginko export

Terminal
ginko export ginko-export-my-site

ginko export [folder] saves the schema, all entries (deleted ones too), and all files of production into a folder. The folder must be new or empty. Without a folder, it is called ginko-export-<site>-<yyyy-mm-dd>. It contains export.json and the folder files/. export.json is written last: a folder with this file is complete. The command prints {"ok":true,"dir":…,"entries":…,"files":…}. It needs a key with the scope export or pull. Run it weekly in CI and upload the folder as an artifact. Without the CLI, owners and admins download the same folder as a ZIP file in the Studio under Team, “Download backup”; unzipped, local:import reads it like an export.

ginko prepare-import

Are you moving a site from an old website to Ginko? Then fill a folder in the export format by hand or with a script: export.json with schema and entries, plus the folder files/. This is the same layout as ginko export. Images and components still look the way the old site knows them: as an address (https://…), with old tag names. ginko prepare-import turns this into a folder that Lupinum can load unchanged with ops:import.

Terminal
ginko prepare-import migration --map map.json --config ginko.config.ts

The map file is JSON. All keys are optional:

json
{ "images": { "folder": "imported" },
  "components": { "ginko-layout": "layout", "ginko-column": { "tag": "column", "attributes": { "size": "width" } } },
  "redirects": { "/old/about-us": "pages/about-us", "/contact.html": "/contact" } }
  • Images: Every address with http or https in an image field ({ "url": … } or a text) and in Markdown (![Alt](https://…)) is downloaded (20 seconds, at most 20 MB, only JPEG, PNG and WebP, as for uploads in the Studio). Each image is stored once in files/, even when several addresses return the same bytes. The text then holds $file.<sha256>, the image field { "file": "<sha256>", "alt": … }. The images go in the file library folder images.folder (default imported). An address that fails stays as it is. The command still finishes everything else, lists it, and ends with exit code 1.
  • Components: components renames tags (::old, :::old, :old) and, if you want, attributes inside {…}. Tags that the site's defineComponents does not know are listed as a warning in components.unknown. This is not an error.
  • Redirects: redirects gives each old path a target: <brick>/<id> is the entry's address according to the brick's route. When the old path starts with a language (/en/…), the address in that language is used. A target that starts with / is used as it is. The result is redirects.json in the folder. Copy the list into redirects in ginko.config.ts. An unknown entry is listed in redirects.problems.

export.json is changed in place. The command first writes export.json.bak. A second run changes nothing and downloads nothing again. With --config (or a ginko.config.ts in the current folder) the command knows the site's routes and components. It prints {"ok":…,"images":{"downloaded":…,"reused":…,"failed":[…]},"components":{"renamed":…,"unknown":[…]},"redirects":{"count":…,"file":…,"problems":[…]}}.

Then send the folder to Lupinum. We load it into the site with ops:import.

Developing a platform site

Outside the Ginko monorepo, there is no local platform. Run your site against the hosted platform instead. Write its address into ginko.config.ts, so npm run dev works without an .env file, in every clone and worktree:

ts
export default defineSite({
  site: 'linde',
  url: 'https://linde.example',
  platform: 'https://platform.example.com',
  // …
})

NUXT_PUBLIC_GINKO_PLATFORM overrides platform, for example for one run against another platform. pnpm local:dev in the monorepo sets it to the local platform, so a local test never reads the hosted content. Without platform and the variable, dev reads the local platform on http://localhost:3100.

The site shows the published content. Your local ginko.config.ts decides what the pages get:

  • A new brick is empty.
  • A new field with a default has its default value.
  • A new optional field has no value.

Owners can fill new fields only after ginko release. Run ginko check to see whether production accepts your schema changes.

Tools of the Ginko monorepo

The Ginko repository has four more commands. They need the local platform (apps/platform and its backend in packages/ginko-server), so they are not part of the published ginko command. Run them from the root of the repository. local:dev needs the site's config with --config:

Terminal
pnpm local:dev --config sites/restaurant/ginko.config.ts
pnpm local:pull
pnpm local:import ginko-export-my-site

They print one JSON line and use the exit codes of ginko.

local:dev starts everything locally: a local Convex backend, the platform, and your site with nuxt dev. It sends your schema to the local platform, right away and after every change of ginko.config.ts. Rejected changes appear as a warning but go through. So you can experiment freely locally. If a file that the configuration imports changes, you must restart local:dev. Ctrl-C stops all processes. For a site with only file bricks, nuxt dev is enough.

Once you have signed in to the local platform, local:dev writes .mcp.json into the site folder (server ginko-local). An agent such as Claude Code running in that folder then works in the local CMS as an admin: it reads the schema, writes content, imports and uploads files. Other servers in the file stay, the key is the same on every start and works only locally. Add .mcp.json to .gitignore.

local:pull copies the content of production into the local platform. It runs export, then import. So you test with real content. The command needs GINKO_PLATFORM and GINKO_KEY with the scope pull or export. It replaces the content of the site locally.

local:import <folder> loads an export into the local platform. It replaces the entries of the site there. People are kept. Use it to check that a backup can be restored.

local:files-export --config <file> [folder] turns the Markdown and YAML files of a site into an export folder (default ginko-export-<site>-files). It reads content/<brick>/ next to the config for every brick, whatever its source is, with the same reader as the site build, and keeps translations (*.en.md). Then local:import <folder> loads the folder, and the Studio shows the file content. Use it to see a site's files in the platform before you move its bricks there. Images stay plain paths; no files are uploaded.

pnpm ops:import <folder> --site <slug> --prod is for Lupinum operators. It loads an export folder into an empty site of the hosted platform and needs --prod and Convex access to production. Run ginko release for the site first: the command does not push a schema, and it stops if the released schema lacks a brick of the export. It refuses a site that already has entries, changes or edits, and it never deletes content.

local:pull and local:import run only against the local platform and stop before they write anything if the target is not local.

Schemas only grow

A schema in production may only grow. Content belongs to people, and older versions of your site still read the old schema. So ginko check and ginko release reject changes that could break data or old code. The rule: everything that is not explicitly allowed is rejected.

Allowed:

  • new bricks; in a new single, every required field needs a default. f.markdown, f.image and f.file cannot have one, so set optional: true on them
  • new optional fields
  • new required fields with a default, or with a migration that fills them
  • new options in choice and choices
  • a higher max and a lower min
  • new texts: label, hint, describe, itemLabel, option labels
  • new locales at the end of locales
  • changes to pages, tasks, order, visible, and untranslated
  • changes to components: the release checks every stored text again, so a component, prop or option that a text still uses cannot go

Rejected:

  • a field or brick that is missing (do not rename, do not delete)
  • a different type
  • an optional field that becomes required
  • a removed option
  • a lower max, a higher min
  • a changed translatability, step, currency, route, timeZone, or main locale
  • a removed locale
  • a changed default

So there is no rename and no type change. To replace a field, you add a new one, fill it with a migration, and retire the old one. For each rejected change, ginko check prints these config lines with your brick and field names filled in: copy them, then set the migration ID (YYYY-MM) and convert the value where the type changes.

ts
export default defineSite({
  // …
  bricks: {
    menu: collection({
      label: 'Menu',
      fields: {
        headline: f.text('Headline'),   // stays unchanged in fields
        title: f.text('Title'),         // new required field
      },
      max: 150,
      order: 'manual',
    }),
  },
  // The old field keeps showing the current value of title in the bundle.
  retired: { 'menu.headline': { from: 'title' } },
  migrations: {
    '2026-11-title-from-headline': {
      brick: 'menu',
      fills: 'title',
      compute: (entry) => entry.headline,
    },
  },
})
  • retired retires a field. It disappears from the editor. It stays in the bundle, so older versions of the site keep working. from mirrors the value of another field; it works only when every value of that field fits the old definition (same type, translatability, format and currency, limits and options within the old ones). With value, you set a fixed value. An optional field can also go without a value: { drop: true }. A whole brick is retired with retired: { gallery: { drop: true } }.
  • A migration is a pure function. It has no access to the network, the clock, or randomness. It runs in ginko release on your machine or in CI, never on the platform. Its ID looks like 2026-11-name. You must not change a known migration.
A schema release cannot be taken back. Run ginko check first. If you really want to replace a field, plan the three steps (new field, migration, retire the old field) into one release.

Bricks with source: 'files' are not part of the schema of the platform. This rule does not apply to them.

Internal, do not use

Ginko uses these names inside the module. They are not part of the API and can change in any release, also in a patch. Read content and site data through the composables and components in Reading content instead.

  • useState keys that start with ginko:, such as ginko:bundle, ginko:today and ginko:preview.
  • The provide keys ginko:components and ginko:toc.
  • The virtual modules #ginko/*, such as #ginko/schema and #ginko/files.
  • The { ref } form of a Markdown value. Ginko keeps long texts out of the page payload, for file and platform bricks, and <GinkoMarkdown> loads them. Pass the value on as it is; never read or build a { ref }. wordsOf(value) gives you its number of words.
  • The GinkoCard component, the template of the generated share card. Set the card through useGinkoSeo.
  • The values in runtimeConfig.public.ginko and runtimeConfig.ginkoRevalidateToken. Read site data with useGinkoSite(), and set only the environment variables in Publishing.
  • The headers x-ginko-rev, x-ginko-min-rev and x-ginko-site-token, and the messages between the preview and the platform (ginko:preview, ginko:ready, ginko:navigate).
  • Addresses under /__ginko/ other than the stable ones in Addresses and headers.
  • The Nuxt hook ginko:files. @lupinum/ginko-docs uses it to keep each entry's source file for its edit links.