npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

grails-cli

v0.5.0

Published

Grails Code Sync from the command line: reads a component library's code and uploads its design tokens and component specs to Grails.

Readme

grails-cli

Grails Code Sync from the command line: grails-cli reads a component library's code and uploads its design tokens and each component's spec to Grails, so the vault shows what the code actually holds, next to Figma. The product is Grails Code Sync the way the Figma plugin is Grails Sync; this is its command line. Full documentation: https://docs.grails.design/cli/overview/

Requirements: Node 20 or newer. The package is free to install and use with Grails; see LICENSE.md.

npm i -D grails-cli          # or run it without installing: npx grails-cli <command>

npx grails-cli scan  /path/to/library              # the manifest of components
npx grails-cli tokens /path/to/library             # the design tokens Grails would read; sends nothing
npx grails-cli sync  /path/to/library --dry-run    # what would be uploaded; sends nothing, needs no key

npx grails-cli connect                             # once: keeps the API key you copied in the system keychain; see "The key"
npx grails-cli sync  /path/to/library              # in CI, GRAILS_API_KEY from a secret does the same job
npx grails-cli status

The examples below write grails-cli; use npx grails-cli if it is not installed globally.

Commands

| Command | What it does | |---|---| | scan [root] [--out f] [--profile f] [--tier syntax\|typed] [--in-repo] | The manifest (stdout without --out). Refuses to write it inside a git repository unless --in-repo. Exits 1 when the scan is incomplete. | | chi [root] [--profile f] [--tier …] [--json] | The Component Health Index of every component, worst first. | | check-classes [root] [--unknown] [--json] | Probable Tailwind typos; exits 1 when there is one. | | init [root] [--write] [--force] | Proposes a grails.code.json from the code, with the evidence. | | tokens [root] [--profile f] [--json] | The design tokens Grails would read (a Tailwind theme, CSS listed in the profile's css, token sources in its tokens), and every value left out. --json is the exact token list a sync uploads. Sends nothing, needs no key. | | tokens baseline [root] --out <dir> [--in-repo] [--profile f] | The scanner's tokens as DTCG files and a resolver, a first DTCG version for the team to own (see "Moving from the scanner to DTCG"). Writes only where --out says, refuses a path inside a git repository unless --in-repo, and never touches a folder that has files. Sends nothing, needs no key. | | sync [root] [--dry-run] [--json] [--trigger ci\|local] [--profile f] [--tier …] [--name n] [--platform p] | Tokens first, then components, in one step. Records the tokens in one request, then opens a component sync, sends the components, finalizes, and prints the result and the vault link. See "Exit codes". | | unsync [root] --collection <name> \| --component <key-or-name> [--dry-run] [--json] [--profile f] | Takes a token collection or a component out of grails.code.json and out of Grails, in one step: edits the profile so it is never synced again, then runs the same sync with the edited profile, which marks what is no longer declared as deleted in code. --dry-run writes nothing and shows the sync of the edited profile. See "unsync". | | status | The code sources of the key's project, with the last component sync and the last token sync of each, and how many changes asked for in Grails wait for it. | | tokens pending [root] [--name n] [--collection c] [--request id] [--limit 1..1000] [--json] | The token changes people asked for in Grails, by collection, with each one's type, before → after and id; --collection and --request narrow it. Writes nothing (see "Changes asked for in Grails") | | tokens discard <change-id> \| --all [--collection c] \| --request id --reason "…" [--dry-run] [root] [--name n] [--json] | Closes one change, or many in one step, that your team will not carry into the repository, with the reason; --dry-run lists what would go and changes nothing (see "Changes asked for in Grails") | | rules [root] [--set code=figma \| --delete code \| --threshold n] [--json] | The naming rules of the Code ↔ Figma match (see "Naming rules"). | | connect [--prompt] | Keeps your API key in the operating system's keychain, so no command needs GRAILS_API_KEY in your shell. Reads the key from the clipboard (then clears it) or, with --prompt, from a prompt that shows nothing as you type; checks it against Grails first, and stores nothing if Grails refuses it. See "The key". | | disconnect | Removes the key connect stored for the API GRAILS_API_URL points at. | | update [root] | Asks the npm registry now for the latest grails-cli and prints the installed version, the latest, and the command that updates it: for your package manager when the project depends on grails-cli, for the one that installed it when it is a global install. It does not run it. Exits 4 when the registry cannot be reached. See "Updates". | | skill | Prints the agent skill the package ships (templates/skill/grails-cli/SKILL.md), for a team to save where its coding agent reads skills. Sends nothing, writes nothing. |

sync

Only from the declared branch. grails.code.json says which branch IS the source in Grails: "branch": "main" (or preview, or whatever merges land on); init proposes the branch the checkout is on. A real sync:

  • without a declared branch is refused before anything is read (exit 2), with that sentence;
  • on another branch uploads nothing and exits 0, so a pull request's job stays green, and says so where a CI shows it: the terminal, the job summary, a ::notice on the run, and the step outputs result=skipped-branch and exit_code=0 (GITHUB_OUTPUT). A detached checkout is judged by CI_COMMIT_REF_NAME / GITHUB_REF_NAME, so a GitHub pull request run (<number>/merge) is "another branch"; a folder that is not a checkout has no branch;
  • on the declared branch syncs as below, and writes result=synced (or failed, with its exit code). A dry run is never held back: it stores nothing. The server enforces the version that carries this guard: the two storing routes refuse a CLI older than 0.2.0 with 400 GRAILS_CODE_CLI_OUTDATED (exit 3).

Tokens go first. The token list is read and recorded with POST /v1/code/token-syncs in one request. Grails builds the DTCG document from it and answers what changed against the source's previous snapshot; the same content is stored once. A token step that fails (a project Grails cannot read, the server refusing the list) does not stop the components: they sync anyway, and the run exits with the token step's code. Once the tokens are recorded, Grails also compares the changes people asked for in Grails with what the repository now holds, marks the ones it satisfies applied, and the sync says how many (see "Changes asked for in Grails"); that comparison never fails the sync. Then the components:

  1. Opens the sync with every component key the scan found (declared_keys). That is how the server tells a component deleted from the code from one that failed to arrive.
  2. Sends the components in requests of at most 50 components and 1 MB (the API refuses more than 50, and accepts 2 MB). Grails answers the open with the hash of the spec it already holds for each key; the spec travels only for a key that is new or whose hash differs.
  3. Finalizes with the scan's own complete. Only a complete scan lets the server mark anything deleted, and even then it refuses to delete 5 or more components and more than 20 % of the source in one sync. Since 0.3.0 the finalize also carries what each component uses (canonical entities D1): the components of the same source it renders, resolved with the TypeScript checker to their keys, and the tokens its styles reference, as the collection and name of the token list just recorded. Grails keeps them as edges of the project graph. They are sent only when the tokens were read and recorded (or there are none): otherwise Grails keeps the usage it had. A reference no token of the source answers to (a framework default, a source missing from grails.code.json) is counted in a warning and not sent.

If a sync fails after it was opened, the CLI closes it as incomplete before raising the error. Otherwise it stays open for two hours and the next run of the same source (a CI retry) is answered "another sync is running".

The source (what Grails calls this library) is named after the package's own name, so it is the same on a laptop and in CI; --name sets another one, for a monorepo whose packages share nothing else. The platform is react-native when the package depends on React Native or Expo or any file imports react-native, and web otherwise; --platform overrides. The commit and branch come from git when the root is in a checkout (the branch from CI_COMMIT_REF_NAME or GITHUB_REF_NAME when the checkout is detached, as GitLab's is). --trigger is ci when CI=true and local otherwise.

unsync

grails-cli unsync --collection Color --dry-run     # what would change in the profile and in Grails; writes nothing
grails-cli unsync --collection Color               # edits grails.code.json, then syncs it
grails-cli unsync --component src/ui/Legacy.tsx#Legacy

Removing something from grails.code.json and removing it from Grails are one gesture (the owner's decision, 2026-10-19). unsync edits the profile, so it is never synced again, and runs the same code path as sync with the edited profile: the sync marks what the profile no longer declares deleted_in_code (a token collection and its tokens; a component only at a sync that read every component). Grails keeps the row, its match with design and its documentation, and a later sync that has it again revives the same row. There is no confirmation: --dry-run is the preview, like tokens discard. Exactly one of the two flags. It needs a grails.code.json (a detected profile has nothing to take an entry out of: grails-cli init --write creates one; --profile <file> edits another file) and, for a real run, the key and a declared branch, checked before anything is written.

It prints what changes in the profile (entries removed and added, as JSON fragments), then what leaves Grails. The file is written the way it was found: its indentation (a tab, or the width of its first indented line), its line endings, its final newline and the order of its keys. What it cannot keep is a line laid out by hand: an array that was on one line is written one item per line, and the run says so.

  • --collection <name> takes a collection grails-cli tokens lists. If it comes from a tokens entry of the profile, that entry is removed; the text-style collection the entry made (Type-typography) goes with it, and can be named too. Every source that stays and owes its name to its folders gets "collection" written, equal to the name it has now (Color and v2-Color: removing src/constants/Color writes "collection": "v2-Color" onto the v2 entry), so nothing else moves; without it the other source would be renamed and every token under it would change path in Grails. It then reads the tokens again with the edited profile and refuses, writing nothing, unless the collections that remain are exactly the ones there were minus this entry's. Refused (exit 2): a collection from Tailwind or CSS (the profile has no entry for it: it names the file to edit by hand), one from dtcg (it lives in the DTCG files), and the last token the repository has (a sync with no tokens uploads none, so Grails would keep the collection).
  • --component <key-or-name> takes a key (file#export) or a name; an ambiguous name lists the keys. Its file goes into exclude. A file that holds other components is refused (exit 2), listing them: excluding a file takes them all.

If the sync after the edit fails or does not complete (the network, an API error, an incomplete scan) the file is not restored: the edit is what you asked for. The run ends with one line, grails.code.json was edited (…); Grails still has the collection "Color": the next grails-cli sync from main removes it, and exits with the sync's own code. From a branch that is not the declared one the file is edited and nothing is sent: commit it and sync from the declared branch, which removes it from Grails. With --json, stdout is one document, { "unsync": { target, profile_file, written, changes, leaves_grails }, "sync": <the sync report> }.

--dry-run, --json and the job summary

  • --dry-run with a key (GRAILS_API_KEY, or the one connect kept) asks Grails what each step would change, through the preview routes (POST /v1/code/token-syncs:preview, POST /v1/code/syncs:preview), which store nothing: tokens added, changed and removed; components added, changed, reactivated and those that would be deleted, with the same floor the real sync applies. It also says what the real sync would do with the changes asked for in Grails (see below): how many it would mark applied, close because their token is gone, or leave open, with their ids (tokens.preview.token_changes in --json, and in the job summary). The same verdict the real sync applies, written nowhere.
  • --dry-run without the key counts locally and sends nothing, as it always did.
  • --json prints one report on stdout (mode, tokens, components, exit_code); the human lines go to stderr, so the output can be piped.
  • In GitHub Actions (GITHUB_STEP_SUMMARY is set) the same report is appended to the job summary: counts, and the changed tokens and would-be-deleted components folded. There is no comment on a pull request.

No state is written to the repository: what Grails holds is the only source of truth, and the comparison is always against it.

Code ↔ Figma

After a real sync records the tokens, sync asks Grails how they compare with the Figma ones (GET /v1/code/token-match) and prints one line: same, differ, conflicts, by value only, only in code, only in Figma, and the colour threshold it used (ΔE2000, 1.5 by default). The job summary adds the table, what differs, the conflicts (code tokens that disagree about one design token, never resolved) and the suggested naming rules, which are never applied: naming rules live in Grails, not in this repository.

  • A project with no Figma snapshot yet says so instead of calling every token missing.
  • A dry run does not ask: Grails would compare the code tokens it already stores, not the ones on disk.
  • It is a report, not a step of the sync: a failure is said and kept in the report, and the exit code does not change.

Naming rules

A naming rule tells the match that a word of your code is another word in Figma: gray in the code is neutral in Figma. Rules belong to one source and live in Grails, never in the repository: the CLI, the REST API and MCP all edit the same rules.

grails-cli rules                      # this source's rules and the project's colour threshold
grails-cli rules --set gray=neutral   # create, or move its target; the same rule twice is one rule
grails-cli rules --delete gray
grails-cli rules --threshold 2        # ΔE2000 under which two colours are equal, for the whole project

The source is named as sync names it (--name, else the package name, else the folder). Listing needs the usual key; editing needs a key with the write:tokens scope. The job summary of a sync lists the rules applied and the ones Grails suggests from tokens with the same value and one different word.

Changes asked for in Grails

In Grails, an admin or an editor can ask for changes to this repository's tokens: a new value, a new token, collection or mode, a removal. Grails never writes your repository, and neither does this CLI.

grails-cli tokens pending               # by collection: type, before → after as DTCG values, and the id
grails-cli tokens pending --json        # the list as Grails answers it, every value exact
grails-cli tokens pending --limit 1000  # the read answers 500 by default, at most 1000
grails-cli tokens pending --collection color      # only that collection's changes
grails-cli tokens pending --request <request-id>  # only one change request's (its id is under the heading)

Carry each change into your token source, or into the rules that generate it (a Style Dictionary build, a Tokens Studio export, your CSS). The loop closes by itself:

  • The next sync marks a change applied once your repository holds its value. Grails compares the resolved value of the token in that mode (an alias counts by the value it resolves to), with the tolerance the Diff uses for colours and sizes. A token you removed closes its change; a created token, collection or mode closes when it exists. The sync prints how many (token_changes in --json).

  • If the repository holds another value, the change stays. Grails keeps proposing its value, checks again on every sync, and records what your code holds. The "before" that tokens pending prints is what your repository holds now.

  • A change your team will not carry is closed with a reason:

    grails-cli tokens discard <change-id> --reason "Handled in the design-tokens repo"

    The id is the one tokens pending prints beside the change. Grails keeps the change, closed, with who discarded it (the owner of the API key: it is the only identity the API can verify), that it came from code, and the reason, which is required (1 to 1000 characters, one line, no control characters). Discarding a collection takes its modes and tokens with it; one value of a token being created cannot go alone, discard the token. A change that is already applied or discarded answers exit 3.

  • Many at once, in one step. Exactly one of three picks the changes, and the reason is required:

    grails-cli tokens discard --all --reason "Handled in the design-tokens repo"                # everything waiting for this repository
    grails-cli tokens discard --all --collection color --reason "…"                            # one collection's
    grails-cli tokens discard --request <request-id> --reason "…"                              # one change request's

    The set is closed all together or not at all, and at most 1000 at once (more is refused with the way out: a collection, or a request). --dry-run lists what would go (id, token, before → after) and changes nothing; the reason is optional then. There is no --yes: the reason is the commitment. The answer, and --json, say how many were discarded and which. --collection goes with --all only, and the same words select the same changes in tokens pending, so you can look before you close. A change someone discards in Grails is a different path and is deleted; these stay, with the reason.

  • A dry-run sync shows what the next sync would close: grails-cli sync --dry-run (with the key) prints how many changes it would mark applied, close because their token is gone, or leave open, and their ids, without storing anything. grails-cli status shows how many changes wait for each source.

An sRGB colour shows as its hex and a dimension as 24px; anything else shows as its DTCG JSON, and --json has the exact components. The source is named as sync names it (--name, else the package name); a change must belong to it. Both commands need the usual key (write:code).

Exit codes

A contract a CI step can branch on. Only 4 is worth retrying. A sync on another branch is 0; its own result, skipped-branch, is in the step output result (see sync above), since any other code would turn the step red.

| code | meaning | retry? | |---|---|---| | 0 | done: synced, nothing changed, or a dry run | – | | 1 | the component sync ran and ended incomplete (unreadable components, or deletions held back) | no: read the warnings | | 2 | misconfigured: a flag (a flag that takes a value, given none: --profile needs a value), grails.code.json (including no declared branch on a real sync), no key, or a project Grails cannot read (its own tailwindcss missing, or of a major other than 3 and 4); for connect, a key of the wrong shape, an empty or unreadable clipboard, or no keychain on the machine | no: fix it | | 3 | the server refused it: the key (also the one connect was given), the plan, content it does not accept, or a CLI older than the minimum (400) | no: fix it | | 4 | the server could not be reached or failed (network, 5xx, a 429 after one retry); for update, the npm registry | yes |

Where the tokens come from

The team's own DTCG files, when grails.code.json names them (the official way in):

"dtcg": { "files": ["tokens/**/*.json"], "resolver": "tokens/resolver.json" }
  • One source. With dtcg, these files are the only token source. Nothing is read from Tailwind, CSS or tokens, and a run that also lists css or tokens says so.

  • Sent as written. sync uploads them as they are (dtcg: { files, resolver? } with client: grails-cli), and the API reads them with @grails/dtcg's readDtcg.

  • Checked here first, so a wrong repository sends nothing (exit 2):

    • the file count (500) and the body size (4 MiB);
    • a file outside the resolver's folder (a resolver names its files from where it is, so every key is relative to it);
    • two keys that differ only in case;
    • JSON that does not parse.
  • What the API refuses comes back as findings (422, or 413 for a limit). The run prints each one with the file the person knows and its JSON pointer, and exits 3.

  • Usage. Component uses go with the finalize, and so do the token uses a var(--x) can be tied to. Nothing in the files says which token a custom property is, so the team says how its CSS is named:

    "dtcg": { "files": [...], "cssVariables": { "prefix": "ds", "exceptions": { "--brand": "color.brand.500" }, "ignore": ["--radix-*", "--tw-*"] } }
    • The convention is Style Dictionary's default, its css transform group (name/kebab): the token's path segments joined with -, lower case, behind the build's prefix (color.brand.500 is --ds-color-brand-500). A digit stays with its letters (2xl, body2), camelCase is split, any other character is a separator, and a name is case-sensitive. cssVariableName in @grails/dtcg is that rule, pinned against the real Style Dictionary.
    • The direction is token → name. A name does not say its token (brandPrimary and brand-primary are two tokens and one name), so the CLI names every token of the upload and looks a variable up in that index. A name two tokens share is reported as ambiguous, never given to one of them.
    • exceptions map a custom property that does not follow the convention to the token it is. They win over the convention, and an exception whose token is not in the upload is reported (and the name does not fall back to the convention).
    • ignore is a list of patterns for custom properties that are not the team's tokens at all (a library's, the framework's): ["--radix-*", "--tw-*"]. A pattern starts with --, * is the only wildcard, and there are at most 200. A name it covers is taken out first: not resolved (so not sent, even when the convention would have found a token of that name), not counted as used, not listed. An exception for the same name wins over ignore: it names a token on purpose. A pattern that covers nothing used is not an error; --json lists it in css_variables.ignore_unused so the team can delete it. The terminal line says how many were ignored, never which. Use exceptions for what a name IS, ignore for "this is not ours".
    • The files are read here with readDtcg, the reader the API runs, to know the tokens: sync --dry-run lists the result with no key and sends nothing. A token goes to the API as { collection, name } (the first level of its path, and the rest joined with /), which the server turns back into the path with tokenPath; a token with a / inside a level cannot be spelt that way and is reported.
    • Every var(--x) that resolves to no token is listed (the terminal prints 20, --json carries 200 in css_variables, the job summary folds them), with an exception that names a missing token. It never changes the exit code.
    • What it reads. A var(--x) a component can use, in three places, as uses outside the spec (no hash moves, and the manifest keeps its shape and its version):
      • the style of any element of the component, every property (margin, border, boxShadow, …), also *Style, css and sx props, a constant or style sheet it refers to, and every branch of a condition or lookup;
      • the stylesheet its file imports (.css, CSS modules, .scss, .less, .pcss), for every component that file holds; a file two files import counts for both. Each file is read once per scan, only inside the project and never under node_modules, at most 1 MiB, and 16 MiB of CSS in all (what is left out is a warning). @import is not followed: a stylesheet that imports a tokens sheet would hand the component the aliases of the whole token layer. A custom property a rule declares in :root, a theme selector or @theme is a token's definition, not a use;
      • Tailwind classes in className and in cn, clsx, classnames, cx, twMerge and cva calls, variants included: bg-[var(--x)] in either major, bg-(--x) (also bg-(color:--x)) in Tailwind 4 and bg-[--x] in Tailwind 3. Measured on 4.2.1 and 3.4.17: 4 compiles bg-[--x] to a literal --x, and 3 does not know bg-(--x), so each major reads the form it compiles. With no Tailwind version found, the 4 form is read.
    • What it does not read. A name a template assembles at run time, a class helper it does not know (tv()), CSS-in-JS template literals, a component its file defines but does not export, a property set from JavaScript. Other kinds of token reference (a Tailwind theme class such as bg-primary, a TypeScript token object) are counted, not tied. A bg-[var(--x)] stays a literal colour in the spec itself, as before: only uses changed.

Without dtcg, the scanner reads (a convenience for a first sync):

  • A Tailwind 3 theme, read with the project's own tailwindcss (loadConfig + resolveConfig), defaults included. ⚠️ That executes tailwind.config.*, as the project's build does. Install the project's dependencies first (npm ci): without them the run exits 2, and never reads the theme with a copy of its own.
  • Tailwind 4: the @theme stylesheet, read with the project's tailwindcss (__unstable__loadDesignSystem), defaults included; its CSS variables become a collection with a mode per selector (:root light, .dark dark).
  • CSS files listed in grails.code.json's css, read with postcss.
  • TypeScript token objects and enums listed in grails.code.json's tokens. Each entry is a collection named after its export; two entries that export the same name (Color in src/constants/Color and in src/constants/v2/Color) are named by their folders, Color and v2-Color. "collection" fixes the name instead: { "import": "src/constants/v2/Color", "name": "Color", "collection": "v2-Color" } (1 to 100 characters, none of . { }, not starting with $; one entry per name). A fixed name is used as written and nothing moves it: not the other entry going away, not a collision (the unfixed entry is the one that is renamed). Its text-style collection follows it (v2-Color-typography). grails-cli unsync writes it for the sources it leaves behind. An entry whose file is found but gives no token says why: the profile names a wrapper (StyleSheet.create(typographyObject)) and the object is another name in the file, the name is not declared there, or none of its values is a token.

calc() is computed (DTCG has no expressions). Values it cannot express are left out and listed, never guessed.

Text styles are tokens too (typography, one per style, all five fields): a Tailwind 4 --text-X with its --text-X--line-height, --letter-spacing and --font-weight; a Tailwind 3 theme.fontSize entry ([size], [size, lineHeight] or [size, { lineHeight, letterSpacing, fontWeight }]); or a TypeScript object with a fontSize and another typography key. Each field says where it comes from: the style's own value, a token it inherits (the family of fontFamily.sans), a CSS rule that is not a token (body { font-family }), or CSS's initial value (weight 400, letter spacing 0). An em letter spacing is converted against the size and keeps its spelling. A family written as a next/font variable becomes the font the layout names, marked as a heuristic. A style that cannot be formed (no family to inherit, a size that is not a length) is left out and listed.

Moving from the scanner to DTCG: tokens baseline

grails-cli tokens baseline --out ../acme-tokens                # outside the repository: nothing else to say
grails-cli tokens baseline --out tokens --in-repo              # inside it: only because you asked

The scanner's tokens, written as the DTCG files and the resolver Grails' own export writes (exportProject from @grails/dtcg, through the same conversion the API runs, fromCodeUpload). It is the team's first DTCG version : review the files, commit them, and declare them in dtcg. From then on the team owns its generation rules and the scanner stops being the source. The command prints the dtcg block to add and lists the values left out, which are not in the files: the ones the scanner could not express (the list tokens prints, with the reason for each) and what the DTCG export could not carry.

  • It writes only where --out says. A folder inside the git repository also needs --in-repo, exactly like scan --out. A folder that already has files is refused: a baseline never overwrites anything.
  • It refuses while dtcg is set. Those files are the token source, and a baseline from the scanner would compete with them (exit 2).
  • A round trip pins it: what readDtcg reads back from the files equals snapshotProject of the scanner's upload, a text style included: its five fields and where each comes from.

The key

The CLI reads the API key from two places, in this order, and from nowhere else:

  1. GRAILS_API_KEY in the environment: CI, and anyone who prefers it. When it is set the keychain is not even asked.
  2. The operating system's keychain, where grails-cli connect put it.

With neither, a command stops with exit 2 and says to run grails-cli connect or set GRAILS_API_KEY.

A flag would land in the shell history, in the process list and in a CI log that prints the command line, so the CLI refuses any flag that looks like a credential (--api-key, --token, …), and any argument that is itself a key, before it reads anything. The key goes in one header, is never printed, and is scrubbed from everything the CLI prints in case an upstream message echoes it.

Create it in Team Settings › Integrations › API Keys with the write:code scope. It is a personal key: if its owner is demoted to viewer or leaves the team the key is deleted, and the API answers it like a key that never existed (GRAILS_AUTH_INVALID_TOKEN). The CLI says so in those words.

connect and disconnect

The API shows a key once, when it is created. Copy it, then:

grails-cli connect               # reads the clipboard
grails-cli connect --prompt      # asks for it instead; nothing is shown as you type, and the clipboard is not used
grails-cli disconnect            # removes it from the keychain
  • It checks the key before keeping it. The shape first (grails_sk_ and 43 characters), then the same authenticated read status makes. It prints the code sources of the key's project; Grails does not say the team's name yet. A key Grails refuses (unknown, revoked, expired, without the write:code scope) stores nothing, and so does one it could not ask about because the API was unreachable.

  • The clipboard is cleared once it has been read, whatever came of the key, because a key Grails refuses may still be a real one (for another environment, say). It is cleared only if it still holds what was read, so something you copied meanwhile is left alone, and a clipboard with nothing like a key in it is not touched. Clipboard managers can keep a history of what was copied, and nothing here can undo that: --prompt avoids the clipboard altogether.

  • No keychain, no key stored. On a machine with none (a headless Linux box without secret-tool, a platform without an integration) connect says so and stores nothing: use GRAILS_API_KEY. It checks this before it reads the clipboard, so the key you copied is still there, and it never writes the key to a file.

  • The key never goes in an argument, which every process on the machine can read. It reaches the keychain on standard input of the platform's own tool, so there is no native module in the bundle:

    | Platform | Keychain | Clipboard | |---|---|---| | macOS | the Keychain, through /usr/bin/security -i (the command is written to its stdin) | pbpaste, pbcopy | | Linux | the Secret Service (GNOME Keyring, KWallet), through secret-tool from libsecret-tools | wl-paste and wl-copy, else xclip | | Windows | the Credential Manager's PasswordVault, through Windows PowerShell (the key on its stdin) | Get-Clipboard, Set-Clipboard |

  • One entry per API. The service is grails-cli and the account is the host of GRAILS_API_URL, so a staging key and a production key coexist, and a GRAILS_API_URL that something else points at another server finds no key: a stored key is never offered to an address you did not connect it to.

  • disconnect removes the entry. It does not revoke the key: that is done in Team Settings.

GRAILS_API_URL points the CLI at another API (default https://api.grails.design; the docs' …/v1 form is accepted). http:// is accepted only for this machine, and a redirect is never followed, so the key cannot be sent to an address nobody chose.

One retry on a network error, and one on a 429 after the retry_after_seconds the API gives. The Worker counts rate limits but does not block yet, so no real 429 exists today: that retry is the contract's, not a measured behaviour. Opening a sync is retried only when the connection was refused: if the server may have received it, a second open would be answered "another sync is running" and the first would hold the source for two hours.

Updates

Once a day, grails-cli asks the npm registry whether a newer version exists: one plain GET https://registry.npmjs.org/grails-cli/latest, with a 1.5 second limit, no retry, no header of its own, no query, and nothing that says who is asking (not the key, the team, the project or the repository). It never fails or delays a command beyond that limit; offline is silence. The answer is remembered in update-check.json in the config folder ($XDG_CONFIG_HOME/grails-cli, else ~/.config/grails-cli; %APPDATA%\grails-cli on Windows), a file with no secret in it.

While the installed version is behind, every run ends with one line on stderr that names both versions and the command. CI included: a workflow that pins a version is where nobody sees a release go by. A comparison follows semver, so 0.5.0-rc.1 is behind 0.5.0. GRAILS_CLI_NO_UPDATE_CHECK=1 turns it off.

grails-cli update [root] asks now, ignoring the cache, and prints the command for the project's package manager, read from the lockfile in the root: pnpm-lock.yaml is pnpm add -D grails-cli@latest, yarn.lock is yarn add -D grails-cli@latest, anything else is npm i -D grails-cli@latest. A project that does not depend on grails-cli gets the command for the way this copy was installed, read from where the running file is: npm i -g grails-cli@latest for a global npm install, pnpm add -g grails-cli@latest or yarn global add grails-cli@latest for those managers', and npx grails-cli@latest only when npx (or pnpm dlx) ran it, which fetches the latest each time and leaves a global copy as old as it was. It does not run it: the CLI never changes your repository on its own. It exits 0 whether you are behind or not, and 4 when the registry cannot be reached.

What leaves the repository

An allow-list, built field by field (never a copy with fields deleted), enforced again by the API and a last time by the database:

  • the source: name, platform, package name, and the repository address without userinfo, query or fragment (https://gitlab-ci-token:<token>@gitlab.com/… and ?private_token=… are what a CI checkout's remote can look like; an SSH remote becomes its https:// address, or is dropped);
  • git: the commit sha and the branch;
  • per component: key (<file>#<export>), name, file path, export name, the spec hash and the spec, the stories summary (files, titles, count, tags), the coverage numbers, unresolved as { what, line } and other props as { name, kind };
  • at finalize, per component: the keys of the components of the same source it renders, and the collection and name of each token its styles reference.

The spec is the component's public API: prop names, their values, types as declared, anatomy names and resolved style values. It is what a design system documents. Not sent: source code, the CHI (the server computes it), unresolved[].detail (it quotes a fragment of the code), otherProps[].tsType, the scan root, any absolute path. sync --dry-run prints what would go, in counts and bytes, and sends nothing.

Tokens: token names, their declared type, a literal or an alias per mode, and the file each came from, relative to the root. Not sent: file contents, comments, absolute paths, or any environment value a Tailwind config reads outside its theme. grails-cli tokens --json prints exactly what would go.

With dtcg, the files go whole. The token half sends the DTCG files dtcg.files matches, and the resolver, as they are written: every $description, $extensions and $deprecated in them goes too. Grails keeps the descriptions and the extensions with each token. Their keys are paths relative to the resolver's folder (or to the root), never absolute. Only the files the globs match leave; node_modules and dot folders are never walked. grails-cli tokens --json prints the upload exactly.

The GitHub workflow

templates/grails-cli-sync.yml (in this package, and shown in Grails under Team Settings › Integrations) runs npx --yes grails-cli sync --trigger ci on a push to the branch under branches: (main by default), which must be the one grails.code.json declares. Copy it to .github/workflows/grails-cli-sync.yml. Its step has id: grails, so a later step reads steps.grails.outputs.result. The key reaches it through the step's env: from a repository secret named GRAILS_API_KEY, and never appears in a run: line. Do not run it on pull_request: a fork's run has no secrets and no business syncing.

A new release of the CLI can change what a push uploads. To choose when that happens, pin a version in the workflow: npx --yes grails-cli@<version> sync --trigger ci.

License

Proprietary. You may install and use grails-cli to work with Grails; no other right is granted. See LICENSE.md.