@zuilib/cli
v0.5.0
Published
ZUI — install @zuilib/primitives as source from the ZUI registry (zui init, zui add, zui diff, zui update), manage organisational registries (zui registry add/build, integrity pins), and validate and compare theme files (zui theme check, zui theme diff),
Maintainers
Readme
@zuilib/cli
zui: installs @zuilib/primitives as source from the ZUI registry at https://zuilib.com/r. The files land in your repo and are yours to edit; the CLI keeps a pristine copy of what it wrote, so zui update can three-way merge a newer registry version into a file you changed instead of overwriting it.
Only @zuilib/primitives (and the token css it depends on) is distributed this way. @zuilib/data-grid, charts, ai, apps, workflows, builder and text-editor are npm packages.
Usage
npx @zuilib/cli init # zui.json + the stylesheet and tokens, wired into your css
npx @zuilib/cli add button # button.tsx, the helpers it imports, its npm deps
npx @zuilib/cli list # (alias: ls) every item; ✓ installed, ↑ newer in the registry, (via x) inherited; --json for machines
npx @zuilib/cli diff # how the installed files differ from the registry; --stat, --json
npx @zuilib/cli status # drift table; --json for CI, --strict exits 1 on drift
npx @zuilib/cli update # merge the registry's changes into your files (conflict markers on overlap)
npx @zuilib/cli remove button # (alias: rm) delete an item; --prune drops dependencies nothing else needspnpm dlx @zuilib/cli … and bunx @zuilib/cli … work the same. zui --version prints the tool's own version. Node 18+; the only dependency is @zuilib/tokens, for the token contract zui theme check validates against.
Owned but upgradable
add writes each file twice: to your tree, and as a pristine copy (a snapshot) under .zui/snapshots/<item>/<target>. Commit .zui/snapshots with zui.lock.json; together they are the upgrade path. .zui/cache (fetched json with ETags, for --offline) ignores itself.
update looks at every file of every installed item (or the ones you name):
| Your copy | Registry | What happens |
|---|---|---|
| unchanged | unchanged | nothing |
| unchanged | moved | rewritten |
| edited | unchanged | left alone (unchanged) |
| edited | moved | three-way merged: the snapshot (.zui/snapshots) vs yours vs the registry. Hunks changed on one side only are taken; a hunk both sides changed differently is written as <<<<<<< ours … ======= … >>>>>>> zui@<version> |
A CRLF local copy is merged line by line against the LF registry and stays CRLF. A merge writes the result to the file, your pre-merge copy to <file>.orig, the new registry content to .zui/snapshots, and the item's new version and hashes to the lock. Files with conflicts are listed as conflict and the command exits 1; resolve the markers and commit. update refuses to merge a file that still carries markers. --dry-run prints would merge / conflict per file without touching anything. --force skips the merge and overwrites edited files (keeping <file>.orig).
zui status tells you beforehand: its update column (and merge in --json) is merge-needed when the edits merge cleanly, conflict-likely when they overlap, rewrite when your copy is pristine, and no-snapshot for an item installed by a CLI older than .zui/snapshots. For those, zui snapshot restore seeds the snapshot directory: an unedited file is copied from disk, an edited one is fetched from <registry>/v/<locked version>/<item>.json (for a v1 lock, the registry url the lock recorded) and kept only when its hash matches the lock. Without a snapshot, update refuses to touch an edited file (use --force to overwrite).
add itself never merges: an item already installed and edited is refused unless --force.
Theme validation
npx @zuilib/cli theme init acme # acme.css: every token with its default, commented out
npx @zuilib/cli theme check acme.css # exit 1 on any error
npx @zuilib/cli theme check acme.css --json --strict --dark-selector '[data-theme="dark"]'
npx @zuilib/cli theme diff old.css new.css # what a revision changed; exit 1 on a new contrast failurecheck parses the custom-property overrides of a theme file (:root, .theme-x, html[data-zui-theme="x"], and .dark … / @media (prefers-color-scheme: dark) for dark), merges them over the shipped @zuilib/tokens defaults for both modes and runs these rules (the rule field of --json problems):
| Rule | Severity | Catches |
|---|---|---|
| unknown-token | error | a name not in the contract, with the nearest real one (--primry → --primary); a property the file reads with var() itself is a private helper |
| invalid-value | error | a colour token that is not a colour (hex, rgb(), hsl(), oklch(), oklab(), color-mix(), light-dark()), a length token that is not a length, a bad number / font weight / duration |
| var-cycle | error | --a: var(--a) or a loop through several overrides |
| missing-dark | warn | a colour with distinct shipped light/dark values overridden in light only (the :root value wins in dark too), or in dark only |
| contrast | error | text on its surface below WCAG AA 4.5:1 in either mode (every --x-foreground on --x; --foreground, --muted-foreground and every --<hue>-text on --background; sidebar) |
| tint-contrast | error | a --<hue>-text below 4.5:1 on its hue at 10% over --background (badges, callouts, bg-<hue>/10) |
| cross-surface-contrast | error | --foreground / --muted-foreground below 4.5:1 on the surfaces they travel onto (--muted, --accent, --secondary, --card, --popover) |
| ui-contrast | warn | --border, --input or --ring below 3:1 on --background when the theme changes them (WCAG 1.4.11); an error with --strict |
| uncheckable-contrast | warn | a pair that resolves to a colour the checker cannot evaluate (relative colours, exotic colour functions, currentColor, an undeclared var()) |
| inert-override | warn | an override of a deprecated token (no component reads it, so it changes nothing) |
| radius-extreme | warn | --radius over 32px (Card and Popover read it too) |
--strict makes warnings errors; --min-contrast 3 lowers the text bar; --json prints { ok, file, problems: [{ rule, severity, token, message, mode?, line? }] }. The token contract comes from @zuilib/tokens/tokens.json (the CLI's one dependency). Docs: tokens/validation.
Your own rules (themeRules)
An org can gate theme check and theme diff on its own conventions. List rule modules in zui.json:
{ "themeRules": ["./theme-rules/brand.mjs"] }Each module's default export is one rule — the same shape as the built-in ones — or an array of them:
export default {
id: 'no-rem-radius',
severity: 'error', // or 'warn' (promoted to error by --strict)
describe: 'radii are set in px here',
run: ({ declarations }) =>
declarations
.filter((d) => d.name.includes('radius') && d.value.endsWith('rem'))
.map((d) => ({ token: d.name, message: 'use px, not rem', mode: d.mode, line: d.line })),
}run(ctx) gets the file's parsed declarations ({ name, value, mode, scope, line }), the token contract, the per-mode overrides maps, the evaluated contrast ratios, and resolve(name, mode) for the effective value of any token; it returns problems { token, message, mode?, line?, severity? }, reported under the rule's id exactly like the built-ins (including in --json). Paths resolve relative to zui.json and must stay inside the project; a module that is missing a field (or reuses a built-in rule id) fails with an error naming the file. Without a zui.json, theme check runs the built-in rules only.
zui theme diff
zui theme diff old.css new.css is the semantic changelog between two theme files: the --zui-theme-version change, the tokens added / removed / changed per scope and mode grouped by contract section, and the contrast ratio of every pair either file moved — a pair going pass → fail is flagged as a regression. Exits 1 only on a regression (never on the token changes themselves), so a theme revision can gate CI on "did this break contrast" rather than "did this change anything". Both files are parsed with the same contract and cascade as check; --dark-selector, --min-contrast and --json ({ ok, version, sections, contrast, regressions, counts }) work the same.
Several registries, and your own:
npx @zuilib/cli registry add acme https://design.acme.com/r # an organisational registry that extends ZUI's
npx @zuilib/cli add acme/data-table # resolves in acme first, then in what it extends
npx @zuilib/cli list --json # the listing as data: { registries, items: [{ name, key, registry, type, version, description, installed, installedVersion, outdated, via, overrides }] }
npx @zuilib/cli status --json # drift table: installed/latest, edited locally, outdated, mergeable
npx @zuilib/cli registry init && npx @zuilib/cli registry build # build your own registry from registry.config.jsonItem refs are button (default registry), acme/data-table (named registry) or a direct item url, which must sit under latest/ or v/<version>/ (https://…/r/v/1.2.0/item.json). A url inside a configured registry is keyed as that registry; one from an unconfigured registry is locked under <registry base>/<item>, which works as a ref again. A private registry reads its bearer token from ZUI_REGISTRY_TOKEN_<NAME> (- becomes _, _ becomes __); the token and the registry's headers go only to urls under that registry's url, never to another path on the same host. See Organisational registries.
Safety
- Dependency specs. Every npm spec an item declares must match
name,@scope/name, optionally@<range>, with no spaces and no leading dash; anything else is rejected before a file is written. The package manager is spawned without a shell (on Windows throughcmd.exe /d /s /cwith every argument quoted, sincepnpm.cmdcannot be spawned directly). A package already inpackage.jsonis never reinstalled; when its declared range sits on another major than the item needs (^1.2.0vs^2.1.1) the CLI warns, and--strictfails before any file is written. Compound ranges (>=1 <3,1 || 2) are not judged. The files and the lock are written before the install runs, so a failed install leaves nothing orphaned; rerun the printed command. - Index integrity (TOFU-lite). A registry index may carry
integrity, the sha256 of its compact json without that field (zui registry buildwrites it). The CLI recomputes it on every fetch and fails on a mismatch.zui registry add <name> <url> --pincopies the current value intozui.json(registries.<name>.integrity);zui registry pin [<name>]does the same for an already configured registry, the default one when no name is given (registry.integrity), and moves the pin after a change you trust. A pin covers that registry's own index only, not the registries itextendsor other versions of it. Trust is placed once, at pin time; there is no key or signature chain. - Offline. Fetched documents are cached under
.zui/cachewith their ETag and revalidated withIf-None-Match.--offlineon any command reads only the cache and fails on a url never fetched.
zui.json
{
"registry": {
"url": "https://zuilib.com/r",
"version": "latest"
},
"paths": {
"components": "src/components/zui",
"styles": "src/styles/zui",
"css": "src/index.css"
}
}| Key | Meaning |
|---|---|
| registry.url | Base url of the registry. A local directory works too (../zui/public-docs/static/r), for developing against an unpublished build |
| registry.version | latest, or a pinned @zuilib/primitives version (0.1.0), read from <registry>/v/<version>/ |
| registry.integrity | Optional sha256 pin of the default registry's index (see Safety) |
| registry.headers | Extra request headers for the default registry |
| registries | Named registries besides the default: { "acme": { "url", "version", "headers", "integrity" } }; items are acme/<item>. Managed by zui registry add/remove/list (rm/ls work too) |
| paths.components | Where components/zui/… items land. Helpers go to <components>/lib/, so the components' relative imports work unchanged |
| paths.styles | Where styles/zui/… items land (zui.css, tokens.css, …) |
| paths.css | Your global stylesheet; init adds @import "<styles>/zui.css" after @import "tailwindcss" |
| themeRules | Optional array of rule modules (relative to zui.json) merged into the zui theme check / zui theme diff rule set; see Theme validation |
init guesses paths.components/paths.styles (under src/ when it exists) and paths.css (app/globals.css, src/index.css, …); override with --components, --styles, --css, --registry, --registry-version. A zui.json in the old flat layout (a top-level registry url, components, styles, css) is refused with a message spelling out the new shape.
zui.lock.json
Written by add/update/remove ("lockVersion": 2): for every installed item the registry it came from (registryName, url), its version and the sha256 of each file's registry content, the same bytes as .zui/snapshots/<item>/<target>. A file whose hash differs from the lock was edited locally; a registry file whose hash differs from the lock moved. The version of an item only moves when one of its files was written. A version 1 lock (no lockVersion) is read as is and rewritten as version 2 on the next write.
Registry format
The registry is static json, generated by packages/primitives/scripts/build-registry.mjs and served from the docs site:
| Url | Contents |
|---|---|
| /r/schema.json | JSON schema of the index and the items |
| /r/latest/index.json | Every item: name, type (component / lib / theme), version, description, npm dependencies, registryDependencies, file targets |
| /r/latest/<name>.json | One item with the file contents and their sha256 |
| /r/v/<version>/… | The same, frozen per @zuilib/primitives release |
An organisational registry has the same layout (built by zui registry build) plus extends in its index: item lookups fall through to the listed registries, and an item of the same name in the org registry takes precedence. zui registry build also writes integrity into the index.
