grails-cli 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,426 @@
1
- # Temporary Holding Version
1
+ # grails-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Grails Code Sync from the command line: `grails-cli` reads a component library's code and uploads its design
4
+ tokens and each component's spec to [Grails](https://grails.design), so the vault shows what the code actually
5
+ holds, next to Figma. The product is Grails Code Sync the way the Figma plugin is Grails Sync; this is its
6
+ command line. Full documentation: https://docs.grails.design/cli/overview/
7
+
8
+ **Requirements:** Node 20 or newer. The package is free to install and use with Grails; see `LICENSE.md`.
9
+
10
+ ```bash
11
+ npm i -D grails-cli # or run it without installing: npx grails-cli <command>
12
+
13
+ npx grails-cli scan /path/to/library # the manifest of components
14
+ npx grails-cli tokens /path/to/library # the design tokens Grails would read; sends nothing
15
+ npx grails-cli sync /path/to/library --dry-run # what would be uploaded; sends nothing, needs no key
16
+
17
+ npx grails-cli connect # once: keeps the API key you copied in the system keychain; see "The key"
18
+ npx grails-cli sync /path/to/library # in CI, GRAILS_API_KEY from a secret does the same job
19
+ npx grails-cli status
20
+ ```
21
+
22
+ The examples below write `grails-cli`; use `npx grails-cli` if it is not installed globally.
23
+
24
+ ## Commands
25
+
26
+ | Command | What it does |
27
+ |---|---|
28
+ | `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. |
29
+ | `chi [root] [--profile f] [--tier …] [--json]` | The Component Health Index of every component, worst first. |
30
+ | `check-classes [root] [--unknown] [--json]` | Probable Tailwind typos; exits 1 when there is one. |
31
+ | `init [root] [--write] [--force]` | Proposes a `grails.code.json` from the code, with the evidence. |
32
+ | `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. |
33
+ | `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. |
34
+ | `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". |
35
+ | `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. |
36
+ | `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") |
37
+ | `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") |
38
+ | `rules [root] [--set code=figma \| --delete code \| --threshold n] [--json]` | The naming rules of the Code ↔ Figma match (see "Naming rules"). |
39
+ | `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". |
40
+ | `disconnect` | Removes the key `connect` stored for the API `GRAILS_API_URL` points at. |
41
+ | `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. It does not run it. Exits 4 when the registry cannot be reached. See "Updates". |
42
+ | `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. |
43
+
44
+ ### `sync`
45
+
46
+ **Only from the declared branch.** `grails.code.json` says which branch IS the
47
+ source in Grails: `"branch": "main"` (or `preview`, or whatever merges land on); `init` proposes the branch the
48
+ checkout is on. A real sync:
49
+ - **without a declared branch** is refused before anything is read (exit 2), with that sentence;
50
+ - **on another branch** uploads nothing and exits **0**, so a pull request's job stays green, and says so where a CI
51
+ shows it: the terminal, the job summary, a `::notice` on the run, and the step outputs `result=skipped-branch` and
52
+ `exit_code=0` (`GITHUB_OUTPUT`). A detached checkout is judged by `CI_COMMIT_REF_NAME` / `GITHUB_REF_NAME`, so a
53
+ GitHub pull request run (`<number>/merge`) is "another branch"; a folder that is not a checkout has no branch;
54
+ - **on the declared branch** syncs as below, and writes `result=synced` (or `failed`, with its exit code).
55
+ A dry run is never held back: it stores nothing. The server enforces the version that carries this guard: the two
56
+ storing routes refuse a CLI older than 0.2.0 with 400 `GRAILS_CODE_CLI_OUTDATED` (exit 3).
57
+
58
+ **Tokens go first.** The token list is read and
59
+ recorded with `POST /v1/code/token-syncs` in one request. Grails builds the DTCG document from it and answers what
60
+ changed against the source's previous snapshot; the same content is stored once. A token step that fails (a
61
+ project Grails cannot read, the server refusing the list) does **not** stop the components: they sync anyway, and
62
+ the run exits with the token step's code. Once the tokens are recorded, Grails also compares the changes people asked
63
+ for in Grails with what the repository now holds, marks the ones it satisfies applied, and the sync says how many (see
64
+ "Changes asked for in Grails"); that comparison never fails the sync. Then the components:
65
+
66
+ 1. **Opens** the sync with every component key the scan found (`declared_keys`). That is how the server tells a
67
+ component deleted from the code from one that failed to arrive.
68
+ 2. **Sends** the components in requests of at most 50 components and 1 MB (the API refuses more than 50, and
69
+ accepts 2 MB). Grails answers the open with the hash of the spec it already holds for each key; the spec
70
+ travels **only** for a key that is new or whose hash differs.
71
+ 3. **Finalizes** with the scan's own `complete`. Only a complete scan lets the server mark anything deleted, and
72
+ even then it refuses to delete 5 or more components and more than 20 % of the source in one sync.
73
+ Since 0.3.0 the finalize also carries **what each component uses** (canonical entities D1): the components of
74
+ the same source it renders, resolved with the TypeScript checker to their keys, and the tokens its styles
75
+ reference, as the collection and name of the token list just recorded. Grails keeps them as edges of the
76
+ project graph. They are sent only when the tokens were read and recorded (or there are none): otherwise Grails
77
+ keeps the usage it had. A reference no token of the source answers to (a framework default, a source missing
78
+ from `grails.code.json`) is counted in a warning and not sent.
79
+
80
+ If a sync fails after it was opened, the CLI closes it as incomplete before raising the error. Otherwise it stays
81
+ open for two hours and the next run of the same source (a CI retry) is answered "another sync is running".
82
+
83
+ The **source** (what Grails calls this library) is named after the package's own `name`, so it is the same on a
84
+ laptop and in CI; `--name` sets another one, for a monorepo whose packages share nothing else. The platform is
85
+ `react-native` when the package depends on React Native or Expo or any file imports `react-native`, and `web`
86
+ otherwise; `--platform` overrides. The commit and branch come from git when the root is in a checkout (the branch
87
+ from `CI_COMMIT_REF_NAME` or `GITHUB_REF_NAME` when the checkout is detached, as GitLab's is). `--trigger` is `ci`
88
+ when `CI=true` and `local` otherwise.
89
+
90
+ ### `--dry-run`, `--json` and the job summary
91
+
92
+ - **`--dry-run` with a key** (`GRAILS_API_KEY`, or the one `connect` kept) asks Grails what each step would change, through the preview routes
93
+ (`POST /v1/code/token-syncs:preview`, `POST /v1/code/syncs:preview`), which store nothing: tokens added, changed
94
+ and removed; components added, changed, reactivated and **those that would be deleted**, with the same floor the
95
+ real sync applies. It also says what the real sync would do with the changes asked for in Grails (see below): how many
96
+ it would mark applied, close because their token is gone, or leave open, with their ids (`tokens.preview.token_changes`
97
+ in `--json`, and in the job summary). The same verdict the real sync applies, written nowhere.
98
+ - **`--dry-run` without the key** counts locally and sends nothing, as it always did.
99
+ - **`--json`** prints one report on stdout (`mode`, `tokens`, `components`, `exit_code`); the human lines go to
100
+ stderr, so the output can be piped.
101
+ - **In GitHub Actions** (`GITHUB_STEP_SUMMARY` is set) the same report is appended to the job summary: counts,
102
+ and the changed tokens and would-be-deleted components folded. There is no comment on a pull request.
103
+
104
+ No state is written to the repository: what Grails holds is the only source of truth, and the comparison is
105
+ always against it.
106
+
107
+ ### Code ↔ Figma
108
+
109
+ After a real sync records the tokens, `sync` asks Grails how they compare with the Figma ones
110
+ (`GET /v1/code/token-match`) and prints one line: same, differ, conflicts, by value only, only in code, only in
111
+ Figma, and the colour threshold it used (ΔE2000, 1.5 by default). The job summary adds the table, what differs,
112
+ the conflicts (code tokens that disagree about one design token, never resolved) and the **suggested** naming
113
+ rules, which are never applied: naming rules live in Grails, not in this repository.
114
+
115
+ - A project with no Figma snapshot yet says so instead of calling every token missing.
116
+ - A dry run does not ask: Grails would compare the code tokens it already stores, not the ones on disk.
117
+ - 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.
118
+
119
+ ### Naming rules
120
+
121
+ A naming rule tells the match that a word of your code is another word in Figma: `gray` in the code is `neutral`
122
+ in Figma. Rules belong to one source and live in Grails, never in the repository: the CLI,
123
+ the REST API and MCP all edit the same rules.
124
+
125
+ grails-cli rules # this source's rules and the project's colour threshold
126
+ grails-cli rules --set gray=neutral # create, or move its target; the same rule twice is one rule
127
+ grails-cli rules --delete gray
128
+ grails-cli rules --threshold 2 # ΔE2000 under which two colours are equal, for the whole project
129
+
130
+ The source is named as `sync` names it (`--name`, else the package name, else the folder). Listing needs the
131
+ usual key; editing needs a key with the `write:tokens` scope. The job summary of a sync lists the rules applied
132
+ and the ones Grails suggests from tokens with the same value and one different word.
133
+
134
+ ### Changes asked for in Grails
135
+
136
+ In Grails, an admin or an editor can ask for changes to this repository's tokens: a new value, a new token,
137
+ collection or mode, a removal. **Grails never writes your repository, and neither does this CLI.**
138
+
139
+ grails-cli tokens pending # by collection: type, before → after as DTCG values, and the id
140
+ grails-cli tokens pending --json # the list as Grails answers it, every value exact
141
+ grails-cli tokens pending --limit 1000 # the read answers 500 by default, at most 1000
142
+ grails-cli tokens pending --collection color # only that collection's changes
143
+ grails-cli tokens pending --request <request-id> # only one change request's (its id is under the heading)
144
+
145
+ Carry each change into your token source, or into the rules that generate it (a Style Dictionary build, a Tokens
146
+ Studio export, your CSS). The loop closes by itself:
147
+
148
+ - **The next `sync` marks a change applied** once your repository holds its value. Grails compares the **resolved**
149
+ value of the token in that mode (an alias counts by the value it resolves to), with the tolerance the Diff uses for
150
+ colours and sizes. A token you removed closes its change; a created token, collection or mode closes when it exists.
151
+ The sync prints how many (`token_changes` in `--json`).
152
+ - **If the repository holds another value, the change stays.** Grails keeps proposing its value, checks again on every
153
+ sync, and records what your code holds. The "before" that `tokens pending` prints is what your repository holds now.
154
+ - **A change your team will not carry is closed with a reason:**
155
+
156
+ grails-cli tokens discard <change-id> --reason "Handled in the design-tokens repo"
157
+
158
+ The id is the one `tokens pending` prints beside the change. Grails keeps the change, closed, with who discarded
159
+ it (the owner of the API key: it is the only identity the API can verify), that it came from code, and the reason,
160
+ which is required (1 to 1000 characters, one line, no control characters). Discarding a collection takes its modes
161
+ and tokens with it; one value of a token being created cannot go alone, discard the token. A change that is
162
+ already applied or discarded answers exit 3.
163
+
164
+ - **Many at once, in one step.** Exactly one of three picks the changes, and the reason is required:
165
+
166
+ grails-cli tokens discard --all --reason "Handled in the design-tokens repo" # everything waiting for this repository
167
+ grails-cli tokens discard --all --collection color --reason "…" # one collection's
168
+ grails-cli tokens discard --request <request-id> --reason "…" # one change request's
169
+
170
+ The set is closed all together or not at all, and at most 1000 at once (more is refused with the way out: a
171
+ collection, or a request). **`--dry-run` lists what would go (id, token, before → after) and changes nothing**; the
172
+ reason is optional then. There is no `--yes`: the reason is the commitment. The answer, and `--json`, say how many
173
+ were discarded and which. `--collection` goes with `--all` only, and the same words select the same changes in
174
+ `tokens pending`, so you can look before you close. A change someone discards in Grails is a different path and is
175
+ deleted; these stay, with the reason.
176
+ - **A dry-run sync shows what the next sync would close:** `grails-cli sync --dry-run` (with the key) prints how many
177
+ changes it would mark applied, close because their token is gone, or leave open, and their ids, without storing
178
+ anything. `grails-cli status` shows how many changes wait for each source.
179
+
180
+ An sRGB colour shows as its hex and a dimension as `24px`; anything else shows as its DTCG JSON, and `--json` has the
181
+ exact components. The source is named as `sync` names it (`--name`, else the package name); a change must belong to it.
182
+ Both commands need the usual key (`write:code`).
183
+
184
+ ### Exit codes
185
+
186
+ A contract a CI step can branch on. Only 4 is worth retrying. A sync on another branch is 0; its own
187
+ result, `skipped-branch`, is in the step output `result` (see `sync` above), since any other code would turn the step
188
+ red.
189
+
190
+ | code | meaning | retry? |
191
+ |---|---|---|
192
+ | 0 | done: synced, nothing changed, or a dry run | – |
193
+ | 1 | the component sync ran and ended incomplete (unreadable components, or deletions held back) | no: read the warnings |
194
+ | 2 | misconfigured: a flag, `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 |
195
+ | 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 |
196
+ | 4 | the server could not be reached or failed (network, 5xx, a 429 after one retry); for `update`, the npm registry | yes |
197
+
198
+ ### Where the tokens come from
199
+
200
+ **The team's own DTCG files, when `grails.code.json` names them** (the official way in):
201
+
202
+ "dtcg": { "files": ["tokens/**/*.json"], "resolver": "tokens/resolver.json" }
203
+
204
+ - **One source.** With `dtcg`, these files are the only token source. Nothing is read from Tailwind, CSS or `tokens`,
205
+ and a run that also lists `css` or `tokens` says so.
206
+ - **Sent as written.** `sync` uploads them as they are (`dtcg: { files, resolver? }` with `client: grails-cli`), and
207
+ the API reads them with `@grails/dtcg`'s `readDtcg`.
208
+ - **Checked here first**, so a wrong repository sends nothing (exit 2):
209
+ - the file count (500) and the body size (4 MiB);
210
+ - a file outside the resolver's folder (a resolver names its files from where it is, so every key is relative to it);
211
+ - two keys that differ only in case;
212
+ - JSON that does not parse.
213
+ - **What the API refuses comes back as findings** (422, or 413 for a limit). The run prints each one with the file the
214
+ person knows and its JSON pointer, and exits 3.
215
+ - **Usage.** Component uses go with the finalize, and so do the token uses a `var(--x)` can be tied to. Nothing in the
216
+ files says which token a custom property is, so the team says how its CSS is named:
217
+
218
+ "dtcg": { "files": [...], "cssVariables": { "prefix": "ds", "exceptions": { "--brand": "color.brand.500" }, "ignore": ["--radix-*", "--tw-*"] } }
219
+
220
+ - **The convention** is Style Dictionary's default, its `css` transform group (`name/kebab`): the token's path
221
+ segments joined with `-`, lower case, behind the build's `prefix` (`color.brand.500` is `--ds-color-brand-500`).
222
+ A digit stays with its letters (`2xl`, `body2`), camelCase is split, any other character is a separator, and a name
223
+ is case-sensitive. `cssVariableName` in `@grails/dtcg` is that rule, pinned against the real Style Dictionary.
224
+ - **The direction is token → name.** A name does not say its token (`brandPrimary` and `brand-primary` are two tokens and
225
+ one name), so the CLI names every token of the upload and looks a variable up in that index. A name
226
+ two tokens share is reported as ambiguous, never given to one of them.
227
+ - **`exceptions`** map a custom property that does not follow the convention to the token it is. They win over the
228
+ convention, and an exception whose token is not in the upload is reported (and the name does not fall back to the
229
+ convention).
230
+ - **`ignore`** is a list of patterns for custom properties that are not the team's tokens at all (a library's, the
231
+ framework's): `["--radix-*", "--tw-*"]`. A pattern starts with `--`, `*` is the only wildcard, and there are at most
232
+ 200. A name it covers is taken out first: not resolved (so not sent, **even when the convention would have found a
233
+ token of that name**), not counted as used, not listed. An exception for the same name wins over `ignore`: it names a
234
+ token on purpose. A pattern that covers nothing used is not an error; `--json` lists it in
235
+ `css_variables.ignore_unused` so the team can delete it. The terminal line says how many were ignored, never which.
236
+ Use `exceptions` for what a name IS, `ignore` for "this is not ours".
237
+ - **The files are read here** with `readDtcg`, the reader the API runs, to know the tokens: `sync --dry-run` lists the
238
+ result with no key and sends nothing. A token goes to the API as `{ collection, name }` (the first level of its
239
+ path, and the rest joined with `/`), which the server turns back into the path with `tokenPath`; a token with a `/`
240
+ inside a level cannot be spelt that way and is reported.
241
+ - **Every `var(--x)` that resolves to no token is listed** (the terminal prints 20, `--json` carries 200 in
242
+ `css_variables`, the job summary folds them), with an exception that names a missing token. It never changes the
243
+ exit code.
244
+ - **What it reads.** A `var(--x)` a component can use, in three places, as `uses` outside the spec
245
+ (no hash moves, and the manifest keeps its shape and its version):
246
+ - the `style` of any element of the component, **every property** (`margin`, `border`, `boxShadow`, …), also `*Style`,
247
+ `css` and `sx` props, a constant or style sheet it refers to, and every branch of a condition or lookup;
248
+ - **the stylesheet its file imports** (`.css`, CSS modules, `.scss`, `.less`, `.pcss`), for every component that file
249
+ holds; a file two files import counts for both. Each file is read once per scan, only inside the project and never
250
+ under `node_modules`, at most 1 MiB, and 16 MiB of CSS in all (what is left out is a warning). `@import` is **not**
251
+ followed: a stylesheet that imports a tokens sheet would hand the component the aliases of the whole token layer.
252
+ A custom property a rule *declares* in `:root`, a theme selector or `@theme` is a token's definition, not a use;
253
+ - **Tailwind classes** in `className` and in `cn`, `clsx`, `classnames`, `cx`, `twMerge` and `cva` calls, variants
254
+ included: `bg-[var(--x)]` in either major, `bg-(--x)` (also `bg-(color:--x)`) in Tailwind 4 and `bg-[--x]` in
255
+ 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)`,
256
+ so each major reads the form it compiles. With no Tailwind version found, the 4 form is read.
257
+ - **What it does not read.** A name a template assembles at run time, a class helper it does not know (`tv()`),
258
+ CSS-in-JS template literals, a component its file defines but does not export, a property set from JavaScript. Other
259
+ kinds of token reference (a Tailwind theme class such as `bg-primary`, a TypeScript token object) are counted, not
260
+ tied. A `bg-[var(--x)]` stays a literal colour in the spec itself, as before: only `uses` changed.
261
+
262
+ **Without `dtcg`, the scanner reads** (a convenience for a first sync):
263
+
264
+ - **A Tailwind 3 theme**, read with the **project's own** `tailwindcss` (`loadConfig` + `resolveConfig`), defaults
265
+ included. ⚠️ That executes `tailwind.config.*`, as the project's build does. Install the project's dependencies
266
+ first (`npm ci`): without them the run exits 2, and never reads the theme with a copy of its own.
267
+ - **Tailwind 4**: the `@theme` stylesheet, read with the project's `tailwindcss` (`__unstable__loadDesignSystem`),
268
+ defaults included; its CSS variables become a collection with a mode per selector (`:root` light, `.dark` dark).
269
+ - **CSS files** listed in `grails.code.json`'s `css`, read with postcss.
270
+ - **TypeScript token objects and enums** listed in `grails.code.json`'s `tokens`.
271
+
272
+ `calc()` is computed (DTCG has no expressions). Values it cannot express are left out and listed, never guessed.
273
+
274
+ **Text styles** are tokens too (`typography`, one per style, all five fields): a Tailwind 4 `--text-X` with its
275
+ `--text-X--line-height`, `--letter-spacing` and `--font-weight`; a Tailwind 3 `theme.fontSize` entry (`[size]`,
276
+ `[size, lineHeight]` or `[size, { lineHeight, letterSpacing, fontWeight }]`); or a TypeScript object with a
277
+ `fontSize` and another typography key. Each field says where it comes from: the style's own value, a token it
278
+ inherits (the family of `fontFamily.sans`), a CSS rule that is not a token (`body { font-family }`), or CSS's initial
279
+ value (weight 400, letter spacing 0). An `em` letter spacing is converted against the size and keeps its spelling. A
280
+ family written as a `next/font` variable becomes the font the layout names, marked as a heuristic. A style that
281
+ cannot be formed (no family to inherit, a size that is not a length) is left out and listed.
282
+
283
+ #### Moving from the scanner to DTCG: `tokens baseline`
284
+
285
+ grails-cli tokens baseline --out ../acme-tokens # outside the repository: nothing else to say
286
+ grails-cli tokens baseline --out tokens --in-repo # inside it: only because you asked
287
+
288
+ The scanner's tokens, written as the DTCG files and the resolver Grails' own export writes (`exportProject` from
289
+ `@grails/dtcg`, through the same conversion the API runs, `fromCodeUpload`). It is the team's first DTCG version
290
+ : review the files, commit them, and declare them in `dtcg`. From then on the team owns its
291
+ generation rules and the scanner stops being the source. The command prints the `dtcg` block to
292
+ add and lists the values left out, which are not in the files: the ones the scanner could not express (the list
293
+ `tokens` prints, with the reason for each) and what the DTCG export could not carry.
294
+
295
+ - **It writes only where `--out` says.** A folder inside the git repository also needs `--in-repo`, exactly like
296
+ `scan --out`. A folder that already has files is refused: a baseline never overwrites anything.
297
+ - **It refuses while `dtcg` is set.** Those files are the token source, and a baseline from the scanner would compete
298
+ with them (exit 2).
299
+ - **A round trip pins it:** what `readDtcg` reads back from the files equals
300
+ `snapshotProject` of the scanner's upload, a text style included: its five fields and where each comes from.
301
+
302
+ ## The key
303
+
304
+ The CLI reads the API key from two places, in this order, and from nowhere else:
305
+
306
+ 1. **`GRAILS_API_KEY` in the environment**: CI, and anyone who prefers it. When it is set the keychain is not even asked.
307
+ 2. **The operating system's keychain**, where `grails-cli connect` put it.
308
+
309
+ With neither, a command stops with exit 2 and says to run `grails-cli connect` or set `GRAILS_API_KEY`.
310
+
311
+ 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
312
+ refuses any flag that looks like a credential (`--api-key`, `--token`, …), and any argument that is itself a key,
313
+ before it reads anything. The key goes in one header, is never printed, and is scrubbed from everything the CLI
314
+ prints in case an upstream message echoes it.
315
+
316
+ Create it in **Team Settings › Integrations › API Keys** with the `write:code` scope. It is a personal key: if its
317
+ owner is demoted to viewer or leaves the team the key is **deleted**, and the API answers it like a key that never
318
+ existed (`GRAILS_AUTH_INVALID_TOKEN`). The CLI says so in those words.
319
+
320
+ ### `connect` and `disconnect`
321
+
322
+ The API shows a key once, when it is created. Copy it, then:
323
+
324
+ ```bash
325
+ grails-cli connect # reads the clipboard
326
+ grails-cli connect --prompt # asks for it instead; nothing is shown as you type, and the clipboard is not used
327
+ grails-cli disconnect # removes it from the keychain
328
+ ```
329
+
330
+ - **It checks the key before keeping it.** The shape first (`grails_sk_` and 43 characters), then the same
331
+ authenticated read `status` makes. It prints the code sources of the key's project; Grails does not say the team's
332
+ name yet. A key Grails refuses (unknown, revoked, expired, without the `write:code` scope) stores **nothing**, and so
333
+ does one it could not ask about because the API was unreachable.
334
+ - **The clipboard is cleared once it has been read, whatever came of the key**, because a key Grails refuses may still be
335
+ a real one (for another environment, say). It is cleared only if it still holds what was read, so something you
336
+ copied meanwhile is left alone, and a clipboard with nothing like a key in it is not touched. Clipboard managers can
337
+ keep a history of what was copied, and nothing here can undo that: `--prompt` avoids the clipboard altogether.
338
+ - **No keychain, no key stored.** On a machine with none (a headless Linux box without `secret-tool`, a platform
339
+ without an integration) `connect` says so and stores nothing: use `GRAILS_API_KEY`. It checks this before it reads the
340
+ clipboard, so the key you copied is still there, and it never writes the key to a file.
341
+ - **The key never goes in an argument**, which every process on the machine can read. It reaches the keychain on standard
342
+ input of the platform's own tool, so there is no native module in the bundle:
343
+
344
+ | Platform | Keychain | Clipboard |
345
+ |---|---|---|
346
+ | macOS | the Keychain, through `/usr/bin/security -i` (the command is written to its stdin) | `pbpaste`, `pbcopy` |
347
+ | Linux | the Secret Service (GNOME Keyring, KWallet), through `secret-tool` from `libsecret-tools` | `wl-paste` and `wl-copy`, else `xclip` |
348
+ | Windows | the Credential Manager's `PasswordVault`, through Windows PowerShell (the key on its stdin) | `Get-Clipboard`, `Set-Clipboard` |
349
+
350
+ - **One entry per API.** The service is `grails-cli` and the account is the host of `GRAILS_API_URL`, so a staging key
351
+ and a production key coexist, and a `GRAILS_API_URL` that something else points at another server finds no key: a
352
+ stored key is never offered to an address you did not connect it to.
353
+ - `disconnect` removes the entry. It does not revoke the key: that is done in Team Settings.
354
+
355
+ `GRAILS_API_URL` points the CLI at another API (default `https://api.grails.design`; the docs' `…/v1` form is
356
+ accepted). `http://` is accepted only for this machine, and a redirect is never followed, so the key cannot be sent
357
+ to an address nobody chose.
358
+
359
+ One retry on a network error, and one on a `429` after the `retry_after_seconds` the API gives. The Worker counts
360
+ rate limits but does not block yet, so no real `429` exists today: that retry is the contract's, not a measured
361
+ behaviour. Opening a sync is retried only when the connection was *refused*: if the server may have received it, a
362
+ second open would be answered "another sync is running" and the first would hold the source for two hours.
363
+
364
+ ## Updates
365
+
366
+ Once a day, `grails-cli` asks the npm registry whether a newer version exists: one plain
367
+ `GET https://registry.npmjs.org/grails-cli/latest`, with a 1.5 second limit, no retry, no header of its own, no query,
368
+ and nothing that says who is asking (not the key, the team, the project or the repository). It never fails or delays a
369
+ command beyond that limit; offline is silence. The answer is remembered in `update-check.json` in the config folder
370
+ (`$XDG_CONFIG_HOME/grails-cli`, else `~/.config/grails-cli`; `%APPDATA%\grails-cli` on Windows), a file with no secret in it.
371
+
372
+ While the installed version is behind, **every run** ends with one line on stderr that names both versions and the
373
+ command. CI included: a workflow that pins a version is where nobody sees a release go by. A comparison follows
374
+ semver, so `0.5.0-rc.1` is behind `0.5.0`. `GRAILS_CLI_NO_UPDATE_CHECK=1` turns it off.
375
+
376
+ `grails-cli update [root]` asks now, ignoring the cache, and prints the command for the project's package manager,
377
+ read from the lockfile in the root: `pnpm-lock.yaml` is `pnpm add -D grails-cli@latest`, `yarn.lock` is
378
+ `yarn add -D grails-cli@latest`, anything else is `npm i -D grails-cli@latest`; a project that does not depend on
379
+ `grails-cli` gets `npx grails-cli@latest`. **It does not run it**: the CLI never changes your repository on its own.
380
+ It exits 0 whether you are behind or not, and 4 when the registry cannot be reached.
381
+
382
+ ## What leaves the repository
383
+
384
+ An allow-list, built field by field (never a copy with fields deleted), enforced again by the
385
+ API and a last time by the database:
386
+
387
+ - the source: name, platform, package name, and the repository address **without userinfo, query or fragment**
388
+ (`https://gitlab-ci-token:<token>@gitlab.com/…` and `?private_token=…` are what a CI checkout's remote can look
389
+ like; an SSH remote becomes its `https://` address, or is dropped);
390
+ - git: the commit sha and the branch;
391
+ - per component: key (`<file>#<export>`), name, file path, export name, the spec hash and **the spec**, the
392
+ stories summary (files, titles, count, tags), the coverage numbers, `unresolved` as `{ what, line }` and other
393
+ props as `{ name, kind }`;
394
+ - at finalize, per component: the keys of the components of the same source it renders, and the collection and
395
+ name of each token its styles reference.
396
+
397
+ The spec is the component's public API: prop names, their values, types as declared, anatomy names and resolved
398
+ style values. It is what a design system documents. **Not** sent: source code, the CHI (the server computes it),
399
+ `unresolved[].detail` (it quotes a fragment of the code), `otherProps[].tsType`, the scan root, any absolute path.
400
+ `sync --dry-run` prints what would go, in counts and bytes, and sends nothing.
401
+
402
+ **Tokens:** token names, their declared type, a literal or an alias per mode, and the file each came from, relative
403
+ to the root. Not sent: file contents, comments, absolute paths, or any environment value a Tailwind config reads
404
+ outside its theme. `grails-cli tokens --json` prints exactly what would go.
405
+
406
+ **With `dtcg`, the files go whole.** The token half sends the DTCG files `dtcg.files` matches, and the resolver, as
407
+ they are written: every `$description`, `$extensions` and `$deprecated` in them goes too. Grails keeps the
408
+ descriptions and the extensions with each token. Their keys are paths relative to the resolver's folder (or to the
409
+ root), never absolute. Only the files the globs match leave; `node_modules` and dot folders are never walked.
410
+ `grails-cli tokens --json` prints the upload exactly.
411
+
412
+ ## The GitHub workflow
413
+
414
+ `templates/grails-cli-sync.yml` (in this package, and shown in Grails under Team Settings › Integrations) runs
415
+ `npx --yes grails-cli sync --trigger ci` on a push to the branch under `branches:` (`main` by default), which must
416
+ be the one `grails.code.json` declares. Copy it to `.github/workflows/grails-cli-sync.yml`. Its step has
417
+ `id: grails`, so a later step reads `steps.grails.outputs.result`.
418
+ The key reaches it through the step's `env:` from a repository secret named `GRAILS_API_KEY`, and never appears in a
419
+ `run:` line. Do not run it on `pull_request`: a fork's run has no secrets and no business syncing.
420
+
421
+ A new release of the CLI can change what a push uploads. To choose when that happens, pin a version in the
422
+ workflow: `npx --yes grails-cli@<version> sync --trigger ci`.
423
+
424
+ ## License
425
+
426
+ Proprietary. You may install and use `grails-cli` to work with Grails; no other right is granted. See `LICENSE.md`.