reveclicat 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,1905 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { CommanderError } from "commander";
5
+
6
+ // src/program.ts
7
+ import { Command } from "commander";
8
+
9
+ // package.json
10
+ var package_default = {
11
+ name: "reveclicat",
12
+ version: "0.1.0",
13
+ description: "Unofficial CLI to simulate RevenueCat subscription lifecycles and test webhooks locally and in CI. Not affiliated with RevenueCat, Inc.",
14
+ type: "module",
15
+ license: "MIT",
16
+ engines: {
17
+ node: ">=20"
18
+ },
19
+ bin: {
20
+ rcc: "dist/cli.js",
21
+ purr: "dist/cli.js"
22
+ },
23
+ main: "dist/index.js",
24
+ types: "dist/index.d.ts",
25
+ exports: {
26
+ ".": {
27
+ types: "./dist/index.d.ts",
28
+ import: "./dist/index.js"
29
+ }
30
+ },
31
+ files: [
32
+ "dist",
33
+ "scenarios",
34
+ "examples",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE"
38
+ ],
39
+ scripts: {
40
+ build: "tsup",
41
+ dev: "tsx src/cli.ts",
42
+ test: "vitest run",
43
+ "test:watch": "vitest",
44
+ lint: "eslint .",
45
+ typecheck: "tsc --noEmit",
46
+ check: "npm run typecheck && npm run lint && npm test",
47
+ prepublishOnly: "npm run check && npm run build"
48
+ },
49
+ keywords: [
50
+ "revenuecat",
51
+ "webhooks",
52
+ "cli",
53
+ "subscriptions",
54
+ "testing",
55
+ "ci"
56
+ ],
57
+ repository: {
58
+ type: "git",
59
+ url: "git+https://github.com/RadW2020/ReveCliCat.git"
60
+ },
61
+ dependencies: {
62
+ commander: "15.0.0",
63
+ yaml: "2.9.0",
64
+ zod: "4.5.1"
65
+ },
66
+ devDependencies: {
67
+ "@eslint/js": "10.0.1",
68
+ "@types/express": "5.0.6",
69
+ "@types/node": "26.4.0",
70
+ eslint: "10.9.1",
71
+ express: "5.2.1",
72
+ tsup: "8.5.1",
73
+ tsx: "4.23.12",
74
+ typescript: "6.0.3",
75
+ "typescript-eslint": "8.68.0",
76
+ vitest: "4.1.11"
77
+ },
78
+ homepage: "https://github.com/RadW2020/ReveCliCat#readme",
79
+ bugs: {
80
+ url: "https://github.com/RadW2020/ReveCliCat/issues"
81
+ },
82
+ author: "RadW2020"
83
+ };
84
+
85
+ // src/core/colors.ts
86
+ var ESC = String.fromCharCode(27);
87
+ var enabled = () => process.env["NO_COLOR"] === void 0 && process.env["FORCE_COLOR"] !== "0" && (process.stdout.isTTY || process.env["FORCE_COLOR"] !== void 0);
88
+ var wrap = (open, close = 39) => (s) => enabled() ? `${ESC}[${open}m${s}${ESC}[${close}m` : s;
89
+ var red = wrap(31);
90
+ var green = wrap(32);
91
+ var yellow = wrap(33);
92
+ var cyan = wrap(36);
93
+ var magenta = wrap(35);
94
+ var dim = wrap(2, 22);
95
+ var bold = wrap(1, 22);
96
+
97
+ // src/core/errors.ts
98
+ var RccError = class extends Error {
99
+ hint;
100
+ exitCode;
101
+ constructor(message, opts = {}) {
102
+ super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
103
+ this.name = "RccError";
104
+ this.hint = opts.hint;
105
+ this.exitCode = opts.exitCode ?? 1;
106
+ }
107
+ };
108
+ function formatError(err) {
109
+ const debug = process.env["RCC_DEBUG"] === "1";
110
+ const mark = red("\u2716");
111
+ if (err instanceof RccError) {
112
+ let out = `${mark} ${err.message}`;
113
+ if (err.hint) out += `
114
+ ${dim("\u2192 " + err.hint)}`;
115
+ if (debug && err.stack) out += `
116
+ ${dim(err.stack)}`;
117
+ return out;
118
+ }
119
+ if (err instanceof Error) {
120
+ return debug ? `${mark} ${err.stack ?? err.message}` : `${mark} ${err.message}
121
+ ${dim("\u2192 Set RCC_DEBUG=1 for a stack trace.")}`;
122
+ }
123
+ return `${mark} ${String(err)}`;
124
+ }
125
+ function exitCodeFor(err) {
126
+ return err instanceof RccError ? err.exitCode : 1;
127
+ }
128
+
129
+ // src/core/config.ts
130
+ import { existsSync, readFileSync } from "fs";
131
+ import { dirname, join } from "path";
132
+ import { fileURLToPath } from "url";
133
+ import { z } from "zod";
134
+
135
+ // src/schemas/common.ts
136
+ var EVENT_TYPES = [
137
+ "TEST",
138
+ "INITIAL_PURCHASE",
139
+ "RENEWAL",
140
+ "CANCELLATION",
141
+ "UNCANCELLATION",
142
+ "BILLING_ISSUE",
143
+ "EXPIRATION"
144
+ ];
145
+ var ENVIRONMENTS = ["SANDBOX", "PRODUCTION"];
146
+ var STORES = [
147
+ "AMAZON",
148
+ "APP_STORE",
149
+ "MAC_APP_STORE",
150
+ "PADDLE",
151
+ "PLAY_STORE",
152
+ "PROMOTIONAL",
153
+ "RC_BILLING",
154
+ "ROKU",
155
+ "STRIPE",
156
+ "TEST_STORE"
157
+ ];
158
+ var PERIOD_TYPES = ["TRIAL", "INTRO", "NORMAL", "PROMOTIONAL", "PREPAID"];
159
+ var CANCEL_REASONS = [
160
+ "UNSUBSCRIBE",
161
+ "BILLING_ERROR",
162
+ "DEVELOPER_INITIATED",
163
+ "PRICE_INCREASE",
164
+ "CUSTOMER_SUPPORT",
165
+ "UNKNOWN"
166
+ ];
167
+ var EXPIRATION_REASONS = [...CANCEL_REASONS, "SUBSCRIPTION_PAUSED"];
168
+ var CLI_STORES = ["app_store"];
169
+ var CLI_STORE_TO_STORE = { app_store: "APP_STORE" };
170
+
171
+ // src/core/config.ts
172
+ var CONFIG_FILE = "reveclicat.config.json";
173
+ var DEFAULT_TARGET = "http://localhost:3000/webhook";
174
+ var ConfigSchema = z.strictObject({
175
+ to: z.url({ error: "`to` must be an absolute http(s) URL." }).optional(),
176
+ authHeader: z.string().optional(),
177
+ store: z.enum(CLI_STORES, { error: `\`store\` must be one of: ${CLI_STORES.join(", ")}.` }).optional(),
178
+ environment: z.enum(ENVIRONMENTS, { error: `\`environment\` must be one of: ${ENVIRONMENTS.join(", ")}.` }).optional()
179
+ });
180
+ function loadConfig(dir = process.cwd()) {
181
+ const file = join(dir, CONFIG_FILE);
182
+ if (!existsSync(file)) return {};
183
+ let raw;
184
+ try {
185
+ raw = JSON.parse(readFileSync(file, "utf8"));
186
+ } catch (cause) {
187
+ throw new RccError(`${file} is not valid JSON.`, { hint: "Fix the file or delete it and run `rcc init` again.", cause });
188
+ }
189
+ const result = ConfigSchema.safeParse(raw);
190
+ if (!result.success) {
191
+ const issue = result.error.issues[0];
192
+ const detail = issue.code === "unrecognized_keys" ? `unknown key ${issue.keys.map((k) => `"${k}"`).join(", ")} (allowed: to, authHeader, store, environment)` : `${issue.path.join(".")}: ${issue.message}`;
193
+ throw new RccError(`${file}: ${detail}`, { hint: "See the config format in the README." });
194
+ }
195
+ return result.data;
196
+ }
197
+ function resolveDefaults(flags, config) {
198
+ return {
199
+ to: flags.to ?? config.to ?? DEFAULT_TARGET,
200
+ authHeader: flags.authHeader ?? config.authHeader,
201
+ store: flags.store ?? config.store ?? "app_store",
202
+ environment: flags.environment ?? config.environment ?? "SANDBOX"
203
+ };
204
+ }
205
+ function packageRoot() {
206
+ let dir = dirname(fileURLToPath(import.meta.url));
207
+ for (let i = 0; i < 6; i++) {
208
+ const pkg = join(dir, "package.json");
209
+ if (existsSync(pkg)) {
210
+ try {
211
+ if (JSON.parse(readFileSync(pkg, "utf8")).name === "reveclicat") return dir;
212
+ } catch {
213
+ }
214
+ }
215
+ dir = dirname(dir);
216
+ }
217
+ throw new RccError("Could not locate the reveclicat package root.", { hint: "Reinstall the package: npm i -g reveclicat" });
218
+ }
219
+
220
+ // src/core/clock.ts
221
+ var InvalidDurationError = class extends RccError {
222
+ constructor(input) {
223
+ super(
224
+ input === "" ? "Duration is empty." : `Invalid ISO-8601 duration: "${input}".`,
225
+ { hint: "Use the form PnYnMnWnDTnHnMnS, e.g. P1M (one month), P1W (one week), P3D, PT12H." }
226
+ );
227
+ this.name = "InvalidDurationError";
228
+ }
229
+ };
230
+ var ClockError = class extends RccError {
231
+ constructor(message, hint) {
232
+ super(message, hint === void 0 ? {} : { hint });
233
+ this.name = "ClockError";
234
+ }
235
+ };
236
+ var DURATION_RE = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
237
+ function parseDuration(input) {
238
+ if (input === "") throw new InvalidDurationError(input);
239
+ const m = DURATION_RE.exec(input);
240
+ if (!m || input === "P" || input.endsWith("T")) throw new InvalidDurationError(input);
241
+ const n = (i) => m[i] === void 0 ? 0 : Number(m[i]);
242
+ return {
243
+ years: n(1),
244
+ months: n(2),
245
+ weeks: n(3),
246
+ days: n(4),
247
+ hours: n(5),
248
+ minutes: n(6),
249
+ seconds: n(7)
250
+ };
251
+ }
252
+ function formatDuration(d) {
253
+ let out = "P";
254
+ if (d.years) out += `${d.years}Y`;
255
+ if (d.months) out += `${d.months}M`;
256
+ if (d.weeks) out += `${d.weeks}W`;
257
+ if (d.days) out += `${d.days}D`;
258
+ if (d.hours || d.minutes || d.seconds) {
259
+ out += "T";
260
+ if (d.hours) out += `${d.hours}H`;
261
+ if (d.minutes) out += `${d.minutes}M`;
262
+ if (d.seconds) out += `${d.seconds}S`;
263
+ }
264
+ return out === "P" ? "PT0S" : out;
265
+ }
266
+ function isZeroDuration(d) {
267
+ return Object.values(d).every((v) => v === 0);
268
+ }
269
+ var MS = { second: 1e3, minute: 6e4, hour: 36e5, day: 864e5, week: 6048e5 };
270
+ function addDuration(ms2, d) {
271
+ const date = new Date(ms2);
272
+ if (d.years || d.months) {
273
+ const totalMonths = date.getUTCFullYear() * 12 + date.getUTCMonth() + d.years * 12 + d.months;
274
+ const year = Math.floor(totalMonths / 12);
275
+ const month = totalMonths % 12;
276
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
277
+ const day = Math.min(date.getUTCDate(), lastDay);
278
+ date.setUTCFullYear(year, month, day);
279
+ }
280
+ return date.getTime() + d.weeks * MS.week + d.days * MS.day + d.hours * MS.hour + d.minutes * MS.minute + d.seconds * MS.second;
281
+ }
282
+ var SEEDED_EPOCH_MS = Date.UTC(2025, 0, 1);
283
+ var VirtualClock = class _VirtualClock {
284
+ current;
285
+ constructor(startMs) {
286
+ this.current = startMs;
287
+ }
288
+ /** Seeded runs start at a fixed epoch so payloads are reproducible; unseeded runs start now. */
289
+ static forSeed(seed, startAt) {
290
+ if (startAt !== void 0) return new _VirtualClock(startAt);
291
+ return new _VirtualClock(seed === void 0 ? Date.now() : SEEDED_EPOCH_MS);
292
+ }
293
+ now() {
294
+ return this.current;
295
+ }
296
+ iso() {
297
+ return new Date(this.current).toISOString();
298
+ }
299
+ /** Advance by a Duration or ISO-8601 string. Returns the new now(). */
300
+ advance(duration2) {
301
+ const d = typeof duration2 === "string" ? parseDuration(duration2) : duration2;
302
+ if (isZeroDuration(d)) {
303
+ throw new ClockError(
304
+ `Cannot advance the clock by a zero-length duration (${formatDuration(d)}).`,
305
+ "The virtual clock only moves forward; use a positive duration such as P1D."
306
+ );
307
+ }
308
+ const next = addDuration(this.current, d);
309
+ if (next <= this.current) {
310
+ throw new ClockError("The virtual clock cannot move backwards.");
311
+ }
312
+ this.current = next;
313
+ return next;
314
+ }
315
+ };
316
+
317
+ // src/core/http.ts
318
+ function unreachableError(url, cause) {
319
+ return new RccError(`Could not reach ${url}. Is your server running? Try \`rcc listen\` to test locally.`, {
320
+ hint: `Then send events with: rcc send INITIAL_PURCHASE --to http://localhost:8787/webhook`,
321
+ cause
322
+ });
323
+ }
324
+ async function postEvent(url, envelope, opts = {}) {
325
+ const headers = { "content-type": "application/json", "user-agent": "reveclicat" };
326
+ if (opts.authHeader !== void 0) headers["authorization"] = opts.authHeader;
327
+ const started = performance.now();
328
+ let res;
329
+ try {
330
+ res = await fetch(url, {
331
+ method: "POST",
332
+ headers,
333
+ body: JSON.stringify(envelope),
334
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 3e4)
335
+ });
336
+ } catch (cause) {
337
+ throw unreachableError(url, cause);
338
+ }
339
+ const body = await res.text().catch(() => "");
340
+ return { status: res.status, latencyMs: Math.round(performance.now() - started), body };
341
+ }
342
+ function assertUrl(value, flag) {
343
+ try {
344
+ const u = new URL(value);
345
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("protocol");
346
+ return value;
347
+ } catch {
348
+ throw new RccError(`Invalid URL for ${flag}: "${value}".`, { hint: "Use an absolute http(s) URL, e.g. http://localhost:3000/webhook." });
349
+ }
350
+ }
351
+
352
+ // src/core/rng.ts
353
+ import { randomUUID } from "crypto";
354
+ function fnv1a(str) {
355
+ let h = 2166136261;
356
+ for (let i = 0; i < str.length; i++) {
357
+ h ^= str.charCodeAt(i);
358
+ h = Math.imul(h, 16777619) >>> 0;
359
+ }
360
+ return h >>> 0;
361
+ }
362
+ function mulberry32(seed) {
363
+ let a = seed >>> 0;
364
+ return () => {
365
+ a = a + 1831565813 >>> 0;
366
+ let t = a;
367
+ t = Math.imul(t ^ t >>> 15, t | 1);
368
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
369
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
370
+ };
371
+ }
372
+ var HEX = "0123456789abcdef";
373
+ function normalizeSeed(seed) {
374
+ return typeof seed === "number" ? seed >>> 0 : fnv1a(seed);
375
+ }
376
+ function createRng(seed) {
377
+ const seeded = seed !== void 0;
378
+ const next = seeded ? mulberry32(normalizeSeed(seed)) : Math.random;
379
+ const hex = (n) => {
380
+ let s = "";
381
+ for (let i = 0; i < n; i++) s += HEX[Math.floor(next() * 16)];
382
+ return s;
383
+ };
384
+ return {
385
+ next,
386
+ int: (max) => Math.floor(next() * max),
387
+ hex,
388
+ uuid: () => {
389
+ if (!seeded) return randomUUID();
390
+ const variant = HEX[8 + Math.floor(next() * 4)];
391
+ return `${hex(8)}-${hex(4)}-4${hex(3)}-${variant}${hex(3)}-${hex(12)}`;
392
+ }
393
+ };
394
+ }
395
+
396
+ // src/core/set-path.ts
397
+ function setPath(target, path, value) {
398
+ const keys = path.split(".");
399
+ let cur = target;
400
+ for (let i = 0; i < keys.length - 1; i++) {
401
+ const k = keys[i];
402
+ const next = cur[k];
403
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
404
+ const fresh = {};
405
+ cur[k] = fresh;
406
+ cur = fresh;
407
+ } else {
408
+ cur = next;
409
+ }
410
+ }
411
+ cur[keys[keys.length - 1]] = value;
412
+ return target;
413
+ }
414
+ function applyOverrides(target, overrides) {
415
+ for (const [k, v] of Object.entries(overrides)) setPath(target, k, v);
416
+ return target;
417
+ }
418
+
419
+ // src/core/state-machine.ts
420
+ var IllegalTransitionError = class extends RccError {
421
+ state;
422
+ event;
423
+ legal;
424
+ constructor(state, event) {
425
+ const legal = legalEvents(state);
426
+ super(
427
+ `Illegal transition: cannot apply ${event} while the subscription is "${state}". Legal events from "${state}": ${legal.join(", ")}.`,
428
+ { hint: "Check the order of the steps in your scenario (e.g. a RENEWAL needs an INITIAL_PURCHASE first)." }
429
+ );
430
+ this.name = "IllegalTransitionError";
431
+ this.state = state;
432
+ this.event = event;
433
+ this.legal = legal;
434
+ }
435
+ };
436
+ var TABLE = {
437
+ none: { INITIAL_PURCHASE: (ctx) => ctx.hasTrial ? "trial" : "active" },
438
+ trial: {
439
+ RENEWAL: () => "active",
440
+ CANCELLATION: () => "cancelled_pending_expiration",
441
+ BILLING_ISSUE: () => "billing_issue",
442
+ EXPIRATION: () => "expired"
443
+ },
444
+ active: {
445
+ RENEWAL: () => "active",
446
+ CANCELLATION: () => "cancelled_pending_expiration",
447
+ BILLING_ISSUE: () => "billing_issue",
448
+ EXPIRATION: () => "expired"
449
+ },
450
+ cancelled_pending_expiration: {
451
+ UNCANCELLATION: (ctx) => ctx.resumeState,
452
+ EXPIRATION: () => "expired"
453
+ },
454
+ billing_issue: {
455
+ RENEWAL: () => "active",
456
+ EXPIRATION: () => "expired",
457
+ CANCELLATION: () => "cancelled_pending_expiration"
458
+ },
459
+ expired: { INITIAL_PURCHASE: () => "active" }
460
+ };
461
+ function legalEvents(state) {
462
+ return [...Object.keys(TABLE[state]), "TEST"];
463
+ }
464
+ function transition(state, event, ctx) {
465
+ if (event === "TEST") return state;
466
+ const rule = TABLE[state][event];
467
+ if (!rule) throw new IllegalTransitionError(state, event);
468
+ return rule(ctx);
469
+ }
470
+
471
+ // src/schemas/events.ts
472
+ import { z as z2 } from "zod";
473
+ var ms = z2.int();
474
+ var double = z2.number();
475
+ var SubscriberAttributeSchema = z2.looseObject({
476
+ value: z2.string(),
477
+ updated_at_ms: ms
478
+ });
479
+ var ExperimentSchema = z2.looseObject({
480
+ experiment_id: z2.string(),
481
+ experiment_variant: z2.string(),
482
+ enrolled_at_ms: ms.nullable()
483
+ });
484
+ var common = {
485
+ id: z2.string(),
486
+ event_timestamp_ms: ms,
487
+ app_id: z2.string().optional()
488
+ };
489
+ var identity = {
490
+ app_user_id: z2.string(),
491
+ original_app_user_id: z2.string(),
492
+ aliases: z2.array(z2.string()),
493
+ subscriber_attributes: z2.record(z2.string(), SubscriberAttributeSchema).optional(),
494
+ experiments: z2.array(ExperimentSchema).optional()
495
+ };
496
+ var lifecycle = {
497
+ product_id: z2.string(),
498
+ period_type: z2.enum(PERIOD_TYPES),
499
+ purchased_at_ms: ms,
500
+ expiration_at_ms: ms.nullable(),
501
+ environment: z2.enum(ENVIRONMENTS),
502
+ entitlement_id: z2.string().nullable(),
503
+ entitlement_ids: z2.array(z2.string()).nullable(),
504
+ presented_offering_id: z2.string().nullable(),
505
+ transaction_id: z2.string(),
506
+ original_transaction_id: z2.string(),
507
+ // Docs: "Always" = key present, value may be null. Real PROMOTIONAL events carry null here (T-064).
508
+ is_family_share: z2.boolean().nullable(),
509
+ country_code: z2.string().nullable(),
510
+ store: z2.enum(STORES).optional(),
511
+ currency: z2.string().nullable().optional(),
512
+ price: double.nullable().optional(),
513
+ price_in_purchased_currency: double.nullable().optional(),
514
+ tax_percentage: double.nullable().optional(),
515
+ commission_percentage: double.nullable().optional(),
516
+ takehome_percentage: double.nullable().optional(),
517
+ offer_code: z2.string().nullable().optional(),
518
+ renewal_number: z2.int().nullable().optional(),
519
+ metadata: z2.record(z2.string(), z2.unknown()).nullable().optional(),
520
+ discount_percentage: double.nullable().optional(),
521
+ discount_amount: double.nullable().optional(),
522
+ discount_identifier: z2.string().nullable().optional()
523
+ };
524
+ var LifecycleEventBase = z2.looseObject({ ...common, ...identity, ...lifecycle });
525
+ var InitialPurchaseEventSchema = LifecycleEventBase.extend({ type: z2.literal("INITIAL_PURCHASE") });
526
+ var RenewalEventSchema = LifecycleEventBase.extend({
527
+ type: z2.literal("RENEWAL"),
528
+ is_trial_conversion: z2.boolean().optional()
529
+ });
530
+ var CancellationEventSchema = LifecycleEventBase.extend({
531
+ type: z2.literal("CANCELLATION"),
532
+ cancel_reason: z2.enum(CANCEL_REASONS)
533
+ });
534
+ var UncancellationEventSchema = LifecycleEventBase.extend({ type: z2.literal("UNCANCELLATION") });
535
+ var BillingIssueEventSchema = LifecycleEventBase.extend({
536
+ type: z2.literal("BILLING_ISSUE"),
537
+ grace_period_expiration_at_ms: ms.nullable()
538
+ });
539
+ var ExpirationEventSchema = LifecycleEventBase.extend({
540
+ type: z2.literal("EXPIRATION"),
541
+ expiration_reason: z2.enum(EXPIRATION_REASONS)
542
+ });
543
+ function nullableOptional(shape) {
544
+ return Object.fromEntries(Object.entries(shape).map(([k, schema]) => [k, schema.nullable().optional()]));
545
+ }
546
+ var TestEventSchema = z2.looseObject({
547
+ type: z2.literal("TEST"),
548
+ ...common,
549
+ ...identity,
550
+ ...nullableOptional(lifecycle)
551
+ });
552
+ var EVENT_SCHEMAS = {
553
+ TEST: TestEventSchema,
554
+ INITIAL_PURCHASE: InitialPurchaseEventSchema,
555
+ RENEWAL: RenewalEventSchema,
556
+ CANCELLATION: CancellationEventSchema,
557
+ UNCANCELLATION: UncancellationEventSchema,
558
+ BILLING_ISSUE: BillingIssueEventSchema,
559
+ EXPIRATION: ExpirationEventSchema
560
+ };
561
+ var EventSchema = z2.discriminatedUnion("type", [
562
+ TestEventSchema,
563
+ InitialPurchaseEventSchema,
564
+ RenewalEventSchema,
565
+ CancellationEventSchema,
566
+ UncancellationEventSchema,
567
+ BillingIssueEventSchema,
568
+ ExpirationEventSchema
569
+ ]);
570
+ var WebhookEnvelopeSchema = z2.looseObject({
571
+ api_version: z2.string(),
572
+ event: EventSchema
573
+ });
574
+ var UnknownEventSchema = z2.looseObject({ type: z2.string().min(1), ...common, ...identity });
575
+ var UnknownWebhookEnvelopeSchema = z2.looseObject({ api_version: z2.string(), event: UnknownEventSchema });
576
+ function classifyEnvelope(body) {
577
+ const known = WebhookEnvelopeSchema.safeParse(body);
578
+ if (known.success) return { kind: "known", envelope: known.data };
579
+ const loose = UnknownWebhookEnvelopeSchema.safeParse(body);
580
+ const typeIsOurs = loose.success && EVENT_TYPES.includes(loose.data.event.type);
581
+ if (loose.success && !typeIsOurs) return { kind: "unknown-type", type: loose.data.event.type, envelope: loose.data };
582
+ const source = loose.success ? known : loose;
583
+ return {
584
+ kind: "invalid",
585
+ issues: source.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
586
+ };
587
+ }
588
+
589
+ // src/core/subscriber.ts
590
+ var PrematureEventError = class extends RccError {
591
+ constructor(event, nowMs, dueMs) {
592
+ const remaining = msToDuration(dueMs - nowMs);
593
+ super(
594
+ `Cannot emit ${event} yet: the virtual clock is at ${new Date(nowMs).toISOString()} but the subscription runs until ${new Date(dueMs).toISOString()}. Add \`advance: ${remaining}\` (or more) before this step.`,
595
+ { hint: "The virtual clock must reach expiration_at_ms (or the end of the grace period) before EXPIRATION." }
596
+ );
597
+ this.name = "PrematureEventError";
598
+ }
599
+ };
600
+ function msToDuration(ms2) {
601
+ const days = Math.ceil(ms2 / 864e5);
602
+ if (days >= 1) return `P${days}D`;
603
+ return formatDuration({ ...parseDuration("PT1S"), seconds: Math.max(1, Math.ceil(ms2 / 1e3)) });
604
+ }
605
+ var asDuration = (d) => typeof d === "string" ? parseDuration(d) : d;
606
+ var Subscriber = class {
607
+ constructor(opts, deps) {
608
+ this.deps = deps;
609
+ this.period = asDuration(opts.period);
610
+ this.trial = opts.trial === void 0 ? void 0 : asDuration(opts.trial);
611
+ this.grace = asDuration(opts.gracePeriod ?? "P16D");
612
+ this.store = CLI_STORE_TO_STORE[opts.store ?? "app_store"];
613
+ this.environment = opts.environment ?? "SANDBOX";
614
+ this.price = opts.price ?? 9.99;
615
+ this.currency = opts.currency ?? "USD";
616
+ this.countryCode = opts.countryCode ?? "US";
617
+ this.entitlementIds = opts.entitlementIds ?? ["premium"];
618
+ this.productId = opts.productId;
619
+ this.appUserId = opts.appUserId === void 0 || opts.appUserId === "auto" ? `$RCAnonymousID:${deps.rng.hex(32)}` : opts.appUserId;
620
+ this.appId = opts.appId ?? `app${deps.rng.hex(12)}`;
621
+ }
622
+ deps;
623
+ history = [];
624
+ _state = "none";
625
+ resumeState = "active";
626
+ period;
627
+ trial;
628
+ grace;
629
+ store;
630
+ environment;
631
+ price;
632
+ currency;
633
+ countryCode;
634
+ entitlementIds;
635
+ productId;
636
+ appUserId;
637
+ appId;
638
+ originalTransactionId;
639
+ transactionId;
640
+ purchasedAtMs;
641
+ expirationAtMs;
642
+ periodType = "NORMAL";
643
+ gracePeriodExpirationAtMs = null;
644
+ get state() {
645
+ return this._state;
646
+ }
647
+ /** Current period end (ms) or undefined before the first purchase. */
648
+ get expiresAt() {
649
+ return this.expirationAtMs;
650
+ }
651
+ /** Emit an event: check legality, time guards, build + validate payload, commit state. */
652
+ emit(type, overrides = {}) {
653
+ const from = this._state;
654
+ const next = transition(from, type, { hasTrial: this.trial !== void 0, resumeState: this.resumeState });
655
+ const now = this.deps.clock.now();
656
+ if (type === "EXPIRATION") {
657
+ const due = Math.max(this.expirationAtMs ?? 0, from === "billing_issue" ? this.gracePeriodExpirationAtMs ?? 0 : 0);
658
+ if (now < due) throw new PrematureEventError(type, now, due);
659
+ }
660
+ const draft = this.draftFor(type, from, now);
661
+ const payload = applyOverrides(this.buildPayload(type, from, now, draft), overrides);
662
+ const result = EVENT_SCHEMAS[type].safeParse(payload);
663
+ if (!result.success) {
664
+ const issue = result.error.issues[0];
665
+ throw new RccError(`Generated ${type} payload is invalid at "${issue.path.join(".")}": ${issue.message}`, {
666
+ hint: "Check your --set / set: overrides against the RevenueCat field types (docs/payload-sources.md)."
667
+ });
668
+ }
669
+ if (type !== "TEST") {
670
+ this.originalTransactionId = draft.originalTransactionId;
671
+ this.transactionId = draft.transactionId;
672
+ this.purchasedAtMs = draft.purchasedAtMs;
673
+ this.expirationAtMs = draft.expirationAtMs;
674
+ this.periodType = draft.periodType;
675
+ this.gracePeriodExpirationAtMs = draft.gracePeriodExpirationAtMs;
676
+ if (type === "CANCELLATION") this.resumeState = from === "trial" ? "trial" : "active";
677
+ this._state = next;
678
+ }
679
+ const event = result.data;
680
+ this.history.push(event);
681
+ return event;
682
+ }
683
+ /* ----------------------------------------------------------- internals */
684
+ newTransactionId() {
685
+ let s = String(1 + this.deps.rng.int(9));
686
+ for (let i = 0; i < 15; i++) s += String(this.deps.rng.int(10));
687
+ return s;
688
+ }
689
+ draftFor(type, from, now) {
690
+ const d = {
691
+ originalTransactionId: this.originalTransactionId,
692
+ transactionId: this.transactionId,
693
+ purchasedAtMs: this.purchasedAtMs,
694
+ expirationAtMs: this.expirationAtMs,
695
+ periodType: this.periodType,
696
+ gracePeriodExpirationAtMs: this.gracePeriodExpirationAtMs
697
+ };
698
+ switch (type) {
699
+ case "INITIAL_PURCHASE": {
700
+ const startsTrial = from === "none" && this.trial !== void 0;
701
+ d.transactionId = this.newTransactionId();
702
+ d.originalTransactionId ??= d.transactionId;
703
+ d.purchasedAtMs = now;
704
+ d.expirationAtMs = addDuration(now, startsTrial ? this.trial : this.period);
705
+ d.periodType = startsTrial ? "TRIAL" : "NORMAL";
706
+ d.gracePeriodExpirationAtMs = null;
707
+ break;
708
+ }
709
+ case "RENEWAL": {
710
+ const start = d.expirationAtMs ?? now;
711
+ d.transactionId = this.newTransactionId();
712
+ d.purchasedAtMs = start;
713
+ d.expirationAtMs = addDuration(start, this.period);
714
+ d.periodType = "NORMAL";
715
+ d.gracePeriodExpirationAtMs = null;
716
+ break;
717
+ }
718
+ case "BILLING_ISSUE":
719
+ d.gracePeriodExpirationAtMs = addDuration(now, this.grace);
720
+ break;
721
+ case "TEST":
722
+ if (from === "none") {
723
+ d.transactionId = d.originalTransactionId = this.newTransactionId();
724
+ d.purchasedAtMs = now;
725
+ d.expirationAtMs = addDuration(now, this.period);
726
+ d.periodType = "NORMAL";
727
+ }
728
+ break;
729
+ default:
730
+ break;
731
+ }
732
+ return d;
733
+ }
734
+ buildPayload(type, from, now, d) {
735
+ const isPurchase = type === "INITIAL_PURCHASE" || type === "RENEWAL" || type === "TEST";
736
+ const price = isPurchase && d.periodType !== "TRIAL" ? this.price : 0;
737
+ const payload = {
738
+ type,
739
+ id: this.deps.rng.uuid(),
740
+ event_timestamp_ms: now,
741
+ app_id: this.appId,
742
+ app_user_id: this.appUserId,
743
+ original_app_user_id: this.appUserId,
744
+ aliases: [this.appUserId],
745
+ subscriber_attributes: {},
746
+ product_id: this.productId,
747
+ period_type: d.periodType,
748
+ purchased_at_ms: d.purchasedAtMs,
749
+ expiration_at_ms: d.expirationAtMs,
750
+ environment: this.environment,
751
+ entitlement_id: null,
752
+ entitlement_ids: [...this.entitlementIds],
753
+ presented_offering_id: null,
754
+ transaction_id: d.transactionId,
755
+ original_transaction_id: d.originalTransactionId,
756
+ is_family_share: false,
757
+ country_code: this.countryCode,
758
+ store: this.store,
759
+ currency: this.currency,
760
+ price,
761
+ price_in_purchased_currency: price,
762
+ tax_percentage: 0,
763
+ commission_percentage: 0.3,
764
+ takehome_percentage: 0.7,
765
+ offer_code: null
766
+ };
767
+ switch (type) {
768
+ case "RENEWAL":
769
+ payload["is_trial_conversion"] = from === "trial";
770
+ break;
771
+ case "CANCELLATION":
772
+ payload["cancel_reason"] = from === "billing_issue" ? "BILLING_ERROR" : "UNSUBSCRIBE";
773
+ break;
774
+ case "BILLING_ISSUE":
775
+ payload["grace_period_expiration_at_ms"] = d.gracePeriodExpirationAtMs;
776
+ break;
777
+ case "EXPIRATION":
778
+ payload["expiration_reason"] = from === "billing_issue" ? "BILLING_ERROR" : "UNSUBSCRIBE";
779
+ break;
780
+ default:
781
+ break;
782
+ }
783
+ return payload;
784
+ }
785
+ };
786
+
787
+ // src/core/engine.ts
788
+ function createSimulation(opts, seed, startAt) {
789
+ const clock4 = VirtualClock.forSeed(seed, startAt);
790
+ const subscriber = new Subscriber(opts, { clock: clock4, rng: createRng(seed) });
791
+ return { clock: clock4, subscriber };
792
+ }
793
+ function applyStep(sim, step) {
794
+ if (step.advance !== void 0) {
795
+ sim.clock.advance(step.advance);
796
+ return void 0;
797
+ }
798
+ return sim.subscriber.emit(step.event, step.set ?? {});
799
+ }
800
+ function spanOf(steps, fromMs) {
801
+ let t = fromMs;
802
+ for (const s of steps) if (s.advance !== void 0) t = addDuration(t, parseDuration(s.advance));
803
+ return t - fromMs;
804
+ }
805
+ function preludeFor(type) {
806
+ const ip = { event: "INITIAL_PURCHASE" };
807
+ switch (type) {
808
+ case "TEST":
809
+ case "INITIAL_PURCHASE":
810
+ return [];
811
+ case "RENEWAL":
812
+ case "BILLING_ISSUE":
813
+ return [ip, { advance: "P1M" }];
814
+ case "CANCELLATION":
815
+ return [ip, { advance: "P10D" }];
816
+ case "UNCANCELLATION":
817
+ return [ip, { advance: "P10D" }, { event: "CANCELLATION" }, { advance: "P1D" }];
818
+ case "EXPIRATION":
819
+ return [ip, { advance: "P10D" }, { event: "CANCELLATION" }, { advance: "P21D" }];
820
+ }
821
+ }
822
+ var sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
823
+ function subscriberOptions(scenario) {
824
+ const s = scenario.subscriber;
825
+ return {
826
+ appUserId: s.app_user_id,
827
+ productId: s.product_id,
828
+ period: s.period,
829
+ trial: s.trial,
830
+ gracePeriod: s.grace_period,
831
+ store: s.store,
832
+ environment: s.environment
833
+ };
834
+ }
835
+ function stepLabel(index, source) {
836
+ const pos = source?.stepPositions[index];
837
+ return pos ? `step ${index + 1} (${source.file}:${pos.line})` : `step ${index + 1}`;
838
+ }
839
+ async function runScenario(scenario, opts) {
840
+ const sim = createSimulation(subscriberOptions(scenario), opts.seed);
841
+ const startedMs = sim.clock.now();
842
+ const events = [];
843
+ let delivered = 0;
844
+ for (const [index, step] of scenario.steps.entries()) {
845
+ if (step.advance !== void 0) {
846
+ sim.clock.advance(step.advance);
847
+ continue;
848
+ }
849
+ if (delivered > 0 && opts.speed !== "instant") await sleep(opts.speed);
850
+ let event;
851
+ try {
852
+ event = sim.subscriber.emit(step.event, step.set ?? {});
853
+ } catch (err) {
854
+ if (err instanceof RccError) {
855
+ throw new RccError(`${stepLabel(index, opts.source)}: ${err.message}`, {
856
+ ...err.hint === void 0 ? {} : { hint: err.hint },
857
+ exitCode: err.exitCode,
858
+ cause: err
859
+ });
860
+ }
861
+ throw err;
862
+ }
863
+ const envelope = { api_version: "1.0", event };
864
+ let status = null;
865
+ let latencyMs = null;
866
+ if (!opts.dryRun) {
867
+ const res = await postEvent(opts.to, envelope, { authHeader: opts.authHeader });
868
+ status = res.status;
869
+ latencyMs = res.latencyMs;
870
+ }
871
+ delivered++;
872
+ const result = { step: index, type: event.type, virtualTime: new Date(sim.clock.now()).toISOString(), status, latencyMs, event };
873
+ events.push(result);
874
+ opts.onEvent?.(result, envelope);
875
+ }
876
+ const endedMs = sim.clock.now();
877
+ const expectations = evaluateExpectations(scenario, events);
878
+ const allDelivered = events.every((e) => e.status === null || e.status >= 200 && e.status < 300);
879
+ return {
880
+ scenario: scenario.name,
881
+ seed: opts.seed ?? null,
882
+ startedAt: new Date(startedMs).toISOString(),
883
+ endedAt: new Date(endedMs).toISOString(),
884
+ virtualSpanMs: endedMs - startedMs,
885
+ events,
886
+ expectations,
887
+ ok: allDelivered && expectations.every((e) => e.ok)
888
+ };
889
+ }
890
+ var SKIPPED = "skipped";
891
+ function evaluateExpectations(scenario, events) {
892
+ const out = [];
893
+ const label = (e) => `step ${e.step + 1} ${e.type}`;
894
+ for (const e of events) {
895
+ const want = scenario.steps[e.step]?.expect?.response_status;
896
+ if (want === void 0) continue;
897
+ const skipped = e.status === null;
898
+ out.push({
899
+ scope: "step",
900
+ step: e.step,
901
+ rule: "response_status",
902
+ expected: String(want),
903
+ actual: skipped ? SKIPPED : String(e.status),
904
+ ok: skipped || e.status === want
905
+ });
906
+ }
907
+ const all = scenario.expect?.all_responses_status;
908
+ if (all !== void 0) {
909
+ const offenders = events.filter((e) => e.status !== null && e.status !== all);
910
+ const skipped = events.every((e) => e.status === null);
911
+ out.push({
912
+ scope: "scenario",
913
+ step: null,
914
+ rule: "all_responses_status",
915
+ expected: String(all),
916
+ actual: skipped ? SKIPPED : offenders.length === 0 ? String(all) : offenders.map((e) => `${e.status} (${label(e)})`).join(", "),
917
+ ok: skipped || offenders.length === 0
918
+ });
919
+ }
920
+ const max = scenario.expect?.max_response_ms;
921
+ if (max !== void 0) {
922
+ const measured = events.filter((e) => e.latencyMs !== null);
923
+ const slowest = measured.reduce((acc, e) => acc === void 0 || e.latencyMs > acc.latencyMs ? e : acc, void 0);
924
+ const skipped = slowest === void 0;
925
+ out.push({
926
+ scope: "scenario",
927
+ step: null,
928
+ rule: "max_response_ms",
929
+ expected: `\u2264 ${max} ms`,
930
+ actual: skipped ? SKIPPED : `${slowest.latencyMs} ms (${label(slowest)})`,
931
+ ok: skipped || slowest.latencyMs <= max
932
+ });
933
+ }
934
+ return out;
935
+ }
936
+
937
+ // src/core/io.ts
938
+ var defaultIo = { stdout: process.stdout, stderr: process.stderr };
939
+ var println = (w, s = "") => {
940
+ w.write(s + "\n");
941
+ };
942
+
943
+ // src/commands/send.ts
944
+ function parseSetFlag(pairs) {
945
+ const out = {};
946
+ for (const pair of pairs) {
947
+ const eq = pair.indexOf("=");
948
+ if (eq <= 0) {
949
+ throw new RccError(`Invalid --set "${pair}": expected key=value.`, {
950
+ hint: "Example: --set price=4.99 --set subscriber_attributes.plan.value=pro"
951
+ });
952
+ }
953
+ const key = pair.slice(0, eq);
954
+ const raw = pair.slice(eq + 1);
955
+ let value = raw;
956
+ try {
957
+ value = JSON.parse(raw);
958
+ } catch {
959
+ }
960
+ out[key] = value;
961
+ }
962
+ return out;
963
+ }
964
+ function parseEventType(input) {
965
+ const upper = input.toUpperCase();
966
+ if (EVENT_TYPES.includes(upper)) return upper;
967
+ throw new RccError(`Unknown event type "${input}". Valid types: ${EVENT_TYPES.join(", ")}.`, {
968
+ hint: "Example: rcc send INITIAL_PURCHASE"
969
+ });
970
+ }
971
+ function parseEnvironment(input) {
972
+ if (ENVIRONMENTS.includes(input)) return input;
973
+ throw new RccError(`Invalid --environment "${input}". Use one of: ${ENVIRONMENTS.join(", ")}.`);
974
+ }
975
+ function parseStore(input) {
976
+ if (CLI_STORES.includes(input)) return input;
977
+ throw new RccError(`Unsupported --store "${input}". v0.1 supports: ${CLI_STORES.join(", ")}.`, {
978
+ hint: "Other stores are on the roadmap (see docs/BACKLOG.md \u2192 Icebox)."
979
+ });
980
+ }
981
+ function parseSeed(input) {
982
+ if (input === void 0) return void 0;
983
+ return /^\d+$/.test(input) ? Number(input) : input;
984
+ }
985
+ function buildSingleEvent(type, opts) {
986
+ const seed = parseSeed(opts.seed);
987
+ const prelude = preludeFor(type);
988
+ const subscriberOpts = {
989
+ appUserId: opts.user ?? "auto",
990
+ productId: opts.product,
991
+ period: "P1M",
992
+ store: parseStore(opts.store ?? "app_store"),
993
+ environment: parseEnvironment(opts.environment ?? "SANDBOX")
994
+ };
995
+ const startAt = seed === void 0 ? Date.now() - spanOf(prelude, Date.now()) : void 0;
996
+ const sim = createSimulation(subscriberOpts, seed, startAt);
997
+ for (const step of prelude) applyStep(sim, step);
998
+ const event = sim.subscriber.emit(type, parseSetFlag(opts.set ?? []));
999
+ return { api_version: "1.0", event };
1000
+ }
1001
+ function registerSend(program, io) {
1002
+ program.command("send").argument("<EVENT_TYPE>", `event to send: ${EVENT_TYPES.join(" | ")}`).description("Send a single, schema-valid RevenueCat webhook event to your endpoint.").option("--to <url>", `target URL (default: ${DEFAULT_TARGET}, or "to" in ${CONFIG_FILE})`).option("--store <store>", `store: ${CLI_STORES.join(" | ")} (default: app_store, or "store" in ${CONFIG_FILE})`).option("--user <app_user_id>", "app_user_id (default: generated $RCAnonymousID)").option("--product <product_id>", "product_id", "com.example.premium.monthly").option("--auth-header <value>", `value sent as the Authorization header (default: "authHeader" in ${CONFIG_FILE})`).option("--environment <env>", `${ENVIRONMENTS.join(" | ")} (default: SANDBOX, or "environment" in ${CONFIG_FILE})`).option("--set <key=value>", "override a payload field (repeatable, dot paths allowed)", (v, acc) => [...acc ?? [], v]).option("--seed <seed>", "deterministic ids and timestamps").option("--dry-run", "print the payload instead of sending it").addHelpText("after", `
1003
+ Examples:
1004
+ $ rcc send INITIAL_PURCHASE
1005
+ $ rcc send RENEWAL --to http://localhost:8787/webhook --auth-header "Bearer dev"
1006
+ $ rcc send CANCELLATION --set cancel_reason=BILLING_ERROR --dry-run | jq .event.type`).action(async (eventType2, opts) => {
1007
+ const type = parseEventType(eventType2);
1008
+ const d = resolveDefaults(opts, loadConfig());
1009
+ const to = assertUrl(d.to, "--to");
1010
+ const envelope = buildSingleEvent(type, { ...opts, to, store: d.store, environment: d.environment, authHeader: d.authHeader });
1011
+ if (opts.dryRun) {
1012
+ println(io.stdout, JSON.stringify(envelope, null, 2));
1013
+ return;
1014
+ }
1015
+ const res = await postEvent(to, envelope, { authHeader: d.authHeader });
1016
+ const ok = res.status >= 200 && res.status < 300;
1017
+ const mark = ok ? green("\u2714") : red("\u2716");
1018
+ println(io.stdout, `${mark} ${type.padEnd(16)} \u2192 ${to} ${res.status} ${dim(`(${res.latencyMs} ms)`)}`);
1019
+ if (!ok) {
1020
+ throw new RccError(`Endpoint answered ${res.status} for ${type}.`, {
1021
+ hint: "RevenueCat treats anything other than 200 as a failure and retries. Check your handler logs."
1022
+ });
1023
+ }
1024
+ });
1025
+ }
1026
+
1027
+ // src/commands/listen.ts
1028
+ import { createServer } from "http";
1029
+ var DEFAULT_PORT = 8787;
1030
+ function readBody(req) {
1031
+ return new Promise((resolve, reject) => {
1032
+ let raw = "";
1033
+ req.on("data", (c) => raw += c.toString("utf8"));
1034
+ req.on("end", () => resolve(raw));
1035
+ req.on("error", reject);
1036
+ });
1037
+ }
1038
+ function json(res, status, body) {
1039
+ res.writeHead(status, { "content-type": "application/json" });
1040
+ res.end(JSON.stringify(body));
1041
+ }
1042
+ var clock = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
1043
+ async function startListener(opts) {
1044
+ const { io } = opts;
1045
+ const log = (s) => println(io.stdout, s);
1046
+ const server = createServer((req, res) => {
1047
+ void handle(req, res);
1048
+ });
1049
+ async function handle(req, res) {
1050
+ if (req.method !== "POST") {
1051
+ json(res, 404, { error: `Use POST to deliver webhook events (got ${req.method ?? "?"} ${req.url ?? ""}).` });
1052
+ return;
1053
+ }
1054
+ const raw = await readBody(req);
1055
+ const time = dim(clock());
1056
+ if (opts.authHeader !== void 0 && req.headers["authorization"] !== opts.authHeader) {
1057
+ log(`${time} ${red(bold("AUTH MISMATCH"))} Authorization header ${req.headers["authorization"] === void 0 ? "missing" : "does not match --auth-header"} \u2192 401`);
1058
+ json(res, 401, { error: "Authorization header mismatch" });
1059
+ return;
1060
+ }
1061
+ let parsed;
1062
+ try {
1063
+ parsed = JSON.parse(raw);
1064
+ } catch {
1065
+ log(`${time} ${red(bold("INVALID"))} body is not JSON \u2192 400`);
1066
+ json(res, 400, { error: "Body is not valid JSON" });
1067
+ return;
1068
+ }
1069
+ const classified = classifyEnvelope(parsed);
1070
+ if (classified.kind === "invalid") {
1071
+ const issues = classified.issues.map((i) => ({ path: i.path || "(root)", message: i.message }));
1072
+ const shown = issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join("; ");
1073
+ const more = issues.length > 3 ? ` (+${issues.length - 3} more)` : "";
1074
+ log(`${time} ${red(bold("INVALID"))} ${shown}${more} \u2192 400`);
1075
+ if (opts.verbose) log(dim(JSON.stringify(parsed, null, 2)));
1076
+ json(res, 400, { error: "Invalid RevenueCat webhook envelope", issues });
1077
+ return;
1078
+ }
1079
+ const ev = classified.envelope.event;
1080
+ const typeLabel = classified.kind === "known" ? cyan(bold(ev.type.padEnd(16))) : `${yellow(bold("UNSUPPORTED"))} ${yellow(ev.type)}`;
1081
+ let status = 200;
1082
+ let suffix = "";
1083
+ if (opts.forward !== void 0) {
1084
+ try {
1085
+ const headers = { "content-type": "application/json" };
1086
+ const auth = req.headers["authorization"];
1087
+ if (auth !== void 0) headers["authorization"] = auth;
1088
+ const started = performance.now();
1089
+ const upstream = await fetch(opts.forward, { method: "POST", headers, body: raw, signal: AbortSignal.timeout(3e4) });
1090
+ status = upstream.status;
1091
+ suffix = ` ${dim("\u2192 forwarded")} ${opts.forward} ${status < 300 ? green(String(status)) : red(String(status))} ${dim(`(${Math.round(performance.now() - started)} ms)`)}`;
1092
+ } catch (err) {
1093
+ status = 502;
1094
+ suffix = ` ${red("forward failed")}: ${err instanceof Error ? err.message : String(err)}`;
1095
+ }
1096
+ }
1097
+ const statusText = status < 300 ? green(String(status)) : red(String(status));
1098
+ const productId = typeof ev["product_id"] === "string" ? ev["product_id"] : "";
1099
+ log(`${time} ${typeLabel} ${yellow(ev.app_user_id)} ${productId} \u2192 ${statusText}${suffix}`);
1100
+ if (opts.verbose) log(dim(JSON.stringify(classified.envelope, null, 2)));
1101
+ json(res, status, { ok: status < 300 });
1102
+ }
1103
+ await new Promise((resolve, reject) => {
1104
+ server.once("error", (err) => {
1105
+ reject(
1106
+ err.code === "EADDRINUSE" ? new RccError(`Port ${opts.port} is already in use.`, { hint: `Pick another one: rcc listen --port ${opts.port + 1}` }) : new RccError(`Could not start the listener: ${err.message}`, { cause: err })
1107
+ );
1108
+ });
1109
+ server.listen(opts.port, () => resolve());
1110
+ });
1111
+ const port = server.address().port;
1112
+ const url = `http://localhost:${port}/webhook`;
1113
+ log(`${green("\u25CF")} Listening on ${bold(url)}`);
1114
+ if (opts.authHeader !== void 0) log(dim(` expecting Authorization: ${opts.authHeader}`));
1115
+ if (opts.forward !== void 0) log(dim(` forwarding to ${opts.forward}`));
1116
+ log(dim(` try: rcc send INITIAL_PURCHASE --to ${url}`));
1117
+ return {
1118
+ url,
1119
+ port,
1120
+ close: () => new Promise((resolve, reject) => {
1121
+ server.closeAllConnections();
1122
+ server.close((err) => err ? reject(err) : resolve());
1123
+ })
1124
+ };
1125
+ }
1126
+ function registerListen(program, io) {
1127
+ program.command("listen").description("Start a local HTTP server that receives, validates and pretty-prints webhook events.").option("--port <n>", "port to listen on", String(DEFAULT_PORT)).option("--forward <url>", "forward each request (body + Authorization) to this URL and relay its status").option("--auth-header <value>", "expected Authorization header; mismatches are flagged and answered 401").option("--verbose", "print the full JSON payload of each event").addHelpText("after", `
1128
+ Examples:
1129
+ $ rcc listen
1130
+ $ rcc listen --port 9000 --auth-header "Bearer dev" --verbose
1131
+ $ rcc listen --forward http://localhost:3000/webhook`).action(async (opts) => {
1132
+ if (!/^\d{1,5}$/.test(opts.port) || Number(opts.port) > 65535) {
1133
+ throw new RccError(`Invalid --port "${opts.port}".`, { hint: "Use an integer between 1 and 65535." });
1134
+ }
1135
+ if (opts.forward !== void 0) assertUrl(opts.forward, "--forward");
1136
+ const listener = await startListener({ ...opts, port: Number(opts.port), io });
1137
+ const stop = () => {
1138
+ void listener.close().finally(() => process.exit(0));
1139
+ };
1140
+ process.once("SIGINT", stop);
1141
+ process.once("SIGTERM", stop);
1142
+ await new Promise(() => {
1143
+ });
1144
+ });
1145
+ }
1146
+
1147
+ // src/core/output.ts
1148
+ function humanDays(ms2) {
1149
+ return `${Math.round(ms2 / 864e5)}d`;
1150
+ }
1151
+ var RUN_HEADERS = ["#", "event", "virtual time", "status", "latency"];
1152
+ function createRunTable(scenario) {
1153
+ const eventCount = scenario.steps.filter((s) => s.event !== void 0).length;
1154
+ const eventTypes = scenario.steps.map((s) => s.event ?? "");
1155
+ const widths = [
1156
+ Math.max(RUN_HEADERS[0].length, String(eventCount).length),
1157
+ Math.max(RUN_HEADERS[1].length, ...eventTypes.map((t) => t.length)),
1158
+ Math.max(RUN_HEADERS[2].length, "2025-01-01T00:00:00.000Z".length),
1159
+ RUN_HEADERS[3].length,
1160
+ Math.max(RUN_HEADERS[4].length, "99999 ms".length)
1161
+ ];
1162
+ const pad = (cells, colour) => cells.map((c, i) => {
1163
+ const padded = i === 0 ? c.padStart(widths[i]) : c.padEnd(widths[i]);
1164
+ return colour ? colour(padded, i) : padded;
1165
+ }).join(" ").trimEnd();
1166
+ return {
1167
+ header: () => dim(pad(RUN_HEADERS)),
1168
+ row: (e, index) => pad(
1169
+ [String(index + 1), e.type, e.virtualTime, e.status === null ? "\u2014" : String(e.status), e.latencyMs === null ? "\u2014" : `${e.latencyMs} ms`],
1170
+ (cell, col) => {
1171
+ if (col !== 3) return cell;
1172
+ if (e.status === null) return dim(cell);
1173
+ return e.status >= 200 && e.status < 300 ? green(cell) : red(cell);
1174
+ }
1175
+ )
1176
+ };
1177
+ }
1178
+ function renderRunSummary(result) {
1179
+ const total = result.events.length;
1180
+ const okCount = result.events.filter((e) => e.status !== null && e.status >= 200 && e.status < 300).length;
1181
+ const failed = result.events.filter((e) => e.status !== null && !(e.status >= 200 && e.status < 300)).length;
1182
+ const span = `${humanDays(result.virtualSpanMs)} (${result.startedAt.slice(0, 10)} \u2192 ${result.endedAt.slice(0, 10)})`;
1183
+ const counts = result.events.some((e) => e.status === null) ? `${total} events \xB7 dry run` : `${total} events \xB7 ${okCount} ok \xB7 ${failed} failed`;
1184
+ const exps = result.expectations;
1185
+ const expText = exps.length ? ` \xB7 ${exps.filter((e) => e.ok).length}/${exps.length} expectations passed` : "";
1186
+ const mark = result.ok ? green("\u2714") : red("\u2716");
1187
+ return `${mark} ${bold(counts)} \xB7 virtual span ${span}${expText}`;
1188
+ }
1189
+ function renderFailedExpectations(result) {
1190
+ return result.expectations.filter((e) => !e.ok).map((e) => {
1191
+ const where = e.scope === "step" ? `step ${e.step + 1} ${result.events.find((ev) => ev.step === e.step)?.type ?? ""}` : "scenario";
1192
+ return `${red("\u2716")} expectation failed \xB7 ${where} \xB7 ${e.rule}: expected ${e.expected}, got ${e.actual}`;
1193
+ });
1194
+ }
1195
+
1196
+ // src/core/scenario.ts
1197
+ import { readFileSync as readFileSync2 } from "fs";
1198
+ import { LineCounter, isMap, isNode, parseDocument } from "yaml";
1199
+ import { z as z3 } from "zod";
1200
+ var duration = z3.string().superRefine((val, ctx) => {
1201
+ try {
1202
+ parseDuration(val);
1203
+ } catch {
1204
+ ctx.addIssue({ code: "custom", message: `Invalid ISO-8601 duration: "${val}". Examples: P1M, P1W, P3D, PT12H.` });
1205
+ }
1206
+ });
1207
+ var list = (values) => values.join(", ");
1208
+ var eventType = z3.enum(EVENT_TYPES, {
1209
+ error: (iss) => `Unknown event type "${String(iss.input)}". Valid types: ${list(EVENT_TYPES)}.`
1210
+ });
1211
+ var httpStatus = z3.int({ error: "response_status must be an integer HTTP status (e.g. 200)." }).min(100).max(599);
1212
+ var SubscriberConfigSchema = z3.strictObject({
1213
+ app_user_id: z3.string().min(1).default("auto"),
1214
+ product_id: z3.string().min(1).default("com.example.premium.monthly"),
1215
+ period: duration.default("P1M"),
1216
+ trial: duration.optional(),
1217
+ grace_period: duration.default("P16D"),
1218
+ store: z3.enum(CLI_STORES, {
1219
+ error: (iss) => `Unsupported store "${String(iss.input)}". v0.1 supports: ${list(CLI_STORES)}.`
1220
+ }).default("app_store"),
1221
+ environment: z3.enum(ENVIRONMENTS, {
1222
+ error: (iss) => `Invalid environment "${String(iss.input)}". Use one of: ${list(ENVIRONMENTS)}.`
1223
+ }).default("SANDBOX")
1224
+ });
1225
+ var StepExpectSchema = z3.strictObject({ response_status: httpStatus });
1226
+ var SetSchema = z3.record(z3.string(), z3.union([z3.string(), z3.number(), z3.boolean(), z3.null()]));
1227
+ var RawStepSchema = z3.strictObject({
1228
+ event: eventType.optional(),
1229
+ advance: duration.optional(),
1230
+ set: SetSchema.optional(),
1231
+ expect: StepExpectSchema.optional()
1232
+ });
1233
+ var StepSchema = RawStepSchema.superRefine((step, ctx) => {
1234
+ const hasEvent = step.event !== void 0;
1235
+ const hasAdvance = step.advance !== void 0;
1236
+ if (hasEvent === hasAdvance) {
1237
+ ctx.addIssue({
1238
+ code: "custom",
1239
+ message: "A step must have exactly one of `event: <TYPE>` or `advance: <duration>`."
1240
+ });
1241
+ return;
1242
+ }
1243
+ if (hasAdvance && (step.set !== void 0 || step.expect !== void 0)) {
1244
+ ctx.addIssue({ code: "custom", message: "`set` and `expect` are only allowed on `event` steps." });
1245
+ }
1246
+ });
1247
+ var ScenarioExpectSchema = z3.strictObject({
1248
+ all_responses_status: httpStatus.optional(),
1249
+ max_response_ms: z3.int().positive({ error: "max_response_ms must be a positive integer (milliseconds)." }).optional()
1250
+ });
1251
+ var ScenarioSchema = z3.strictObject({
1252
+ name: z3.string({ error: "`name` is required." }).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "name may only contain letters, digits, '.', '_' and '-'."),
1253
+ description: z3.string().optional(),
1254
+ subscriber: SubscriberConfigSchema.default({
1255
+ app_user_id: "auto",
1256
+ product_id: "com.example.premium.monthly",
1257
+ period: "P1M",
1258
+ grace_period: "P16D",
1259
+ store: "app_store",
1260
+ environment: "SANDBOX"
1261
+ }),
1262
+ steps: z3.array(StepSchema).min(1, "`steps` must contain at least one step."),
1263
+ expect: ScenarioExpectSchema.optional()
1264
+ });
1265
+ var ScenarioValidationError = class extends RccError {
1266
+ file;
1267
+ line;
1268
+ column;
1269
+ path;
1270
+ constructor(opts) {
1271
+ const where = opts.path === "" ? "" : `${opts.path}: `;
1272
+ super(`${opts.file}:${opts.line}:${opts.column} \u2014 ${where}${opts.detail}`, {
1273
+ hint: opts.hint ?? "See the scenario format in the README or run `rcc init` for working examples."
1274
+ });
1275
+ this.name = "ScenarioValidationError";
1276
+ this.file = opts.file;
1277
+ this.line = opts.line;
1278
+ this.column = opts.column;
1279
+ this.path = opts.path;
1280
+ }
1281
+ };
1282
+ function dotted(path) {
1283
+ return path.reduce((acc, seg) => {
1284
+ if (typeof seg === "number") return `${acc}[${seg}]`;
1285
+ return acc === "" ? String(seg) : `${acc}.${String(seg)}`;
1286
+ }, "");
1287
+ }
1288
+ function locate(doc, counter, path, keyName) {
1289
+ const segs = path.filter((s) => typeof s !== "symbol");
1290
+ for (let depth = segs.length; depth >= 0; depth--) {
1291
+ const sub = segs.slice(0, depth);
1292
+ const node = sub.length === 0 ? doc.contents : doc.getIn(sub, true);
1293
+ if (!isNode(node)) continue;
1294
+ if (keyName !== void 0 && depth === segs.length && isMap(node)) {
1295
+ const pair = node.items.find((p) => isNode(p.key) && String(p.key.toJSON()) === keyName);
1296
+ const keyNode = pair?.key;
1297
+ if (isNode(keyNode) && keyNode.range) return toPos(counter, keyNode.range[0]);
1298
+ }
1299
+ if (node.range) return toPos(counter, node.range[0]);
1300
+ }
1301
+ return { line: 1, column: 1 };
1302
+ }
1303
+ function toPos(counter, offset) {
1304
+ const { line, col } = counter.linePos(offset);
1305
+ return { line, column: col };
1306
+ }
1307
+ function parseScenarioWithSource(text, file = "<inline>") {
1308
+ const counter = new LineCounter();
1309
+ const doc = parseDocument(text, { lineCounter: counter, keepSourceTokens: true });
1310
+ const syntax = doc.errors[0];
1311
+ if (syntax) {
1312
+ const pos2 = syntax.linePos?.[0];
1313
+ throw new ScenarioValidationError({
1314
+ file,
1315
+ line: pos2?.line ?? 1,
1316
+ column: pos2?.col ?? 1,
1317
+ path: "",
1318
+ detail: `YAML syntax error: ${syntax.message.split("\n")[0] ?? syntax.code}`
1319
+ });
1320
+ }
1321
+ const result = ScenarioSchema.safeParse(doc.toJS() ?? {});
1322
+ if (result.success) {
1323
+ const stepPositions = result.data.steps.map((_, i) => locate(doc, counter, ["steps", i]));
1324
+ return { scenario: result.data, file, stepPositions };
1325
+ }
1326
+ const issue = result.error.issues[0];
1327
+ const keyName = issue.code === "unrecognized_keys" ? issue.keys[0] : void 0;
1328
+ const pos = locate(doc, counter, issue.path, keyName);
1329
+ const detail = issue.code === "unrecognized_keys" ? `Unknown key${issue.keys.length > 1 ? "s" : ""} ${issue.keys.map((k) => `"${k}"`).join(", ")}.` : issue.code === "invalid_type" && issue.input === void 0 ? `Missing required field \`${String(issue.path.at(-1) ?? "")}\`.` : issue.message;
1330
+ throw new ScenarioValidationError({ file, line: pos.line, column: pos.column, path: dotted(issue.path), detail });
1331
+ }
1332
+ function loadScenarioWithSource(file) {
1333
+ let text;
1334
+ try {
1335
+ text = readFileSync2(file, "utf8");
1336
+ } catch (cause) {
1337
+ throw new RccError(`Scenario file not found: ${file}`, {
1338
+ hint: "Check the path, or run `rcc init` to create a scenarios/ folder with examples.",
1339
+ cause
1340
+ });
1341
+ }
1342
+ return parseScenarioWithSource(text, file);
1343
+ }
1344
+
1345
+ // src/commands/run.ts
1346
+ function parseSpeed(input) {
1347
+ if (input === "instant") return "instant";
1348
+ const n = Number(input);
1349
+ if (Number.isInteger(n) && n >= 0) return n;
1350
+ throw new RccError(`Invalid --speed "${input}": use \`instant\` or a number of milliseconds between events (e.g. --speed 500).`);
1351
+ }
1352
+ function registerRun(program, io) {
1353
+ program.command("run").argument("<scenario.yaml>", "scenario file to execute").description("Run a scenario: advance a virtual clock, emit a coherent event sequence and deliver it over HTTP.").option("--to <url>", `target URL (default: ${DEFAULT_TARGET}, or "to" in ${CONFIG_FILE})`).option("--auth-header <value>", `value sent as the Authorization header (default: "authHeader" in ${CONFIG_FILE})`).option("--speed <instant|ms>", "wall-clock pause between events", "instant").option("--seed <seed>", "deterministic ids and timestamps").option("--dry-run", "print each envelope as JSON (one per line) instead of sending").option("--json", "print the full run result as one JSON document on stdout (human output goes to stderr)").addHelpText("after", `
1354
+ Examples:
1355
+ $ rcc run scenarios/trial-churns.yaml
1356
+ $ rcc run scenarios/happy-year.yaml --to http://localhost:8787/webhook --speed 250
1357
+ $ rcc run scenarios/billing-issue-recovers.yaml --dry-run --seed 42 | jq .event.type
1358
+ $ rcc run scenarios/happy-year.yaml --json > result.json # CI: exit 1 on any failed expectation`).action(async (file, opts) => {
1359
+ const d = resolveDefaults(opts, loadConfig());
1360
+ const to = assertUrl(d.to, "--to");
1361
+ const speed = parseSpeed(opts.speed);
1362
+ const loaded = loadScenarioWithSource(file);
1363
+ const human = opts.dryRun || opts.json ? io.stderr : io.stdout;
1364
+ const desc = loaded.scenario.description ? ` \u2014 ${loaded.scenario.description}` : "";
1365
+ println(human, `\u25B6 ${bold(loaded.scenario.name)}${dim(desc)}`);
1366
+ const live = createRunTable(loaded.scenario);
1367
+ println(human, live.header());
1368
+ let index = 0;
1369
+ const result = await runScenario(loaded.scenario, {
1370
+ to,
1371
+ authHeader: d.authHeader,
1372
+ speed,
1373
+ seed: parseSeed(opts.seed),
1374
+ dryRun: opts.dryRun ?? false,
1375
+ source: loaded,
1376
+ onEvent: (r, envelope) => {
1377
+ println(human, live.row(r, index++));
1378
+ if (opts.dryRun && !opts.json) println(io.stdout, JSON.stringify(envelope));
1379
+ }
1380
+ });
1381
+ for (const line of renderFailedExpectations(result)) println(human, line);
1382
+ println(human, renderRunSummary(result));
1383
+ if (opts.json) println(io.stdout, JSON.stringify(result, null, 2));
1384
+ if (!result.ok) {
1385
+ const failedExp = result.expectations.filter((e) => !e.ok).length;
1386
+ throw new RccError(
1387
+ failedExp > 0 ? `Scenario finished with ${failedExp} failed expectation${failedExp === 1 ? "" : "s"}.` : "Scenario finished with failed deliveries.",
1388
+ { hint: "Every event must be answered with a 2xx status and every expect: block must hold. Check your handler logs, or run with --dry-run to inspect payloads." }
1389
+ );
1390
+ }
1391
+ });
1392
+ }
1393
+
1394
+ // src/commands/init.ts
1395
+ import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, writeFileSync } from "fs";
1396
+ import { join as join2, relative } from "path";
1397
+ function initProject(cwd, opts) {
1398
+ const scenariosSrc = join2(packageRoot(), "scenarios");
1399
+ const examples = readdirSync(scenariosSrc).filter((f) => f.endsWith(".yaml")).sort();
1400
+ const targets = [CONFIG_FILE, ...examples.map((f) => join2("scenarios", f))];
1401
+ const existing = targets.filter((t) => existsSync2(join2(cwd, t)));
1402
+ if (existing.length > 0 && !opts.force) {
1403
+ throw new RccError(`Refusing to overwrite existing file${existing.length === 1 ? "" : "s"}: ${existing.join(", ")}`, {
1404
+ hint: "Run `rcc init --force` to overwrite, or delete them first."
1405
+ });
1406
+ }
1407
+ const written = [];
1408
+ const config = { to: DEFAULT_TARGET, store: "app_store", environment: "SANDBOX" };
1409
+ writeFileSync(join2(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
1410
+ written.push(CONFIG_FILE);
1411
+ mkdirSync(join2(cwd, "scenarios"), { recursive: true });
1412
+ for (const f of examples) {
1413
+ copyFileSync(join2(scenariosSrc, f), join2(cwd, "scenarios", f));
1414
+ written.push(join2("scenarios", f));
1415
+ }
1416
+ return written;
1417
+ }
1418
+ function registerInit(program, io) {
1419
+ program.command("init").description(`Create ${CONFIG_FILE} and a scenarios/ folder with the six example scenarios in the current directory.`).option("--force", "overwrite existing files").addHelpText("after", `
1420
+ Examples:
1421
+ $ rcc init
1422
+ $ rcc init --force`).action((opts) => {
1423
+ const cwd = process.cwd();
1424
+ const written = initProject(cwd, opts);
1425
+ println(io.stdout, `${green("\u2714")} Created ${written.length} files in ${bold(relative(process.cwd(), cwd) || ".")}:`);
1426
+ for (const f of written) println(io.stdout, ` ${dim("+")} ${f}`);
1427
+ println(io.stdout, "");
1428
+ println(io.stdout, `Next: start your webhook handler (or \`rcc listen\`), then run`);
1429
+ println(io.stdout, ` ${bold("rcc run scenarios/trial-churns.yaml")}`);
1430
+ println(io.stdout, dim(`Defaults (target URL, auth header, store, environment) live in ${CONFIG_FILE}.`));
1431
+ });
1432
+ }
1433
+
1434
+ // src/commands/tail.ts
1435
+ var SMEE_ORIGIN = "https://smee.io";
1436
+ async function* parseSseStream(source) {
1437
+ const decoder = new TextDecoder();
1438
+ let buffer = "";
1439
+ let cur = { data: [] };
1440
+ const flush = () => {
1441
+ if (cur.data.length === 0) {
1442
+ cur = { data: [] };
1443
+ return void 0;
1444
+ }
1445
+ const frame = { data: cur.data.join("\n") };
1446
+ if (cur.id !== void 0) frame.id = cur.id;
1447
+ if (cur.event !== void 0) frame.event = cur.event;
1448
+ cur = { data: [] };
1449
+ return frame;
1450
+ };
1451
+ for await (const chunk of source) {
1452
+ buffer += decoder.decode(chunk, { stream: true });
1453
+ let nl;
1454
+ while ((nl = buffer.indexOf("\n")) !== -1) {
1455
+ const line = buffer.slice(0, nl).replace(/\r$/, "");
1456
+ buffer = buffer.slice(nl + 1);
1457
+ if (line === "") {
1458
+ const f = flush();
1459
+ if (f) yield f;
1460
+ continue;
1461
+ }
1462
+ if (line.startsWith(":")) continue;
1463
+ const colon = line.indexOf(":");
1464
+ const field = colon === -1 ? line : line.slice(0, colon);
1465
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
1466
+ if (field === "data") cur.data.push(value);
1467
+ else if (field === "id") cur.id = value;
1468
+ else if (field === "event") cur.event = value;
1469
+ }
1470
+ }
1471
+ const last = flush();
1472
+ if (last) yield last;
1473
+ }
1474
+ async function createSmeeChannel(origin = SMEE_ORIGIN) {
1475
+ let res;
1476
+ try {
1477
+ res = await fetch(`${origin}/new`, { redirect: "manual", signal: AbortSignal.timeout(15e3) });
1478
+ } catch (cause) {
1479
+ throw new RccError(`Could not reach ${origin} to create a channel.`, {
1480
+ hint: "Check your connection, or pass an existing channel: rcc tail --smee https://smee.io/<channel>",
1481
+ cause
1482
+ });
1483
+ }
1484
+ const location = res.headers.get("location");
1485
+ if (!location) throw new RccError(`${origin}/new did not return a channel URL (HTTP ${res.status}).`);
1486
+ return new URL(location, origin).toString();
1487
+ }
1488
+ function fromInbox(data) {
1489
+ let rec;
1490
+ try {
1491
+ rec = JSON.parse(data);
1492
+ } catch {
1493
+ return void 0;
1494
+ }
1495
+ if (typeof rec.body !== "string") return void 0;
1496
+ let body = rec.body;
1497
+ try {
1498
+ body = JSON.parse(rec.body);
1499
+ } catch {
1500
+ }
1501
+ return { headers: rec.headers ?? {}, body, raw: rec.body, timestamp: rec.receivedAt ? Date.parse(rec.receivedAt) : void 0 };
1502
+ }
1503
+ function fromSmee(data) {
1504
+ let obj;
1505
+ try {
1506
+ obj = JSON.parse(data);
1507
+ } catch {
1508
+ return void 0;
1509
+ }
1510
+ if (typeof obj !== "object" || obj === null) return void 0;
1511
+ const { body, query: _q, timestamp, ...rest } = obj;
1512
+ const headers = {};
1513
+ for (const [k, v] of Object.entries(rest)) if (typeof v === "string") headers[k.toLowerCase()] = v;
1514
+ return { headers, body, timestamp: typeof timestamp === "number" ? timestamp : void 0 };
1515
+ }
1516
+ var clock2 = (ms2) => new Date(ms2 ?? Date.now()).toISOString().slice(11, 19);
1517
+ var sleep2 = (ms2, signal) => new Promise((resolve) => {
1518
+ if (signal.aborted) return resolve();
1519
+ const t = setTimeout(resolve, ms2);
1520
+ signal.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true });
1521
+ });
1522
+ function startTail(opts) {
1523
+ const { io } = opts;
1524
+ const log = (s) => println(io.stdout, s);
1525
+ const controller = new AbortController();
1526
+ const backoff = opts.backoffMs ?? [1e3, 2e3, 5e3, 1e4, 3e4];
1527
+ const src = opts.source;
1528
+ const streamUrl = () => {
1529
+ if (src.kind === "smee") return src.url;
1530
+ const u = new URL("/events/stream", src.url.endsWith("/") ? src.url : src.url + "/");
1531
+ if (src.since !== void 0) u.searchParams.set("since", String(src.since));
1532
+ return u.toString();
1533
+ };
1534
+ const streamHeaders = () => src.kind === "inbox" ? { accept: "text/event-stream", authorization: `Bearer ${src.token}` } : { accept: "text/event-stream" };
1535
+ log(`${green("\u25CF")} Tailing ${bold(opts.source.url)}`);
1536
+ if (opts.source.kind === "smee") {
1537
+ log(` Paste this URL in RevenueCat \u2192 Integrations \u2192 Webhooks: ${cyan(opts.source.url)}`);
1538
+ log(dim(" smee.io is a public relay with no persistence: events only arrive while this command is running."));
1539
+ }
1540
+ if (opts.forward) log(dim(` forwarding each event to ${opts.forward}`));
1541
+ async function handle(req) {
1542
+ const time = dim(clock2(req.timestamp));
1543
+ const classified = classifyEnvelope(req.body);
1544
+ let label;
1545
+ if (classified.kind === "invalid") {
1546
+ const issues = classified.issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join("; ");
1547
+ label = `${red(bold("INVALID"))} ${issues}`;
1548
+ } else {
1549
+ const ev = classified.envelope.event;
1550
+ const productId = typeof ev["product_id"] === "string" ? ev["product_id"] : "";
1551
+ const typeLabel = classified.kind === "known" ? cyan(bold(ev.type.padEnd(16))) : `${yellow(bold("UNSUPPORTED"))} ${yellow(ev.type)}`;
1552
+ label = `${typeLabel} ${yellow(ev.app_user_id)} ${productId}`;
1553
+ }
1554
+ let suffix = "";
1555
+ if (opts.forward) {
1556
+ try {
1557
+ const headers = { "content-type": "application/json" };
1558
+ const auth = req.headers["authorization"];
1559
+ if (auth !== void 0) headers["authorization"] = auth;
1560
+ const started = performance.now();
1561
+ const res = await fetch(opts.forward, {
1562
+ method: "POST",
1563
+ headers,
1564
+ body: req.raw ?? JSON.stringify(req.body),
1565
+ signal: AbortSignal.timeout(3e4)
1566
+ });
1567
+ const status = res.status < 300 ? green(String(res.status)) : red(String(res.status));
1568
+ suffix = ` \u2192 ${status} ${dim(`(${Math.round(performance.now() - started)} ms)`)}`;
1569
+ } catch (err) {
1570
+ suffix = ` ${red("forward failed")}: ${err instanceof Error ? err.message : String(err)}`;
1571
+ }
1572
+ }
1573
+ log(`${time} ${magenta("real")} ${label}${suffix}`);
1574
+ if (opts.verbose) log(dim(JSON.stringify(req.body, null, 2)));
1575
+ }
1576
+ async function loop() {
1577
+ let attempt = 0;
1578
+ while (!controller.signal.aborted) {
1579
+ try {
1580
+ const res = await fetch(streamUrl(), { headers: streamHeaders(), signal: controller.signal });
1581
+ if (res.status === 401) {
1582
+ throw new RccError(`The inbox at ${src.url} rejected the token (401).`, { hint: "Check --token against the inbox's --token / INBOX_TOKEN." });
1583
+ }
1584
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
1585
+ attempt = 0;
1586
+ for await (const frame of parseSseStream(res.body)) {
1587
+ if (frame.event === "ready" || frame.data === "{}") continue;
1588
+ const req = src.kind === "inbox" ? fromInbox(frame.data) : fromSmee(frame.data);
1589
+ if (req) await handle(req);
1590
+ }
1591
+ if (controller.signal.aborted) return;
1592
+ throw new Error("stream ended");
1593
+ } catch (err) {
1594
+ if (controller.signal.aborted) return;
1595
+ const delay = backoff[Math.min(attempt, backoff.length - 1)];
1596
+ attempt++;
1597
+ const reason = err instanceof RccError ? `${err.message} ${err.hint ?? ""}` : err instanceof Error ? err.message : String(err);
1598
+ log(`${dim(clock2())} ${yellow("reconnecting")} in ${delay} ms ${dim(`(${reason.trim()})`)}`);
1599
+ await sleep2(delay, controller.signal);
1600
+ }
1601
+ }
1602
+ }
1603
+ const done = loop();
1604
+ return {
1605
+ done,
1606
+ close: async () => {
1607
+ controller.abort();
1608
+ await done.catch(() => void 0);
1609
+ }
1610
+ };
1611
+ }
1612
+ function registerTail(program, io) {
1613
+ program.command("tail").description("Receive real RevenueCat webhooks on your machine through a relay, print them, and optionally forward them to a local URL.").option("--smee [channel-url]", "use the public smee.io relay; creates a channel when no URL is given").option("--inbox <url>", "use a self-hosted `rcc inbox` at this URL (requires --token)").option("--token <secret>", "read token of the inbox").option("--since <seq>", "inbox only: replay stored events after this sequence number (0 = everything)").option("--all", "inbox only: replay the whole history (same as --since 0)").option("--forward <url>", "re-POST each event (body + Authorization) to this local URL").option("--verbose", "print the full JSON payload of each event").addHelpText("after", `
1614
+ Examples:
1615
+ $ rcc tail --smee # prints a URL to paste in RevenueCat \u2192 Integrations \u2192 Webhooks
1616
+ $ rcc tail --smee https://smee.io/abc123 --forward http://localhost:3000/webhook
1617
+ $ rcc tail --smee --verbose
1618
+ $ rcc tail --inbox https://hooks.example.com --token s3cret --all --forward http://localhost:3000/webhook`).action(async (opts) => {
1619
+ if (opts.smee !== void 0 && opts.inbox !== void 0) {
1620
+ throw new RccError("Use either --smee or --inbox, not both.");
1621
+ }
1622
+ if (opts.smee === void 0 && opts.inbox === void 0) {
1623
+ throw new RccError("rcc tail needs a source.", {
1624
+ hint: "Use --smee to receive events through smee.io (zero setup), or --inbox <url> --token <t> for a self-hosted inbox."
1625
+ });
1626
+ }
1627
+ if (opts.forward !== void 0) assertUrl(opts.forward, "--forward");
1628
+ let source;
1629
+ if (opts.inbox !== void 0) {
1630
+ if (!opts.token) throw new RccError("--inbox requires --token.", { hint: "The token is the inbox's --token / INBOX_TOKEN." });
1631
+ const since = opts.all ? 0 : opts.since !== void 0 ? Number(opts.since) : void 0;
1632
+ if (since !== void 0 && (!Number.isInteger(since) || since < 0)) throw new RccError(`Invalid --since "${opts.since ?? ""}".`);
1633
+ source = { kind: "inbox", url: assertUrl(opts.inbox, "--inbox"), token: opts.token, since };
1634
+ } else {
1635
+ const url = typeof opts.smee === "string" ? assertUrl(opts.smee, "--smee") : await createSmeeChannel();
1636
+ source = { kind: "smee", url };
1637
+ }
1638
+ const handle = startTail({ source, forward: opts.forward, verbose: opts.verbose, io });
1639
+ const stop = () => {
1640
+ void handle.close().finally(() => process.exit(0));
1641
+ };
1642
+ process.once("SIGINT", stop);
1643
+ process.once("SIGTERM", stop);
1644
+ await handle.done;
1645
+ });
1646
+ }
1647
+
1648
+ // src/commands/inbox.ts
1649
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1650
+ import { createServer as createServer2 } from "http";
1651
+ import { join as join3 } from "path";
1652
+ var DEFAULT_INBOX_PORT = 8788;
1653
+ var EVENTS_FILE = "events.jsonl";
1654
+ var KEPT_HEADERS = ["authorization", "content-type", "user-agent", "x-revenuecat-webhook-signature"];
1655
+ var Store = class {
1656
+ constructor(dir, maxEvents) {
1657
+ this.dir = dir;
1658
+ this.maxEvents = maxEvents;
1659
+ mkdirSync2(dir, { recursive: true });
1660
+ this.file = join3(dir, EVENTS_FILE);
1661
+ if (existsSync3(this.file)) {
1662
+ for (const line of readFileSync3(this.file, "utf8").split("\n")) {
1663
+ if (!line.trim()) continue;
1664
+ try {
1665
+ this.records.push(JSON.parse(line));
1666
+ } catch {
1667
+ }
1668
+ }
1669
+ this.lastSeq = this.records.at(-1)?.seq ?? 0;
1670
+ if (this.records.length > this.maxEvents) this.compact();
1671
+ }
1672
+ }
1673
+ dir;
1674
+ maxEvents;
1675
+ records = [];
1676
+ lastSeq = 0;
1677
+ file;
1678
+ nextSeq() {
1679
+ return ++this.lastSeq;
1680
+ }
1681
+ add(rec) {
1682
+ this.records.push(rec);
1683
+ appendFileSync(this.file, JSON.stringify(rec) + "\n");
1684
+ if (this.records.length > this.maxEvents) this.compact();
1685
+ }
1686
+ compact() {
1687
+ this.records = this.records.slice(-this.maxEvents);
1688
+ writeFileSync2(this.file, this.records.map((r) => JSON.stringify(r)).join("\n") + "\n");
1689
+ }
1690
+ since(seq, limit) {
1691
+ return this.records.filter((r) => r.seq > seq).slice(0, limit);
1692
+ }
1693
+ findByEventId(id) {
1694
+ return this.records.find((r) => r.eventId === id);
1695
+ }
1696
+ get count() {
1697
+ return this.records.length;
1698
+ }
1699
+ };
1700
+ function readBody2(req) {
1701
+ return new Promise((resolve, reject) => {
1702
+ let raw = "";
1703
+ req.on("data", (c) => raw += c.toString("utf8"));
1704
+ req.on("end", () => resolve(raw));
1705
+ req.on("error", reject);
1706
+ });
1707
+ }
1708
+ function json2(res, status, body) {
1709
+ res.writeHead(status, { "content-type": "application/json" });
1710
+ res.end(JSON.stringify(body));
1711
+ }
1712
+ var clock3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
1713
+ async function startInbox(opts) {
1714
+ if (!opts.token) {
1715
+ throw new RccError("rcc inbox needs a read token.", {
1716
+ hint: "Pass --token <secret> (or set INBOX_TOKEN). Clients read events with `rcc tail --inbox <url> --token <secret>`."
1717
+ });
1718
+ }
1719
+ const { io } = opts;
1720
+ const log = (s) => println(io.stdout, s);
1721
+ const store = new Store(opts.dataDir, opts.maxEvents ?? 1e4);
1722
+ const streams = /* @__PURE__ */ new Set();
1723
+ const authorized = (req, url2) => req.headers["authorization"] === `Bearer ${opts.token}` || url2.searchParams.get("token") === opts.token;
1724
+ const server = createServer2((req, res) => {
1725
+ void handle(req, res);
1726
+ });
1727
+ async function handle(req, res) {
1728
+ const url2 = new URL(req.url ?? "/", "http://inbox");
1729
+ const path = url2.pathname;
1730
+ if (req.method === "GET" && path === "/health") {
1731
+ json2(res, 200, { ok: true, events: store.count });
1732
+ return;
1733
+ }
1734
+ if (req.method === "POST" && path === "/webhook") {
1735
+ await receive(req, res);
1736
+ return;
1737
+ }
1738
+ if (req.method === "GET" && (path === "/events" || path === "/events/stream")) {
1739
+ if (!authorized(req, url2)) {
1740
+ json2(res, 401, { error: "Missing or invalid token. Use `Authorization: Bearer <token>` or ?token=." });
1741
+ return;
1742
+ }
1743
+ const since = Number(url2.searchParams.get("since") ?? (path === "/events" ? "0" : String(store.count === 0 ? 0 : store.since(0, Infinity).at(-1).seq)));
1744
+ if (path === "/events") {
1745
+ const limit = Math.min(Number(url2.searchParams.get("limit") ?? "100"), 1e3);
1746
+ const events = store.since(since, limit);
1747
+ json2(res, 200, { events, next: events.at(-1)?.seq ?? since });
1748
+ return;
1749
+ }
1750
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1751
+ res.write(`event: ready
1752
+ data: {}
1753
+
1754
+ `);
1755
+ for (const rec of store.since(since, Infinity)) res.write(`id: ${rec.seq}
1756
+ event: webhook
1757
+ data: ${JSON.stringify(rec)}
1758
+
1759
+ `);
1760
+ streams.add(res);
1761
+ const ping = setInterval(() => res.write(": ping\n\n"), 25e3);
1762
+ res.on("close", () => {
1763
+ clearInterval(ping);
1764
+ streams.delete(res);
1765
+ });
1766
+ return;
1767
+ }
1768
+ json2(res, 404, { error: `Not found. Endpoints: POST /webhook, GET /events, GET /events/stream, GET /health.` });
1769
+ }
1770
+ async function receive(req, res) {
1771
+ const raw = await readBody2(req);
1772
+ const headers = {};
1773
+ for (const h of KEPT_HEADERS) {
1774
+ const v = req.headers[h];
1775
+ if (typeof v === "string") headers[h] = v;
1776
+ }
1777
+ const authOk = opts.authHeader === void 0 || headers["authorization"] === opts.authHeader;
1778
+ let parsed;
1779
+ let isJson = true;
1780
+ try {
1781
+ parsed = JSON.parse(raw);
1782
+ } catch {
1783
+ isJson = false;
1784
+ }
1785
+ const rec = { seq: store.nextSeq(), receivedAt: (/* @__PURE__ */ new Date()).toISOString(), headers, body: raw, valid: false, authOk };
1786
+ let status;
1787
+ let label;
1788
+ if (!isJson) {
1789
+ status = 400;
1790
+ label = `${red(bold("INVALID"))} body is not JSON`;
1791
+ } else {
1792
+ const classified = classifyEnvelope(parsed);
1793
+ if (classified.kind !== "invalid") {
1794
+ const ev = classified.envelope.event;
1795
+ rec.valid = true;
1796
+ rec.eventId = ev.id;
1797
+ rec.eventType = ev.type;
1798
+ if (classified.kind === "unknown-type") rec.unsupportedType = true;
1799
+ const dup = store.findByEventId(rec.eventId);
1800
+ if (dup) rec.duplicateOf = dup.seq;
1801
+ const typeLabel = classified.kind === "known" ? cyan(bold(ev.type.padEnd(16))) : `${yellow(bold("UNSUPPORTED"))} ${yellow(ev.type)}`;
1802
+ label = `${typeLabel} ${yellow(ev.app_user_id)}${dup ? dim(` (retry of #${dup.seq})`) : ""}`;
1803
+ } else {
1804
+ rec.issues = classified.issues;
1805
+ label = `${red(bold("INVALID"))} ${rec.issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join("; ")}`;
1806
+ }
1807
+ status = authOk ? 200 : 401;
1808
+ }
1809
+ if (!authOk) label = `${red(bold("AUTH MISMATCH"))} ${label}`;
1810
+ store.add(rec);
1811
+ for (const s of streams) s.write(`id: ${rec.seq}
1812
+ event: webhook
1813
+ data: ${JSON.stringify(rec)}
1814
+
1815
+ `);
1816
+ log(`${dim(clock3())} #${rec.seq} ${label} \u2192 ${status < 300 ? green(String(status)) : red(String(status))}`);
1817
+ json2(res, status, status === 400 ? { error: "Body is not valid JSON" } : status === 401 ? { error: "Authorization header mismatch" } : { ok: true, seq: rec.seq });
1818
+ }
1819
+ await new Promise((resolve, reject) => {
1820
+ server.once("error", (err) => {
1821
+ reject(
1822
+ err.code === "EADDRINUSE" ? new RccError(`Port ${opts.port} is already in use.`, { hint: `Pick another one: rcc inbox --port ${opts.port + 1}` }) : new RccError(`Could not start the inbox: ${err.message}`, { cause: err })
1823
+ );
1824
+ });
1825
+ server.listen(opts.port, () => resolve());
1826
+ });
1827
+ const port = server.address().port;
1828
+ const url = `http://localhost:${port}`;
1829
+ log(`${green("\u25CF")} Inbox listening on ${bold(url)} \u2014 ${store.count} stored event${store.count === 1 ? "" : "s"} in ${opts.dataDir}`);
1830
+ log(dim(` RevenueCat \u2192 POST ${url}/webhook${opts.authHeader === void 0 ? " (no --auth-header: accepting any Authorization)" : ""}`));
1831
+ log(dim(` you \u2192 rcc tail --inbox <public-url> --token <token>`));
1832
+ return {
1833
+ url,
1834
+ port,
1835
+ get subscribers() {
1836
+ return streams.size;
1837
+ },
1838
+ close: () => new Promise((resolve, reject) => {
1839
+ for (const s of streams) s.destroy();
1840
+ server.closeAllConnections();
1841
+ server.close((err) => err ? reject(err) : resolve());
1842
+ })
1843
+ };
1844
+ }
1845
+ function registerInbox(program, io) {
1846
+ program.command("inbox").description("Run a self-hosted webhook inbox: stores every delivery (JSONL) and streams it to `rcc tail --inbox`. Put HTTPS in front.").option("--port <n>", "port to listen on (env PORT)", process.env["PORT"] ?? String(DEFAULT_INBOX_PORT)).option("--token <secret>", "bearer token clients need to read events (env INBOX_TOKEN)", process.env["INBOX_TOKEN"]).option("--auth-header <value>", "Authorization value RevenueCat must send; mismatches stored and answered 401 (env RC_WEBHOOK_AUTH)", process.env["RC_WEBHOOK_AUTH"]).option("--data-dir <dir>", "where events.jsonl lives (env INBOX_DATA_DIR)", process.env["INBOX_DATA_DIR"] ?? "./inbox-data").option("--max-events <n>", "keep only the newest N events", "10000").addHelpText("after", `
1847
+ Examples:
1848
+ $ rcc inbox --token s3cret --auth-header "Bearer from-dashboard"
1849
+ $ INBOX_TOKEN=s3cret PORT=8080 rcc inbox --data-dir /data`).action(async (opts) => {
1850
+ if (!/^\d{1,5}$/.test(opts.port) || Number(opts.port) > 65535) {
1851
+ throw new RccError(`Invalid --port "${opts.port}".`, { hint: "Use an integer between 1 and 65535." });
1852
+ }
1853
+ const maxEvents = Number(opts.maxEvents);
1854
+ if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new RccError(`Invalid --max-events "${opts.maxEvents}".`);
1855
+ const box = await startInbox({ port: Number(opts.port), token: opts.token ?? "", authHeader: opts.authHeader, dataDir: opts.dataDir, maxEvents, io });
1856
+ const stop = () => {
1857
+ void box.close().finally(() => process.exit(0));
1858
+ };
1859
+ process.once("SIGINT", stop);
1860
+ process.once("SIGTERM", stop);
1861
+ await new Promise(() => {
1862
+ });
1863
+ });
1864
+ }
1865
+
1866
+ // src/program.ts
1867
+ var USAGE_EXIT_CODE = 2;
1868
+ function buildProgram(io = defaultIo) {
1869
+ const program = new Command();
1870
+ program.name("rcc").description(
1871
+ "Simulate RevenueCat subscription lifecycles and test webhooks locally and in CI.\nUnofficial project \u2014 not affiliated with RevenueCat, Inc."
1872
+ ).version(package_default.version, "-v, --version", "print the version").exitOverride((err) => {
1873
+ if (err.exitCode !== 0) err.exitCode = USAGE_EXIT_CODE;
1874
+ throw err;
1875
+ });
1876
+ registerSend(program, io);
1877
+ registerListen(program, io);
1878
+ registerRun(program, io);
1879
+ registerInit(program, io);
1880
+ registerTail(program, io);
1881
+ registerInbox(program, io);
1882
+ for (const cmd of [program, ...program.commands]) {
1883
+ const label = cmd === program ? "rcc" : `rcc ${cmd.name()}`;
1884
+ cmd.configureOutput({
1885
+ writeOut: (s) => io.stdout.write(s),
1886
+ writeErr: (s) => io.stderr.write(s),
1887
+ outputError: (str, write) => {
1888
+ const message = str.trim().replace(/^error:\s*/i, "");
1889
+ write(formatError(new RccError(message, { hint: `Run \`${label} --help\` for usage.`, exitCode: USAGE_EXIT_CODE })) + "\n");
1890
+ }
1891
+ });
1892
+ }
1893
+ return program;
1894
+ }
1895
+
1896
+ // src/cli.ts
1897
+ buildProgram().parseAsync(process.argv).catch((err) => {
1898
+ if (err instanceof CommanderError) {
1899
+ process.exitCode = err.exitCode;
1900
+ return;
1901
+ }
1902
+ process.stderr.write(formatError(err) + "\n");
1903
+ process.exitCode = exitCodeFor(err);
1904
+ });
1905
+ //# sourceMappingURL=cli.js.map