readyup
v0.35.0
Published
Run pre-deployment checks to verify environment and configuration
Maintainers
Readme
ReadyUp
Run pre-deployment verification checks against your environment and configuration. Define checklists in TypeScript kits, run them locally or from a remote source, and get clear pass/fail reporting with remediation hints.
Release notes — v0.35.0 (2026-08-30)
🎉 Features
Enforce a kit's minReadyupVersion and warn on version skew (#430)
Adds
minReadyupVersionto the ReadyUp kit contract, naming the minimumreadyupversion a kit's checks require. A kit declaring a floor above the running readyup fails to load.A kit declaring no floor falls back to an advisory floor, the version its bundle records at compile time, which a lower runner reports as a
version-skewwarning rather than a failure.Name the remedy for each failing verdict in rdy verify (#434)
Adds a remedy to
rdy verify's human-readable output, describing how to fix a failing kit.Drop linguist-generated and vendored files from the project sweep (#441)
Excludes generated and vendored files from the files checked by ReadyUp kits. These files are identified by a
linguist-generatedorlinguist-vendoredgit attribute.
🐛 Bug fixes
Compare a recorded hash at its own length in rdy verify (#432)
Fixes an issue where
rdy verifytreated a kit as stale if its manifest contained hashes longer than the eight characters written byrdy compile. Every axis now compares the digest at the recorded value's own length. The manifest schema constrains a recorded hash to a lowercase hex digest prefix of 8 to 64 characters.Separately,
rdy runraises a newmanifest-unreadableadvisory when.readyup/manifest.jsonis present but unparseable.Two new functions,
hashToRecordedLengthandisRecordedHash, have been added toreadyup/check-utils.computeHashnow accepts a byte sequence as well as a string.
Contents
- Installation
- Quick start
- Concepts
- Authoring kits
- Running checks
- JSON output
- Publishing kits
- Check utilities
- Compatibility
- License
Installation
pnpm add --save-dev readyupNode 24 or later is required, for the runner and for the kits it compiles.
Quick start
rdy initThis creates two files:
.config/readyup.config.ts -- repo-level settings:
import { defineRdyConfig } from 'readyup';
export default defineRdyConfig({
compile: {
srcDir: '.readyup/kits',
outDir: '.readyup/kits',
},
});.readyup/kits/default.ts -- starter kit:
import { defineRdyKit } from 'readyup';
export default defineRdyKit({
checklists: [
{
name: 'deploy',
checks: [
{
name: 'NODE_ENV is set',
check: () => {
const value = process.env['NODE_ENV'];
if (!value) return { ok: false, detail: 'NODE_ENV has no value in the environment' };
return { ok: true, detail: `NODE_ENV is ${value}` };
},
fix: 'Set NODE_ENV before deploying',
},
],
},
],
});Compile the kit, then run it:
rdy compile
rdy runWith NODE_ENV unset:
🔴 NODE_ENV is set
NODE_ENV has no value in the environment
── Fixes
🔴 NODE_ENV is set
💊 Set NODE_ENV before deploying
🔴 Total: 1 error (0ms)rdy run --jit skips compilation and runs the TypeScript source directly, which is the faster loop while writing checks. Compiled kits stay the vetted artifact: they are what rdy verify hashes and what a consumer running rdy run --from gets.
Concepts
Kits, checklists, and checks
A kit is a file exporting one or more checklists. A checklist holds checks, and a check may nest further checks beneath it. A check that fails blocks its descendants.
kit
└── checklist
└── check
└── checkSeverities
Every check has a severity. It decides whether a failure fails the run and whether the result is reported, and it never decides whether that check itself runs. It reaches later work in one place only: a failed check at or above the failure threshold stops the remaining groups of a staged checklist.
| Severity | Meaning |
| ----------- | ----------------- |
| error | Must be fixed |
| warn | Should be fixed |
| recommend | Worth considering |
Statuses
A check result has one of three statuses -- passed, failed, or skipped. The token shown in output is derived by crossing status with severity (for failures) or with the skip reason (for skips), which is why an author returns a boolean and declares severity separately rather than choosing a token.
| Rich | Plain | Status | Derived from |
| ---- | ------- | --------- | -------------------------------------------- |
| 🟢 | PASS | passed | -- |
| 🔴 | FAIL | failed | severity error |
| 🟠 | WARN | failed | severity warn |
| 🟡 | RECO | failed | severity recommend |
| ⚪ | SKIP | skipped | skip returned a reason; counts as optional |
| 🚫 | BLOCK | skipped | a precondition failed; counts as blocked |
💊 FIX marks a remediation hint rather than a result.
Role glyphs are nouns rather than statuses. They name what something is, in a heading segment or beside a listed row, and plain style renders none of them: position shows the meaning instead.
| Rich | Names |
| ---- | ------------------------------------------------------ |
| 📄 | a kit's TypeScript source |
| 📓 | a kit |
| 📋 | a checklist |
| 📦 | the npm package a kit was published in |
| 🌐 | a kit fetched from github:, bitbucket:, or --url |
| 📁 | a directory a kit was read from |
Thresholds
Two thresholds govern a run, each resolved as CLI flag, then the kit's own field, then the default.
| Threshold | Field / flag | Default | Governs |
| --------- | -------------------------- | ----------- | -------------------------------------- |
| Failure | failOn / --fail-on | error | Whether a failure fails the run |
| Reporting | reportOn / --report-on | recommend | Whether a result appears in the output |
A check with no severity takes the kit's defaultSeverity, which itself defaults to error.
Reporting prunes the detail tree only. Summary counts, worst severity, and the exit code always reflect the whole run.
Authoring kits
All helpers are type-safe identity functions that provide editor autocomplete without runtime overhead. Import them from readyup.
| Helper | Defines |
| -------------------------- | ------------------- |
| defineRdyConfig | Repo-level config |
| defineRdyKit | Kit |
| defineRdyChecklist | Flat checklist |
| defineRdyStagedChecklist | Staged checklist |
| defineChecklists | Array of checklists |
Config
Repo-level settings live in .config/readyup.config.ts.
| Key | Default | Meaning |
| ----------------- | --------------- | ------------------------------------------------------------- |
| compile.srcDir | .readyup/kits | Directory rdy compile reads sources from |
| compile.outDir | .readyup/kits | Directory it writes bundles to |
| compile.include | all .ts files | Glob limiting which sources a sweep compiles |
| internal.dir | . | Directory holding internal sources, relative to the kits root |
| internal.infix | none | Filename segment marking a file as internal |
| packages | none | Packages rdy run --packages runs a published kit from |
See internal kits for what the internal keys select, and package-hosted kits for packages.
Kit
| Field | Type | Default | Meaning |
| ------------------- | ---------------------------- | ----------- | ------------------------------------------ |
| checklists | Array<Checklist \| Staged> | required | The checklists this kit runs |
| description | string | -- | Summary, reported by rdy list --manifest |
| minReadyupVersion | string | -- | Readyup version the checks require |
| suites | Record<string, string[]> | -- | Named subsets of checklists |
| defaultSeverity | Severity | error | Severity for checks that declare none |
| failOn | Severity | error | Failure threshold |
| reportOn | Severity | recommend | Reporting threshold |
| fixLocation | 'inline' \| 'end' | end | Where fixes render |
A kit declaring minReadyupVersion fails to load on a runner below it. A kit declaring none falls back to an advisory floor, the version its bundle records at compile time, which a lower runner reports as a version-skew warning rather than a failure.
Checklists
| Field | Type | Default | Meaning |
| --------------- | ------------------- | ------------------ | ------------------------------------------- |
| name | string | required | Display name |
| checks | RdyCheck[] | required if flat | Checks, run concurrently (flat checklist) |
| groups | RdyCheck[][] | required if staged | Groups, run sequentially (staged checklist) |
| preconditions | RdyCheck[] | -- | Gating checks |
| fixLocation | 'inline' \| 'end' | the kit's setting | Overrides the kit's setting |
A checklist has either checks or groups, never both.
Checks
| Field | Type | Default | Meaning |
| ---------- | ------------------------------------------------- | --------------------------- | --------------------------------------------- |
| name | string | required | The claim being asserted |
| id | string | -- | What a pragma writes to suppress its findings |
| check | () => boolean \| CheckOutcome \| FindingOutcome | required | The assertion; may be async |
| severity | Severity | the kit's defaultSeverity | Overrides the kit's defaultSeverity |
| quiet | boolean | false | Renders only when the check does not pass |
| skip | () => false \| string | -- | Reason string to skip; false to run |
| fix | string | -- | Remediation, shown when the check fails |
| checks | RdyCheck[] | -- | Nested checks, run only if this one passes |
A check returns a boolean or a CheckOutcome:
| Field | Type | Meaning |
| ---------- | ---------- | ---------------------------------------------------------------------------- |
| ok | boolean | Whether the assertion holds |
| detail | string | Why this status |
| progress | Progress | { type: 'fraction', passedCount, count } or { type: 'percent', percent } |
A check naming located sites returns a FindingOutcome instead, and the runner derives all three from it:
| Field | Type | Meaning |
| -------------- | ------------------ | -------------------------------------------------------------------------- |
| findings | OutcomeFinding[] | Every located site, as { path, line, symbol?, reported } |
| adoptedCount | number | Sites already settled, the fraction's numerator; omitted, there is none |
| scanned | string[] | Paths this check examined and read no other way; omitted, it declares none |
reported marks the sites this check names; the rest count toward the fraction and do nothing else. The runner drops the sites a pragma suppresses, renders the reported survivors as the detail, reads ok off whether any survived, and counts every survivor into the fraction. buildFindingReport builds one of these for the common case; see project sources.
scanned is the escape hatch, not the usual path. A sweep read through readTrackedSources is recorded on its own, in skip and in check alike, so a check reading the project that way declares nothing and its files are still evidence for the pragma that suppressed nothing. Declare scanned where the check reads files another way -- shelling out to a tool, walking listTrackedFiles and reading them itself, or reaching for fs directly -- because nothing else can see what those read.
Naming checks
Three fields, three questions:
namestates what must be true.detailexplains why this status.fixsays what to do about it.
A name is a claim that reads true on a pass and false on a fail. 🔴 Node >= 24 fails that test: the operator leaves the reader to infer which direction is the violation.
State the claim in the third person indicative and capitalize it like a sentence, so a column of names reads as a column of assertions rather than labels.
| Poor | Better | Why |
| ---------------------------- | ------------------------------------------ | -------------------------------------------------------- |
| Node >= 24 | Node.js runtime is v24 or later | words fix the direction, and the subject says which Node |
| outdated dependencies | Dependencies are current | a name true on failure inverts the status token |
| check git status | Working tree is clean | names the action, not the condition |
| env vars | NODE_ENV is set | names the subject, not the claim |
| Docker | Docker is configured | a bare noun asserts nothing to be true or false |
| extends recommended preset | renovate.json extends config:recommended | a verb with no subject leaves the claim half-stated |
Rewriting a name often exposes an ambiguous predicate: an author writing "newer than 24" frequently discovers they meant a floor of 24.
A check that exists only to gate the checks nested beneath it is no exception. It still reports a status of its own, so it still needs a claim.
Neither is a quiet check, though it looks like one: its name reaches the reader only on a failure, where the claim reads false. That is the rule working rather than breaking. The name states what must be true, and the line appears precisely when it is not.
The detail contract
detail explains "why this status" -- not "what this check asserts", which the name already says. On a pass it reports the evidence; on a skip, why the check did not apply; on a failure, what went wrong. Write it as a complete sentence, capitalized and with no terminal period -- the register name and fix already use. A sentence whose subject is a code identifier keeps that identifier's own case, as in package.json is missing or unreadable.
| Status | Where detail renders |
| ------- | ------------------------------------------------------- |
| passed | inline, after the separator |
| skipped | inline, after the separator |
| failed | in a block beneath the claim, above any thrown Error: |
Remediation is not detail. It belongs in fix.
This kit exercises all three placements at three levels of nesting:
import { defineRdyKit } from 'readyup';
export default defineRdyKit({
checklists: [
{
name: 'release',
checks: [
{
name: 'Working tree is clean',
check: () => ({ ok: true, detail: 'There are no uncommitted changes' }),
},
{
name: 'Dependencies are installed',
check: () => true,
checks: [
{
name: 'Lockfile is current',
check: () => ({ ok: true, progress: { type: 'fraction', passedCount: 4, count: 4 } }),
checks: [
{
name: 'No dependency has duplicated majors',
check: () => ({ ok: false, detail: 'react resolves to both 18.3.1 and 19.0.0' }),
fix: 'Run `pnpm dedupe`, then commit the lockfile',
},
],
},
{
name: 'Native modules are rebuilt',
check: () => true,
skip: () => 'This workspace has no native dependencies',
},
],
},
],
},
],
});It produces:
🟢 Working tree is clean · There are no uncommitted changes
🟢 Dependencies are installed
🟢 Lockfile is current [4 of 4]
🔴 No dependency has duplicated majors
react resolves to both 18.3.1 and 19.0.0
⚪ Native modules are rebuilt · This workspace has no native dependencies
── Fixes
🔴 No dependency has duplicated majors
💊 Run `pnpm dedupe`, then commit the lockfile
🔴 Total: 1 error, 3 passed, 1 skipped (0ms)A failing descendant turns the tail line red while every ancestor stays green. progress needs no detail: [4 of 4] is already the evidence.
When a check skips
skip exists to prevent a wrong failure, not to suppress a right pass. A skip reports that the check does not apply to this repo, so the first question is whether the thing being checked is yours to assert about; only then ask what check would have returned.
- If
checkwould have failed, and failing would misjudge a conformant repo, the skip is correct. - If
checkwould have passed, delete the skip and let the check pass.
The second question is a fast check, not the rule. A skip is correct whenever the subject is not yours to assert about, whatever check would have returned. Five checks from published kits separate the two cases:
| Check | In the skipped state, check would | Verdict |
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------ |
| eslint >= 10.0.0 | fail -- no version to satisfy the floor | the skip prevents a wrong failure |
| .config/git-cliff.toml matches current template | fail -- hash of a missing file | the skip prevents a wrong failure |
| audit-ci configs are under .config/audit-ci/ | pass | the skip masks a pass, in every passing state |
| code-quality workflow does not use nmr prepush | pass | the skip masks a pass |
| .github/labels.yaml exists | pass | the skip is correct; release-kit does not own the file |
The last row is the one the fast check alone gets wrong. .github/labels.yaml is a filename several label-sync tools write, and release-kit generates it only from a repoLabels block, so a repo with that file but no such block would have passed fileExists and still deserves the skip.
The third row is the failure mode to watch for: skip and check ran the identical predicate, so the check could never pass. rdy run --diagnose decides that mechanical half, reporting every check its own skip turned off that would have passed. It decides nothing about applicability.
Only a skipping parent collapses a group. A parent whose skip fires reports alone: its descendants are not run, not reported, and not counted. A parent that fails instead renders every descendant as its own 🚫, which is one blocked line per descendant where one skipped line was wanted. quiet helps with neither, suppressing passes only.
⚪ and 🚫 read differently. ⚪ means the check does not apply; 🚫 means it never ran, because an ancestor failed or a precondition gated it. A blocked subtree does not consult a descendant's own skip, so a check that would have reported "does not apply" renders as blocked instead. Read a 🚫 as evidence about an ancestor, never about the thing the blocked check names.
Prefer a plain-string fix. Outcome-specific remediation belongs in detail, which the check returns after running and can therefore name what actually went wrong. A getter serves one purpose: reaching a value declared below the kit literal.
Agent guidance
The doctrine above ships as agent guidance too, in a CodeAssembly content root under agents/ in the installed package. A repo that names readyup under packages in its .agents/codeassembly.yaml and runs codeassembly sync gets it as the consult-readyup-kits skill, in every harness that repo targets.
The skill holds the judgment a kit author needs while writing; this README stays the reference for everything mechanical.
Staged checklists
A staged checklist replaces checks with groups. Groups run in order; checks within a group run concurrently.
import { defineRdyStagedChecklist } from 'readyup';
export default defineRdyStagedChecklist({
name: 'release',
groups: [[{ name: 'Working tree is clean', check: () => true }], [{ name: 'Tests pass', check: () => true }]],
});A failure at or above the failure threshold stops the groups after it; a below-threshold failure is reported and the next group still runs. Only top-level results gate: a failing nested check does not halt the next group.
This is the one gate that consults the threshold. A failed check blocks its own descendants, and a failed precondition gates its checklist, whatever the severity.
Preconditions
A checklist's preconditions gate the checks that follow. If any precondition fails, every check is skipped and each records precondition as its reason.
- A failed precondition gates regardless of severity. Severity decides whether the run fails; the gate decides whether the checks are worth running. Unlike a staged checklist's groups, the gate does not consult the failure threshold.
- A precondition skipped
n/adoes not gate. To make a whole checklist inapplicable, nest its checks under one parent check whoseskipreturns a reason. When a check skips covers why that structure and not a failing parent.
Suites
suites names reusable subsets of checklists. A suite name is accepted anywhere a checklist name is, and expands in the order the suite declares.
export default defineRdyKit({
suites: { fast: ['lint', 'types'] },
checklists: [/* lint, types, integration */],
});rdy deploy:fastValidation
Neither rdy compile nor rdy run --jit type-checks the kit it loads, so both validate structure at load time, identically -- rdy compile refuses to publish a kit that rdy run would reject.
Every check is validated wherever it appears: in checks, in groups, in preconditions, and nested. A check needs a non-empty name and a check function; severity must be a valid value; skip must be a function, and a fix written as a data property must be a string. Unknown keys are allowed, so a kit written for a later ReadyUp still loads.
Invalid kit at .readyup/kits/default.js:
checklists[0].checks[1].severity: expected one of "error", "warn", "recommend", got "info"
checklists[0].checks[2].check: expected a function, got stringA typo'd severity is the mistake this matters most for: an unrecognized value would otherwise exclude the check from both thresholds, and the run would pass.
A fix written as a getter is the half of fix validation that is deferred. Load leaves it unread, and the check that fails resolves it -- so a getter may reference a constant declared below the kit literal, and a check that passes, skips, or is blocked never invokes it. A getter that throws or yields a non-string is reported as Unresolvable fix: ... in that failure's remediation slot, rather than as a load error taking the whole kit down.
Testing a kit
A kit's checks are ordinary functions, and the shape of the test follows what a check reads.
A check that calls discoverWorkspaces itself is tested against a real directory tree, with cwd pointed at it. Nothing is mocked, so the check sees the workspace list discovery actually produces, root included:
import { createTempTree } from '@williamthorsen/toolbelt.filesystem/candidate';
import { pointCwdAt } from '@williamthorsen/toolbelt.testing/candidate';
it('passes when every package README carries the marker', () => {
using temp = createTempTree({
'package.json': '{"name":"root","private":true}',
'pnpm-workspace.yaml': 'packages:\n - packages/*\n',
'packages/alpha/package.json': '{"name":"alpha"}',
'packages/alpha/README.md': '<!-- marker -->',
});
using _cwd = pointCwdAt(temp.dir);
expect(readmesHaveMarkers()).toBe(true);
});createTempTree and pointCwdAt are the helpers ReadyUp uses for its own suites; any equivalent will do, since what the pattern needs is a real tree and a cwd pointed at it.
Mocking readyup/check-utils instead is what produces a workspace list discovery cannot return -- most often one with no root entry, which every !isRoot filter then passes through untouched, so the filter is never exercised.
A function that takes a Workspace parameter needs a value rather than a tree. readyup/testing exports a builder for one:
import { makeWorkspace } from 'readyup/testing';
expect(skipIfNotPublishable(makeWorkspace({ packageJson: { name: 'example', private: true } }))).toBe(
'package.json#private is true',
);makeWorkspace fills every field the call leaves out, so a field added to Workspace in a later release does not break the fixture. Its defaults are:
| Field | Default |
| -------------- | ------------------------------------------------------------------------- |
| dir | 'packages/example' |
| absolutePath | /repo joined to dir, in forward slashes: '/repo/packages/example' |
| packageJson | { name }, the name being dir's last segment, or 'repo' for the root |
| name | packageJson.name |
| isPackage | packageJson.private !== true |
| isRoot | dir === '.' |
The last three are derived by the same code discoverWorkspaces uses, so makeWorkspace({ dir: '.' }) reports isRoot: true without being told. An explicit override wins over the derivation, which is how a test states a shape discovery would not produce. The result is frozen, as a discovered workspace is, and the manifest passed in is copied before freezing, so a literal shared between fixtures stays writable.
Inlining JSON at compile time
A compiled kit is self-contained, so it cannot read a JSON file that sits next to its source. pickJson closes that gap by copying selected fields into the bundle while it is being built:
import { pickJson } from 'readyup';
const pkg = pickJson('../../package.json', ['name', 'version', ['engines', 'node']]);rdy compile replaces the call with the literal it resolves to. Nothing of pickJson survives, not even the import:
var pkg = { "name": "my-app", "version": "3.1.0", "engines": { "node": ">=24" } };The path resolves relative to the source file. Each entry in the second argument names a field to keep: a string for a top-level key, an array of strings for a nested one, whose nesting the result preserves. Naming a path the file does not have fails the compile rather than inlining undefined.
Both arguments must be literals written in place. They are read out of the source text before it is parsed, so a variable, a template literal, or a concatenation is a compile error -- and a call inside a comment or a string is still processed, since that reader cannot tell the difference.
Two consequences follow from the value being resolved at compile time:
pickJsonthrows if it is ever reached at runtime. A kit that hits it was not compiled.- Editing a picked field afterward leaves the bundle stale. Neither recorded hash changes -- the source did not move, and neither did the bundle -- but the compile records the projection it inlined, so
rdy verifynames the file andrdy runwarns on it.rdy verify --rebuildis the exact check, reading the file rather than a record of it.
TypeScript settings
Kits compile with no tsconfig.json. Whatever config sits above a kit is ignored, so the same source compiles to the same bundle in any repository and a published bundle is the one its author built.
Kits are bundled by esbuild, and its defaults apply, with two settings declared:
| Setting | Value |
| ------------------------- | ------- |
| experimentalDecorators | false |
| useDefineForClassFields | true |
One consequence reaches every kit: paths aliases do not resolve. Import by relative path or package specifier. A kit that reaches for an alias fails to compile and is told why, rather than compiling into something that breaks when it runs.
Running checks
rdy [kit[:checklist,...] ...] [options]
rdy <command> [options]Commands
| Command | Description |
| ---------------- | ------------------------------------------------ |
| run [names...] | Run checklists (default) |
| compile [file] | Bundle TypeScript kit(s) into self-contained ESM |
| help [<topic>] | Show help for a command or a topic |
| init | Scaffold a starter config and kit |
| list | List available kits |
| verify | Check compiled kits against manifest hashes |
rdy help <command> prints what rdy <command> --help prints, and rdy help <topic> prints a section of this README. Run rdy help for the topics on offer.
Selecting what runs
A positional argument names a kit, optionally with checklists or suites after a colon:
rdy deploy # every checklist in the deploy kit
rdy deploy:build,test # two checklists from it
rdy deploy:fast # a suite
rdy deploy release # two kits--checklists filters within a single kit, and pairs with one positional kit, with --file or --url, or with no kit at all. Naming two kits, or one that already has a :checklist filter, is an error rather than a merge.
Kit names may contain /, as in shared/deploy. To name one that starts with -, place it last, after --:
rdy run -- "--odd-kit-name"Run options
| Option | Description |
| ----------------------------- | --------------------------------------------------------------------- |
| --from <source> | Kit source (see kit sources) |
| --file, -f <path> | Path to a local kit file |
| --url <url> | Fetch kit from a URL |
| --packages [<name>] | Run a kit the config's packages list publishes (default: default) |
| --jit | Run from TypeScript source instead of compiled JS |
| --internal | Use the internal kit directory and infix from config |
| --checklists, -c <name,...> | Filter checklists within the selected kit |
| --json | Output results as JSON |
| --detail <summary\|full> | How much of the JSON report to emit (default: full) |
| --diagnose | Report skipped checks whose check would have passed |
| --fail-on <severity> | Fail on this severity or above |
| --report-on <severity> | Show this severity or above |
| --quiet | Hide passed checks; incompatible with --json |
--quiet filters by status where --report-on filters by severity, so the two compose rather than override. Both keep the parent checks of anything they show, so a failure nested under passing parents stays reachable.
A checklist either filter empties renders no block at all: its summary-table row states the same counts in a column the reader can compare across the run. A block is withheld only where a table will include its row, so a run of one checklist reports its block however little it has to say, and a run that withholds one always ends with the table.
--diagnose runs the check of every check its own skip turned off, and reports the ones that would have passed: a skip exists to prevent a wrong failure, and one that suppresses a right pass instead renders as an ordinary white circle that nothing fails. When a check skips holds the judgment this flag cannot decide. It is opt-in because it executes exactly the work a skip was written to avoid, which may reach a network or a registry. What it finds is reported as advisory warnings, and the statuses, counts, durations, and exit code are those of an undiagnosed run.
A check's own quiet is this flag narrowed to that one check, and a kit whose every check declares it renders what --quiet renders. It is not skip, which reports that the check did not run and why: a quiet check runs, and its pass reaches the count line and the exit code like any other -- only the line is withheld. --json is unaffected, so rdy run --json --detail full shows a quiet check that passed.
Global options
| Option | Description |
| ----------------------------- | ------------------------------ |
| --style <auto\|plain\|rich> | Output style (default: auto) |
| --help, -h | Show help for the command |
| --version, -V | Show the version number |
Kit sources
| Source | Format | Example |
| ---------- | ------------------------- | ----------------------------- |
| GitHub | github:org/repo[@ref] | --from github:acme/ops@v2 |
| Bitbucket | bitbucket:ws/repo[@ref] | --from bitbucket:team/ops |
| npm | npm:<package> | --from npm:@acme/eslint-cfg |
| Local repo | <path> | --from ../other-repo |
| Directory | dir:<path> | --from dir:/shared/kits |
| Global | global | --from global |
@ref defaults to main. Local repo paths look for kits in <path>/.readyup/kits/; dir: paths are used directly.
npm: resolves an installed dependency, so the kit that runs is the one shipped with the version the project has. See package-hosted kits.
Private repositories use ambient tokens: GITHUB_TOKEN (falling back to gh auth token) and BITBUCKET_TOKEN. Without a token, requests go anonymous and only public repositories succeed.
Reading the output
A check line reads token name <separator> detail [progress] (duration). The separator is · in rich and - in plain; progress takes brackets. Durations appear from 100 ms up, never on a check that did not run, and always on a tail or total line.
A failed line states only its claim. The reason renders beneath it, indented to the name column -- the authored detail first, then any thrown exception behind its Error: label. Passes and skips keep their detail inline.
Every block closes with its count line. A count line is labelled Total:, leads with the run's worst severity, and reports counts in a fixed order -- errors, warnings, recommendations, passed, blocked, skipped -- omitting any that is zero, separated by commas. The label is what tells the line from the check lines above it, which lead with a token in the same column. It is the block's last line, following any Fixes recap.
Every block heads itself with a breadcrumb. A run block is headed ━━, and its segments read source, then kit, then checklist, separated by a spaced slash. A segment appears only where it distinguishes something: the source where the kit came from anywhere but the local kits directory or the working directory, the kit where the run holds more than one or a source segment is already there, the checklist where the kit runs more than one. A lone local kit running one checklist heads nothing at all. The summary table heads itself at ━━ too, as a peer of the blocks it tallies, and each of its rows repeats the breadcrumb of the block it summarizes, the same segments elided; Fixes and each command's own heading stay at ──, which heads a section and nothing else.
Blank lines separate blocks rather than decorate headings: none opens a command's output, follows a heading, or falls inside a block, and exactly one separates one block from the next, a kit boundary included. More than one checklist anywhere in the run adds a summary table:
━━ 📋 build
🟢 Types check cleanly (343ms)
🟢 Bundle is within budget · 42kB of a 50kB budget [84%]
🟢 Total: 2 passed (343ms)
━━ 📋 integration
🟢 Database is reachable
🔴 Migrations are applied (151ms)
2 migrations pending: add_users, add_index
⚪ Seed data is loaded · seeding is disabled outside CI
── Fixes
🔴 Migrations are applied
💊 Run `pnpm migrate` against the target database
🔴 Total: 1 error, 1 passed, 1 skipped (151ms)
━━ Summary
───────────────────────────────────────────────────
🟢 build 343ms 2 passed
🔴 integration 151ms 1 error, 1 passed, 1 skipped
───────────────────────────────────────────────────
🔴 Total: 1 error, 3 passed, 1 skipped (494ms)A kit from an installed package, a repository, or a URL names where it came from, so a long run says which checks belong to which kit without the reader scrolling for it:
━━ 📦 @acme/[email protected] / 📓 npm-auto-publish / 📋 repo
━━ 🌐 github:acme/checks@main / 📓 default
━━ 📁 ../shared-kits / 📓 defaultA run spanning several kits tallies them together, each row naming its source and kit so it reads without reference to the blocks above. Row names have no role glyphs, since the padding that aligns the columns counts characters rather than terminal cells:
━━ Summary
─────────────────────────────────────────────────────────────────────────────────
🟢 @acme/[email protected] / npm-auto-publish / repo 12ms 4 passed
🟢 @acme/[email protected] / npm-auto-publish / secrets 9ms 2 passed
🔴 github:acme/checks@main / default 151ms 1 error, 1 passed
🟢 ../shared-kits / default 4ms 3 passed
─────────────────────────────────────────────────────────────────────────────────
🔴 Total: 1 error, 10 passed (176ms)Output styles
--style selects rendering; RDY_STYLE holds a standing preference. The flag outranks the environment variable, which outranks detection.
| Value | Renders |
| ------- | ----------------------------------------------------------------------------- |
| auto | plain under CI or when output is not a terminal, rich otherwise (default) |
| plain | Fixed-width ASCII words |
| rich | Emoji tokens |
CI catches a runner that attaches a pseudo-terminal; the terminal check catches an interactive rdy | grep FAIL. An explicit CI=false is read as a denial. Naming a style that does not exist fails the invocation.
In plain, every character is printable ASCII, heading rules and separators included. A role glyph is omitted while keeping its column, so names stay aligned; in a breadcrumb, where there is no column to keep, the spaced separator is what separates one segment from the next:
== integration
PASS Database is reachable
FAIL Migrations are applied (151ms)
2 migrations pending: add_users, add_index
SKIP Seed data is loaded - seeding is disabled outside CI
FAIL Total: 1 error, 1 passed, 1 skipped (151ms)== @acme/[email protected] / npm-auto-publish / repoOnce a style is named explicitly, output is identical to a terminal or a pipe. --style is independent of --json: the JSON document never changes.
Suppressing a finding
A check naming located sites reports each as path:line. A source suppresses one with a pragma:
// rdy-ignore-next-line -- the bootstrap shim, no deps allowed
error instanceof Error ? error.message : String(error);| Token | Covers |
| ---------------------- | ------------------- |
| rdy-ignore | The line it sits on |
| rdy-ignore-next-line | The line below it |
With no argument a pragma covers every check for the line, which is the form to reach for: a kit publishes advice rather than a lint rule, so silencing one reviewed site should cost a comment and nothing more. A trailing -- <reason> is optional everywhere and changes nothing about what is suppressed.
One or more comma-separated check ids may follow the token, and the pragma then suppresses for those checks alone:
// rdy-ignore-next-line toolbelt.errors/no-instanceof-error -- the bootstrap shim, no deps allowed
error instanceof Error ? error.message : String(error);A failed check prints its id bracketed ahead of its fraction, and that printed form is what a pragma writes:
❌ No source narrows a thrown value by hand [toolbelt.errors/no-instanceof-error] [2 of 5]
src/a.ts:4, src/b.ts:9A kit an installed package publishes namespaces its checks under that package's name with the scope stripped, so @williamthorsen/toolbelt.errors yields toolbelt.errors/<id>. The fully-qualified @williamthorsen/toolbelt.errors/<id> is accepted too; the bare id is not, because the namespace is what keeps two kits' same-named checks apart. A kit reached any other way -- from the local kits directory, a --from directory, or a URL -- has no namespace, and its bare id stands. An id naming no check in the run suppresses nothing, as does a pragma on a check that declares no id at all.
The id list ends at the first token that is not an id: a -- reason, the delimiter closing a block comment, a second pragma token, or the line's end. Everything before that is read as ids, so a reason written without -- names checks rather than explaining the decision: // rdy-ignore because the API is frozen suppresses for a check called because, and therefore for none. Write a reason behind --. Under --json, each check entry includes its id in both detail projections.
A suppressed finding leaves the audit rather than being downgraded: out of the detail, and out of both halves of the check's fraction, so a project that has settled every remaining site reaches completion rather than resting one short. An unqualified pragma takes the site out of every check's fraction at once, which is what keeps the checks of one run comparable; a qualified one takes it out of the checks it names and leaves it standing in the rest.
The token is read from the source's raw text and matched wherever it appears on the line, so a detector that blanks comments before it scans cannot erase a pragma first, and a line that quotes the token in a string suppresses a finding sited on it.
A pragma that outlives the finding it was written for is reported under pragma-unused, so a site rewritten or a check retired leaves a comment the next run names rather than dead text nobody notices.
Advisory warnings
rdy run raises advisories about the run it is performing. Warnings go to stderr in both modes and appear under warnings in JSON; none affects the exit code.
Three compare the kits it is about to run against .readyup/manifest.json and say so when they disagree.
| Code | Raised when |
| -------------- | ------------------------------------------------------------------------ |
| input-stale | A file the compile inlined changed since the bundle was built from it |
| source-stale | The kit's TypeScript changed since the compiled bundle was built from it |
| target-drift | The compiled bundle no longer matches the manifest's recorded hash |
They are silent when the manifest is absent, when no entry describes the kit, when an entry records no hashes or no input closure, or when a file they would compare is gone or cannot be read. Only the local manifest is consulted, so a kit reached through --from is out of scope -- run rdy verify in that root instead. They also do not apply to --url or --jit.
A manifest that is present and cannot be read is the one case that speaks for itself, because all three then go unchecked for every kit in the run.
| Code | Raised when |
| --------------------- | --------------------------------------------------------------------- |
| manifest-unreadable | .readyup/manifest.json exists but does not parse against the schema |
An absent manifest stays silent: it is the normal state of a project that never compiled, and says nothing about any kit.
Two more come from --diagnose, and are raised only where that flag asked for them.
| Code | Raised when |
| ------------------------ | ------------------------------------------------------------------ |
| diagnosis-inconclusive | A diagnosed check threw, or returned a value expressing no verdict |
| skip-masks-pass | A check its own skip turned off would have passed had it run |
These read the checks rather than the manifest, so none of the silencing conditions above reaches them: they apply wherever the kit came from, --url, --from, --packages, and --jit alike. A check blocked by a failed precondition declared nothing and is not diagnosed.
One compares the readyup that compiled a bundle against the one running it.
| Code | Raised when |
| -------------- | ---------------------------------------------------------------- |
| version-skew | A bundle was compiled by a newer readyup than the one running it |
Only that direction is reported: the recorded version freezes at publish time while runners move on, so a bundle behind the runner is the ordinary state of a published kit. The advisory stands in for a floor the author never declared, so a kit declaring minReadyupVersion never raises it -- a runner below that floor has already failed the load. A bundle recording no version is silent, --jit runs from TypeScript source included.
One more reads the sources the run's checks examined and reports the pragmas among them that suppressed nothing.
| Code | Raised when |
| --------------- | ---------------------------------------------------------------------------------- |
| pragma-unused | An rdy-ignore pragma suppressed no finding in this run |
The evidence is what the checks read. A pragma is reported only where some check examined the file holding it -- swept it through readTrackedSources, or named it in scanned -- and no check of the run suppressed a finding on the line the pragma covers; a pragma in a file no check examined is not reported, because the run established nothing about it. Paths are matched by their resolved form, so a check declaring absolute paths and one reporting relative finding paths agree, and the warning prints the path relative to cwd, the form findings print in. One ledger spans the invocation, so a file two kits both examined is scanned once. A diagnosis contributes neither examined paths nor suppressions, the run having turned that check off; a sweep the check read in its own skip before returning the reason was recorded when it ran, and stands.
Recognition for the report is stricter than for suppression. A token is a site when it sits in a comment with nothing but whitespace and * between it and the // or /* that opened one, in a JavaScript-family file. A token in a string, in a regular expression, following prose or code inside a comment, or second on its line is not a site. Suppression is unchanged and still matches the raw text of every file type, so the report can only ever withhold a warning, never license a finding.
Two limits follow from that. Recognition reads JavaScript-family syntax, so a pragma in a source of any other kind is never reported. And a pragma written for a check that skipped, was blocked, or was not loaded is reported where any check examined its file, that skipped check's own skip included where it swept before skipping: the run holds no evidence the check would have suppressed anything.
Kit import compatibility
A compiled kit leaves its readyup imports unbundled, so it binds whichever readyup runs it rather than the one that built it. Before running a bundle, rdy run reads the readyup symbols it imports and compares them against what the running readyup exports.
A kit importing a symbol, or a readyup subpath, the runner does not export does not run: the failure is a kit-load error naming every missing symbol, the kit, and the publishing package where the kit has one, and it exits 2. Unlike the staleness advisories above, this check is not manifest-derived and applies wherever the kit came from, --url, --from, and --packages included. --jit runs load TypeScript source rather than a bundle, and are unaffected.
The remedy follows where the kit is maintained:
| Kit source | Remedy |
| ------------------------------ | ------------------------------------------------------- |
| This project's .readyup/kits | Run rdy compile to rebuild it |
| An installed package | Upgrade the package to a release built for this readyup |
| A URL or remote repository | Ask the kit's publisher to recompile it |
An import binding no name the runner could be asked for -- a namespace import, a default import, a dynamic import -- has its names left unchecked. Its subpath is still checked, so a namespace import of a subpath readyup does not publish fails like any other.
Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------- |
| 0 | Ran and found no problems |
| 1 | Ran and found problems: failed checks, a kit that fails verify, a kit that fails to compile |
| 2 | Could not complete the invocation: a usage, config, kit-load, or internal error |
The distinction is "fix the repo" (1) versus "fix the invocation" (2). rdy list and rdy init produce only 0 and 2. A run that loses a kit part-way exits 2 even when the kits that ran found problems, and still reports what it collected.
Listing kits
rdy list List internal, compiled, and configured-package kits (owner view)
rdy list --packages List the kits this project's dependencies publish
rdy list --recursive List compiled kits in every project below the working directory
rdy list --recursive --packages List each project's kit-publishing dependencies
rdy list --from <source> List compiled kits at a local path, remote source, or installed package
rdy list --manifest <path> List the kits a manifest file declaresEach section names the command that runs the kits beneath it:
── Internal
To run: rdy run --jit <name>
📄 deploy
📄 smoke
── Compiled
To run: rdy run <name>
📓 deploy
📓 smokeKits from configured packages get their own section, each named package-first so a kit reads the same here as in the heading rdy run gives it, and any installed dependency publishing kits the config omits is named as a candidate:
── Packages
To run: rdy run --packages [<name>]
📦 @acme/[email protected] / 📓 drift
── Available
Add to "packages" in the readyup config
📦 @acme/release-kit--packages covers the dependency question on its own, and covers it for both groups at once. rdy list --packages reports every installed direct dependency that publishes kits, plus every package the config names, one block apiece with the kits it publishes and the descriptions their manifests record:
━━ 📦 @acme/[email protected]
To run: rdy run --packages <name>
📓 drift · Dependency drift
━━ 📦 @acme/[email protected] · not listed in the readyup config
To run: rdy run --from npm:@acme/release-kit [<name>]
📓 default
📓 npm-auto-publishThe hint above each block is what marks the package. A package the config names is headed by rdy run --packages, which is exactly the run that would reach it; one the config omits is headed by the source that names it directly, and reads not listed in the readyup config. Every kit listed is therefore runnable by the command above it, and learning what an unconfigured package holds no longer means a --from npm: listing per package.
Configured packages are resolved through node_modules rather than through the project's declared dependencies, so one that is installed without being declared is reported here as it is under a plain rdy list; where that misses, a name matching one of the project's own workspaces resolves to that workspace, and a name matching neither warns and is omitted. On its own, --packages reads the working directory, and it is not combinable with --from or --manifest. Pairing it with --recursive sweeps the whole repository, which Listing a repository's dependencies covers.
--manifest reports each kit's compile-time ReadyUp version and description:
── Manifest: .readyup/manifest.json
📓 deploy (readyup v0.22.0) · Pre-deployment checks
📓 smoke (readyup v0.22.0)A local --from source with no manifest falls back to listing the compiled kits on disk; those rows have a name and path only. A remote source still requires a manifest.
Listing a whole repository
--recursive sweeps down from the working directory and reports each project's compiled kits under a heading naming the directory they live in, with the descriptions that project's manifest records:
━━ 📁 ./
To run: rdy run <name>
📓 demo
━━ 📁 packages/readyup/
To run: rdy run --from packages/readyup [<name>]
📓 default · Authoring hygiene for a project that defines readyup kits
📓 publishing · Publication readiness for a package that ships readyup kitsEvery listed kit is reachable by the command above it, from wherever the sweep was run. A project that sets a custom compile.outDir is reached by file instead, since that is the only resolution path that respects it, and its rows are named by a path that resolves from the sweep root:
━━ 📁 packages/tooling/
To run: rdy run --file <file path>
📓 packages/tooling/dist/kits/lint.js · Shared lint and format gateInternal kits and configured-package kits are absent: no invocation reaches another project's uncompiled sources, and packages are the other axis of discovery rather than this one. A project with nothing compiled is not rendered at all, so a sweep of a repository whose kits are all uncompiled prints No kit projects found.
The sweep considers every directory holding a package.json, the working directory included, and skips node_modules and dot-directories. Each project it finds is read under its own .config/readyup.config.ts. Topology comes from the filesystem rather than a workspace file, so the sweep does not care which package manager the repository uses -- but a kit directory with no package.json beside it is not a candidate. --recursive cannot be combined with --from or --manifest, which name a single foreign source.
Listing a repository's dependencies
--recursive --packages is the two axes at once: the locality of the sweep and the provenance of the dependency view. It reports each project's kit-publishing dependencies under the directory that declares them, with the command that runs each package's kits:
📁 ./
📦 @acme/[email protected]
To run: rdy run --packages [<name>]
📓 drift · Dependency drift
📁 packages/tooling/
📦 @acme/[email protected] · not listed in the readyup config
To run: cd packages/tooling && rdy run --from npm:@acme/release-kit [<name>]
📓 default
📓 npm-auto-publishEvery project's dependencies and configured packages are read from its own package.json and its own .config/readyup.config.ts, so a package one workspace names and another does not reads not listed in the readyup config only where it is unnamed. A workspace's own dependency is reachable from nowhere else, so its command includes the cd that gets there: rdy run takes no directory, and --from names a kit source rather than a working directory.
This sweep is wider than the one --recursive makes alone. It considers every directory holding a package.json, whether or not that directory has a readyup footprint, because a workspace authoring no kits of its own still declares dependencies that publish them -- and that workspace is the one the question is about. A project with no kit-publishing dependency is not rendered at all, its directory line included, and a sweep left with nothing prints No dependency of any project below this directory publishes kits.
Unlike every other listing, this view has no heading rules. The two rule weights it would otherwise need are a stroke apart, and the roles they would mark are already told apart by their glyphs; under --style plain, where the role glyphs are empty, the indentation marks all three levels on its own. That is also why each command is labelled To run:: it shares a column with the kits beneath it, and the label is what keeps it from reading as one more kit.
Rows are keyed by name, kind, project, and origin.package together. Under the default configuration a compiled source appears twice -- once as internal and once as compiled. A package's kit is compiled like any other bundle, distinguished by the package it records rather than by a kind of its own, so name and kind alone collide between your kit and a package's kit of the same name; under --recursive they collide again between two projects that each hold a default, and under --recursive --packages between two workspaces depending on the same package. A consumer indexing on less than the full key silently drops a row.
Every kit a package published has origin.configured, reporting whether the config names that package and so whether rdy run --packages would reach it. It is emitted under --packages, under --recursive --packages, and under a plain rdy list alike, so a consumer never has to know which invocation wrote the payload; it is absent only from a payload written before the field existed. Candidates from the Available section are not kits and appear separately in availablePackages, which accompanies the owner listing alone: under --packages those packages' kits are rows of their own, so there is nothing left to name separately.
Scaffolding
rdy init Scaffold a starter config and kit| Option | Description |
| --------------- | ------------------------------------- |
| --dry-run, -n | Preview changes without writing files |
| --force | Overwrite existing files |
JSON output
run, compile, list, and verify accept --json; init does not. With --json, stdout holds exactly one JSON document and every human-readable line goes to stderr. --help and --version have no JSON form.
Published schemas
Each payload is specified by a JSON Schema shipped with the package and includes an integer schemaVersion matching the vN in its filename.
| Payload | Import path |
| -------------- | ---------------------------------------- |
| compile | readyup/schemas/compile.v1.json |
| error envelope | readyup/schemas/error-envelope.v1.json |
| list | readyup/schemas/list.v1.json |
| run report | readyup/schemas/report.v1.json |
| verify | readyup/schemas/verify.v1.json |
Each $id is the same path under https://unpkg.com/readyup/. The schemas are generated from the definitions the exported Json* types derive from, so the published contract and the types cannot drift apart.
Evolution policy
The five payloads version independently.
- Adding an optional field does not bump
schemaVersion. A validator pinned tov1keeps accepting payloads from a later ReadyUp. - Removing, renaming, or re-typing a field does bump it, publishing a new
vNbeside the old. Widening a closed set counts as re-typing. - A field is
requiredonly when every payload has it. Omission is reserved for absent or empty data. warnings[].codeis an open set, exempt from the widening rule. Consumers must tolerate an unknown code, displaying itsmessageandremedy.error.codestays closed.
Error envelope
An invocation that fails before producing anything else emits:
{ "schemaVersion": 1, "error": { "code": "usage", "message": "Unknown option '--bogus'" } }code is one of usage, config, kit-load, or internal. The envelope covers only failures preceding dispatch; once the run reaches its kits, a failing kit is reported inside the report:
{ "name": "release", "error": { "code": "kit-load", "message": "Cannot find .readyup/kits/release.js" } }An error entry has no counts and no verdict, and the top-level totals cover only the kits that ran.
An error body may also include hint, one action that would clear the failure:
{
"schemaVersion": 1,
"error": {
"code": "config",
"message": "No manifest found at https://raw.githubusercontent.com/acme/private/HEAD/.readyup/manifest.json.",
"hint": "If the repository is private, set GITHUB_TOKEN 