Organizational registries
The registry gives one project components it owns but can still upgrade. An organizational registry does the same one level up: a design-system team publishes its composites (a DataTable, an AppShell, a themed Button) plus its tokens as a registry of its own, and every product team pulls from that. The company's registry extends ZUI's, so a team that asks for acme/data-table gets the Acme table, the Acme button it uses, and the ZUI helpers under it, in one zui add.
What you get, at organization scale:
- Owned but upgradable. Components land in each team's repo as source.
zui updatethree-way merges the design-system team's next version into a file a team edited (pristine copies live under.zui/snapshots); overlapping edits become conflict markers to resolve, nothing is overwritten without--force. - Override by name. The org registry ships its own
button; every base component that imports./buttongets the Acme one, because dependencies resolve in the org registry first. - No server. The registry is a folder of json. Host it on the same static host as your docs, a bucket, an internal GitHub Pages site, a private CDN behind a token.
- One drift report.
zui status --jsonin CI tells you which teams run which version and who has patched what.
Walkthrough
1. Describe the registry
In the repo that holds the shared components:
npx @zuilib/cli registry init --registry-name acme --extend https://zuilib.com/r
That scans components/ (or src/components/) for .tsx files and writes registry.config.json, one item per file (lib/ files become lib items). Fill in descriptions, drop what should stay private, set url to where the registry will be hosted:
{
"name": "acme",
"version": "1.0.0",
"homepage": "https://design.acme.com",
"url": "https://design.acme.com/r",
"extends": ["https://zuilib.com/r"],
"items": [
{
"name": "data-table",
"type": "component",
"title": "DataTable",
"description": "Sortable, paginated table on the Acme grid tokens",
"files": [{ "path": "components/data-table.tsx", "target": "components/zui/data-table.tsx" }],
"registryDependencies": ["button", "lib-cn"]
},
{
"name": "button",
"type": "component",
"title": "Button",
"description": "The Acme button (overrides zui/button)",
"files": [{ "path": "components/button.tsx" }]
},
{
"name": "acme-theme",
"type": "theme",
"title": "Acme theme",
"description": "Token overrides for the Acme brand",
"files": [{ "path": "styles/acme.css" }]
}
]
}
Files keep the registry's tree convention: target is components/zui/… or styles/zui/… and defaults from the path (components/x.tsx → components/zui/x.tsx, lib/y.ts → components/zui/lib/y.ts, *.css → styles/zui/). Components are written next to ZUI's, so import Button from './button' in your DataTable resolves to whichever button the registry chain provides.
dependencies (npm) and registryDependencies are inferred from a file's imports when you leave them out: a package import becomes name@range from your package.json, a relative import of a file another item owns becomes that item. A relative import of something no item owns (./lib/cn, provided by ZUI) is reported as a warning and the build still succeeds; declare it in registryDependencies and the warning goes away. (ZUI's own registry build is stricter and fails on an unmatched import, since it has nothing to fall through to.)
2. Build
npx @zuilib/cli registry build # registry.config.json -> out/
npx @zuilib/cli registry build --out-dir dist/r
The output is exactly the tree the CLI consumes:
| Path | Contents |
|---|---|
index.json, latest/index.json | Every item: name, type, version, description, dependencies, file targets and hashes. Carries extends and integrity |
latest/<item>.json | One item with its file contents and sha256 |
v/1.0.0/… | Immutable copy of latest/ for this version; teams can pin to it |
schema.json | JSON schema of the index and item documents |
llms.txt | One line per item for coding agents |
Bump version in registry.config.json for each release; earlier v/<version>/ folders are left as published, latest/ moves.
3. Host
Copy out/ to any static host under a stable url, say https://design.acme.com/r. Nothing dynamic is involved: the CLI reads <url>/latest/index.json and <url>/latest/<item>.json, or <url>/v/<version>/… when pinned.
Private registry? Put it behind a bearer token. Teams set ZUI_REGISTRY_TOKEN_ACME (the registry name upper-cased; - becomes _, _ becomes __) and the CLI sends Authorization: Bearer <token> on every request under that registry's url and nowhere else. Other headers (a team id, a Cloudflare Access pair) go in zui.json under the registry's headers and follow the same rule. A url that is not under a configured registry's url gets no headers at all, even on the same host: shared static hosts (storage.googleapis.com/<bucket>, <user>.github.io/<repo>) serve many tenants, and an extends entry must not carry your token to one of them.
The build writes integrity into the index (sha256 of the index without that field). The CLI verifies it on every fetch; a team can pin it with zui registry add acme <url> --pin (or zui registry pin acme later), after which a changed index is refused until the pin is moved with zui registry pin acme. The pin covers Acme's own index, not the registries it extends. Trust on first use; there is no signing key.
4. Teams add from it
In a product repo that already ran zui init:
npx @zuilib/cli registry add acme https://design.acme.com/r
npx @zuilib/cli add acme/data-table
registry add verifies the index and records the registry in zui.json:
{
"registry": { "url": "https://zuilib.com/r", "version": "latest" },
"registries": {
"acme": { "url": "https://design.acme.com/r", "version": "latest" }
},
"paths": {
"components": "src/components/zui",
"styles": "src/styles/zui",
"css": "src/index.css"
}
}
add acme/data-table resolves the item in acme, then its registryDependencies in acme first, then in what acme extends: button is found in Acme (the override), lib-cn falls through to ZUI. The lock file records where each file came from:
{
"acme/data-table": { "registryName": "acme", "url": "https://design.acme.com/r/latest", "version": "1.0.0", "files": { … } },
"acme/button": { "registryName": "acme", "url": "https://design.acme.com/r/latest", "version": "1.0.0", "files": { … } },
"lib-cn": { "registryName": "zui", "url": "https://zuilib.com/r/latest", "version": "0.1.0", "files": { … } }
}
Item refs, everywhere the CLI takes an item:
| Ref | Meaning |
|---|---|
button | The default registry (registry.url in zui.json) |
acme/data-table | A named registry from registries |
https://design.acme.com/r/v/1.0.0/data-table.json | One item document directly, under latest/ or v/<version>/ (any other layout is refused, since the lock could not resolve it again); a url inside a configured registry is keyed as that registry, one from an unconfigured registry is locked as <base url>/<item> and resolves again from there |
A company that wants its teams to see only its registry sets it as the default instead: zui init --registry https://design.acme.com/r. Then plain zui add button reads Acme first and ZUI through extends, with no named registry at all.
5. Keep up
npx @zuilib/cli list # every registry; ✓ installed, ↑ newer, (overrides zui/button), (via zui)
npx @zuilib/cli diff # installed files vs their registries
npx @zuilib/cli update # merge the new files; conflicts get markers and exit 1
npx @zuilib/cli status # the drift table, with what update would do per item
npx @zuilib/cli remove acme/data-table --prune # drop it and the helpers nothing else needs
list prints each registry in turn: its own items, an org item that shadows a base item marked (overrides zui/button), and the items it inherits through extends marked (via zui). diff and update follow the lock: an item installed from acme is compared against acme, one that fell through to ZUI against ZUI, in each case against the registry's current content (latest or the pinned version). add refuses to touch a file you edited unless --force; update three-way merges it against the pristine copy in .zui/snapshots (see the registry page for the merge rules), and --force overwrites instead, keeping <file>.orig. --dry-run on add and update prints the plan. Commit zui.lock.json and .zui/snapshots with the repo: the drift report, diff and update are all read from them. --offline serves every command from .zui/cache.
Pinning is per registry (registries.<name>.version, or registry.version for the default); an individual item cannot be pinned.
zui status in CI
item registry installed latest state update
acme/button acme 1.0.0 1.1.0 ok -
acme/data-table acme 1.0.0 1.1.0 outdated rewrite
acme/lib-row-key acme 1.0.0 1.1.0 modified, outdated merge-needed
lib-cn zui 0.1.0 0.1.0 modified -
4 item(s), 3 drifted modified: edited locally outdated: newer files in the registry
update: merge-needed (edits merge cleanly) conflict-likely (overlapping edits) no-snapshot (run zui snapshot restore)
outdated means the registry's file content moved since the lock was written (a version bump alone is not drift); modified means the local file no longer matches the registry content it was installed from; missing means it was deleted. The update column previews zui update for a file that is both: merge-needed merges cleanly, conflict-likely has overlapping edits, no-snapshot has no pristine copy yet (zui snapshot restore). --json gives the same rows as data, the preview as merge:
{
"registries": [{ "name": "zui", "url": "https://zuilib.com/r/latest" }, { "name": "acme", "url": "https://design.acme.com/r/latest" }],
"items": [{ "item": "acme/lib-row-key", "registryName": "acme", "installed": "1.0.0", "latest": "1.1.0", "outdated": true, "modified": true, "missing": false, "merge": "merge-needed", "error": null }],
"drift": 1
}
--strict exits 1 on any drift, for a check that keeps a repo current; without it the command only reports, for a console that aggregates every repo's json.
- run: npx @zuilib/cli status --json > zui-status.json
env:
ZUI_REGISTRY_TOKEN_ACME: ${{ secrets.ZUI_REGISTRY_TOKEN }}
Config reference
registry.config.json
| Key | Meaning |
|---|---|
name | Registry name, ^[a-z0-9][a-z0-9_-]*$. Teams refer to items as <name>/<item> |
version | Registry version; latest/ and v/<version>/ are written from it |
url | Public base url of the hosted registry; used for $schema and the links in llms.txt |
homepage, description | Shown by zui list / in llms.txt |
extends | Base urls of registries to fall back to, in order. Usually ["https://zuilib.com/r"]; a pinned https://zuilib.com/r/v/0.1.0 works too |
items[].name | ^[a-z0-9-]+$, unique |
items[].type | component, lib or theme |
items[].title, description, docs, meta | Passed through to the item document |
items[].files[] | { path, target? }; path relative to the config file, target under components/zui/ or styles/zui/ |
items[].dependencies | npm specs (clsx@^2.1.1); inferred from imports when absent |
items[].registryDependencies | Item names, resolved in this registry first, then in extends; inferred for files other items own when absent |
zui.json additions
| Key | Meaning |
|---|---|
registries.<name>.url | Base url (or local directory / file:// url) of a named registry |
registries.<name>.version | latest or a pinned version of that registry |
registries.<name>.headers | Extra request headers for that registry |
registries.<name>.integrity | sha256 pin of that registry's index; written by registry add --pin |
registry.headers | Extra request headers for the default registry |
registry.integrity | sha256 pin of the default registry’s index |
Tokens come from the environment only: ZUI_REGISTRY_TOKEN_<NAME> (ZUI_REGISTRY_TOKEN_ZUI for the default), never from zui.json.
Commands
zui registry add <name> <url> [--registry-version <x.y.z>] [--pin]
zui registry pin [<name>]
zui registry remove <name>
zui registry list
zui registry init [--registry-name <n>] [--extend <url>]… [--force]
zui registry build [--config registry.config.json] [--out-dir out]
zui add <item…> [--force] [--dry-run] [--strict]
zui update [<item…>] [--force] [--dry-run] [--strict]
zui remove <item…> [--force] [--prune]
zui status [--json] [--strict]
zui snapshot restore [<item…>]
Every registry-reading command also takes --offline.
Developing a registry locally
Point a registry at a folder while iterating; no server needed:
npx @zuilib/cli registry build --out-dir ../acme-registry
npx @zuilib/cli registry add acme ../acme-registry # in the consuming app; file:///abs/path works too
extends accepts a local path as well, so a checkout of ZUI (../zui/public-docs/static/r) can stand in for https://zuilib.com/r offline.