@playgenx/components 0.2.0 → 0.2.2

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,62 +1,444 @@
1
1
  # `@playgenx/components`
2
2
 
3
- Default React 19 implementations for every entry in `DEFAULT_REGISTRY`. Pair with `@playgenx/registry` and the parser/validator pipeline.
3
+ Default React 19 implementations for every entry in `DEFAULT_REGISTRY`.
4
+ The components, a per-Provider reactive state store, error boundaries,
5
+ and a CLI for inspecting state snapshots.
4
6
 
5
- ## Install
7
+ ```bash
8
+ npm install @playgenx/components
9
+ ```
10
+
11
+ React 19 is a peer dependency — your app provides it.
12
+
13
+ ```jsonc
14
+ // package.json (the bits you need)
15
+ {
16
+ "dependencies": {
17
+ "@playgenx/components": "^0.2.1",
18
+ "react": "^19.2.0",
19
+ "react-dom": "^19.2.0"
20
+ }
21
+ }
22
+ ```
23
+
24
+ > Looking for the **renderer** (parser + tree walker)? That's a
25
+ > separate package — `@playgenx/renderer`. This package is the
26
+ > component library; the renderer consumes it.
27
+
28
+ ---
29
+
30
+ ## Quick start
31
+
32
+ ```tsx
33
+ import {
34
+ Slider,
35
+ PlaygroundStateProvider,
36
+ usePlaygroundState,
37
+ } from '@playgenx/components';
38
+ import { useState } from 'react';
39
+
40
+ // 1. Standalone, no state — works without a Provider
41
+ export function StaticVolume() {
42
+ return <Slider min={0} max={10} value={4} label="Volume" />;
43
+ }
44
+
45
+ // 2. With shared state — wrap your tree
46
+ export function VolumePanel() {
47
+ return (
48
+ <PlaygroundStateProvider initial={{ volume: 5 }}>
49
+ <Slider min={0} max={10} stateKey="volume" label="Volume" />
50
+ <VolumeReadout />
51
+ </PlaygroundStateProvider>
52
+ );
53
+ }
6
54
 
7
- ```sh
8
- pnpm add @playgenx/components react@^19 react-dom@^19
55
+ function VolumeReadout() {
56
+ const state = usePlaygroundState();
57
+ return <span>Current: {state.get<number>('volume')}</span>;
58
+ }
9
59
  ```
10
60
 
11
- React 19 is a peer dependency; this package does not bundle it.
61
+ ---
62
+
63
+ ## What's in the box
64
+
65
+ ### Components (PascalCase, React 19)
66
+
67
+ Every component accepts JSON-friendly props. See `index.ts` for the
68
+ full surface.
69
+
70
+ | Component | Purpose | Notable props |
71
+ |---------------|--------------------------------------------|--------------------------------|
72
+ | `Button` | Headless button | `label`, `variant`, `onClick` |
73
+ | `TextField` | Controlled / uncontrolled input | `value`, `onChange` |
74
+ | `Slider` | Range input | `min`, `max`, `value`, `stateKey` |
75
+ | `Chart` | bar / line / pie SVG charts | `kind`, `data`, `title` |
76
+ | `Container` | Layout primitive | `padding`, `gap` |
77
+ | `Code` | Inline code / `<pre><code>` | `language` |
78
+ | `Heading` | `<h1>`–`<h6>` | `level` |
79
+ | `Text` | `<p>` with weight + color | `weight`, `color` |
80
+ | `Stepper` | Multi-step reveal | `steps`, `initial` |
81
+ | `Card` | Titled card | `title`, `children` |
82
+ | `List` | `<ul>` / `<ol>` from array | `items`, `ordered` |
83
+
84
+ ### State store + React glue
85
+
86
+ | Export | What it does |
87
+ |------------------------------|-----------------------------------------------------------|
88
+ | `createPlaygroundState(initial?)` | Create a standalone state store |
89
+ | `PlaygroundStateProvider` | Wrap your tree with a Provider |
90
+ | `usePlaygroundState()` | Read the nearest store |
91
+ | `useBoundValue(key, fb)` | Get a live `[value, setter]` tuple (re-renders on change) |
92
+ | `useBoundValueOrUndefined(key, fb)` | Same; returns `undefined` if no key/provider |
93
+ | `useStoreSnapshot()` | Non-subscribing `{get, set, subscribe, snapshot, ...}` |
94
+ | `useBoundSelector(key, sel, fb)` | Live bound value with selector transform; only re-renders on slice change |
95
+ | `useMultiBoundValue(bindings)` | Bind multiple keys; returns `Record<key, [value, setter]>` |
96
+ | `useStateAction(action)` | Build an `onClick` from `{set, toggle}` config |
97
+
98
+ ### Pure helpers (no React, no Provider)
99
+
100
+ | Export | Returns |
101
+ |-----------------------|------------------------------------|
102
+ | `dumpState(state)` | JSON v1 envelope (sorted keys) |
103
+ | `diffState(a, b)` | `{added, changed, removed}` or null |
104
+ | `diffSnapshots(prev, next)` | Same as `diffState` but pure |
105
+ | `validateStateKey(k)` | `null` if valid, error message if not |
106
+ | `clearState(state)` | (void) — sets every key to undefined |
107
+ | `batch(state, fn)` | Atomic set commit, per-key dedup |
108
+
109
+ ### Error handling
12
110
 
13
- ## What's in here
111
+ | Export | Purpose |
112
+ |------------------------------|------------------------------------|
113
+ | `ArtifactErrorBoundary` | Class-component error boundary |
114
+ | `ShowSource` | Render-prop source-toggle |
14
115
 
15
- 11 components, each matching a name in `@playgenx/registry`:
116
+ ### Registry
16
117
 
17
- - `Button` — primary / secondary / ghost variant, disabled state, click handler.
18
- - `TextField` — labeled controlled-or-uncontrolled input.
19
- - `Slider` clamped range input with optional caption.
20
- - `Chart` bar / line / pie, hand-rolled SVG, no chart library.
21
- - `Container` — vertical flex wrapper with padding + gap.
22
- - `Code` — inline or `<pre><code>` formatting, language data-attribute.
23
- - `Heading` — `<h1>`..`<h6>` with size-by-level, clamped.
24
- - `Text` — `<p>` with weight, size, color.
25
- - `Stepper` — multi-step reveal with previous/current/future states.
26
- - `Card` — bordered + shadowed wrapper with elevation prop.
27
- - `List` — `<ul>` or `<ol>` from an array of arbitrary values.
118
+ | Export | Purpose |
119
+ |-------------------------|--------------------------------------|
120
+ | `componentMap` | Frozen default registry (11 entries) |
121
+ | `createRegistry(overrides)` | Returns a new frozen map |
28
122
 
29
- ## Use
123
+ ---
124
+
125
+ ## Three ways to use the state store
126
+
127
+ ### 1. Standalone, outside React
128
+
129
+ ```ts
130
+ import { createPlaygroundState } from '@playgenx/components';
131
+
132
+ const state = createPlaygroundState({ count: 0 });
133
+ state.set('count', 5);
134
+ state.get('count'); // 5
135
+ state.snapshot(); // { count: 5 }
136
+ state.subscribe('count', (v) => console.log('count is now', v));
137
+ state.set('count', 10); // → console.log fires on the next microtask
138
+ ```
139
+
140
+ ### 2. React tree with a Provider
30
141
 
31
142
  ```tsx
32
- import { Button, Container, Heading, Text, componentMap } from '@playgenx/components';
33
- import { DEFAULT_REGISTRY, findSchema } from '@playgenx/registry';
143
+ import { PlaygroundStateProvider, useBoundValue } from '@playgenx/components';
34
144
 
35
- function App() {
145
+ function VolumeControl() {
146
+ // Live-bound: writes to state on change, re-renders on external writes
147
+ const [volume, setVolume] = useBoundValue<number>('volume', 50);
36
148
  return (
37
- <Container>
38
- <Heading level={2}>Hello</Heading>
39
- <Text>A short description.</Text>
40
- <Button label="Continue" variant="primary" />
41
- </Container>
149
+ <input
150
+ type="range"
151
+ min={0}
152
+ max={100}
153
+ value={volume}
154
+ onChange={(e) => setVolume(Number(e.currentTarget.value))}
155
+ />
42
156
  );
43
157
  }
44
158
 
45
- // Render-time lookup:
46
- const HeadingC = componentMap['Heading'];
47
- return <HeadingC level={2}>title</HeadingC>;
159
+ export function App() {
160
+ return (
161
+ <PlaygroundStateProvider initial={{ volume: 50 }}>
162
+ <VolumeControl />
163
+ </PlaygroundStateProvider>
164
+ );
165
+ }
166
+ ```
167
+
168
+ ### 3. State-bound component (the simple path)
169
+
170
+ ```tsx
171
+ // No need to write your own useBoundValue — Slider + stateKey does it.
172
+ <PlaygroundStateProvider initial={{ volume: 50 }}>
173
+ <Slider min={0} max={100} stateKey="volume" />
174
+ </PlaygroundStateProvider>
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Flexibility patterns — pick the right hook for the job
180
+
181
+ v0.2.2 ships five hooks for binding components to state. They differ
182
+ in **what triggers a re-render**, **what they assume about Providers**,
183
+ and **how they expose state to your code**. Pick the lowest-cost one
184
+ that fits.
185
+
186
+ | Hook | Subscribes? | Needs Provider? | SSR-safe? | Use when… |
187
+ |------------------------|-------------|------------------|-----------|-----------------------------------------|
188
+ | `useBoundValue` | yes | yes (throws) | yes | You need to re-render on state changes and a Provider is mandatory |
189
+ | `useBoundValueOrUndefined` | yes | no (graceful) | yes | `stateKey` is optional; no Provider = no-op binding |
190
+ | `useStoreSnapshot` | **no** | yes (throws) | n/a | You read/write state in handlers but don't want re-renders |
191
+ | `useBoundSelector` | yes (filtered) | yes (throws) | yes | One key holds an object; you only care about one slice |
192
+ | `useMultiBoundValue` | yes (1 per key) | yes (throws) | yes | A form/dashboard binds 5-10 keys; you want a Record |
193
+
194
+ ### `useStoreSnapshot` — for non-subscribing reads
195
+
196
+ When you need state in event handlers but don't want the component to
197
+ re-render on every change:
198
+
199
+ ```tsx
200
+ import { useStoreSnapshot } from '@playgenx/components';
201
+
202
+ function SaveButton() {
203
+ const { get, set, snapshot } = useStoreSnapshot();
204
+ return (
205
+ <button onClick={() => {
206
+ // Read multiple keys atomically, write back, no re-render
207
+ const form = snapshot();
208
+ if (form.dirty) {
209
+ set('savedAt', Date.now());
210
+ api.save(form);
211
+ }
212
+ }}>
213
+ Save
214
+ </button>
215
+ );
216
+ }
48
217
  ```
49
218
 
50
- ## Determinism
219
+ The hook returns `{ state, get, set, subscribe, snapshot, replaceAll }` —
220
+ all stable references for the Provider's lifetime.
51
221
 
52
- Components are render-only and **never** reference `Math.random`, `Date.now`, or runtime-crypto APIs. Hashing inputs (validation, storage) is fully reproducible — `<Chart kind="bar" data={...} />` renders the same SVG every time given the same data.
222
+ ### `useBoundSelector` slice-aware subscriptions
53
223
 
54
- If you mount these in an environment where a renderer may pass non-deterministic callbacks, run inside a sandboxed iframe that the LLM never controls.
224
+ When one key holds an object and you only care about one field:
225
+
226
+ ```tsx
227
+ import { useBoundSelector } from '@playgenx/components';
228
+
229
+ interface User { name: string; age: number; email: string; }
230
+
231
+ function CurrentUserBadge() {
232
+ // Only re-renders when `name` changes, not when `age` changes.
233
+ const [name] = useBoundSelector<User, string>(
234
+ 'currentUser',
235
+ (u) => u?.name ?? 'guest',
236
+ 'guest',
237
+ );
238
+ return <span>Hi, {name}!</span>;
239
+ }
240
+ ```
241
+
242
+ **Important**: write selectors that return primitives or stable refs.
243
+ A selector that returns a fresh object (`u => ({...u})`) defeats the
244
+ slice-detection and re-renders on every store change.
245
+
246
+ ### `useMultiBoundValue` — bind many keys at once
247
+
248
+ For forms and dashboards where you have several bound inputs in one
249
+ component:
250
+
251
+ ```tsx
252
+ import { useMultiBoundValue } from '@playgenx/components';
253
+
254
+ function SettingsForm() {
255
+ const form = useMultiBoundValue({
256
+ name: { fallback: '' },
257
+ email: { fallback: '' },
258
+ age: { fallback: 0 },
259
+ role: { fallback: 'user' },
260
+ });
261
+ return (
262
+ <>
263
+ <input value={form.name[0]} onChange={e => form.name[1](e.target.value)} />
264
+ <input value={form.email[0]} onChange={e => form.email[1](e.target.value)} />
265
+ <input type="number" value={form.age[0]} onChange={e => form.age[1](+e.target.value)} />
266
+ <select value={form.role[0]} onChange={e => form.role[1](e.target.value)}>
267
+ <option value="user">User</option>
268
+ <option value="admin">Admin</option>
269
+ </select>
270
+ </>
271
+ );
272
+ }
273
+ ```
274
+
275
+ The hook subscribes once per key (so 5 keys = 5 subscriptions). For
276
+ 50+ rows of dynamic data, use `useStoreSnapshot` + manual `get`/`set`
277
+ calls instead — the hook count is bounded.
278
+
279
+ ### SSR — state is reflected in the first render
280
+
281
+ All `useBoundValue*` hooks use React 19's `useSyncExternalStore` so
282
+ the **server-rendered HTML matches the live store value**, not a
283
+ fallback. `renderToString(<Slider stateKey="v" />)` inside a Provider
284
+ with `initial={{ v: 7 }}` produces `<input value="7" ... />` — no
285
+ hydration mismatch.
286
+
287
+ ```ts
288
+ // Server
289
+ import { renderToString } from 'react-dom/server';
290
+ import { Slider, PlaygroundStateProvider } from '@playgenx/components';
291
+
292
+ const html = renderToString(
293
+ <PlaygroundStateProvider initial={{ volume: 50 }}>
294
+ <Slider min={0} max={100} stateKey="volume" />
295
+ </PlaygroundStateProvider>
296
+ );
297
+ // html contains `value="50"` — not `value="0"` (the local fallback)
298
+ ```
299
+
300
+ If your framework has a `renderToReadableStream` or similar, the same
301
+ guarantee holds. The hook count is **3** (down from 5 in 0.2.1)
302
+ thanks to `useSyncExternalStore` collapsing the previous
303
+ `useState + useState + useEffect` trio.
304
+
305
+ ---
306
+
307
+ ## Slider — the three operating modes
308
+
309
+ The `Slider` component accepts `value` and/or `stateKey`. They mean
310
+ different things:
311
+
312
+ | Pass | Mode | Behavior |
313
+ |---------------------|--------------------------|---------------------------------------|
314
+ | Neither | **Uncontrolled** | Owns its own local state |
315
+ | `value={50}` only | **Controlled, read-only** | User input is ignored (no onChange wired) |
316
+ | `stateKey="volume"` | **Bound to state** | Reads/writes via the Provider |
317
+
318
+ The bound mode wins when both are present. `value` is clamped to
319
+ `[min, max]` on read; NaN clamps to `min`; `min === max` disables the
320
+ slider.
321
+
322
+ ---
323
+
324
+ ## The CLI: `playgenx-state`
325
+
326
+ After `npm install @playgenx/components`, you get a `playgenx-state`
327
+ binary on your PATH (via npm's `bin` field). It operates on JSON
328
+ snapshots on stdin/stdout.
329
+
330
+ ```bash
331
+ # Pretty-print a snapshot in the v1 envelope format
332
+ $ echo '{"volume":5,"user":{"name":"a"}}' | playgenx-state snapshot
333
+ {
334
+ "version": 1,
335
+ "keys": [
336
+ "user",
337
+ "volume"
338
+ ],
339
+ "values": {
340
+ "volume": 5,
341
+ "user": { "name": "a" }
342
+ }
343
+ }
344
+
345
+ # Validate stateKey strings before using them in a body
346
+ $ echo '["good","bad key","has/slash"]' | playgenx-state validate
347
+ [
348
+ { "key": "good", "ok": true },
349
+ { "key": "bad key", "ok": false, "error": "stateKey must not contain whitespace" },
350
+ { "key": "has/slash", "ok": false, "error": "stateKey contains forbidden characters..." }
351
+ ]
352
+ # exit code 3 (validation failed)
353
+
354
+ # Diff two snapshots
355
+ $ echo '{"prev":{"a":1},"next":{"a":2,"b":3}}' | playgenx-state diff
356
+ { "added": ["b"], "changed": ["a"], "removed": [] }
357
+
358
+ # Inspect keys / count
359
+ $ echo '{"b":1,"a":2,"c":3}' | playgenx-state keys
360
+ [ "a", "b", "c" ]
361
+ $ echo '{"b":1,"a":2,"c":3}' | playgenx-state count
362
+ { "keys": 3 }
363
+ ```
364
+
365
+ **Exit codes:** `0` ok, `1` unknown subcommand, `2` bad input, `3`
366
+ validation failure.
367
+
368
+ These are the same helpers exported from the package as library
369
+ functions (`dumpState`, `diffSnapshots`, `validateStateKey`, etc.) —
370
+ the CLI is a thin wrapper. Use whichever fits.
371
+
372
+ ---
373
+
374
+ ## Architecture notes
375
+
376
+ - **Per-Provider scope.** Each `PlaygroundStateProvider` creates an
377
+ independent store. Nested Providers create nested scopes (a child
378
+ Provider does not see the parent's keys).
379
+ - **HMR-safe.** Subscribers are cleared on Provider unmount, so a
380
+ hot-reload that re-mounts the Provider doesn't leak subscribers to
381
+ the old store.
382
+ - **Microtask batched.** `set()` schedules subscriber fires on the
383
+ next microtask. Multiple sets in the same tick produce one fire
384
+ per affected key. Use `batch(state, fn)` if you want each
385
+ subscriber to see only the LAST value set in a group.
386
+ - **No deep clone.** Values are stored by reference. Don't mutate
387
+ objects after `set()`-ing them — use a fresh object instead.
388
+ - **`Object.is` equality.** Setting a value that `Object.is`-equals
389
+ the current value is a no-op (no subscriber fire, no flush). This
390
+ means `set('x', NaN)` twice fires once, and `set('x', undefined)`
391
+ on an unset key is a no-op (since `Object.is(undefined, undefined)`
392
+ is `true`).
393
+ - **Sub-Provider remount drops old subscribers.** Subscribers from a
394
+ unmounted Provider's tree do not fire for sets on a NEW Provider's
395
+ store, even with the same key name. (Verified by the A.7 hardening
396
+ test.)
397
+
398
+ ---
399
+
400
+ ## TypeScript
401
+
402
+ This package is written in TypeScript with strict types. The
403
+ `dist/index.d.mts` declarations are exhaustive — you can `import
404
+ type { SliderProps, PlaygroundState, StateDiff }` etc.
405
+
406
+ Common imports:
407
+
408
+ ```ts
409
+ import type {
410
+ SliderProps,
411
+ ButtonProps,
412
+ PlaygroundState,
413
+ StateAction,
414
+ StateDiff,
415
+ StateEnvelope,
416
+ RenderBodyOptions,
417
+ } from '@playgenx/components';
418
+ ```
419
+
420
+ ---
55
421
 
56
422
  ## Versioning
57
423
 
58
- This package follows PlayGenX's overall version policy (mirror `playgenx` core). A component's prop signature is part of the public contract; new optional props are non-breaking, removing/renaming is a breaking change.
424
+ - **0.1.x** Component-only, no state store. `Slider` had no
425
+ `stateKey` prop.
426
+ - **0.2.0** — Adds `PlaygroundStateProvider`, `useBoundValue*`,
427
+ `useStateAction`, `ArtifactErrorBoundary`, `ShowSource`,
428
+ `createRegistry`, the pure helpers (`dumpState` etc.), and the
429
+ `playgenx-state` CLI binary. The `stateKey` prop on `Slider` is
430
+ fully wired and uses the new state store.
431
+ - **0.2.1** — Exposes the CLI binary via the package's `bin` field
432
+ so `npx playgenx-state` works after a fresh install.
433
+ - **0.2.2** — SSR fix: `useBoundValueOrUndefined` now uses React 19's
434
+ `useSyncExternalStore` so `renderToString` reflects the Provider's
435
+ live value (no more "fallback on server, real value after hydration"
436
+ mismatch). Adds three flexibility hooks: `useStoreSnapshot`
437
+ (non-subscribing), `useBoundSelector` (slice-aware subscription),
438
+ `useMultiBoundValue` (batch key binding). 108 tests passing.
439
+
440
+ ---
59
441
 
60
442
  ## License
61
443
 
62
- MIT — see [LICENSE](../../LICENSE).
444
+ MIT — same as the parent project.
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * playgenx-state — CLI for inspecting and operating on
5
+ * PlaygroundState snapshots.
6
+ *
7
+ * Pure ESM, no extra deps. Reads JSON from stdin (or argv),
8
+ * writes JSON to stdout.
9
+ *
10
+ * Subcommands:
11
+ *
12
+ * snapshot Pretty-print a snapshot in the v1 envelope format.
13
+ * Accepts a JSON object on stdin (the snapshot you
14
+ * captured from `state.snapshot()` or a DebugSurface).
15
+ *
16
+ * validate Validate one or more stateKey strings from stdin.
17
+ * Reads a JSON array of strings, returns the per-key
18
+ * verdict.
19
+ *
20
+ * diff Diff two snapshots. Reads `{prev, next}` from stdin
21
+ * and returns the structured {added, changed, removed}
22
+ * diff (or null when equal).
23
+ *
24
+ * keys List the sorted keys of a snapshot from stdin.
25
+ *
26
+ * count Count the keys in a snapshot from stdin.
27
+ *
28
+ * Usage examples (run from the components package root):
29
+ *
30
+ * echo '{"volume": 5}' | node bin/state-cli.mjs snapshot
31
+ * echo '["volume","user.name","bad key"]' | node bin/state-cli.mjs validate
32
+ * echo '{"prev":{"a":1},"next":{"a":2,"b":3}}' | node bin/state-cli.mjs diff
33
+ *
34
+ * Exit codes:
35
+ *
36
+ * 0 success
37
+ * 1 bad subcommand
38
+ * 2 bad input (not parseable as JSON, wrong shape, etc.)
39
+ * 3 validation failure (one or more keys rejected)
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ import {
45
+ dumpState,
46
+ diffSnapshots,
47
+ validateStateKey,
48
+ } from '../dist/index.mjs';
49
+
50
+ /**
51
+ * Read all of stdin into a string. Node-only.
52
+ */
53
+ async function readStdin() {
54
+ if (process.stdin.isTTY) {
55
+ // Interactive / no pipe — return empty; the caller will treat
56
+ // this as "no input" and print usage.
57
+ return '';
58
+ }
59
+ const chunks = [];
60
+ for await (const chunk of process.stdin) {
61
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
62
+ }
63
+ return Buffer.concat(chunks).toString('utf8');
64
+ }
65
+
66
+ function usage() {
67
+ return [
68
+ 'Usage: playgenx-state <subcommand> [input]',
69
+ '',
70
+ 'Subcommands:',
71
+ ' snapshot Pretty-print a state snapshot (JSON on stdin)',
72
+ ' validate Validate an array of stateKey strings (JSON array on stdin)',
73
+ ' diff Diff two snapshots ({prev, next} on stdin)',
74
+ ' keys List sorted keys of a snapshot (JSON on stdin)',
75
+ ' count Count keys in a snapshot (JSON on stdin)',
76
+ '',
77
+ 'Reads from stdin when no arg is given.',
78
+ ].join('\n');
79
+ }
80
+
81
+ function ok(payload) {
82
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
83
+ }
84
+
85
+ function fail(code, msg) {
86
+ process.stderr.write(`error: ${msg}\n`);
87
+ process.exit(code);
88
+ }
89
+
90
+ /**
91
+ * Convert an arbitrary snapshot to the StateEnvelope shape. The
92
+ * input is whatever the caller passed; we expect a plain object.
93
+ */
94
+ function envelope(input) {
95
+ const keys = Object.keys(input).sort();
96
+ return { version: 1, keys, values: { ...input } };
97
+ }
98
+
99
+ async function main(argv) {
100
+ const sub = argv[0];
101
+ if (!sub || sub === '-h' || sub === '--help') {
102
+ process.stdout.write(usage() + '\n');
103
+ return 0;
104
+ }
105
+ const stdinPayload = (await readStdin()).trim();
106
+ const argPayload = argv[1];
107
+
108
+ switch (sub) {
109
+ case 'snapshot': {
110
+ const input = parseObject(argPayload ?? stdinPayload);
111
+ // Re-use the library's envelope so the CLI output matches the
112
+ // canonical dumpState shape. (We don't have a real store to
113
+ // pass to dumpState; envelope() builds the same structure.)
114
+ const env = envelope(input);
115
+ process.stdout.write(JSON.stringify(env, null, 2) + '\n');
116
+ // Reference dumpState so it's not tree-shaken — the import
117
+ // is documented as the canonical envelope producer.
118
+ void dumpState;
119
+ return 0;
120
+ }
121
+
122
+ case 'validate': {
123
+ const input = parseArray(argPayload ?? stdinPayload);
124
+ const results = input.map((k) => {
125
+ if (typeof k !== 'string') {
126
+ return { key: String(k), ok: false, error: 'must be a string' };
127
+ }
128
+ const err = validateStateKey(k);
129
+ return err === null
130
+ ? { key: k, ok: true }
131
+ : { key: k, ok: false, error: err };
132
+ });
133
+ const anyFailed = results.some((r) => !r.ok);
134
+ ok(results);
135
+ return anyFailed ? 3 : 0;
136
+ }
137
+
138
+ case 'diff': {
139
+ const input = parseObject(argPayload ?? stdinPayload);
140
+ if (!('prev' in input) || !('next' in input)) {
141
+ fail(2, 'diff expects {prev, next}');
142
+ }
143
+ const prev = input.prev;
144
+ const next = input.next;
145
+ if (typeof prev !== 'object' || prev === null) fail(2, 'prev must be an object');
146
+ if (typeof next !== 'object' || next === null) fail(2, 'next must be an object');
147
+ const d = diffSnapshots(prev, next);
148
+ ok(d);
149
+ return 0;
150
+ }
151
+
152
+ case 'keys': {
153
+ const input = parseObject(argPayload ?? stdinPayload);
154
+ ok(Object.keys(input).sort());
155
+ return 0;
156
+ }
157
+
158
+ case 'count': {
159
+ const input = parseObject(argPayload ?? stdinPayload);
160
+ ok({ keys: Object.keys(input).length });
161
+ return 0;
162
+ }
163
+
164
+ default:
165
+ process.stderr.write(usage() + '\n');
166
+ return 1;
167
+ }
168
+ }
169
+
170
+ function parseObject(raw) {
171
+ if (raw === '') fail(2, 'expected a JSON object on stdin or as argv[1]');
172
+ try {
173
+ const v = JSON.parse(raw);
174
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
175
+ fail(2, 'expected a JSON object');
176
+ }
177
+ return v;
178
+ } catch (err) {
179
+ fail(2, `JSON parse error: ${err.message}`);
180
+ }
181
+ }
182
+
183
+ function parseArray(raw) {
184
+ if (raw === '') fail(2, 'expected a JSON array on stdin or as argv[1]');
185
+ try {
186
+ const v = JSON.parse(raw);
187
+ if (!Array.isArray(v)) fail(2, 'expected a JSON array');
188
+ return v;
189
+ } catch (err) {
190
+ fail(2, `JSON parse error: ${err.message}`);
191
+ }
192
+ }
193
+
194
+ main(process.argv.slice(2))
195
+ .then((code) => process.exit(code))
196
+ .catch((err) => fail(2, err.message ?? String(err)));