@variantlab/core 0.1.4 → 0.1.6

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/docs/API.md ADDED
@@ -0,0 +1,692 @@
1
+ # API Reference
2
+
3
+ Complete TypeScript API surface for all packages. This is the source of truth during Phase 0 — any PR that changes an API must update this file first.
4
+
5
+ ## Table of contents
6
+
7
+ - [@variantlab/core](#variantlabcore)
8
+ - [Types](#types)
9
+ - [Engine](#engine)
10
+ - [Storage interface](#storage-interface)
11
+ - [Fetcher interface](#fetcher-interface)
12
+ - [Telemetry interface](#telemetry-interface)
13
+ - [Targeting](#targeting)
14
+ - [Assignment strategies](#assignment-strategies)
15
+ - [Errors](#errors)
16
+ - [@variantlab/react](#variantlabreact)
17
+ - [@variantlab/react-native](#variantlabreact-native)
18
+ - [@variantlab/next](#variantlabnext)
19
+ - [@variantlab/cli](#variantlabcli)
20
+
21
+ ---
22
+
23
+ ## `@variantlab/core`
24
+
25
+ ### Types
26
+
27
+ ```ts
28
+ /** Top-level config file shape. */
29
+ export interface ExperimentsConfig {
30
+ /** Schema version. Current is 1. */
31
+ version: 1;
32
+ /** Optional HMAC signature for remote configs. Verified via Web Crypto. */
33
+ signature?: string;
34
+ /** Optional global kill switch. When false, all experiments return defaults. */
35
+ enabled?: boolean;
36
+ /** The experiments. */
37
+ experiments: Experiment[];
38
+ }
39
+
40
+ /** A single experiment definition. */
41
+ export interface Experiment {
42
+ /** Unique identifier. Must match /^[a-z0-9-]+$/. */
43
+ id: string;
44
+ /** Human-readable name for the debug overlay. */
45
+ name: string;
46
+ /** Human-readable description. Shown in debug overlay. */
47
+ description?: string;
48
+ /** "render" swaps components; "value" returns primitive values. */
49
+ type?: "render" | "value";
50
+ /** The variants to test. Must have at least 2. */
51
+ variants: Variant[];
52
+ /** Default variant ID. Must match one of variants[].id. */
53
+ default: string;
54
+ /** Routes this experiment applies to. Glob patterns. */
55
+ routes?: string[];
56
+ /** Targeting predicates. ALL must match for a user to be eligible. */
57
+ targeting?: Targeting;
58
+ /** Assignment strategy. Defaults to "default". */
59
+ assignment?: AssignmentStrategy;
60
+ /** Traffic split (for "weighted" strategy). */
61
+ split?: Record<string, number>;
62
+ /** Mutual exclusion group. Experiments in the same group cannot co-run. */
63
+ mutex?: string;
64
+ /** Enable crash-triggered rollback for this experiment. */
65
+ rollback?: RollbackConfig;
66
+ /** Lifecycle state. Archived experiments are hidden from debug overlay. */
67
+ status?: "draft" | "active" | "archived";
68
+ /** Start date (ISO 8601). Experiment inactive before this. */
69
+ startDate?: string;
70
+ /** End date (ISO 8601). Experiment inactive after this. */
71
+ endDate?: string;
72
+ /** Owner, for tracking. Free text. */
73
+ owner?: string;
74
+ }
75
+
76
+ /** A variant of an experiment. */
77
+ export interface Variant {
78
+ /** Unique within the experiment. Must match /^[a-z0-9-]+$/. */
79
+ id: string;
80
+ /** Human-readable label for debug overlay. */
81
+ label?: string;
82
+ /** Human-readable description. */
83
+ description?: string;
84
+ /** For "value" experiments, the value returned by useVariantValue. */
85
+ value?: unknown;
86
+ }
87
+
88
+ /** Runtime context used for targeting and assignment. */
89
+ export interface VariantContext {
90
+ /** Stable user identifier for sticky assignment. */
91
+ userId?: string;
92
+ /** Current route / pathname. */
93
+ route?: string;
94
+ /** Platform: "ios" | "android" | "web" | "node". */
95
+ platform?: string;
96
+ /** Semver string. */
97
+ appVersion?: string;
98
+ /** IETF language tag. */
99
+ locale?: string;
100
+ /** Screen size bucket, derived automatically by adapters. */
101
+ screenSize?: "small" | "medium" | "large";
102
+ /** Arbitrary user attributes for custom targeting. */
103
+ attributes?: Record<string, string | number | boolean>;
104
+ }
105
+
106
+ /** Targeting predicate. All fields are optional; all specified fields must match. */
107
+ export interface Targeting {
108
+ platform?: Array<"ios" | "android" | "web" | "node">;
109
+ appVersion?: string; // semver range, e.g. ">=1.2.0 <2.0.0"
110
+ locale?: string[];
111
+ screenSize?: Array<"small" | "medium" | "large">;
112
+ routes?: string[]; // glob patterns
113
+ userId?: string[] | { hash: string; mod: number }; // explicit list or hash bucket
114
+ attributes?: Record<string, unknown>; // exact match
115
+ predicate?: (context: VariantContext) => boolean; // escape hatch
116
+ }
117
+
118
+ /** Assignment strategy. */
119
+ export type AssignmentStrategy =
120
+ | "default" // always return the default variant
121
+ | "random" // uniform random on first eligibility
122
+ | "sticky-hash" // deterministic hash of (userId, experimentId)
123
+ | "weighted"; // weighted by split config, sticky-hashed
124
+
125
+ /** Crash-rollback configuration. */
126
+ export interface RollbackConfig {
127
+ /** Number of crashes that trigger rollback. Default: 3. */
128
+ threshold: number;
129
+ /** Time window in milliseconds. Default: 60000. */
130
+ window: number;
131
+ /** Whether to persist the rollback across sessions. Default: false. */
132
+ persistent?: boolean;
133
+ }
134
+ ```
135
+
136
+ ### Engine
137
+
138
+ ```ts
139
+ /** Options passed to createEngine. */
140
+ export interface EngineOptions {
141
+ /** Storage adapter. Required. */
142
+ storage: Storage;
143
+ /** Optional remote config fetcher. */
144
+ fetcher?: Fetcher;
145
+ /** Optional telemetry sink. */
146
+ telemetry?: Telemetry;
147
+ /** Optional HMAC verification key (raw bytes or CryptoKey). */
148
+ hmacKey?: Uint8Array | CryptoKey;
149
+ /** Initial runtime context. */
150
+ context?: VariantContext;
151
+ /** Fail-open (return defaults) or fail-closed (throw) on errors. Default: fail-open. */
152
+ errorMode?: "fail-open" | "fail-closed";
153
+ /** Enable time-travel recording. Default: false. */
154
+ timeTravel?: boolean;
155
+ }
156
+
157
+ /** The runtime engine. */
158
+ export class VariantEngine {
159
+ constructor(config: ExperimentsConfig, options: EngineOptions);
160
+
161
+ /** Get the current variant ID for an experiment. Synchronous, O(1) after warmup. */
162
+ getVariant(experimentId: string, context?: VariantContext): string;
163
+
164
+ /** Get the variant value (for "value" experiments). */
165
+ getVariantValue<T = unknown>(experimentId: string, context?: VariantContext): T;
166
+
167
+ /** Force a variant. Used by debug overlay and deep links. */
168
+ setVariant(experimentId: string, variantId: string): void;
169
+
170
+ /** Clear a forced variant, falling back to assignment. */
171
+ clearVariant(experimentId: string): void;
172
+
173
+ /** Clear all forced variants. */
174
+ resetAll(): void;
175
+
176
+ /** Get all experiments, optionally filtered by route. */
177
+ getExperiments(route?: string): Experiment[];
178
+
179
+ /** Subscribe to variant changes. Used by framework adapters. */
180
+ subscribe(listener: (event: EngineEvent) => void): () => void;
181
+
182
+ /** Update runtime context. Triggers re-evaluation of all experiments. */
183
+ updateContext(patch: Partial<VariantContext>): void;
184
+
185
+ /** Replace the config (e.g., after a remote fetch). Validates + verifies signature. */
186
+ loadConfig(config: ExperimentsConfig): Promise<void>;
187
+
188
+ /** Report a crash for rollback tracking. */
189
+ reportCrash(experimentId: string, error: Error): void;
190
+
191
+ /** Track an arbitrary event. Forwarded to telemetry. */
192
+ track(eventName: string, properties?: Record<string, unknown>): void;
193
+
194
+ /** Get time-travel history (if enabled). */
195
+ getHistory(): EngineEvent[];
196
+
197
+ /** Clean up subscriptions, timers, and listeners. */
198
+ dispose(): void;
199
+ }
200
+
201
+ /** Factory — preferred over `new VariantEngine()`. */
202
+ export function createEngine(
203
+ config: ExperimentsConfig,
204
+ options: EngineOptions
205
+ ): VariantEngine;
206
+
207
+ /** Events emitted by the engine. */
208
+ export type EngineEvent =
209
+ | { type: "ready"; config: ExperimentsConfig }
210
+ | { type: "assignment"; experimentId: string; variantId: string; context: VariantContext }
211
+ | { type: "exposure"; experimentId: string; variantId: string }
212
+ | { type: "variantChanged"; experimentId: string; variantId: string; source: "user" | "system" | "deeplink" | "qr" }
213
+ | { type: "rollback"; experimentId: string; variantId: string; reason: string }
214
+ | { type: "configLoaded"; config: ExperimentsConfig }
215
+ | { type: "error"; error: Error };
216
+ ```
217
+
218
+ ### Storage interface
219
+
220
+ ```ts
221
+ /** Pluggable storage adapter. All methods may be sync or async. */
222
+ export interface Storage {
223
+ getItem(key: string): string | null | Promise<string | null>;
224
+ setItem(key: string, value: string): void | Promise<void>;
225
+ removeItem(key: string): void | Promise<void>;
226
+ /** Optional: returns all keys with the variantlab prefix. */
227
+ keys?(): string[] | Promise<string[]>;
228
+ }
229
+
230
+ /** In-memory storage, useful for tests and SSR. */
231
+ export function createMemoryStorage(): Storage;
232
+ ```
233
+
234
+ Adapter packages provide concrete implementations:
235
+
236
+ - `@variantlab/react-native` exports `AsyncStorageAdapter`, `MMKVStorageAdapter`, `SecureStoreAdapter`
237
+ - `@variantlab/react` exports `LocalStorageAdapter`, `SessionStorageAdapter`, `CookieStorageAdapter`
238
+ - `@variantlab/next` exports `NextCookieAdapter` (SSR-aware)
239
+
240
+ ### Fetcher interface
241
+
242
+ ```ts
243
+ /** Optional remote config fetcher. */
244
+ export interface Fetcher {
245
+ /** Fetch the latest config. May throw on network failure. */
246
+ fetch(): Promise<ExperimentsConfig>;
247
+ /** Polling interval in ms. Default: 0 (no polling). */
248
+ pollInterval?: number;
249
+ }
250
+
251
+ /** Simple HTTP fetcher helper. */
252
+ export function createHttpFetcher(options: {
253
+ url: string;
254
+ headers?: Record<string, string>;
255
+ pollInterval?: number;
256
+ /** Signal for abort. */
257
+ signal?: AbortSignal;
258
+ }): Fetcher;
259
+ ```
260
+
261
+ ### Telemetry interface
262
+
263
+ ```ts
264
+ /** Optional telemetry sink. Called for every engine event. */
265
+ export interface Telemetry {
266
+ track(event: EngineEvent): void;
267
+ }
268
+
269
+ /** Helper to combine multiple telemetry sinks. */
270
+ export function combineTelemetry(...sinks: Telemetry[]): Telemetry;
271
+ ```
272
+
273
+ ### Targeting
274
+
275
+ ```ts
276
+ /**
277
+ * Runtime context augmented with the optional precomputed sha256
278
+ * bucket (0..99) for the hash-mod userId operator. The engine fills
279
+ * this via `hashUserId` on every context update; `evaluate` reads it
280
+ * synchronously.
281
+ */
282
+ export interface EvalContext extends VariantContext {
283
+ readonly userIdBucket?: number;
284
+ }
285
+
286
+ /**
287
+ * Targeting augmented with the optional application-provided predicate
288
+ * escape hatch. Functions can't live in JSON configs, so this type
289
+ * only exists at the evaluation layer.
290
+ */
291
+ export interface EvaluableTargeting extends Targeting {
292
+ readonly predicate?: (context: VariantContext) => boolean;
293
+ }
294
+
295
+ /** Result of a single `evaluate()` call. */
296
+ export interface TargetingResult {
297
+ readonly matched: boolean;
298
+ /** The first failing field name; undefined on match. */
299
+ readonly reason?: string;
300
+ }
301
+
302
+ /** Which field an `ExplainStep` corresponds to. */
303
+ export type ExplainField =
304
+ | "startDate"
305
+ | "endDate"
306
+ | "platform"
307
+ | "screenSize"
308
+ | "locale"
309
+ | "appVersion"
310
+ | "routes"
311
+ | "attributes"
312
+ | "userId"
313
+ | "predicate";
314
+
315
+ /** One step in an `explain()` trace. */
316
+ export interface ExplainStep {
317
+ readonly field: ExplainField;
318
+ readonly matched: boolean;
319
+ /** Short human-readable summary; present on failures. */
320
+ readonly detail?: string;
321
+ }
322
+
323
+ /** Result of `explain()` — a full trace of every check performed. */
324
+ export interface ExplainResult {
325
+ readonly matched: boolean;
326
+ readonly reason?: ExplainField;
327
+ readonly steps: readonly ExplainStep[];
328
+ }
329
+
330
+ /**
331
+ * Evaluate a targeting predicate against a context. Pure, synchronous,
332
+ * short-circuits at the first failing field. `evaluate` and `explain`
333
+ * are both sync — use `hashUserId` to precompute the `userIdBucket`
334
+ * field on the context when using hash-bucket targeting.
335
+ */
336
+ export function evaluate(
337
+ targeting: EvaluableTargeting,
338
+ context: VariantContext | EvalContext,
339
+ ): TargetingResult;
340
+
341
+ /** Thin wrapper returning `evaluate(...).matched`. */
342
+ export function matchTargeting(
343
+ targeting: EvaluableTargeting,
344
+ context: VariantContext | EvalContext,
345
+ ): boolean;
346
+
347
+ /**
348
+ * Walk an experiment's date gates and targeting and return a full
349
+ * trace. Short-circuits at the first failing step; `steps` contains
350
+ * every check actually performed.
351
+ */
352
+ export function explain(
353
+ experiment: Experiment,
354
+ context: VariantContext | EvalContext,
355
+ ): ExplainResult;
356
+
357
+ /** Match a route pattern. Supports `/foo`, `/foo/*`, `/foo/**`, `/user/:id`. */
358
+ export function matchRoute(pattern: string, route: string): boolean;
359
+
360
+ /** Match a semver range. Supports `>=1.2.0`, `^1.2.0`, `1.2.0 - 2.0.0`. */
361
+ export function matchSemver(range: string, version: string): boolean;
362
+
363
+ /**
364
+ * Compute a sha256 bucket (0..99) for a userId. Async because Web
365
+ * Crypto is async; the engine precomputes this once per context
366
+ * update and stores the result on `EvalContext.userIdBucket`.
367
+ */
368
+ export function hashUserId(userId: string): Promise<number>;
369
+ ```
370
+
371
+ ### Assignment strategies
372
+
373
+ ```ts
374
+ /** Deterministic hash of (userId + experimentId) to a [0, 1) float. */
375
+ export function stickyHash(userId: string, experimentId: string): number;
376
+
377
+ /** Evaluate an assignment strategy. */
378
+ export function assignVariant(
379
+ experiment: Experiment,
380
+ context: VariantContext
381
+ ): string;
382
+ ```
383
+
384
+ ### Errors
385
+
386
+ ```ts
387
+ /** A single validation issue surfaced by `validateConfig`. */
388
+ export interface ConfigIssue {
389
+ /** RFC 6901 JSON Pointer into the source config. "" for root. */
390
+ readonly path: string;
391
+ /** Machine-readable code for programmatic handling (CLI, debug overlay). */
392
+ readonly code: IssueCode;
393
+ /** Human-readable message safe for CLI output. */
394
+ readonly message: string;
395
+ }
396
+
397
+ /** Union of all validation issue codes.
398
+ * Narrow union in the implementation — see `packages/core/src/config/codes.ts`. */
399
+ export type IssueCode = string;
400
+
401
+ /** Thrown when config validation fails. Collects all issues before throwing. */
402
+ export class ConfigValidationError extends Error {
403
+ readonly issues: ReadonlyArray<ConfigIssue>;
404
+ }
405
+
406
+ /** Thrown when HMAC verification fails. */
407
+ export class SignatureVerificationError extends Error {}
408
+
409
+ /** Thrown when an experiment ID is unknown. Only in fail-closed mode. */
410
+ export class UnknownExperimentError extends Error {
411
+ readonly experimentId: string;
412
+ }
413
+ ```
414
+
415
+ ---
416
+
417
+ ## `@variantlab/react`
418
+
419
+ ### Provider
420
+
421
+ ```tsx
422
+ export interface VariantLabProviderProps {
423
+ /** The experiments config. Inline JSON or from a fetcher. */
424
+ config: ExperimentsConfig;
425
+ /** Engine options. */
426
+ options?: Omit<EngineOptions, "storage"> & { storage?: Storage };
427
+ /** Runtime context. Defaults to auto-detect (platform, locale, screen size). */
428
+ context?: Partial<VariantContext>;
429
+ /** Children. */
430
+ children: React.ReactNode;
431
+ }
432
+
433
+ export const VariantLabProvider: React.FC<VariantLabProviderProps>;
434
+ ```
435
+
436
+ ### Hooks
437
+
438
+ ```ts
439
+ /** Returns the current variant ID for an experiment. */
440
+ export function useVariant<K extends keyof GeneratedExperiments = string>(
441
+ experimentId: K
442
+ ): GeneratedExperiments[K]["variants"] | string;
443
+
444
+ /** Returns the variant value (for "value" experiments). */
445
+ export function useVariantValue<T = unknown>(
446
+ experimentId: string
447
+ ): T;
448
+
449
+ /** Returns an object with the variant, value, and event trackers. */
450
+ export function useExperiment<T = unknown>(
451
+ experimentId: string
452
+ ): {
453
+ variant: string;
454
+ value: T;
455
+ track: (eventName: string, properties?: Record<string, unknown>) => void;
456
+ };
457
+
458
+ /** Imperative variant setter. Dev-only by default. */
459
+ export function useSetVariant(): (
460
+ experimentId: string,
461
+ variantId: string
462
+ ) => void;
463
+
464
+ /** Low-level engine access. */
465
+ export function useVariantLabEngine(): VariantEngine;
466
+
467
+ /** Returns the list of experiments applicable to the current route. */
468
+ export function useRouteExperiments(route?: string): Experiment[];
469
+ ```
470
+
471
+ ### Components
472
+
473
+ ```tsx
474
+ /** Render-prop switch for "render" experiments. */
475
+ export interface VariantProps {
476
+ experimentId: string;
477
+ children: Record<string, React.ReactNode>;
478
+ fallback?: React.ReactNode;
479
+ }
480
+ export const Variant: React.FC<VariantProps>;
481
+
482
+ /** Render-prop for "value" experiments. */
483
+ export interface VariantValueProps<T> {
484
+ experimentId: string;
485
+ children: (value: T) => React.ReactNode;
486
+ }
487
+ export function VariantValue<T>(props: VariantValueProps<T>): React.ReactElement;
488
+
489
+ /** Error boundary that reports crashes to the engine. */
490
+ export interface VariantErrorBoundaryProps {
491
+ experimentId: string;
492
+ fallback?: React.ReactNode | ((error: Error) => React.ReactNode);
493
+ children: React.ReactNode;
494
+ }
495
+ export const VariantErrorBoundary: React.ComponentType<VariantErrorBoundaryProps>;
496
+
497
+ /** Debug overlay. Tree-shaken in production. */
498
+ export const VariantDebugOverlay: React.FC<{
499
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
500
+ routeFilter?: boolean;
501
+ }>;
502
+ ```
503
+
504
+ ---
505
+
506
+ ## `@variantlab/react-native`
507
+
508
+ Re-exports everything from `@variantlab/react` plus:
509
+
510
+ ### Storage adapters
511
+
512
+ ```ts
513
+ /** AsyncStorage-backed storage. Requires @react-native-async-storage/async-storage. */
514
+ export function createAsyncStorageAdapter(): Storage;
515
+
516
+ /** MMKV-backed storage. Requires react-native-mmkv. */
517
+ export function createMMKVStorageAdapter(): Storage;
518
+
519
+ /** Expo SecureStore-backed storage. Requires expo-secure-store. */
520
+ export function createSecureStoreAdapter(): Storage;
521
+ ```
522
+
523
+ ### Debug overlay (RN-specific)
524
+
525
+ ```tsx
526
+ /** Native debug overlay with floating button, bottom sheet, QR share, shake-to-open. */
527
+ export const VariantDebugOverlay: React.FC<{
528
+ /** Enable shake-to-open gesture. Default: true. */
529
+ shakeToOpen?: boolean;
530
+ /** Show only experiments matching the current route. Default: true. */
531
+ routeFilter?: boolean;
532
+ /** Button position. */
533
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
534
+ /** Hide button entirely; open only via shake or programmatic trigger. */
535
+ hideButton?: boolean;
536
+ }>;
537
+
538
+ /** Imperatively open the debug overlay. */
539
+ export function openDebugOverlay(): void;
540
+ ```
541
+
542
+ ### Deep link handler
543
+
544
+ ```ts
545
+ /** Registers a listener for variantlab:// deep links. Returns unsubscribe. */
546
+ export function registerDeepLinkHandler(
547
+ engine: VariantEngine,
548
+ options?: { scheme?: string; host?: string }
549
+ ): () => void;
550
+ ```
551
+
552
+ ### Auto-detected context
553
+
554
+ ```ts
555
+ /** Returns a VariantContext filled with platform, screenSize, locale. */
556
+ export function getAutoContext(): VariantContext;
557
+ ```
558
+
559
+ ---
560
+
561
+ ## `@variantlab/next`
562
+
563
+ ### Server
564
+
565
+ ```ts
566
+ /** Create an engine for server-side use. Singleton per request or per process. */
567
+ export function createVariantLabServer(
568
+ config: ExperimentsConfig,
569
+ options?: Omit<EngineOptions, "storage"> & { storage?: Storage }
570
+ ): VariantEngine;
571
+
572
+ /** Read a variant from a Next.js request (App Router or Pages Router). */
573
+ export function getVariantSSR(
574
+ experimentId: string,
575
+ request: Request | NextApiRequest,
576
+ config: ExperimentsConfig
577
+ ): string;
578
+
579
+ /** Read a variant value from a Next.js request. */
580
+ export function getVariantValueSSR<T = unknown>(
581
+ experimentId: string,
582
+ request: Request | NextApiRequest,
583
+ config: ExperimentsConfig
584
+ ): T;
585
+ ```
586
+
587
+ ### Middleware
588
+
589
+ ```ts
590
+ /** Next.js middleware that assigns a sticky cookie for variant stability. */
591
+ export function variantLabMiddleware(config: ExperimentsConfig): (
592
+ req: NextRequest
593
+ ) => NextResponse;
594
+ ```
595
+
596
+ ### Client (re-exports from `@variantlab/react`)
597
+
598
+ ```tsx
599
+ export {
600
+ VariantLabProvider,
601
+ useVariant,
602
+ useVariantValue,
603
+ useExperiment,
604
+ Variant,
605
+ VariantValue,
606
+ VariantErrorBoundary,
607
+ VariantDebugOverlay,
608
+ } from "@variantlab/react";
609
+ ```
610
+
611
+ ### App Router usage
612
+
613
+ ```tsx
614
+ // app/layout.tsx
615
+ import { cookies } from "next/headers";
616
+ import { VariantLabProvider } from "@variantlab/next/client";
617
+ import experiments from "./experiments.json";
618
+
619
+ export default function RootLayout({ children }) {
620
+ const initialVariants = /* read from cookies */;
621
+ return (
622
+ <VariantLabProvider config={experiments} initialVariants={initialVariants}>
623
+ {children}
624
+ </VariantLabProvider>
625
+ );
626
+ }
627
+
628
+ // middleware.ts
629
+ import { variantLabMiddleware } from "@variantlab/next/middleware";
630
+ import experiments from "./experiments.json";
631
+
632
+ export default variantLabMiddleware(experiments);
633
+ ```
634
+
635
+ ---
636
+
637
+ ## `@variantlab/cli`
638
+
639
+ ### Commands
640
+
641
+ ```
642
+ variantlab init Scaffold experiments.json + install adapter
643
+ variantlab generate [--out <path>] Generate .d.ts from experiments.json
644
+ variantlab validate [--config <path>] Validate config + check for orphaned IDs
645
+ variantlab scaffold <experimentId> Scaffold boilerplate for a new experiment
646
+ variantlab sign --key <path> HMAC-sign an experiments.json
647
+ variantlab verify --key <path> Verify an HMAC-signed experiments.json
648
+ ```
649
+
650
+ ### `variantlab generate`
651
+
652
+ Reads `experiments.json` (or path from `--config`) and writes a `.d.ts` with:
653
+
654
+ - A `GeneratedExperiments` interface mapping IDs to literal union types for variant IDs
655
+ - Module augmentation that narrows `useVariant(id)` returns
656
+ - JSDoc with experiment names and descriptions for IDE tooltips
657
+
658
+ Example output:
659
+
660
+ ```ts
661
+ // variantlab-generated.d.ts (DO NOT EDIT)
662
+
663
+ declare module "@variantlab/core" {
664
+ interface GeneratedExperiments {
665
+ /** CTA button copy */
666
+ "cta-copy": {
667
+ variants: "buy-now" | "get-started" | "try-free";
668
+ value: string;
669
+ };
670
+ /** News card layout */
671
+ "news-card-layout": {
672
+ variants: "responsive" | "scale-to-fit" | "pip-thumbnail";
673
+ value: never;
674
+ };
675
+ }
676
+ }
677
+
678
+ export {};
679
+ ```
680
+
681
+ ---
682
+
683
+ ## API stability
684
+
685
+ Everything above is **proposed, not implemented**. During Phase 0 any signature in this file may change in response to design review. The goal is to lock it before Phase 1 begins.
686
+
687
+ PRs that change public APIs must:
688
+
689
+ 1. Update this file first
690
+ 2. Include a changeset describing the change
691
+ 3. Link to a GitHub discussion for non-trivial changes
692
+ 4. Provide a migration guide if breaking