@productmakersro/kit
v0.1.0
Published
Community CLI for the Product Makers design system kit.
Readme
@productmakersro/kit
The community CLI for the Product Makers design system. Plain Node ESM,
requires Node 20+. Runtime dependencies: @productmakersro/fidelity-engine
and an exact-pinned shadcn (see "kit add delegates to shadcn" below).
npx @productmakersro/kit <command>Status
Every command in the table below is implemented for real — no stubs remain.
Commands
| Command | Does | Status |
| ----------------------- | ------------------------------------------------------------ | ------------------------ |
| kit init | Create kit.config.json + components.json, vendor pm-guidance/pm-tokens | implemented |
| kit add <item...> | Add one or more items from the registry to this project | implemented |
| kit list | List every item available in the registry | implemented |
| kit search <query> | Search the registry for items matching a query | implemented |
| kit view <item> | Show details for a single registry item | implemented |
| kit render <file> | Render a canvas HTML file to a PNG through the fidelity engine | implemented |
| kit verify <lock\|project> | Verify a design-lock.json (or a project that has one) against the fidelity engine | implemented |
| kit doctor | Check this machine and project for problems kit needs fixed | implemented |
| kit update [item...] | Report/apply upstream updates for installed items, without ever clobbering a local edit | implemented |
Global flags on every command:
--json— machine-readable output instead of human text--help,-h— show help (kit <command> --helpfor a specific command)--version,-v— print the installed version--registry-url <url>— override the registry to talk to (init,add,list,search,view,update); without it, these commands readregistryUrlfromkit.config.json(written bykit init) and fail with a "run kit init" error if neither is set--dry-run— (add,update) resolve and print the plan; write nothing
kit init
Writes two files in the current directory:
kit.config.json—{ "registryUrl": "..." }(plus afontsarray once something vendors a font — seekit addbelow).--registry-urloverrides the default (a placeholder constant until the registry host is actually deployed — seesrc/registry-url.mjs); with no flag and a real TTY, you're prompted once with that default; non-interactively (scripts, CI) the default is used silently, no hang.components.json— the config the pinnedshadcnCLI needs to resolve@productmakersro/<item>against this registry (a namespacedregistriesentry pointing at<registryUrl>/{name}.json). If acomponents.jsonalready exists (a project that uses shadcn for its own UI too), only the@productmakersroregistry entry is added — nothing else in the file is touched.
Then installs the pm-guidance and pm-tokens items through the exact
same path kit add uses. If that install fails, kit init still reports
the two config files it already wrote and tells you to re-run kit add
pm-guidance pm-tokens.
kit add delegates to shadcn
kit add <item...> does not place files itself — it wraps the pinned
shadcn CLI (4.19.0, exact — see DECISIONS.md):
- Pre-flight — fetches the registry index itself and confirms every
requested item exists (a closest-match suggestion when one doesn't),
then resolves the requested items' full local dependency tree
(
registryDependenciesrefs namespaced@productmakersro/...) so it knows every file target and font asset before touching disk. --dry-runstops here: prints the resolved target list, writes nothing, never invokes shadcn.- Delegate — spawns the pinned
shadcn's ownaddcommand (-y, deliberately never-o/--overwrite) to do the actual file placement. - Font binary channel —
shadcn's installer only ever writesfiles[]entries that carry inlinecontent; a font's real bytes never do (seeDECISIONS.md), sokit addseparately fetches everymeta.fonts[]asset the resolved tree declares, verifies its sha256 (and byte length, if given) before writing anything, and writes the verified bytes as binary — never through shadcn's text writer. Fonts vendored this way are recorded intokit.config.json'sfontsarray (the same shapekit doctor'sfontscheck reads). - Post-verify — every declared file target (code files with content,
plus every font just fetched) must exist on disk afterward. shadcn's
own conflict handling (an existing, differently-content file) makes it
auto-answer "No" and abort without writing the rest of that run's
files —
kit addnever passes-o, so it never silently overwrites, and post-verify turns that abort into an honest non-zero exit instead of a false "exit 0, files missing".
kit add never reports success on a partial install: item-not-found, a
dead/HTML-responding registry, a delegate failure, a font sha256 mismatch,
or a post-verify miss all exit non-zero with a message naming what
happened.
kit list / kit search <query> / kit view <item>
Read-only. list/search read <registryUrl>/registry.json; view
additionally fetches <registryUrl>/<item>.json for the full item
(files, dependencies, meta). All three accept --registry-url to bypass
kit.config.json. Same network-error contract as kit doctor's
registry-reachable check: 5s timeout, HTML-as-200 detected, a dead host
fails fast with a clear message.
kit render wraps the fidelity engine's pm-export
kit render <file> [--format linkedin|luma|stories|badge-a6] [--out <png>]
[--width <px> --height <px>] [--scale <n>] [--timeout <ms>]Resolves @productmakersro/fidelity-engine's installed pm-export bin
(the same programmatic require.resolve this CLI already uses for
shadcn — no PATH lookup, works fully offline once kit is installed) and
spawns it. This command never reinterprets the engine's exit code —
what pm-export returns (its own small scheme: 0 pass, 1 render
failure, 2 usage/setup error — see
@productmakersro/fidelity-engine's README) is exactly what kit render
returns, except for the CLI's own --timeout kill (below).
- Guardrails kit itself enforces, before ever touching the engine:
<file>must exist, must not be a directory, and must have a.html/.htmextension — any of those is a usage error (2). Dimensions and scale are not second-guessed here (a 20000×20000 canvas is a legitimate, if slow, request) — they pass straight through topm-export. --formatlooks up named dimensions:linkedin(1080×1350),luma(1080×1080),stories/story(1080×1920),badge-a6(1311×1818 — a print object, at 300dpi with a 3mm bleed). These mirror ProductMakersStudio's own social-format picker exactly (there is noa4preset anywhere in the source system — its one print format is an A6 badge, not A4). A project's ownpm/tokens/design-lock.json(the filekit add pm-tokensinstalls) can override any of these names with a top-levelformatPresets: { name: { width, height } }object if one ever declares it — checked first, falling back to the builtin table. Explicit--width/--height(always required together) override--formatwhen both are given.--timeout(default120000ms) bounds the wholepm-exportchild's wall clock — an absurd canvas (huge dimensions, a 40MB HTML with thousands of inline images, a page that never settles) is killed (SIGTERM, thenSIGKILLafter a grace period) rather than hangingkit renderforever. Whether a run actually timed out is decided by atimedOutflag set the moment kit starts that kill escalation — never inferred from how the child died, because that's ambiguous both ways: a child that catches theSIGTERMfallout can still exit with a plain status and no signal (would silently launder a real timeout into a generic failure), and a child killed by something else entirely (OOM killer, a stray manualkill) exits with a signal despite never having overrun the budget (would invent a timeout that never happened). A real--timeoutkill is reported aspm-export's own exit1("render failure") with a message naming the timeout and how to raise it; a child killed by an unrelated signal is reported as the same exit1but with a message naming the signal and stating plainly that it ran well within the timeout budget — no timeout language, no false remediation.- No Chromium/Playwright, or the wrong pinned build:
pm-export's own pre-flight chromium-pin guard (identical topm-verify's — see@productmakersro/fidelity-engine's README) exits2or4with the exact install/fix command, passed through untouched.
kit verify wraps the fidelity engine's pm-verify
kit verify <lock|project> [--screen <id>] [--calibrate] [--src <dir>] [--timeout <ms>]<lock|project> is either a design-lock.json path directly, or a
project directory — resolved against the one place kit add pm-tokens
actually installs it (pm/tokens/design-lock.json), falling back to a
bare design-lock.json at the directory's root. Neither found is a usage
error (2) naming both paths it checked.
Exit codes 0-5 are passed through VERBATIM — this command maps
nothing (see the engine's own exit-code table below). --screen,
--calibrate, and --src forward straight to pm-verify's matching
flags. --timeout (default 600000ms — ten minutes, since a lock can
declare many screens and the engine's own render child already budgets up
to 180s per screen) kills a hung pm-verify and reports it as the
engine's own exit 5 ("render failure/timeout") — the same bucket the
engine would use for the same real-world condition, never a code this
wrapper invents. Whether a run actually timed out is decided by a
timedOut flag set the moment kit starts the kill escalation, never
inferred from how the child died (see kit render's --timeout
paragraph above for why that inference is unreliable in both
directions). A child killed by a signal kit never sent — a crash, not a
timeout — is also reported as exit 5 (same "render failure/environment"
bucket) but with timedOut: false, the real signal name, and no timeout
language.
kit doctor
Runs eight checks, every one of them for real, and never stops early — a FAIL on one check doesn't skip the rest:
| Check | What it does |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| node-version | Node >= 20. |
| disk-space | Free space in the current directory (portable fs.statfs, no df spawn). FAIL under 500MB, WARN under 2GB. |
| cwd-writable | The current directory is writable. |
| config-present | Whether kit.config.json exists above the current directory and, if it does, that its top level is a JSON object. Absence is WARN, not FAIL — run kit init to create one. A file that exists but isn't a usable object is FAIL. |
| registry-reachable | If kit.config.json sets registryUrl, fetches it (5s timeout) and confirms it answers JSON. A server that answers HTML (a homepage, a dead deploy's error page, a misrouted path) is a distinct failure: "got HTML, expected JSON; is the URL right?". No registryUrl configured is PASS — it's optional until you set one. The value is only ever echoed with control characters (e.g. ANSI escapes) stripped, never interpolated raw. If kit.config.json exists but couldn't be evaluated (invalid JSON, non-object top level, unreadable) this is WARN — "could not be evaluated" — never the "no registryUrl configured" PASS, since a field the file can't be parsed for might still be set in it. |
| chromium-pin | Delegates to @productmakersro/fidelity-engine's own readiness gate (pm-setup-check) to report the resolved Chromium build against the pinned build read from that package's vendor/engine.config.json. render/verify are optional for colleagues who only compose kit items, so a missing fidelity-engine install, missing Playwright, or a build mismatch all degrade to WARN with the exact command to fix it — including pm-setup-check's own [MISSING] ... expected at: <path> line and, when PLAYWRIGHT_BROWSERS_PATH is set, that value and a hint to unset it if it points at a stale/foreign cache, so two different broken-install causes never produce byte-identical output. The one case that FAILs is an unreadable/corrupt vendor/engine.config.json: kit doctor never falls back to a guessed build — that would risk a false PASS while pm-verify itself exits non-zero on the same corruption. |
| fonts | If kit.config.json declares fonts: [{ family, files: [...] }] (the same shape the fidelity engine's own design-lock uses — kit add's font binary channel writes it), confirms every declared file exists on disk. No fonts field, or no kit.config.json yet, is PASS/WARN respectively — nothing to check until kit init/kit add vendor something. Same as registry-reachable above: a kit.config.json that exists but couldn't be evaluated is WARN — "could not be evaluated" — never the "no kit.config.json found" WARN. |
| engine-vendor-integrity | Verifies @productmakersro/fidelity-engine's own vendor/ tree against its vendor/CHECKSUMS.sha256 manifest (offline, no Chromium, ~200ms — the same check as that package's pm-verify --check-vendor). FAILs with the changed/missing/extra files named if the vendor tree has been hand-edited or corrupted since install; a missing fidelity-engine install is WARN, same as chromium-pin. |
Every check reports one of PASS, WARN, or FAIL, plus a detail line
and a remediation line (empty when nothing needs doing). kit doctor
exits 0 when every check is PASS or WARN, and 1 when any check is FAIL
— a FAIL always means a real, actionable problem, never a "not built yet"
placeholder. Each check runs with its own timeout, so one hung network call
or child process can't stall the others — a timed-out check reports WARN
rather than hanging kit doctor forever.
kit doctor --json returns { ok, checks: [{ check, status, detail,
remediation }, ...] }.
kit update
kit update [item...] [--check] [--dry-run] [--force] [--keep-local] [--registry-url <url>]Every kit add/kit init install records what it wrote into
.kit/manifest.json: per item, a content-derived version, an
installedAt timestamp, and a target -> sha256 map of every file (and
font) that item declared. kit update reads that manifest, refetches each
item's current registry JSON, and puts every item into exactly one of four
states:
| State | Meaning |
| --------------------- | ------------------------------------------------------------------------ |
| up-to-date | Local files match the manifest; upstream is unchanged. |
| update-available | Local files match the manifest; upstream changed — safe to reinstall. |
| locally-modified | One or more of the item's files differ from what the manifest recorded (edited, deleted, or an untracked collision — see below) — never touched without --force. |
| missing-upstream | The item no longer exists in the registry at all — local files are kept, untouched. |
Untracked-collision detection. locally-modified isn't driven only by
the manifest's own recorded file set — it's cross-checked against the
full set of targets the item's CURRENT upstream JSON declares (the exact
set kit update is about to write). Any upstream target that (a) this
item is about to write and (b) the manifest never recorded for it, but
which already has a real file sitting at that path on disk, is treated
exactly like any other locally-modified file: reported by name, refused
without --force, and backed up first when --force is given. This
closes three routes a target could otherwise reach the writer with no
hash check at all: upstream adding a file whose target collides with a
file the user already had (never installed by kit); upstream dropping a
file and a LATER version re-adding the same target, after an intervening
update already stopped tracking it; and a truncated/partial manifest
entry (files: {}) that would otherwise report zero targets to check
even though real files sit on disk. A target with nothing on disk yet is
never a collision — it's simply a new file the upgrade is free to create.
files-removed-upstream. When the current upstream item no longer
declares a target the manifest DOES have recorded, that target is never
deleted — same "never touch a file kit didn't write" rule as
missing-upstream — but a real (non-dry-run) update does stop tracking it
in the item's next manifest entry (that item's own record no longer
recognizes it as one of its files). This is reported explicitly, every
time it's still true of the manifest being evaluated: an explicit
filesRemovedUpstream: [...] array on every item in both --check and
plain/--dry-run --json output, and a [files-removed-upstream: ...]
suffix in the human-readable line — never silent. If the registry later
re-adds the same target, the untracked-collision check above is what
protects whatever the user did with the file in the meantime.
kit update --check never writes anything, regardless of what it
finds — it's the staleness report. kit update (no --check) applies
what it safely can: up-to-date and missing-upstream items are left
alone; update-available items are reinstalled and the manifest updated;
locally-modified items are refused by default (reported, files
untouched) unless --force is given, in which case every locally-modified
file for that item is first copied to <file>.backup-<8-hex-sha> (the
backup's bytes are exactly what was on disk before the overwrite), then the
item is reinstalled and the manifest updated. --keep-local explicitly
skips locally-modified items (same effect as the default, worded as an
acknowledgement rather than a warning). --force and --keep-local are
mutually exclusive; --check cannot be combined with --dry-run/
--force/--keep-local — it never writes on its own. --dry-run shows
the exact same plan (including the backup path a force-upgrade would
create) without writing anything. With no item... given, every item in
the manifest is in scope; naming specific items restricts the run to
those (and fails clearly if one was never installed).
Reinstalling an item writes ONLY that item's own declared files (content
files verbatim, font assets through the same fetch+sha256-verify+write path
kit add's font channel uses) — never a dependency's files, even if the
item's registryDependencies names one. This is a deliberate difference
from kit add's delegated-to-shadcn install path: shadcn add always
re-resolves and re-writes the FULL dependency tree, which for kit update
would risk silently overwriting a colleague's local edit to a DIFFERENT,
unrelated item just because it happened to be a dependency of the one item
being updated. Every item in this registry is registry:item/
registry:file with literal inline content (see item-tree.mjs's
collectFileTargets comment) — never the registry:component/ui/block
types that trigger shadcn's import-alias/AST rewrite — so writing
file.content directly produces the identical on-disk result shadcn add
would, scoped correctly.
Config
kit.config.json is discovered by walking upward from the current
directory. Its absence is tolerated everywhere — kit init creates one.
The two fields it holds are registryUrl (a string, written by kit
init, read by add/list/search/view when --registry-url isn't
passed) and fonts (an array of { family, files: [...] }, files
relative to kit.config.json's own directory — the same shape the
fidelity engine's design-lock uses for vendored fonts; appended to by kit
add's font binary channel).
A file that exists must be a JSON object at the top level, and if it
sets registryUrl or fonts, those fields must match the shapes above —
anything else (an array/bare string/number/null top level, a non-string
registryUrl, a non-array fonts, or a fonts entry missing files) is
reported as a real problem by kit doctor (see below), not silently
accepted.
Flags never take an inline value
--json, --help/-h, --version/-v, --dry-run, --calibrate,
--check, --force, and --keep-local are plain boolean switches.
--json=false (or =anything) is a usage error (exit 2), not a silent
way to turn JSON off — always pass the flag on its own. Every other flag
(--registry-url, --format, --out, --width, --height, --scale,
--timeout, --screen, --src) takes a value (--flag value or
--flag=value); passing any flag to a command that doesn't use it (kit
doctor --registry-url ..., kit list --format ..., kit verify
--width ...) is a usage error naming which command was given and which
flag it doesn't take. kit update additionally rejects --check combined
with --dry-run/--force/--keep-local, and --force combined with
--keep-local — both are usage errors, not silently-resolved conflicts.
Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------------------------- |
| 0 | Success |
| 1 | Generic failure (e.g. kit doctor found a real problem); also kit render's own "render failure" bucket (including a --timeout kill) |
| 2 | Usage error — bad command, bad flag, wrong number of arguments; also kit render/kit verify's "setup/usage error" bucket (e.g. no Chromium/Playwright installed) |
| 3–5 | kit verify only — passed through VERBATIM from @productmakersro/fidelity-engine's pm-verify (its own exit-code table: 3 DIMENSION_MISMATCH, 4 FONT_PARITY or an unconfirmed Chromium pin, 5 render failure/timeout, including a kit verify --timeout kill) |
| 64 | Not implemented — the command exists but its logic ships in a later wave |
kit render and kit verify are the two exceptions to "every other
command returns 0 on success, non-zero on failure": their real exit
code is whatever the delegated fidelity-engine binary returned (see
each command's own section above) — a non-zero code from either can mean
a legitimate finding (a real fidelity/lint failure, DIMENSION_MISMATCH,
FONT_PARITY), not that kit itself failed to run the command.
On Node < 20, kit prints one line to stderr and exits 1 before doing
anything else — no stack trace, regardless of command.
If kit's own files can't be loaded at all (a corrupted or partial install —
e.g. a missing or syntactically broken file under src/), bin/kit.mjs
catches that too and prints one line telling you to reinstall, exiting 1
— never a raw Node stack trace, even when the failure is in kit's own
loading code rather than in a command.
Error contract
Every error goes to stderr as one human-readable line:
kit: unknown command 'lst'. Did you mean 'list'?With --json, the same failure is a single structured object on stderr
instead:
{ "error": { "code": "USAGE_ERROR", "message": "unknown command 'lst'.", "suggestion": "list" } }Error codes: USAGE_ERROR, NOT_IMPLEMENTED, INTERNAL_ERROR (V1-C2);
REGISTRY_UNREACHABLE, REGISTRY_HTML_RESPONSE, REGISTRY_HTTP_ERROR,
REGISTRY_INVALID_JSON, REGISTRY_MALFORMED, REGISTRY_NOT_CONFIGURED,
ITEM_NOT_FOUND, CONFIG_INVALID, CONFIG_WRITE_FAILED,
SHADCN_NOT_FOUND, SHADCN_VERSION_MISMATCH, INSTALL_FAILED,
INSTALL_VERIFY_FAILED, FONT_VERIFY_FAILED, FONT_TARGET_UNSAFE,
INIT_BOOTSTRAP_FAILED (V1-C3, init/add/list/search/view);
ENGINE_NOT_FOUND, RENDER_LAUNCH_FAILED, VERIFY_LAUNCH_FAILED (V1-C5,
render/verify — a broken/partial @productmakersro/fidelity-engine
install; distinct from a real engine exit code, which is not a KitError
at all — see "Exit codes" above); MANIFEST_READ_FAILED,
MANIFEST_INVALID, NOTHING_INSTALLED, ITEM_NOT_INSTALLED,
UPDATE_CHECK_FAILED (V1-C8, update). Unknown commands and unknown flags
both exit 2 with a closest-match suggestion when one is close enough to
guess.
bin/kit.mjs sets process.exitCode rather than calling process.exit(),
so Node drains any output still buffered for a piped stdout/stderr before
exiting — large --json payloads (a future wave's kit list --json | jq,
for example) are not silently truncated.
Tests
npm testRuns the node:test suite in test/, which drives the real
bin/kit.mjs as a child process (not internal unit calls) and checks:
version/help, the uniform not-implemented contract (human + --json),
kit doctor's real checks, usage-error exit codes and suggestions, that no
raw stack trace ever reaches stderr, the Node < 20 guard, and a regression
test that pushes a >64KiB error message through a piped stderr to confirm
it survives intact.
The init/add/list/search/view section serves test/fixtures/
registry/ (3 items, schema-valid per node tools/registry-lint.mjs
--registry test/fixtures/registry) over a real local HTTP server and
exercises the REAL pinned shadcn CLI end to end — no mocking — covering:
happy-path init/add (files + the font binary channel, sha256-verified),
--dry-run writing nothing, an offline/dead registry, an HTML-as-200
registry, item-not-found, a foreign registry namespace, and a pre-existing
conflicting file causing shadcn's own install to abort mid-run (verified
live against the pinned CLI) — which kit add's post-verify step turns
into a clean non-zero exit instead of a false success.
kit render/kit verify's CLI-subprocess-level coverage (argv parsing,
--help, cross-command flag rejection, guardrails that reject before the
engine is ever touched) lives in this same file's "kit render / kit
verify (V1-C5)" section, following the same real-subprocess style as
everything else here. Argument-to-engine-args mapping and exit-code
(0-5) passthrough are covered separately in test/render.test.mjs
and test/verify.test.mjs, at the mock level (runRender/runVerify
called directly, engine binary swapped for a shim script via
binPathOverride) — those files' own header comments explain why a real
PATH-based engine-bin shim can't be landed from a bin/kit.mjs
subprocess in this workspace (the real @productmakersro/fidelity-engine
package always wins node's ordinary node_modules resolution walk before
NODE_PATH is ever consulted, verified empirically). Both suites also
carry one real end-to-end smoke test each (kit render/pm-export
against a real installed Chromium) — conditional, not skipped by default:
they only skip when the pinned Chromium build genuinely isn't cached on
the machine running the tests, and never trigger a download themselves.
kit update's (V1-C8) install-then-update flow lives in
test/update.test.mjs, against its own fixture registry (test/fixtures/
update-registry/) rather than reusing test/fixtures/registry/ — one
dependency-free item (widget) so every write a test asserts on is
unambiguously that one item's own file, plus a v2 snapshot with the
item's content bumped and a v3-missing snapshot with an empty items[]
(the four --check states: up-to-date, update-available, locally-modified
— including the "deleted locally" case — and missing-upstream). Covers the
full apply path too: a safe upgrade with no local edits, a locally-modified
item refused by default and by --keep-local (file provably untouched
byte-for-byte), --force (the backup file's bytes proven equal to the
edited content that was overwritten, then a follow-up --check confirming
up-to-date), and --dry-run (directory listing AND every file's mtime
proven unchanged — not just existence).
A round-3 adversarial-critic gap (the manifest's own recorded file set
driving locally-modified with no cross-check against what an upgrade is
actually about to write) is covered by a dedicated describe block against
two more fixture items (grower, shrinker) plus a v3-readd snapshot:
upstream ADDING a file whose target collides with a hand-written file the
user already had; upstream DROPPING a file (--check/--json both report
it via filesRemovedUpstream, the file is kept on disk, untracked) and a
LATER version re-adding the same target (refused without --force, backed
up with --force, even though the manifest had already forgotten it); and
a truncated manifest entry (files: {}) still protecting the real,
locally-edited file on disk instead of reporting zero targets to check.
