@recur-tw/cli 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.mjs ADDED
@@ -0,0 +1,2552 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import readline from "node:readline";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { z } from "zod";
11
+ //#region src/config.ts
12
+ const CONFIG_DIR = path.join(os.homedir(), ".recur");
13
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
14
+ function ensureConfigDir() {
15
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, {
16
+ mode: 448,
17
+ recursive: true
18
+ });
19
+ else if ((fs.statSync(CONFIG_DIR).mode & 511) !== 448) fs.chmodSync(CONFIG_DIR, 448);
20
+ }
21
+ function readCredentials() {
22
+ if (!fs.existsSync(CREDENTIALS_FILE)) return {
23
+ profiles: {},
24
+ activeProfile: "default"
25
+ };
26
+ const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
27
+ try {
28
+ return JSON.parse(raw);
29
+ } catch {
30
+ throw new Error(`Credentials file is corrupted: ${CREDENTIALS_FILE}\nDelete it and run "recur login" to re-authenticate.`);
31
+ }
32
+ }
33
+ function writeCredentials(creds) {
34
+ ensureConfigDir();
35
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
36
+ }
37
+ function getProfile(profileName) {
38
+ const creds = readCredentials();
39
+ const name = profileName ?? creds.activeProfile ?? "default";
40
+ return creds.profiles[name] ?? null;
41
+ }
42
+ function saveProfile(name, profile) {
43
+ const creds = readCredentials();
44
+ creds.profiles[name] = profile;
45
+ if (!creds.activeProfile) creds.activeProfile = name;
46
+ writeCredentials(creds);
47
+ }
48
+ function setActiveProfile(name) {
49
+ const creds = readCredentials();
50
+ if (!creds.profiles[name]) throw new Error(`Profile "${name}" not found`);
51
+ creds.activeProfile = name;
52
+ writeCredentials(creds);
53
+ }
54
+ function listProfiles() {
55
+ const creds = readCredentials();
56
+ return Object.entries(creds.profiles).map(([name, profile]) => ({
57
+ name,
58
+ active: name === creds.activeProfile,
59
+ environment: profile.environment
60
+ }));
61
+ }
62
+ function deleteProfile(name) {
63
+ const creds = readCredentials();
64
+ delete creds.profiles[name];
65
+ if (creds.activeProfile === name) creds.activeProfile = Object.keys(creds.profiles)[0] ?? "default";
66
+ writeCredentials(creds);
67
+ }
68
+ /**
69
+ * Resolve the secret key from (in priority order):
70
+ * 1. --key flag
71
+ * 2. RECUR_SECRET_KEY env var
72
+ * 3. Active profile in ~/.recur/credentials.json
73
+ *
74
+ * Validates format before returning.
75
+ */
76
+ function resolveSecretKey(opts) {
77
+ let key;
78
+ if (opts.key) key = opts.key;
79
+ else {
80
+ const envKey = process.env["RECUR_SECRET_KEY"];
81
+ if (envKey) key = envKey;
82
+ else {
83
+ const profile = getProfile(opts.profile);
84
+ if (profile?.secretKey) key = profile.secretKey;
85
+ }
86
+ }
87
+ if (!key) throw new Error("No API key found. Provide one via:\n --key sk_test_xxx\n RECUR_SECRET_KEY environment variable\n recur login");
88
+ if (!/^sk_(test|live)_[a-zA-Z0-9]+$/.test(key)) {
89
+ if (/^pk_(test|live)_[a-zA-Z0-9]+$/.test(key)) throw new Error("CLI requires a Secret Key (sk_*). Publishable keys (pk_*) are for client-side SDKs only.");
90
+ throw new Error("Invalid API key format.\nExpected sk_test_* or sk_live_*");
91
+ }
92
+ return key;
93
+ }
94
+ /**
95
+ * Validate a URL uses HTTPS (or HTTP for localhost in development).
96
+ * Rejects non-http(s) schemes like file://, javascript://, data://.
97
+ */
98
+ function validateUrl(url, label) {
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(url);
102
+ } catch {
103
+ throw new Error(`Invalid ${label}: "${url}" is not a valid URL`);
104
+ }
105
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`Invalid ${label}: only http/https allowed, got ${parsed.protocol}`);
106
+ if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) console.error(`Warning: ${label} uses HTTP (${url}). API keys will be sent in plaintext. Use HTTPS in production.`);
107
+ return url;
108
+ }
109
+ function isLocalhost(hostname) {
110
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
111
+ }
112
+ /**
113
+ * Resolve the base URL for API calls.
114
+ */
115
+ function resolveBaseUrl(opts) {
116
+ if (opts.baseUrl) return validateUrl(opts.baseUrl, "base URL");
117
+ const envUrl = process.env["RECUR_BASE_URL"];
118
+ if (envUrl) return validateUrl(envUrl, "RECUR_BASE_URL");
119
+ const profile = getProfile(opts.profile);
120
+ if (profile?.baseUrl) return validateUrl(profile.baseUrl, "profile base URL");
121
+ return "https://api.recur.tw";
122
+ }
123
+ //#endregion
124
+ //#region src/errors.ts
125
+ var CLIError = class extends Error {
126
+ constructor(message, statusCode) {
127
+ super(message);
128
+ this.statusCode = statusCode;
129
+ this.name = "CLIError";
130
+ }
131
+ };
132
+ /**
133
+ * Top-level error handler for CLI commands.
134
+ * Prints a clean message and exits with code 1.
135
+ */
136
+ function handleError(err) {
137
+ if (err instanceof CLIError) console.error(pc.red(`Error: ${err.message}`));
138
+ else if (err instanceof Error) {
139
+ console.error(pc.red(`Error: ${err.message}`));
140
+ if (process.env["RECUR_DEBUG"]) console.error(err.stack);
141
+ } else console.error(pc.red("An unexpected error occurred"));
142
+ process.exit(1);
143
+ }
144
+ //#endregion
145
+ //#region ../core/src/api/resources.ts
146
+ /** Shorthand constructors for response field definitions */
147
+ const f = {
148
+ str: (name, description, nullable) => ({
149
+ name,
150
+ type: "string",
151
+ description,
152
+ nullable
153
+ }),
154
+ num: (name, description, nullable) => ({
155
+ name,
156
+ type: "number",
157
+ description,
158
+ nullable
159
+ }),
160
+ bool: (name, description) => ({
161
+ name,
162
+ type: "boolean",
163
+ description
164
+ }),
165
+ dt: (name, description, nullable) => ({
166
+ name,
167
+ type: "datetime",
168
+ description,
169
+ nullable
170
+ }),
171
+ obj: (name, description, nullable) => ({
172
+ name,
173
+ type: "object",
174
+ description,
175
+ nullable
176
+ }),
177
+ arr: (name, description) => ({
178
+ name,
179
+ type: "array",
180
+ description
181
+ })
182
+ };
183
+ const ProductType = [
184
+ "SUBSCRIPTION",
185
+ "ONE_TIME",
186
+ "CREDITS",
187
+ "DONATION"
188
+ ];
189
+ const ProductStatus = ["active", "archived"];
190
+ const BillingInterval = ["monthly", "yearly"];
191
+ const SubscriptionStatus = [
192
+ "active",
193
+ "canceled",
194
+ "expired",
195
+ "past_due",
196
+ "trialing"
197
+ ];
198
+ const OrderStatus = [
199
+ "pending",
200
+ "paid",
201
+ "failed",
202
+ "refunded"
203
+ ];
204
+ const InvoiceStatus = [
205
+ "draft",
206
+ "open",
207
+ "paid",
208
+ "void",
209
+ "uncollectible"
210
+ ];
211
+ const cursorPagination = {
212
+ cursor: "starting_after",
213
+ defaultLimit: 10,
214
+ maxLimit: 100
215
+ };
216
+ /** All API resources in registration order */
217
+ const allResources = [
218
+ {
219
+ resource: "products",
220
+ description: "Manage subscription and one-time products",
221
+ actions: {
222
+ list: {
223
+ method: "GET",
224
+ path: "/v1/products",
225
+ description: "List all products",
226
+ params: {
227
+ status: {
228
+ type: "string",
229
+ description: "Filter by status",
230
+ enum: ProductStatus
231
+ },
232
+ limit: {
233
+ type: "number",
234
+ description: "Max results",
235
+ default: 20
236
+ }
237
+ },
238
+ responseFields: [
239
+ f.str("id", "Product ID (CUID)"),
240
+ f.str("name", "Product name"),
241
+ f.str("slug", "URL-friendly slug", true),
242
+ f.str("description", "Product description", true),
243
+ f.str("type", "SUBSCRIPTION, ONE_TIME, CREDITS, or DONATION"),
244
+ f.str("interval", "Billing interval: month or year", true),
245
+ f.num("interval_count", "Billing interval multiplier", true),
246
+ f.num("price", "Price in TWD (integer, e.g. 299 = NT$299)"),
247
+ f.str("currency", "Always TWD"),
248
+ f.num("trial_days", "Free trial days", true),
249
+ f.num("display_order", "Sort order for UI", true),
250
+ f.obj("metadata", "Custom key-value data", true),
251
+ f.bool("active", "Whether the product is active"),
252
+ f.dt("created_at", "Creation timestamp")
253
+ ]
254
+ },
255
+ get: {
256
+ method: "GET",
257
+ path: "/v1/products/:id",
258
+ description: "Get a product by ID (CUID) or slug (contains hyphen)",
259
+ responseFields: [
260
+ f.str("id"),
261
+ f.str("name"),
262
+ f.str("slug", void 0, true),
263
+ f.str("description", void 0, true),
264
+ f.str("type"),
265
+ f.str("interval", void 0, true),
266
+ f.num("interval_count", void 0, true),
267
+ f.num("price", "Price in TWD (integer)"),
268
+ f.str("currency"),
269
+ f.num("trial_days", void 0, true),
270
+ f.num("display_order", void 0, true),
271
+ f.obj("metadata", void 0, true),
272
+ f.bool("active"),
273
+ f.dt("created_at"),
274
+ f.dt("updated_at")
275
+ ]
276
+ },
277
+ create: {
278
+ method: "POST",
279
+ path: "/v1/products",
280
+ description: "Create a new product",
281
+ bodySchema: {
282
+ name: {
283
+ type: "string",
284
+ description: "Product name",
285
+ required: true
286
+ },
287
+ price: {
288
+ type: "number",
289
+ description: "Price in TWD (integer, e.g. 299 = NT$299)",
290
+ required: true
291
+ },
292
+ interval: {
293
+ type: "string",
294
+ description: "Billing interval",
295
+ enum: BillingInterval
296
+ },
297
+ type: {
298
+ type: "string",
299
+ description: "Product type",
300
+ enum: ProductType
301
+ },
302
+ description: {
303
+ type: "string",
304
+ description: "Product description"
305
+ }
306
+ },
307
+ responseFields: [
308
+ f.str("id"),
309
+ f.str("name"),
310
+ f.str("slug", void 0, true),
311
+ f.num("price"),
312
+ f.str("interval", void 0, true),
313
+ f.str("type"),
314
+ f.bool("active"),
315
+ f.dt("created_at")
316
+ ],
317
+ supportsDryRun: true
318
+ },
319
+ update: {
320
+ method: "PATCH",
321
+ path: "/v1/products/:id",
322
+ description: "Update a product",
323
+ bodySchema: {
324
+ name: {
325
+ type: "string",
326
+ description: "Product name"
327
+ },
328
+ description: {
329
+ type: "string",
330
+ description: "Product description"
331
+ },
332
+ price: {
333
+ type: "number",
334
+ description: "Price in TWD (integer)"
335
+ }
336
+ },
337
+ responseFields: [
338
+ f.str("id"),
339
+ f.str("name"),
340
+ f.str("slug", void 0, true),
341
+ f.num("price"),
342
+ f.dt("updated_at")
343
+ ],
344
+ supportsDryRun: true
345
+ },
346
+ archive: {
347
+ method: "POST",
348
+ path: "/v1/products/:id/archive",
349
+ description: "Archive a product",
350
+ responseFields: [f.str("id"), f.bool("active")],
351
+ supportsDryRun: true
352
+ }
353
+ }
354
+ },
355
+ {
356
+ resource: "customers",
357
+ description: "Manage customers",
358
+ actions: {
359
+ list: {
360
+ method: "GET",
361
+ path: "/v1/customers",
362
+ description: "List all customers",
363
+ params: {
364
+ email: {
365
+ type: "string",
366
+ description: "Filter by email"
367
+ },
368
+ status: {
369
+ type: "string",
370
+ description: "Filter by status",
371
+ enum: [
372
+ "ACTIVE",
373
+ "SUSPENDED",
374
+ "BANNED"
375
+ ]
376
+ },
377
+ limit: {
378
+ type: "number",
379
+ description: "Max results (1-100)",
380
+ default: 10
381
+ },
382
+ startingAfter: {
383
+ type: "string",
384
+ description: "Cursor: ID of last item from previous page",
385
+ cliFlag: "--starting-after"
386
+ }
387
+ },
388
+ responseFields: [
389
+ f.str("id", "Customer ID (CUID)"),
390
+ f.str("email", "Customer email"),
391
+ f.str("name", "Customer name", true),
392
+ f.str("external_id", "External system ID", true),
393
+ f.bool("email_verified", "Whether email is verified"),
394
+ f.str("status", "ACTIVE, SUSPENDED, or BANNED"),
395
+ f.dt("created_at", "Creation timestamp"),
396
+ f.dt("updated_at", "Last update timestamp"),
397
+ f.num("subscriptions_count", "Number of subscriptions"),
398
+ f.num("orders_count", "Number of orders")
399
+ ],
400
+ pagination: cursorPagination
401
+ },
402
+ get: {
403
+ method: "GET",
404
+ path: "/v1/customers/:id",
405
+ description: "Get a customer by ID",
406
+ responseFields: [
407
+ f.str("id"),
408
+ f.str("email"),
409
+ f.str("name", void 0, true),
410
+ f.str("external_id", void 0, true),
411
+ f.bool("email_verified"),
412
+ f.str("status"),
413
+ f.arr("subscriptions", "Customer subscriptions"),
414
+ f.dt("created_at"),
415
+ f.dt("updated_at")
416
+ ]
417
+ },
418
+ update: {
419
+ method: "PATCH",
420
+ path: "/v1/customers/:id",
421
+ description: "Update a customer",
422
+ bodySchema: {
423
+ name: {
424
+ type: "string",
425
+ description: "Customer name"
426
+ },
427
+ externalId: {
428
+ type: "string",
429
+ description: "External system ID",
430
+ cliFlag: "--external-id"
431
+ }
432
+ },
433
+ responseFields: [
434
+ f.str("id"),
435
+ f.str("email"),
436
+ f.str("name", void 0, true),
437
+ f.str("external_id", void 0, true)
438
+ ],
439
+ supportsDryRun: true
440
+ }
441
+ }
442
+ },
443
+ {
444
+ resource: "subscriptions",
445
+ description: "Manage subscriptions",
446
+ actions: {
447
+ list: {
448
+ method: "GET",
449
+ path: "/v1/subscriptions",
450
+ description: "List subscriptions",
451
+ params: {
452
+ status: {
453
+ type: "string",
454
+ description: "Filter by status",
455
+ enum: SubscriptionStatus
456
+ },
457
+ customerId: {
458
+ type: "string",
459
+ description: "Filter by customer ID",
460
+ cliFlag: "--customer-id"
461
+ },
462
+ email: {
463
+ type: "string",
464
+ description: "Filter by customer email"
465
+ },
466
+ limit: {
467
+ type: "number",
468
+ description: "Max results (1-100)",
469
+ default: 10
470
+ },
471
+ startingAfter: {
472
+ type: "string",
473
+ description: "Cursor: ID of last item from previous page",
474
+ cliFlag: "--starting-after"
475
+ }
476
+ },
477
+ responseFields: [
478
+ f.str("id", "Subscription ID"),
479
+ f.str("status", "active, canceled, expired, past_due, or trialing"),
480
+ f.str("product_id", "Product ID"),
481
+ f.str("product_slug", "Product slug", true),
482
+ f.str("product_name", "Product name"),
483
+ f.num("amount", "Billing amount in TWD (integer)"),
484
+ f.str("interval", "Billing interval: month or year"),
485
+ f.num("interval_count", "Interval multiplier"),
486
+ f.dt("current_period_start", "Current billing period start"),
487
+ f.dt("current_period_end", "Current billing period end"),
488
+ f.dt("canceled_at", "When subscription was canceled", true),
489
+ f.dt("started_at", "When subscription started", true),
490
+ f.obj("customer", "Customer object (id, email, name)", true)
491
+ ],
492
+ pagination: cursorPagination
493
+ },
494
+ get: {
495
+ method: "GET",
496
+ path: "/v1/subscriptions/:id",
497
+ description: "Get subscription details",
498
+ responseFields: [
499
+ f.str("id"),
500
+ f.str("status"),
501
+ f.obj("product", "Product details (id, slug, name, price, interval)"),
502
+ f.obj("customer", "Customer details (id, email, name, external_id)"),
503
+ f.num("amount", "Billing amount in TWD"),
504
+ f.str("interval"),
505
+ f.num("interval_count"),
506
+ f.dt("current_period_start"),
507
+ f.dt("current_period_end"),
508
+ f.bool("cancel_at_period_end", "Whether subscription cancels at period end"),
509
+ f.dt("canceled_at", void 0, true),
510
+ f.dt("started_at", void 0, true),
511
+ f.dt("trial_start", void 0, true),
512
+ f.dt("trial_end", void 0, true),
513
+ f.arr("invoices", "Invoice history"),
514
+ f.obj("metadata", void 0, true),
515
+ f.dt("created_at")
516
+ ]
517
+ },
518
+ cancel: {
519
+ method: "POST",
520
+ path: "/v1/subscriptions/:id/cancel",
521
+ description: "Cancel a subscription",
522
+ bodySchema: { immediately: {
523
+ type: "boolean",
524
+ description: "Cancel immediately (flag, no value). Omit for safe period-end cancel. Do NOT pass --immediately false.",
525
+ default: false
526
+ } },
527
+ responseFields: [
528
+ f.str("id"),
529
+ f.str("status"),
530
+ f.bool("cancel_at_period_end"),
531
+ f.dt("canceled_at", void 0, true)
532
+ ],
533
+ supportsDryRun: true
534
+ }
535
+ }
536
+ },
537
+ {
538
+ resource: "orders",
539
+ description: "View orders",
540
+ actions: {
541
+ list: {
542
+ method: "GET",
543
+ path: "/v1/orders",
544
+ description: "List orders",
545
+ params: {
546
+ status: {
547
+ type: "string",
548
+ description: "Filter by status",
549
+ enum: OrderStatus
550
+ },
551
+ customerId: {
552
+ type: "string",
553
+ description: "Filter by customer ID",
554
+ cliFlag: "--customer-id"
555
+ },
556
+ limit: {
557
+ type: "number",
558
+ description: "Max results (1-100)",
559
+ default: 10
560
+ },
561
+ startingAfter: {
562
+ type: "string",
563
+ description: "Cursor: ID of last item from previous page",
564
+ cliFlag: "--starting-after"
565
+ }
566
+ },
567
+ responseFields: [
568
+ f.str("id", "Order ID"),
569
+ f.str("status", "pending, paid, failed, or refunded"),
570
+ f.num("total", "Total amount in TWD (integer)"),
571
+ f.num("subtotal", "Subtotal before discounts"),
572
+ f.str("currency", "Always TWD"),
573
+ f.str("customer_id", "Customer ID", true),
574
+ f.num("items_count", "Number of line items"),
575
+ f.dt("created_at", "Creation timestamp")
576
+ ],
577
+ pagination: cursorPagination
578
+ },
579
+ get: {
580
+ method: "GET",
581
+ path: "/v1/orders/:id",
582
+ description: "Get order details",
583
+ responseFields: [
584
+ f.str("id"),
585
+ f.str("status"),
586
+ f.num("total"),
587
+ f.num("subtotal"),
588
+ f.num("discount_amount", "Discount applied"),
589
+ f.str("currency"),
590
+ f.obj("customer", "Customer details (id, email, name)"),
591
+ f.arr("items", "Line items (id, productId, productName, price, quantity)"),
592
+ f.dt("created_at")
593
+ ]
594
+ }
595
+ }
596
+ },
597
+ {
598
+ resource: "invoices",
599
+ description: "View invoices",
600
+ actions: {
601
+ list: {
602
+ method: "GET",
603
+ path: "/v1/invoices",
604
+ description: "List invoices",
605
+ params: {
606
+ subscriptionId: {
607
+ type: "string",
608
+ description: "Filter by subscription ID",
609
+ cliFlag: "--subscription-id"
610
+ },
611
+ customerId: {
612
+ type: "string",
613
+ description: "Filter by customer ID",
614
+ cliFlag: "--customer-id"
615
+ },
616
+ status: {
617
+ type: "string",
618
+ description: "Filter by status",
619
+ enum: InvoiceStatus
620
+ },
621
+ limit: {
622
+ type: "number",
623
+ description: "Max results (1-100)",
624
+ default: 10
625
+ },
626
+ startingAfter: {
627
+ type: "string",
628
+ description: "Cursor: ID of last item from previous page",
629
+ cliFlag: "--starting-after"
630
+ }
631
+ },
632
+ responseFields: [
633
+ f.str("id", "Invoice ID"),
634
+ f.str("status", "draft, open, paid, void, or uncollectible"),
635
+ f.num("amount", "Invoice amount in TWD (integer)"),
636
+ f.str("currency", "Always TWD"),
637
+ f.str("subscription_id", "Associated subscription ID"),
638
+ f.str("customer_id", "Customer ID"),
639
+ f.dt("period_start", "Billing period start"),
640
+ f.dt("period_end", "Billing period end"),
641
+ f.dt("paid_at", "When invoice was paid", true),
642
+ f.dt("created_at", "Creation timestamp")
643
+ ],
644
+ pagination: cursorPagination
645
+ },
646
+ get: {
647
+ method: "GET",
648
+ path: "/v1/invoices/:id",
649
+ description: "Get invoice details",
650
+ responseFields: [
651
+ f.str("id"),
652
+ f.str("status"),
653
+ f.num("amount"),
654
+ f.str("currency"),
655
+ f.str("subscription_id"),
656
+ f.obj("subscription", "Subscription details (id, productId, status)"),
657
+ f.str("customer_id"),
658
+ f.dt("period_start"),
659
+ f.dt("period_end"),
660
+ f.dt("paid_at", void 0, true),
661
+ f.dt("created_at")
662
+ ]
663
+ }
664
+ }
665
+ },
666
+ {
667
+ resource: "webhooks",
668
+ description: "Manage webhook endpoints",
669
+ actions: {
670
+ list: {
671
+ method: "GET",
672
+ path: "/v1/webhooks",
673
+ description: "List webhook endpoints",
674
+ params: { limit: {
675
+ type: "number",
676
+ description: "Max results",
677
+ default: 20
678
+ } },
679
+ responseFields: [
680
+ f.str("id", "Webhook ID"),
681
+ f.str("url", "Endpoint URL"),
682
+ f.arr("events", "Subscribed event types"),
683
+ f.bool("is_active", "Whether the webhook is active"),
684
+ f.dt("created_at", "Creation timestamp")
685
+ ]
686
+ },
687
+ create: {
688
+ method: "POST",
689
+ path: "/v1/webhooks",
690
+ description: "Create a webhook endpoint",
691
+ bodySchema: {
692
+ url: {
693
+ type: "string",
694
+ description: "Webhook URL",
695
+ required: true
696
+ },
697
+ events: {
698
+ type: "string",
699
+ description: "Comma-separated event types (default: essential events)"
700
+ }
701
+ },
702
+ responseFields: [
703
+ f.str("id"),
704
+ f.str("url"),
705
+ f.str("secret", "Signing secret (shown once)"),
706
+ f.arr("events"),
707
+ f.bool("is_active")
708
+ ],
709
+ supportsDryRun: true
710
+ },
711
+ test: {
712
+ method: "POST",
713
+ path: "/v1/webhooks/:id/test",
714
+ description: "Send a test event to verify webhook endpoint",
715
+ bodySchema: { eventType: {
716
+ type: "string",
717
+ description: "Event type to send",
718
+ default: "checkout.completed",
719
+ cliFlag: "--event"
720
+ } },
721
+ responseFields: [
722
+ f.bool("success", "Whether the test delivery succeeded"),
723
+ f.num("status_code", "HTTP status code from endpoint"),
724
+ f.num("response_time", "Response time in ms")
725
+ ]
726
+ },
727
+ delete: {
728
+ method: "DELETE",
729
+ path: "/v1/webhooks/:id",
730
+ description: "Delete a webhook endpoint",
731
+ responseFields: [f.str("id")],
732
+ supportsDryRun: true
733
+ }
734
+ }
735
+ },
736
+ {
737
+ resource: "checkouts",
738
+ description: "Create checkout sessions",
739
+ actions: {
740
+ create: {
741
+ method: "POST",
742
+ path: "/v1/checkouts",
743
+ description: "Create a checkout session",
744
+ bodySchema: {
745
+ productId: {
746
+ type: "string",
747
+ description: "Product ID",
748
+ required: true,
749
+ cliFlag: "--product-id"
750
+ },
751
+ customerEmail: {
752
+ type: "string",
753
+ description: "Customer email",
754
+ cliFlag: "--customer-email"
755
+ },
756
+ successUrl: {
757
+ type: "string",
758
+ description: "Redirect URL on success",
759
+ cliFlag: "--success-url"
760
+ },
761
+ cancelUrl: {
762
+ type: "string",
763
+ description: "Redirect URL on cancel",
764
+ cliFlag: "--cancel-url"
765
+ }
766
+ },
767
+ responseFields: [
768
+ f.str("id", "Checkout session ID"),
769
+ f.str("url", "Hosted checkout URL"),
770
+ f.str("status", "Session status"),
771
+ f.dt("expires_at", "Session expiration")
772
+ ],
773
+ supportsDryRun: true
774
+ },
775
+ get: {
776
+ method: "GET",
777
+ path: "/v1/checkouts/:id",
778
+ description: "Get checkout session status",
779
+ responseFields: [
780
+ f.str("id"),
781
+ f.str("url", void 0, true),
782
+ f.str("status"),
783
+ f.str("customer_id", void 0, true),
784
+ f.str("product_id"),
785
+ f.dt("created_at")
786
+ ]
787
+ }
788
+ }
789
+ }
790
+ ];
791
+ //#endregion
792
+ //#region src/schema.ts
793
+ /**
794
+ * Schema registry for runtime introspection.
795
+ * Resource definitions are imported from @workspace/core/api (canonical source).
796
+ * Enables `recur schema <resource>.<action>` for agents to discover
797
+ * available parameters, fields, and request bodies.
798
+ */
799
+ var SchemaError = class extends CLIError {
800
+ available;
801
+ constructor(message, available) {
802
+ super(`${message}\nAvailable: ${available.join(", ")}`);
803
+ this.available = available;
804
+ }
805
+ };
806
+ const registry = {};
807
+ for (const def of allResources) registry[def.resource] = def;
808
+ function getAction(resourceAction) {
809
+ const [resource, action] = resourceAction.split(".");
810
+ if (!resource || !action) return void 0;
811
+ return registry[resource]?.actions[action];
812
+ }
813
+ /**
814
+ * Dump schema as machine-readable JSON for agents.
815
+ * Throws CLIError for unknown resources/actions (non-zero exit).
816
+ */
817
+ function dumpSchema(resourceAction) {
818
+ if (resourceAction) {
819
+ const [resourceName, actionName] = resourceAction.split(".");
820
+ if (!resourceName) throw new SchemaError("Invalid format. Use: <resource> or <resource>.<action>", Object.keys(registry));
821
+ const resource = registry[resourceName];
822
+ if (!resource) throw new SchemaError(`Unknown resource: ${resourceName}`, Object.keys(registry));
823
+ if (!actionName) return resource;
824
+ const action = resource.actions[actionName];
825
+ if (!action) throw new SchemaError(`Unknown action: ${actionName} on ${resourceName}`, Object.keys(resource.actions));
826
+ return {
827
+ resource: resourceName,
828
+ action: actionName,
829
+ ...action
830
+ };
831
+ }
832
+ return Object.values(registry).map((r) => ({
833
+ resource: r.resource,
834
+ description: r.description,
835
+ actions: Object.entries(r.actions).map(([name, def]) => ({
836
+ name,
837
+ method: def.method,
838
+ path: def.path,
839
+ description: def.description
840
+ }))
841
+ }));
842
+ }
843
+ //#endregion
844
+ //#region src/validator.ts
845
+ /**
846
+ * Validate a resource ID or slug against dangerous input patterns.
847
+ * Rejects path traversals, embedded query params, control chars.
848
+ *
849
+ * Does NOT enforce ID prefixes — Recur uses CUID-format IDs (e.g.
850
+ * "ro91zsticf41uwq8bungmklk") without a prefix like "cus_".
851
+ */
852
+ function validateResourceId(id) {
853
+ if (!id || typeof id !== "string") throw new CLIError("Resource ID is required");
854
+ if (id.includes("..") || id.includes("/") || id.includes("\\")) throw new CLIError(`Invalid resource ID: path traversal detected in "${id}"`);
855
+ if (id.includes("?") || id.includes("#") || id.includes("%")) throw new CLIError(`Invalid resource ID: special characters not allowed in "${id}"`);
856
+ for (let i = 0; i < id.length; i++) {
857
+ const code = id.charCodeAt(i);
858
+ if (code < 32 || code === 127) throw new CLIError(`Invalid resource ID: control character at position ${i}`);
859
+ }
860
+ if (/[\u200B-\u200F\u2028\u2029\uFEFF]/.test(id)) throw new CLIError("Invalid resource ID: invisible Unicode characters detected");
861
+ return id;
862
+ }
863
+ /**
864
+ * Parse and validate a raw JSON payload from --json flag.
865
+ */
866
+ function parseJsonPayload(raw) {
867
+ try {
868
+ const parsed = JSON.parse(raw);
869
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CLIError("JSON payload must be an object");
870
+ return parsed;
871
+ } catch (err) {
872
+ if (err instanceof CLIError) throw err;
873
+ throw new CLIError(`Invalid JSON payload: ${err.message}`);
874
+ }
875
+ }
876
+ /**
877
+ * Validate that a string looks like a valid API key.
878
+ */
879
+ function validateApiKey(key) {
880
+ if (!/^sk_(test|live)_[a-zA-Z0-9]+$/.test(key)) {
881
+ if (/^pk_(test|live)_[a-zA-Z0-9]+$/.test(key)) throw new CLIError("CLI requires a Secret Key (sk_*). Publishable keys (pk_*) are for client-side SDKs only.");
882
+ throw new CLIError("Invalid API key format. Expected sk_test_* or sk_live_*");
883
+ }
884
+ return key;
885
+ }
886
+ /**
887
+ * Validate fields from schema definition during dry-run.
888
+ * Checks required fields, enum values, and numeric types.
889
+ * Returns list of error messages, or empty array if valid.
890
+ */
891
+ function validateRequiredFields(body, schemaAction) {
892
+ const action = getAction(schemaAction);
893
+ if (!action?.bodySchema) return [];
894
+ const errors = [];
895
+ for (const [field, def] of Object.entries(action.bodySchema)) {
896
+ const val = body[field];
897
+ if (def.required && (val === void 0 || val === null)) {
898
+ errors.push(field);
899
+ continue;
900
+ }
901
+ if (val === void 0 || val === null) continue;
902
+ if (def.enum && !def.enum.includes(String(val))) errors.push(`${field}: invalid value "${val}" (expected: ${def.enum.join(", ")})`);
903
+ if (def.type === "number" && typeof val === "number" && isNaN(val)) errors.push(`${field}: must be a valid number`);
904
+ }
905
+ return errors;
906
+ }
907
+ //#endregion
908
+ //#region src/client.ts
909
+ var RecurClient = class {
910
+ baseUrl;
911
+ secretKey;
912
+ constructor(opts) {
913
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
914
+ this.secretKey = opts.secretKey;
915
+ }
916
+ async request(method, path, opts) {
917
+ const url = new URL(path, this.baseUrl);
918
+ if (opts?.params) {
919
+ for (const [key, value] of Object.entries(opts.params)) if (value !== void 0) url.searchParams.set(key, value);
920
+ }
921
+ const headers = {
922
+ Authorization: `Bearer ${this.secretKey}`,
923
+ "User-Agent": `@recur-tw/cli/0.1.0`
924
+ };
925
+ const hasBody = opts?.body !== void 0;
926
+ if (hasBody) headers["Content-Type"] = "application/json";
927
+ const response = await fetch(url.toString(), {
928
+ method,
929
+ headers,
930
+ body: hasBody ? JSON.stringify(opts.body) : void 0,
931
+ signal: AbortSignal.timeout(3e4)
932
+ });
933
+ let data;
934
+ if ((response.headers.get("content-type") ?? "").includes("application/json")) data = await response.json();
935
+ else data = await response.text();
936
+ data = sanitizeResponse(data);
937
+ if (!response.ok) {
938
+ const errorBody = data;
939
+ const msg = errorBody?.error?.message ?? `HTTP ${response.status}`;
940
+ let detail = `[${errorBody?.error?.code ?? "unknown"}] ${msg}`;
941
+ if (response.status === 404) {
942
+ const resource = path.split("/").filter(Boolean).pop() ?? "";
943
+ detail += `\nThe resource "${resource}" was not found. Check the ID or slug is correct.`;
944
+ }
945
+ throw new CLIError(detail, response.status);
946
+ }
947
+ return data;
948
+ }
949
+ get(path, params) {
950
+ return this.request("GET", path, { params });
951
+ }
952
+ post(path, body) {
953
+ return this.request("POST", path, { body });
954
+ }
955
+ patch(path, body) {
956
+ return this.request("PATCH", path, { body });
957
+ }
958
+ delete(path) {
959
+ return this.request("DELETE", path);
960
+ }
961
+ };
962
+ /**
963
+ * Strip a single string of dangerous characters:
964
+ * - ASCII control chars (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) — keep \t \n \r
965
+ * - DEL (0x7F)
966
+ * - Zero-width / invisible Unicode (U+200B-200F, U+FEFF)
967
+ * - Line/paragraph separators (U+2028-2029)
968
+ * - ANSI escape sequences (CSI + OSC patterns)
969
+ */
970
+ function sanitizeString(str) {
971
+ return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").replace(/[\u200B-\u200F\u2028\u2029\uFEFF]/g, "").replace(/\x1B\[[0-9;]*[A-Za-z]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
972
+ }
973
+ /**
974
+ * Recursively sanitize all string values (and keys) in API responses.
975
+ * Defends against prompt injection via invisible characters or ANSI
976
+ * escape sequences embedded in user-generated data.
977
+ */
978
+ function sanitizeResponse(data) {
979
+ if (typeof data === "string") return sanitizeString(data);
980
+ if (Array.isArray(data)) return data.map(sanitizeResponse);
981
+ if (data !== null && typeof data === "object") {
982
+ const obj = data;
983
+ const result = {};
984
+ for (const [key, value] of Object.entries(obj)) result[sanitizeString(key)] = sanitizeResponse(value);
985
+ return result;
986
+ }
987
+ return data;
988
+ }
989
+ //#endregion
990
+ //#region src/output.ts
991
+ /**
992
+ * Render data in the specified format.
993
+ * Agent-optimized: --output json produces clean, parseable JSON to stdout.
994
+ * Human-optimized: table format with colors to stderr-safe stdout.
995
+ */
996
+ function render(data, opts = { format: "table" }) {
997
+ switch (opts.format) {
998
+ case "json":
999
+ renderJson(data);
1000
+ break;
1001
+ case "ndjson":
1002
+ renderNdjson(data);
1003
+ break;
1004
+ case "csv":
1005
+ renderCsv(data, opts);
1006
+ break;
1007
+ default:
1008
+ renderTable(data, opts);
1009
+ break;
1010
+ }
1011
+ }
1012
+ function renderJson(data) {
1013
+ console.log(JSON.stringify(data, null, 2));
1014
+ }
1015
+ /**
1016
+ * NDJSON: one JSON object per line, no array wrapper.
1017
+ * Enables stream processing without buffering entire response.
1018
+ */
1019
+ function renderNdjson(data) {
1020
+ const rows = Array.isArray(data) ? data : [data];
1021
+ for (const row of rows) console.log(JSON.stringify(row));
1022
+ }
1023
+ function renderCsv(data, opts) {
1024
+ const rows = Array.isArray(data) ? data : [data];
1025
+ if (rows.length === 0) return;
1026
+ const fields = opts.fields ?? Object.keys(rows[0]);
1027
+ if (!opts.noHeaders) console.log(fields.join(","));
1028
+ for (const row of rows) {
1029
+ const record = row;
1030
+ const values = fields.map((f) => {
1031
+ const val = record[f];
1032
+ if (val === null || val === void 0) return "";
1033
+ const str = typeof val === "object" ? JSON.stringify(val) : String(val);
1034
+ if (str.includes(",") || str.includes("\"") || str.includes("\n") || /^[=+\-@]/.test(str)) return `"${str.replace(/"/g, "\"\"")}"`;
1035
+ return str;
1036
+ });
1037
+ console.log(values.join(","));
1038
+ }
1039
+ }
1040
+ function renderTable(data, opts) {
1041
+ const rows = Array.isArray(data) ? data : [data];
1042
+ if (rows.length === 0) {
1043
+ console.log(pc.dim("No results"));
1044
+ return;
1045
+ }
1046
+ const fields = opts.fields ?? Object.keys(rows[0]);
1047
+ const widths = {};
1048
+ for (const field of fields) widths[field] = field.length;
1049
+ for (const row of rows) {
1050
+ const record = row;
1051
+ for (const field of fields) {
1052
+ const val = formatValue(record[field]);
1053
+ widths[field] = Math.min(Math.max(widths[field] ?? 0, val.length), 50);
1054
+ }
1055
+ }
1056
+ const header = fields.map((f) => pc.bold(f.padEnd(widths[f] ?? 0))).join(" ");
1057
+ console.log(header);
1058
+ console.log(fields.map((f) => "─".repeat(widths[f] ?? 0)).join(" "));
1059
+ for (const row of rows) {
1060
+ const record = row;
1061
+ const line = fields.map((f) => {
1062
+ const val = formatValue(record[f]);
1063
+ const maxWidth = widths[f] ?? 50;
1064
+ const padded = val.padEnd(maxWidth);
1065
+ return padded.length > maxWidth ? padded.slice(0, maxWidth - 1) + "…" : padded;
1066
+ }).join(" ");
1067
+ console.log(line);
1068
+ }
1069
+ console.log(pc.dim(`\n${rows.length} result${rows.length === 1 ? "" : "s"}`));
1070
+ }
1071
+ function formatValue(val) {
1072
+ if (val === null || val === void 0) return pc.dim("—");
1073
+ if (typeof val === "boolean") return val ? pc.green("true") : pc.dim("false");
1074
+ if (typeof val === "object") return sanitizeString(JSON.stringify(val));
1075
+ return sanitizeString(String(val));
1076
+ }
1077
+ /**
1078
+ * Pick specific fields from data (for --fields flag).
1079
+ */
1080
+ function pickFields(data, fields) {
1081
+ warnUnknownFields(data, fields);
1082
+ if (Array.isArray(data)) return data.map((item) => pick(item, fields));
1083
+ return pick(data, fields);
1084
+ }
1085
+ function pick(obj, fields) {
1086
+ const result = {};
1087
+ for (const field of fields) if (field in obj) result[field] = obj[field];
1088
+ return result;
1089
+ }
1090
+ /**
1091
+ * Warn about --fields that don't exist in the data.
1092
+ * Called once per render to avoid repeated warnings.
1093
+ */
1094
+ function warnUnknownFields(data, fields) {
1095
+ const sample = Array.isArray(data) ? data[0] : data;
1096
+ if (!sample || typeof sample !== "object") return;
1097
+ const available = Object.keys(sample);
1098
+ const unknown = fields.filter((f) => !available.includes(f));
1099
+ if (unknown.length > 0) console.error(pc.yellow(`Warning: unknown fields: ${unknown.map(sanitizeString).join(", ")}. Available: ${available.map(sanitizeString).join(", ")}`));
1100
+ }
1101
+ //#endregion
1102
+ //#region src/commands/login.ts
1103
+ function promptSecret(question) {
1104
+ return new Promise((resolve) => {
1105
+ process.stderr.write(question);
1106
+ let input = "";
1107
+ const stdin = process.stdin;
1108
+ if (!stdin.isTTY) {
1109
+ const rl = readline.createInterface({
1110
+ input: stdin,
1111
+ output: process.stderr
1112
+ });
1113
+ rl.question("", (answer) => {
1114
+ rl.close();
1115
+ resolve(answer.trim());
1116
+ });
1117
+ return;
1118
+ }
1119
+ stdin.setRawMode(true);
1120
+ stdin.resume();
1121
+ stdin.setEncoding("utf8");
1122
+ const onData = (ch) => {
1123
+ if (ch === "\r" || ch === "\n") {
1124
+ stdin.setRawMode(false);
1125
+ stdin.pause();
1126
+ stdin.removeListener("data", onData);
1127
+ process.stderr.write("\n");
1128
+ resolve(input.trim());
1129
+ } else if (ch === "") {
1130
+ stdin.setRawMode(false);
1131
+ stdin.pause();
1132
+ stdin.removeListener("data", onData);
1133
+ process.stderr.write("\n");
1134
+ process.exit(130);
1135
+ } else if (ch === "" || ch === "\b") {
1136
+ if (input.length > 0) {
1137
+ input = input.slice(0, -1);
1138
+ process.stderr.write("\b \b");
1139
+ }
1140
+ } else {
1141
+ input += ch;
1142
+ process.stderr.write("*");
1143
+ }
1144
+ };
1145
+ stdin.on("data", onData);
1146
+ });
1147
+ }
1148
+ function registerLoginCommand(program) {
1149
+ program.command("login").description("Configure API key credentials").option("--name <profile>", "Profile name", "default").action(async (cmdOpts) => {
1150
+ try {
1151
+ const globalOpts = program.opts();
1152
+ let key = globalOpts.key;
1153
+ if (!key) key = await promptSecret(`${pc.bold("Secret Key")} (sk_test_* or sk_live_*): `);
1154
+ key = validateApiKey(key);
1155
+ const environment = key.startsWith("sk_live_") ? "production" : "sandbox";
1156
+ const baseUrl = globalOpts.baseUrl ?? "https://api.recur.tw";
1157
+ const client = new RecurClient({
1158
+ baseUrl,
1159
+ secretKey: key
1160
+ });
1161
+ try {
1162
+ await client.get("/v1/products");
1163
+ console.error(pc.green("✓ API key verified"));
1164
+ } catch {
1165
+ console.error(pc.yellow("⚠ Could not verify API key (saved anyway)"));
1166
+ }
1167
+ saveProfile(cmdOpts.name, {
1168
+ secretKey: key,
1169
+ environment,
1170
+ baseUrl
1171
+ });
1172
+ console.error(pc.green(`✓ Saved profile "${cmdOpts.name}" (${environment})`));
1173
+ } catch (err) {
1174
+ handleError(err);
1175
+ }
1176
+ });
1177
+ program.command("logout").description("Remove a saved profile").argument("[profile]", "Profile name to remove", "default").action((profileName) => {
1178
+ try {
1179
+ if (!getProfile(profileName)) {
1180
+ console.error(pc.red(`Profile "${profileName}" not found`));
1181
+ process.exit(1);
1182
+ }
1183
+ deleteProfile(profileName);
1184
+ console.error(pc.green(`✓ Removed profile "${profileName}"`));
1185
+ } catch (err) {
1186
+ handleError(err);
1187
+ }
1188
+ });
1189
+ program.command("whoami").description("Show current profile and verify API key").action(async () => {
1190
+ try {
1191
+ const opts = program.opts();
1192
+ const active = listProfiles().find((p) => p.active);
1193
+ if (!active) {
1194
+ console.error(pc.yellow("No profiles configured. Run: recur login"));
1195
+ process.exit(1);
1196
+ }
1197
+ const globalOpts = program.opts();
1198
+ let maskedKey;
1199
+ try {
1200
+ const key = resolveSecretKey(globalOpts);
1201
+ maskedKey = key.slice(0, key.lastIndexOf("_") + 1) + "..." + key.slice(-4);
1202
+ } catch {}
1203
+ render({
1204
+ profile: active.name,
1205
+ environment: active.environment,
1206
+ ...maskedKey && { key: maskedKey }
1207
+ }, { format: opts.output });
1208
+ } catch (err) {
1209
+ handleError(err);
1210
+ }
1211
+ });
1212
+ program.command("profiles").description("List all saved profiles").action(() => {
1213
+ try {
1214
+ const opts = program.opts();
1215
+ const profiles = listProfiles();
1216
+ if (profiles.length === 0) {
1217
+ console.error(pc.dim("No profiles. Run: recur login"));
1218
+ return;
1219
+ }
1220
+ render(profiles, { format: opts.output });
1221
+ } catch (err) {
1222
+ handleError(err);
1223
+ }
1224
+ });
1225
+ program.command("use").description("Switch active profile").argument("<profile>", "Profile name").action((profileName) => {
1226
+ try {
1227
+ setActiveProfile(profileName);
1228
+ console.error(pc.green(`✓ Switched to profile "${profileName}"`));
1229
+ } catch (err) {
1230
+ handleError(err);
1231
+ }
1232
+ });
1233
+ }
1234
+ //#endregion
1235
+ //#region src/extract.ts
1236
+ /**
1237
+ * Extract list data from API responses.
1238
+ *
1239
+ * All list endpoints return a standard envelope: { object: 'list', data: [...] }.
1240
+ * This function also handles legacy shapes and direct arrays as fallback.
1241
+ */
1242
+ function extractList(response) {
1243
+ if (Array.isArray(response)) return response;
1244
+ if (typeof response === "object" && response !== null) {
1245
+ const obj = response;
1246
+ if (Array.isArray(obj["data"])) return obj["data"];
1247
+ for (const value of Object.values(obj)) if (Array.isArray(value)) return value;
1248
+ }
1249
+ return [response];
1250
+ }
1251
+ /**
1252
+ * Extract list data with pagination metadata.
1253
+ *
1254
+ * API responses include:
1255
+ * - has_more: boolean — whether more pages exist
1256
+ * - next_cursor: string | null — ID to pass as starting_after for next page
1257
+ */
1258
+ function extractPaginatedList(response) {
1259
+ const data = extractList(response);
1260
+ if (typeof response === "object" && response !== null) {
1261
+ const obj = response;
1262
+ return {
1263
+ data,
1264
+ hasMore: obj["has_more"] === true,
1265
+ nextCursor: typeof obj["next_cursor"] === "string" ? obj["next_cursor"] : null
1266
+ };
1267
+ }
1268
+ return {
1269
+ data,
1270
+ hasMore: false,
1271
+ nextCursor: null
1272
+ };
1273
+ }
1274
+ //#endregion
1275
+ //#region src/commands/products.ts
1276
+ function getClient$6(opts) {
1277
+ return new RecurClient({
1278
+ baseUrl: resolveBaseUrl(opts),
1279
+ secretKey: resolveSecretKey(opts)
1280
+ });
1281
+ }
1282
+ function getOutputOpts$6(opts) {
1283
+ return {
1284
+ format: opts.output,
1285
+ fields: opts.fields?.split(",")
1286
+ };
1287
+ }
1288
+ function registerProductsCommand(program) {
1289
+ const products = program.command("products").description("Manage products (SUBSCRIPTION, ONE_TIME, CREDITS, DONATION)");
1290
+ products.addHelpText("after", `
1291
+ Fields: id, name, slug, description, type, interval, interval_count, price, currency, trial_days, display_order, metadata, active, created_at, updated_at
1292
+ Price is in TWD as integer (e.g. 299 = NT$299). Never divide by 100.
1293
+ IDs are CUID format (e.g. k672i1kd6zgrw5b6w39xwpx3). Slugs contain hyphens (e.g. master-monthly).
1294
+
1295
+ Examples:
1296
+ $ recur products list --output json
1297
+ $ recur products list --fields name,price,type
1298
+ $ recur products get master-monthly # by slug (contains hyphen)
1299
+ $ recur products get k672i1kd6zgrw5b6w39xwpx3 # by ID (CUID)
1300
+ $ recur products create --name "Pro" --price 299 --interval monthly --type SUBSCRIPTION
1301
+ $ recur products create --json '{"name":"Pro","price":299,"interval":"monthly","type":"SUBSCRIPTION"}'
1302
+ $ recur products update <id> --price 399 --dry-run
1303
+ $ recur products archive <id>
1304
+ `);
1305
+ products.command("list").description("List all active products. Use --status archived to see archived.").option("--status <status>", "Filter: active (default) or archived").option("--limit <n>", "Max results per page (default: 20)", "20").action(async (cmdOpts) => {
1306
+ try {
1307
+ const opts = products.optsWithGlobals();
1308
+ const result = extractList(await getClient$6(opts).get("/v1/products", {
1309
+ status: cmdOpts.status,
1310
+ limit: cmdOpts.limit
1311
+ }));
1312
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$6(opts));
1313
+ } catch (err) {
1314
+ handleError(err);
1315
+ }
1316
+ });
1317
+ products.command("get").description("Get a product by ID (CUID) or by slug (e.g. \"master-monthly\")").argument("<id>", "Product ID (CUID) or slug").action(async (id) => {
1318
+ try {
1319
+ const opts = products.optsWithGlobals();
1320
+ const client = getClient$6(opts);
1321
+ const validId = validateResourceId(id);
1322
+ const path = id.includes("-") ? `/v1/products/by-slug/${validId}` : `/v1/products/${validId}`;
1323
+ const data = await client.get(path);
1324
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts$6(opts));
1325
+ } catch (err) {
1326
+ handleError(err);
1327
+ }
1328
+ });
1329
+ products.command("create").description("Create a new product. Use --json for full payload or individual flags.").option("--name <name>", "Product name (required)").option("--price <price>", "Price in TWD integer (e.g. 299 = NT$299, required)").option("--interval <interval>", "Billing interval: monthly or yearly").option("--type <type>", "Product type: SUBSCRIPTION, ONE_TIME, CREDITS, DONATION").option("--description <desc>", "Product description").action(async (cmdOpts) => {
1330
+ try {
1331
+ const opts = products.optsWithGlobals();
1332
+ let body;
1333
+ if (opts.json) {
1334
+ const flagsUsed = [
1335
+ "name",
1336
+ "price",
1337
+ "interval",
1338
+ "type",
1339
+ "description"
1340
+ ].filter((f) => cmdOpts[f]);
1341
+ if (flagsUsed.length > 0) console.error(pc.yellow(`Warning: --json provided, ignoring flags: --${flagsUsed.join(", --")}`));
1342
+ body = parseJsonPayload(opts.json);
1343
+ } else {
1344
+ body = {};
1345
+ if (cmdOpts.name) body["name"] = cmdOpts.name;
1346
+ if (cmdOpts.price) {
1347
+ const price = parseInt(cmdOpts.price, 10);
1348
+ if (isNaN(price)) throw new CLIError(`Invalid price: "${cmdOpts.price}" is not a number`);
1349
+ body["price"] = price;
1350
+ }
1351
+ if (cmdOpts.interval) body["interval"] = cmdOpts.interval;
1352
+ if (cmdOpts.type) body["type"] = cmdOpts.type;
1353
+ if (cmdOpts.description) body["description"] = cmdOpts.description;
1354
+ }
1355
+ if (opts.dryRun) {
1356
+ const errors = validateRequiredFields(body, "products.create");
1357
+ if (errors.length > 0) {
1358
+ console.error(pc.red(`Validation errors:\n ${errors.join("\n ")}`));
1359
+ process.exit(1);
1360
+ }
1361
+ console.error(pc.yellow("[dry-run] Would create product:"));
1362
+ render(body, { format: "json" });
1363
+ return;
1364
+ }
1365
+ render(await getClient$6(opts).post("/v1/products", body), getOutputOpts$6(opts));
1366
+ } catch (err) {
1367
+ handleError(err);
1368
+ }
1369
+ });
1370
+ products.command("update").description("Update a product by ID. Only provided fields are changed.").argument("<id>", "Product ID (CUID)").option("--name <name>", "New product name").option("--price <price>", "New price in TWD integer").option("--description <desc>", "New product description").action(async (id, cmdOpts) => {
1371
+ try {
1372
+ const opts = products.optsWithGlobals();
1373
+ const validId = validateResourceId(id);
1374
+ let body;
1375
+ if (opts.json) {
1376
+ const flagsUsed = [
1377
+ "name",
1378
+ "price",
1379
+ "description"
1380
+ ].filter((f) => cmdOpts[f]);
1381
+ if (flagsUsed.length > 0) console.error(pc.yellow(`Warning: --json provided, ignoring flags: --${flagsUsed.join(", --")}`));
1382
+ body = parseJsonPayload(opts.json);
1383
+ } else {
1384
+ body = {};
1385
+ if (cmdOpts.name) body["name"] = cmdOpts.name;
1386
+ if (cmdOpts.price) {
1387
+ const price = parseInt(cmdOpts.price, 10);
1388
+ if (isNaN(price)) throw new CLIError(`Invalid price: "${cmdOpts.price}" is not a number`);
1389
+ body["price"] = price;
1390
+ }
1391
+ if (cmdOpts.description) body["description"] = cmdOpts.description;
1392
+ }
1393
+ if (opts.dryRun) {
1394
+ console.error(pc.yellow(`[dry-run] Would update product ${validId}:`));
1395
+ render(body, { format: "json" });
1396
+ return;
1397
+ }
1398
+ render(await getClient$6(opts).patch(`/v1/products/${validId}`, body), getOutputOpts$6(opts));
1399
+ } catch (err) {
1400
+ handleError(err);
1401
+ }
1402
+ });
1403
+ products.command("archive").description("Archive a product (soft delete, can be restored from dashboard)").argument("<id>", "Product ID (CUID)").action(async (id) => {
1404
+ try {
1405
+ const opts = products.optsWithGlobals();
1406
+ const validId = validateResourceId(id);
1407
+ if (opts.dryRun) {
1408
+ console.error(pc.yellow(`[dry-run] Would archive product ${validId}`));
1409
+ return;
1410
+ }
1411
+ render(await getClient$6(opts).post(`/v1/products/${validId}/archive`), getOutputOpts$6(opts));
1412
+ } catch (err) {
1413
+ handleError(err);
1414
+ }
1415
+ });
1416
+ }
1417
+ //#endregion
1418
+ //#region src/paginate.ts
1419
+ const MAX_PAGES = 100;
1420
+ /**
1421
+ * Auto-paginate through all pages using cursor-based pagination.
1422
+ * Outputs each page as NDJSON lines for streaming consumption.
1423
+ */
1424
+ async function paginateAll(opts) {
1425
+ let cursor = opts.params["startingAfter"];
1426
+ let page = 0;
1427
+ let totalItems = 0;
1428
+ while (page < MAX_PAGES) {
1429
+ const params = { ...opts.params };
1430
+ if (cursor) params["startingAfter"] = cursor;
1431
+ const result = extractPaginatedList(await opts.client.get(opts.path, params));
1432
+ if (result.data.length === 0) break;
1433
+ const output = opts.fields ? pickFields(result.data, opts.fields) : result.data;
1434
+ const rows = Array.isArray(output) ? output : [output];
1435
+ for (const row of rows) console.log(JSON.stringify(row));
1436
+ totalItems += result.data.length;
1437
+ page++;
1438
+ if (!result.hasMore || !result.nextCursor) break;
1439
+ cursor = result.nextCursor;
1440
+ }
1441
+ if (page >= MAX_PAGES) console.error(pc.yellow(`Warning: reached max page limit (${MAX_PAGES}). ${totalItems} items fetched.`));
1442
+ else console.error(pc.dim(`${totalItems} item${totalItems === 1 ? "" : "s"} total (${page} page${page === 1 ? "" : "s"})`));
1443
+ }
1444
+ //#endregion
1445
+ //#region src/commands/customers.ts
1446
+ function getClient$5(opts) {
1447
+ return new RecurClient({
1448
+ baseUrl: resolveBaseUrl(opts),
1449
+ secretKey: resolveSecretKey(opts)
1450
+ });
1451
+ }
1452
+ function getOutputOpts$5(opts) {
1453
+ return {
1454
+ format: opts.output,
1455
+ fields: opts.fields?.split(",")
1456
+ };
1457
+ }
1458
+ function registerCustomersCommand(program) {
1459
+ const customers = program.command("customers").description("Manage customers (subscribers to your products)");
1460
+ customers.addHelpText("after", `
1461
+ Fields: id, email, name, external_id, email_verified, status, created_at, updated_at, subscriptions_count, orders_count
1462
+ IDs are CUID format (e.g. z5gwawfqbh5zcw6si568j4cu).
1463
+
1464
+ Examples:
1465
+ $ recur customers list --output json
1466
+ $ recur customers list --email user@example.com
1467
+ $ recur customers list --fields email,name
1468
+ $ recur customers list --starting-after <last-id> --limit 10
1469
+ $ recur customers list --page-all --fields id,email # Fetch all pages (NDJSON)
1470
+ $ recur customers get <id> --output json
1471
+ $ recur customers update <id> --name "New Name"
1472
+ `);
1473
+ customers.command("list").description("List all customers with optional email filter and pagination").option("--email <email>", "Filter by exact email address").option("--status <status>", "Filter: ACTIVE, SUSPENDED, BANNED").option("--limit <n>", "Max results per page (1-100, default: 10)", "10").option("--starting-after <id>", "Cursor: ID of last item from previous page").option("--page-all", "Auto-paginate through all pages (outputs NDJSON)").action(async (cmdOpts) => {
1474
+ try {
1475
+ const opts = customers.optsWithGlobals();
1476
+ const client = getClient$5(opts);
1477
+ const params = {
1478
+ email: cmdOpts.email,
1479
+ status: cmdOpts.status,
1480
+ limit: cmdOpts.limit,
1481
+ startingAfter: cmdOpts.startingAfter
1482
+ };
1483
+ if (cmdOpts.pageAll) {
1484
+ await paginateAll({
1485
+ client,
1486
+ path: "/v1/customers",
1487
+ params,
1488
+ outputFormat: opts.output,
1489
+ fields: opts.fields?.split(",")
1490
+ });
1491
+ return;
1492
+ }
1493
+ const result = extractList(await client.get("/v1/customers", params));
1494
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$5(opts));
1495
+ } catch (err) {
1496
+ handleError(err);
1497
+ }
1498
+ });
1499
+ customers.command("get").description("Get a single customer by ID. Returns subscriptions and order counts.").argument("<id>", "Customer ID (CUID)").action(async (id) => {
1500
+ try {
1501
+ const opts = customers.optsWithGlobals();
1502
+ const validId = validateResourceId(id);
1503
+ const data = await getClient$5(opts).get(`/v1/customers/${validId}`);
1504
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts$5(opts));
1505
+ } catch (err) {
1506
+ handleError(err);
1507
+ }
1508
+ });
1509
+ customers.command("update").description("Update a customer. Only provided fields are changed.").argument("<id>", "Customer ID (CUID)").option("--name <name>", "Customer display name").option("--external-id <externalId>", "Your application's user ID").action(async (id, cmdOpts) => {
1510
+ try {
1511
+ const opts = customers.optsWithGlobals();
1512
+ const validId = validateResourceId(id);
1513
+ let body;
1514
+ if (opts.json) {
1515
+ const flagsUsed = ["name", "externalId"].filter((f) => cmdOpts[f]);
1516
+ if (flagsUsed.length > 0) console.error(pc.yellow(`Warning: --json provided, ignoring flags: --${flagsUsed.map((f) => f.replace(/([A-Z])/g, "-$1").toLowerCase()).join(", --")}`));
1517
+ body = parseJsonPayload(opts.json);
1518
+ } else {
1519
+ body = {};
1520
+ if (cmdOpts.name) body["name"] = cmdOpts.name;
1521
+ if (cmdOpts.externalId) body["externalId"] = cmdOpts.externalId;
1522
+ }
1523
+ if (opts.dryRun) {
1524
+ console.error(pc.yellow(`[dry-run] Would update customer ${validId}:`));
1525
+ render(body, { format: "json" });
1526
+ return;
1527
+ }
1528
+ render(await getClient$5(opts).patch(`/v1/customers/${validId}`, body), getOutputOpts$5(opts));
1529
+ } catch (err) {
1530
+ handleError(err);
1531
+ }
1532
+ });
1533
+ }
1534
+ //#endregion
1535
+ //#region src/commands/subscriptions.ts
1536
+ function getClient$4(opts) {
1537
+ return new RecurClient({
1538
+ baseUrl: resolveBaseUrl(opts),
1539
+ secretKey: resolveSecretKey(opts)
1540
+ });
1541
+ }
1542
+ function getOutputOpts$4(opts) {
1543
+ return {
1544
+ format: opts.output,
1545
+ fields: opts.fields?.split(",")
1546
+ };
1547
+ }
1548
+ function registerSubscriptionsCommand(program) {
1549
+ const subscriptions = program.command("subscriptions").description("Manage subscriptions (recurring billing)");
1550
+ subscriptions.addHelpText("after", `
1551
+ Fields: id, status, product_id, product_slug, product_name, amount, interval, interval_count, current_period_start, current_period_end, canceled_at, started_at, customer
1552
+ Statuses: active, canceled, expired, past_due, trialing
1553
+ IDs are CUID format (e.g. v1bmt2pkfcwtxw0yxrjgzsly).
1554
+
1555
+ Examples:
1556
+ $ recur subscriptions list --output json
1557
+ $ recur subscriptions list --status active --limit 10
1558
+ $ recur subscriptions list --email user@example.com
1559
+ $ recur subscriptions list --customer-id <customer-id>
1560
+ $ recur subscriptions get <id> --output json
1561
+ $ recur subscriptions cancel <id> # Cancel at period end (safe default)
1562
+ $ recur subscriptions cancel <id> --immediately # Cancel now (irreversible)
1563
+ $ recur subscriptions cancel <id> --dry-run # Preview without canceling
1564
+
1565
+ Note: --immediately is a flag (no value). Do NOT use --immediately false; omit the flag instead.
1566
+ `);
1567
+ subscriptions.command("list").description("List subscriptions. Filter by status, customer ID, or email.").option("--status <status>", "Filter: active, canceled, expired, past_due, trialing").option("--customer-id <id>", "Filter by customer ID").option("--email <email>", "Filter by customer email").option("--limit <n>", "Max results per page (1-100, default: 10)", "10").option("--starting-after <id>", "Cursor: ID of last item from previous page").option("--page-all", "Auto-paginate through all pages (outputs NDJSON)").action(async (cmdOpts) => {
1568
+ try {
1569
+ const opts = subscriptions.optsWithGlobals();
1570
+ const client = getClient$4(opts);
1571
+ const params = {
1572
+ status: cmdOpts.status,
1573
+ email: cmdOpts.email,
1574
+ limit: cmdOpts.limit,
1575
+ startingAfter: cmdOpts.startingAfter
1576
+ };
1577
+ if (cmdOpts.customerId) params["customerId"] = validateResourceId(cmdOpts.customerId);
1578
+ if (cmdOpts.pageAll) {
1579
+ await paginateAll({
1580
+ client,
1581
+ path: "/v1/subscriptions",
1582
+ params,
1583
+ outputFormat: opts.output,
1584
+ fields: opts.fields?.split(",")
1585
+ });
1586
+ return;
1587
+ }
1588
+ const result = extractList(await client.get("/v1/subscriptions", params));
1589
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$4(opts));
1590
+ } catch (err) {
1591
+ handleError(err);
1592
+ }
1593
+ });
1594
+ subscriptions.command("get").description("Get subscription details including customer, product, and invoices").argument("<id>", "Subscription ID (CUID)").action(async (id) => {
1595
+ try {
1596
+ const opts = subscriptions.optsWithGlobals();
1597
+ const validId = validateResourceId(id);
1598
+ const data = await getClient$4(opts).get(`/v1/subscriptions/${validId}`);
1599
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts$4(opts));
1600
+ } catch (err) {
1601
+ handleError(err);
1602
+ }
1603
+ });
1604
+ subscriptions.command("cancel").description("Cancel a subscription. Defaults to cancel at period end (safe).").argument("<id>", "Subscription ID (CUID)").option("--immediately", "Cancel immediately instead of at period end (flag, no value — omit for period-end cancel)").action(async (id, cmdOpts) => {
1605
+ try {
1606
+ const opts = subscriptions.optsWithGlobals();
1607
+ const validId = validateResourceId(id);
1608
+ let body;
1609
+ if (opts.json) {
1610
+ if (cmdOpts.immediately) console.error(pc.yellow("Warning: --json provided, ignoring flag: --immediately"));
1611
+ body = parseJsonPayload(opts.json);
1612
+ } else body = { immediately: cmdOpts.immediately ?? false };
1613
+ if (opts.dryRun) {
1614
+ console.error(pc.yellow(`[dry-run] Would cancel subscription ${validId}:`));
1615
+ render(body, { format: "json" });
1616
+ return;
1617
+ }
1618
+ render(await getClient$4(opts).post(`/v1/subscriptions/${validId}/cancel`, body), getOutputOpts$4(opts));
1619
+ } catch (err) {
1620
+ handleError(err);
1621
+ }
1622
+ });
1623
+ }
1624
+ //#endregion
1625
+ //#region src/commands/listen.ts
1626
+ const DEFAULT_RELAY_URL = "https://recur-cli-relay.kaik.workers.dev";
1627
+ function resolveRelayUrl(opts) {
1628
+ if (opts.relayUrl) return validateUrl(opts.relayUrl, "relay URL");
1629
+ const envUrl = process.env["RECUR_RELAY_URL"];
1630
+ if (envUrl) return validateUrl(envUrl, "RECUR_RELAY_URL");
1631
+ return DEFAULT_RELAY_URL;
1632
+ }
1633
+ function formatTime() {
1634
+ const now = /* @__PURE__ */ new Date();
1635
+ return pc.dim(`${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`);
1636
+ }
1637
+ function formatStatus(status, duration) {
1638
+ const statusText = `${status} (${duration}ms)`;
1639
+ if (status >= 200 && status < 300) return pc.green(statusText);
1640
+ return pc.red(statusText);
1641
+ }
1642
+ /**
1643
+ * Parse SSE stream from a ReadableStream.
1644
+ * Handles line buffering across chunks.
1645
+ */
1646
+ async function parseSSEStream(body, handlers, signal) {
1647
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader();
1648
+ let buffer = "";
1649
+ let currentEvent = "";
1650
+ let currentData = "";
1651
+ let currentId;
1652
+ try {
1653
+ while (!signal.aborted) {
1654
+ const { done, value } = await reader.read();
1655
+ if (done) break;
1656
+ buffer += value;
1657
+ const lines = buffer.split("\n");
1658
+ buffer = lines.pop() || "";
1659
+ for (const line of lines) if (line === "") {
1660
+ if (currentData) handlers.onEvent({
1661
+ event: currentEvent || "message",
1662
+ data: currentData,
1663
+ id: currentId
1664
+ });
1665
+ currentEvent = "";
1666
+ currentData = "";
1667
+ currentId = void 0;
1668
+ } else if (line.startsWith("event:")) currentEvent = line.slice(6).trim();
1669
+ else if (line.startsWith("data:")) currentData = line.slice(5).trim();
1670
+ else if (line.startsWith("id:")) currentId = line.slice(3).trim();
1671
+ }
1672
+ } catch (err) {
1673
+ if (!signal.aborted) handlers.onError(err instanceof Error ? err : new Error(String(err)));
1674
+ } finally {
1675
+ reader.releaseLock();
1676
+ }
1677
+ }
1678
+ function registerListenSubcommand(webhooksCmd) {
1679
+ const listenCmd = webhooksCmd.command("listen").description("Forward webhook events from Recur to your local server in real-time (similar to stripe listen)").argument("<url>", "Local URL to forward events to (e.g. http://localhost:3000/api/webhooks)").option("--events <types>", "Comma-separated event types to filter (e.g. checkout.completed,order.paid)").option("--relay-url <url>", "Override relay server URL (for development)");
1680
+ listenCmd.addHelpText("after", `
1681
+ Connects to Recur's SSE relay and forwards webhook events to your local server.
1682
+ Each session gets a unique signing secret (whsec_*) for verifying event signatures.
1683
+
1684
+ Forwarded requests include these headers:
1685
+ X-Recur-Signature HMAC-SHA256 signature (base64)
1686
+ X-Recur-Event-Type Event type (e.g. checkout.completed)
1687
+ X-Recur-Event-Id Unique event identifier
1688
+ X-Recur-Timestamp ISO 8601 timestamp
1689
+
1690
+ Auto-reconnects on disconnect (max 10 consecutive failures). Ctrl+C to stop.
1691
+
1692
+ Examples:
1693
+ $ recur webhooks listen http://localhost:3000/api/webhooks
1694
+ $ recur webhooks listen http://localhost:3000/api/webhooks --events checkout.completed,order.paid
1695
+ $ RECUR_RELAY_URL=http://localhost:8787 recur webhooks listen http://localhost:3000/hook
1696
+ `);
1697
+ listenCmd.action(async (forwardUrl, cmdOpts) => {
1698
+ try {
1699
+ validateUrl(forwardUrl, "forward URL");
1700
+ const secretKey = resolveSecretKey(webhooksCmd.optsWithGlobals());
1701
+ const relayUrl = resolveRelayUrl(cmdOpts);
1702
+ const eventFilter = cmdOpts.events ? new Set(cmdOpts.events.split(",").map((e) => e.trim())) : null;
1703
+ const ac = new AbortController();
1704
+ let consecutiveFailures = 0;
1705
+ const maxFailures = 10;
1706
+ const shutdown = () => {
1707
+ console.error(pc.dim("\nShutting down..."));
1708
+ ac.abort();
1709
+ process.exit(0);
1710
+ };
1711
+ process.on("SIGINT", shutdown);
1712
+ process.on("SIGTERM", shutdown);
1713
+ async function connect() {
1714
+ while (!ac.signal.aborted && consecutiveFailures < maxFailures) try {
1715
+ console.error(pc.dim("Connecting to relay..."));
1716
+ const res = await fetch(`${relayUrl}/listen`, {
1717
+ headers: {
1718
+ Authorization: `Bearer ${secretKey}`,
1719
+ Accept: "text/event-stream"
1720
+ },
1721
+ signal: ac.signal
1722
+ });
1723
+ if (!res.ok) {
1724
+ const text = await res.text();
1725
+ throw new Error(`Relay returned ${res.status}: ${text.slice(0, 200)}`);
1726
+ }
1727
+ if (!res.body) throw new Error("No response body from relay");
1728
+ const contentType = res.headers.get("content-type") ?? "";
1729
+ if (!contentType.includes("text/event-stream")) throw new Error(`Expected text/event-stream from relay, got: ${contentType}`);
1730
+ consecutiveFailures = 0;
1731
+ await parseSSEStream(res.body, {
1732
+ onEvent: (sseEvent) => {
1733
+ try {
1734
+ if (sseEvent.event === "connected") {
1735
+ const data = JSON.parse(sseEvent.data);
1736
+ console.error("");
1737
+ console.error(pc.bold(" Ready! Listening for webhook events..."));
1738
+ console.error("");
1739
+ console.error(` Signing secret: ${pc.cyan(data.secret)}`);
1740
+ console.error(` Forwarding to: ${pc.cyan(forwardUrl)}`);
1741
+ console.error(` Environment: ${pc.cyan(data.environment.toLowerCase())}`);
1742
+ if (eventFilter) console.error(` Filtering: ${pc.cyan(Array.from(eventFilter).join(", "))}`);
1743
+ console.error("");
1744
+ } else if (sseEvent.event === "webhook") {
1745
+ const data = JSON.parse(sseEvent.data);
1746
+ if (eventFilter && !eventFilter.has(data.type)) return;
1747
+ const start = Date.now();
1748
+ const fwdAc = new AbortController();
1749
+ const fwdTimeout = setTimeout(() => fwdAc.abort(), 3e4);
1750
+ fetch(forwardUrl, {
1751
+ method: "POST",
1752
+ headers: {
1753
+ "Content-Type": "application/json",
1754
+ "X-Recur-Signature": data.signature,
1755
+ "X-Recur-Event-Type": data.type,
1756
+ "X-Recur-Event-Id": data.id,
1757
+ "X-Recur-Timestamp": data.timestamp
1758
+ },
1759
+ body: JSON.stringify(data.data),
1760
+ signal: fwdAc.signal
1761
+ }).then((fwdRes) => {
1762
+ clearTimeout(fwdTimeout);
1763
+ const duration = Date.now() - start;
1764
+ console.error(`${formatTime()} ${pc.dim("->")} ${data.type} ${pc.dim(`[${data.id}]`)} ${formatStatus(fwdRes.status, duration)}`);
1765
+ if (fwdRes.status >= 400) fwdRes.text().then((body) => {
1766
+ if (body) console.error(pc.dim(` ${body.slice(0, 200)}`));
1767
+ });
1768
+ }).catch((err) => {
1769
+ clearTimeout(fwdTimeout);
1770
+ const duration = Date.now() - start;
1771
+ const msg = err.name === "AbortError" ? "Timed out after 30s" : err.message;
1772
+ console.error(`${formatTime()} ${pc.dim("->")} ${data.type} ${pc.dim(`[${data.id}]`)} ${pc.red(`FAILED (${duration}ms)`)}`);
1773
+ console.error(pc.dim(` ${msg}`));
1774
+ });
1775
+ }
1776
+ } catch (parseErr) {
1777
+ console.error(pc.yellow(`Failed to parse SSE event: ${parseErr.message}`));
1778
+ }
1779
+ },
1780
+ onError: (err) => {
1781
+ if (!ac.signal.aborted) console.error(pc.yellow(`Stream error: ${err.message}`));
1782
+ }
1783
+ }, ac.signal);
1784
+ if (!ac.signal.aborted) {
1785
+ console.error(pc.yellow("Connection lost. Reconnecting in 3s..."));
1786
+ consecutiveFailures++;
1787
+ await new Promise((r) => setTimeout(r, 3e3));
1788
+ }
1789
+ } catch (err) {
1790
+ if (ac.signal.aborted) return;
1791
+ consecutiveFailures++;
1792
+ const msg = err instanceof Error ? err.message : String(err);
1793
+ console.error(pc.red(`Connection error: ${msg}`));
1794
+ if (consecutiveFailures < maxFailures) {
1795
+ console.error(pc.dim(`Retrying in 3s... (${consecutiveFailures}/${maxFailures})`));
1796
+ await new Promise((r) => setTimeout(r, 3e3));
1797
+ }
1798
+ }
1799
+ if (consecutiveFailures >= maxFailures) {
1800
+ console.error(pc.red(`\nGave up after ${maxFailures} consecutive failures.`));
1801
+ process.exit(1);
1802
+ }
1803
+ }
1804
+ await connect();
1805
+ } catch (err) {
1806
+ handleError(err);
1807
+ }
1808
+ });
1809
+ }
1810
+ //#endregion
1811
+ //#region src/commands/webhooks.ts
1812
+ function getClient$3(opts) {
1813
+ return new RecurClient({
1814
+ baseUrl: resolveBaseUrl(opts),
1815
+ secretKey: resolveSecretKey(opts)
1816
+ });
1817
+ }
1818
+ function getOutputOpts$3(opts) {
1819
+ return {
1820
+ format: opts.output,
1821
+ fields: opts.fields?.split(",")
1822
+ };
1823
+ }
1824
+ function registerWebhooksCommand(program) {
1825
+ const webhooks = program.command("webhooks").description("Manage webhook endpoints for receiving payment event notifications");
1826
+ webhooks.addHelpText("after", `
1827
+ Fields: id, url, events, is_active, secret, created_at
1828
+ IDs are CUID format. Default events: checkout.completed, subscription.activated,
1829
+ subscription.canceled, subscription.expired, invoice.paid, invoice.payment_failed, order.paid
1830
+
1831
+ Examples:
1832
+ $ recur webhooks list --output json
1833
+ $ recur webhooks create --url https://example.com/webhooks
1834
+ $ recur webhooks create --json '{"url":"https://...","events":["checkout.completed"]}'
1835
+ $ recur webhooks test <id> # Send test checkout.completed
1836
+ $ recur webhooks test <id> --event subscription.canceled
1837
+ $ recur webhooks delete <id>
1838
+ $ recur webhooks listen http://localhost:3000/api/webhooks
1839
+ $ recur webhooks listen http://localhost:3000/api/webhooks --events checkout.completed,order.paid
1840
+ `);
1841
+ registerListenSubcommand(webhooks);
1842
+ webhooks.command("list").description("List all configured webhook endpoints").option("--limit <n>", "Max results per page (default: 20)", "20").action(async (cmdOpts) => {
1843
+ try {
1844
+ const opts = webhooks.optsWithGlobals();
1845
+ const result = extractList(await getClient$3(opts).get("/v1/webhooks", { limit: cmdOpts.limit }));
1846
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$3(opts));
1847
+ } catch (err) {
1848
+ handleError(err);
1849
+ }
1850
+ });
1851
+ webhooks.command("create").description("Create a webhook endpoint. Defaults to 7 essential event types.").option("--url <url>", "Webhook URL (required)").option("--events <events>", "Comma-separated event types (optional, defaults to essentials)").action(async (cmdOpts) => {
1852
+ try {
1853
+ const opts = webhooks.optsWithGlobals();
1854
+ let body;
1855
+ if (opts.json) {
1856
+ const flagsUsed = ["url", "events"].filter((f) => cmdOpts[f]);
1857
+ if (flagsUsed.length > 0) console.error(pc.yellow(`Warning: --json provided, ignoring flags: --${flagsUsed.join(", --")}`));
1858
+ body = parseJsonPayload(opts.json);
1859
+ } else {
1860
+ body = {};
1861
+ if (cmdOpts.url) body["url"] = cmdOpts.url;
1862
+ if (cmdOpts.events) body["events"] = cmdOpts.events.split(",").map((e) => e.trim());
1863
+ }
1864
+ if (opts.dryRun) {
1865
+ const errors = validateRequiredFields(body, "webhooks.create");
1866
+ if (errors.length > 0) {
1867
+ console.error(pc.red(`Validation errors:\n ${errors.join("\n ")}`));
1868
+ process.exit(1);
1869
+ }
1870
+ console.error(pc.yellow("[dry-run] Would create webhook:"));
1871
+ render(body, { format: "json" });
1872
+ return;
1873
+ }
1874
+ render(await getClient$3(opts).post("/v1/webhooks", body), getOutputOpts$3(opts));
1875
+ } catch (err) {
1876
+ handleError(err);
1877
+ }
1878
+ });
1879
+ webhooks.command("test").description("Send a test event to a webhook endpoint to verify it works").argument("<id>", "Webhook ID (CUID)").option("--event <type>", "Event type to send (default: checkout.completed)", "checkout.completed").action(async (id, cmdOpts) => {
1880
+ try {
1881
+ const opts = webhooks.optsWithGlobals();
1882
+ const validId = validateResourceId(id);
1883
+ if (opts.dryRun) {
1884
+ console.error(pc.yellow(`[dry-run] Would send test event "${cmdOpts.event}" to webhook ${validId}`));
1885
+ return;
1886
+ }
1887
+ render(await getClient$3(opts).post(`/v1/webhooks/${validId}/test`, { eventType: cmdOpts.event }), getOutputOpts$3(opts));
1888
+ } catch (err) {
1889
+ handleError(err);
1890
+ }
1891
+ });
1892
+ webhooks.command("delete").description("Delete a webhook endpoint permanently").argument("<id>", "Webhook ID (CUID)").action(async (id) => {
1893
+ try {
1894
+ const opts = webhooks.optsWithGlobals();
1895
+ const validId = validateResourceId(id);
1896
+ if (opts.dryRun) {
1897
+ console.error(pc.yellow(`[dry-run] Would delete webhook ${validId}`));
1898
+ return;
1899
+ }
1900
+ await getClient$3(opts).delete(`/v1/webhooks/${validId}`);
1901
+ console.error(pc.green(`Deleted webhook ${validId}`));
1902
+ } catch (err) {
1903
+ handleError(err);
1904
+ }
1905
+ });
1906
+ }
1907
+ //#endregion
1908
+ //#region src/commands/orders.ts
1909
+ function getClient$2(opts) {
1910
+ return new RecurClient({
1911
+ baseUrl: resolveBaseUrl(opts),
1912
+ secretKey: resolveSecretKey(opts)
1913
+ });
1914
+ }
1915
+ function getOutputOpts$2(opts) {
1916
+ return {
1917
+ format: opts.output,
1918
+ fields: opts.fields?.split(",")
1919
+ };
1920
+ }
1921
+ function registerOrdersCommand(program) {
1922
+ const orders = program.command("orders").description("View orders (payment records for products)");
1923
+ orders.addHelpText("after", `
1924
+ Fields: id, status, total, subtotal, currency, customer_id, items_count, created_at
1925
+ Statuses: pending, paid, failed, refunded
1926
+ IDs are CUID format.
1927
+
1928
+ Examples:
1929
+ $ recur orders list --output json
1930
+ $ recur orders list --status paid
1931
+ $ recur orders list --customer-id <customer-id>
1932
+ $ recur orders list --limit 50
1933
+ $ recur orders get <id> --output json
1934
+ `);
1935
+ orders.command("list").description("List orders with optional status and customer filters").option("--status <status>", "Filter: pending, paid, failed, refunded").option("--customer-id <id>", "Filter by customer ID").option("--limit <n>", "Max results per page (1-100, default: 10)", "10").option("--starting-after <id>", "Cursor: ID of last item from previous page").option("--page-all", "Auto-paginate through all pages (outputs NDJSON)").action(async (cmdOpts) => {
1936
+ try {
1937
+ const opts = orders.optsWithGlobals();
1938
+ const client = getClient$2(opts);
1939
+ const params = {
1940
+ status: cmdOpts.status,
1941
+ limit: cmdOpts.limit,
1942
+ startingAfter: cmdOpts.startingAfter
1943
+ };
1944
+ if (cmdOpts.customerId) params["customerId"] = validateResourceId(cmdOpts.customerId);
1945
+ if (cmdOpts.pageAll) {
1946
+ await paginateAll({
1947
+ client,
1948
+ path: "/v1/orders",
1949
+ params,
1950
+ outputFormat: opts.output,
1951
+ fields: opts.fields?.split(",")
1952
+ });
1953
+ return;
1954
+ }
1955
+ const result = extractList(await client.get("/v1/orders", params));
1956
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$2(opts));
1957
+ } catch (err) {
1958
+ handleError(err);
1959
+ }
1960
+ });
1961
+ orders.command("get").description("Get order details including items and charge history").argument("<id>", "Order ID (CUID)").action(async (id) => {
1962
+ try {
1963
+ const opts = orders.optsWithGlobals();
1964
+ const validId = validateResourceId(id);
1965
+ const data = await getClient$2(opts).get(`/v1/orders/${validId}`);
1966
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts$2(opts));
1967
+ } catch (err) {
1968
+ handleError(err);
1969
+ }
1970
+ });
1971
+ }
1972
+ //#endregion
1973
+ //#region src/commands/invoices.ts
1974
+ function getClient$1(opts) {
1975
+ return new RecurClient({
1976
+ baseUrl: resolveBaseUrl(opts),
1977
+ secretKey: resolveSecretKey(opts)
1978
+ });
1979
+ }
1980
+ function getOutputOpts$1(opts) {
1981
+ return {
1982
+ format: opts.output,
1983
+ fields: opts.fields?.split(",")
1984
+ };
1985
+ }
1986
+ function registerInvoicesCommand(program) {
1987
+ const invoices = program.command("invoices").description("View invoices (billing records for subscriptions)");
1988
+ invoices.addHelpText("after", `
1989
+ Fields: id, status, amount, currency, subscription_id, subscription, customer_id, period_start, period_end, paid_at, created_at
1990
+ Statuses: draft, open, paid, void, uncollectible
1991
+ IDs are CUID format.
1992
+
1993
+ Examples:
1994
+ $ recur invoices list --output json
1995
+ $ recur invoices list --subscription-id <subscription-id>
1996
+ $ recur invoices list --customer-id <customer-id>
1997
+ $ recur invoices list --status paid
1998
+ $ recur invoices get <id> --output json
1999
+ `);
2000
+ invoices.command("list").description("List invoices. Filter by subscription, customer, or status.").option("--subscription-id <id>", "Filter by subscription ID").option("--customer-id <id>", "Filter by customer ID").option("--status <status>", "Filter: draft, open, paid, void, uncollectible").option("--limit <n>", "Max results per page (1-100, default: 10)", "10").option("--starting-after <id>", "Cursor: ID of last item from previous page").option("--page-all", "Auto-paginate through all pages (outputs NDJSON)").action(async (cmdOpts) => {
2001
+ try {
2002
+ const opts = invoices.optsWithGlobals();
2003
+ const client = getClient$1(opts);
2004
+ const params = {
2005
+ status: cmdOpts.status,
2006
+ limit: cmdOpts.limit,
2007
+ startingAfter: cmdOpts.startingAfter
2008
+ };
2009
+ if (cmdOpts.subscriptionId) params["subscriptionId"] = validateResourceId(cmdOpts.subscriptionId);
2010
+ if (cmdOpts.customerId) params["customerId"] = validateResourceId(cmdOpts.customerId);
2011
+ if (cmdOpts.pageAll) {
2012
+ await paginateAll({
2013
+ client,
2014
+ path: "/v1/invoices",
2015
+ params,
2016
+ outputFormat: opts.output,
2017
+ fields: opts.fields?.split(",")
2018
+ });
2019
+ return;
2020
+ }
2021
+ const result = extractList(await client.get("/v1/invoices", params));
2022
+ render(opts.fields ? pickFields(result, opts.fields.split(",")) : result, getOutputOpts$1(opts));
2023
+ } catch (err) {
2024
+ handleError(err);
2025
+ }
2026
+ });
2027
+ invoices.command("get").description("Get invoice details including line items and payment info").argument("<id>", "Invoice ID (CUID)").action(async (id) => {
2028
+ try {
2029
+ const opts = invoices.optsWithGlobals();
2030
+ const validId = validateResourceId(id);
2031
+ const data = await getClient$1(opts).get(`/v1/invoices/${validId}`);
2032
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts$1(opts));
2033
+ } catch (err) {
2034
+ handleError(err);
2035
+ }
2036
+ });
2037
+ }
2038
+ //#endregion
2039
+ //#region src/commands/checkouts.ts
2040
+ function getClient(opts) {
2041
+ return new RecurClient({
2042
+ baseUrl: resolveBaseUrl(opts),
2043
+ secretKey: resolveSecretKey(opts)
2044
+ });
2045
+ }
2046
+ function getOutputOpts(opts) {
2047
+ return {
2048
+ format: opts.output,
2049
+ fields: opts.fields?.split(",")
2050
+ };
2051
+ }
2052
+ function registerCheckoutsCommand(program) {
2053
+ const checkouts = program.command("checkouts").description("Create and inspect checkout sessions (payment pages)");
2054
+ checkouts.addHelpText("after", `
2055
+ Fields: id, url, status, customer_id, product_id, expires_at, created_at
2056
+ IDs are CUID format. The returned "url" is the hosted checkout page URL.
2057
+
2058
+ Examples:
2059
+ $ recur checkouts create --product-id <product-id> --customer-email user@example.com
2060
+ $ recur checkouts create --json '{"productId":"<product-id>","successUrl":"https://..."}'
2061
+ $ recur checkouts create --dry-run --product-id <product-id>
2062
+ $ recur checkouts get <id> --output json
2063
+ `);
2064
+ checkouts.command("create").description("Create a checkout session. Returns a URL for the hosted payment page.").option("--product-id <id>", "Product ID (CUID, required)").option("--customer-email <email>", "Pre-fill customer email").option("--success-url <url>", "Redirect URL after successful payment").option("--cancel-url <url>", "Redirect URL if customer cancels").action(async (cmdOpts) => {
2065
+ try {
2066
+ const opts = checkouts.optsWithGlobals();
2067
+ let body;
2068
+ if (opts.json) {
2069
+ const flagsUsed = [
2070
+ "productId",
2071
+ "customerEmail",
2072
+ "successUrl",
2073
+ "cancelUrl"
2074
+ ].filter((f) => cmdOpts[f]);
2075
+ if (flagsUsed.length > 0) console.error(pc.yellow(`Warning: --json provided, ignoring flags: --${flagsUsed.map((f) => f.replace(/([A-Z])/g, "-$1").toLowerCase()).join(", --")}`));
2076
+ body = parseJsonPayload(opts.json);
2077
+ } else {
2078
+ body = {};
2079
+ if (cmdOpts.productId) body["productId"] = validateResourceId(cmdOpts.productId);
2080
+ if (cmdOpts.customerEmail) body["customerEmail"] = cmdOpts.customerEmail;
2081
+ if (cmdOpts.successUrl) body["successUrl"] = cmdOpts.successUrl;
2082
+ if (cmdOpts.cancelUrl) body["cancelUrl"] = cmdOpts.cancelUrl;
2083
+ }
2084
+ if (opts.dryRun) {
2085
+ const errors = validateRequiredFields(body, "checkouts.create");
2086
+ if (errors.length > 0) {
2087
+ console.error(pc.red(`Validation errors:\n ${errors.join("\n ")}`));
2088
+ process.exit(1);
2089
+ }
2090
+ console.error(pc.yellow("[dry-run] Would create checkout session:"));
2091
+ render(body, { format: "json" });
2092
+ return;
2093
+ }
2094
+ render(await getClient(opts).post("/v1/checkouts", body), getOutputOpts(opts));
2095
+ } catch (err) {
2096
+ handleError(err);
2097
+ }
2098
+ });
2099
+ checkouts.command("get").description("Get checkout session status (pending, completed, expired)").argument("<id>", "Checkout session ID (CUID)").action(async (id) => {
2100
+ try {
2101
+ const opts = checkouts.optsWithGlobals();
2102
+ const validId = validateResourceId(id);
2103
+ const data = await getClient(opts).get(`/v1/checkouts/${validId}`);
2104
+ render(opts.fields ? pickFields(data, opts.fields.split(",")) : data, getOutputOpts(opts));
2105
+ } catch (err) {
2106
+ handleError(err);
2107
+ }
2108
+ });
2109
+ }
2110
+ //#endregion
2111
+ //#region src/commands/schema.ts
2112
+ function registerSchemaCommand(program) {
2113
+ const schema = program.command("schema").description("Dump machine-readable API schema (for AI agents and automation)").argument("[resource.action]", "Resource name or resource.action (e.g. products, products.create)");
2114
+ schema.addHelpText("after", `
2115
+ Returns JSON describing available API resources, actions, parameters, and response fields.
2116
+ Agents can use this to discover the API without reading documentation.
2117
+ No authentication required.
2118
+
2119
+ Examples:
2120
+ $ recur schema # List all resources and their actions
2121
+ $ recur schema products # Show all actions for products
2122
+ $ recur schema products.create # Show params, body schema, response fields for create
2123
+
2124
+ Output includes: method, path, params, bodySchema, responseFields, supportsDryRun
2125
+ `);
2126
+ schema.action((resourceAction) => {
2127
+ try {
2128
+ const opts = program.opts();
2129
+ const result = dumpSchema(resourceAction);
2130
+ const fields = opts.fields?.split(",");
2131
+ render(fields ? pickFields(result, fields) : result, { format: opts.output });
2132
+ } catch (err) {
2133
+ handleError(err);
2134
+ }
2135
+ });
2136
+ }
2137
+ //#endregion
2138
+ //#region src/commands/mcp.ts
2139
+ const READ_ONLY = {
2140
+ readOnlyHint: true,
2141
+ destructiveHint: false,
2142
+ idempotentHint: true
2143
+ };
2144
+ const WRITE = {
2145
+ readOnlyHint: false,
2146
+ destructiveHint: false,
2147
+ idempotentHint: false
2148
+ };
2149
+ const DESTRUCTIVE = {
2150
+ readOnlyHint: false,
2151
+ destructiveHint: true,
2152
+ idempotentHint: false
2153
+ };
2154
+ function text(data) {
2155
+ return { content: [{
2156
+ type: "text",
2157
+ text: JSON.stringify(data, null, 2)
2158
+ }] };
2159
+ }
2160
+ function toolError(message) {
2161
+ return {
2162
+ content: [{
2163
+ type: "text",
2164
+ text: message
2165
+ }],
2166
+ isError: true
2167
+ };
2168
+ }
2169
+ function registerTools(server, client) {
2170
+ server.registerTool("get_schema", {
2171
+ title: "Get API Schema",
2172
+ description: "Discover available API resources, actions, parameters, and response fields. Call without arguments to list all resources, or specify resource.action for details.",
2173
+ inputSchema: { resource_action: z.string().optional().describe("Resource name or resource.action (e.g. \"products\", \"products.create\"). Omit to list all.") },
2174
+ annotations: READ_ONLY
2175
+ }, async (args) => {
2176
+ try {
2177
+ return text(dumpSchema(args.resource_action));
2178
+ } catch (err) {
2179
+ return toolError(err.message);
2180
+ }
2181
+ });
2182
+ server.registerTool("list_products", {
2183
+ title: "List Products",
2184
+ description: "List all products. Supports filtering by type and active status.",
2185
+ inputSchema: {
2186
+ type: z.enum([
2187
+ "SUBSCRIPTION",
2188
+ "ONE_TIME",
2189
+ "CREDITS",
2190
+ "DONATION"
2191
+ ]).optional().describe("Filter by product type"),
2192
+ active: z.enum(["true", "false"]).optional().describe("Filter by active status"),
2193
+ limit: z.string().optional().describe("Max results to return")
2194
+ },
2195
+ annotations: READ_ONLY
2196
+ }, async (args) => {
2197
+ return text(await client.get("/v1/products", {
2198
+ type: args.type,
2199
+ active: args.active,
2200
+ limit: args.limit
2201
+ }));
2202
+ });
2203
+ server.registerTool("get_product", {
2204
+ title: "Get Product",
2205
+ description: "Get a product by ID or slug.",
2206
+ inputSchema: { id: z.string().describe("Product ID (CUID) or slug") },
2207
+ annotations: READ_ONLY
2208
+ }, async (args) => {
2209
+ const path = args.id.includes("-") ? `/v1/products/slug/${args.id}` : `/v1/products/${args.id}`;
2210
+ return text(await client.get(path));
2211
+ });
2212
+ server.registerTool("create_product", {
2213
+ title: "Create Product",
2214
+ description: "Create a new product. Prices are TWD integers (299 = NT$299).",
2215
+ inputSchema: {
2216
+ name: z.string().describe("Product name"),
2217
+ price: z.number().describe("Price in TWD (integer)"),
2218
+ interval: z.enum(["monthly", "yearly"]).optional().describe("Billing interval"),
2219
+ type: z.enum([
2220
+ "SUBSCRIPTION",
2221
+ "ONE_TIME",
2222
+ "CREDITS",
2223
+ "DONATION"
2224
+ ]).optional().describe("Product type"),
2225
+ description: z.string().optional().describe("Product description")
2226
+ },
2227
+ annotations: WRITE
2228
+ }, async (args) => {
2229
+ return text(await client.post("/v1/products", args));
2230
+ });
2231
+ server.registerTool("update_product", {
2232
+ title: "Update Product",
2233
+ description: "Update an existing product. Only provided fields are changed.",
2234
+ inputSchema: {
2235
+ id: z.string().describe("Product ID (CUID)"),
2236
+ name: z.string().optional().describe("New product name"),
2237
+ price: z.number().optional().describe("New price in TWD (integer)"),
2238
+ description: z.string().optional().describe("New product description")
2239
+ },
2240
+ annotations: WRITE
2241
+ }, async (args) => {
2242
+ const { id, ...body } = args;
2243
+ return text(await client.patch(`/v1/products/${id}`, body));
2244
+ });
2245
+ server.registerTool("archive_product", {
2246
+ title: "Archive Product",
2247
+ description: "Archive a product (soft-delete). This is destructive.",
2248
+ inputSchema: { id: z.string().describe("Product ID (CUID)") },
2249
+ annotations: DESTRUCTIVE
2250
+ }, async (args) => {
2251
+ return text(await client.post(`/v1/products/${args.id}/archive`));
2252
+ });
2253
+ server.registerTool("list_customers", {
2254
+ title: "List Customers",
2255
+ description: "List customers with optional filters.",
2256
+ inputSchema: {
2257
+ email: z.string().optional().describe("Filter by email"),
2258
+ limit: z.string().optional().describe("Max results"),
2259
+ starting_after: z.string().optional().describe("Cursor for pagination")
2260
+ },
2261
+ annotations: READ_ONLY
2262
+ }, async (args) => {
2263
+ return text(await client.get("/v1/customers", {
2264
+ email: args.email,
2265
+ limit: args.limit,
2266
+ startingAfter: args.starting_after
2267
+ }));
2268
+ });
2269
+ server.registerTool("get_customer", {
2270
+ title: "Get Customer",
2271
+ description: "Get a customer by ID.",
2272
+ inputSchema: { id: z.string().describe("Customer ID (CUID)") },
2273
+ annotations: READ_ONLY
2274
+ }, async (args) => {
2275
+ return text(await client.get(`/v1/customers/${args.id}`));
2276
+ });
2277
+ server.registerTool("update_customer", {
2278
+ title: "Update Customer",
2279
+ description: "Update customer details.",
2280
+ inputSchema: {
2281
+ id: z.string().describe("Customer ID (CUID)"),
2282
+ name: z.string().optional().describe("Customer name"),
2283
+ email: z.string().optional().describe("Customer email")
2284
+ },
2285
+ annotations: WRITE
2286
+ }, async (args) => {
2287
+ const { id, ...body } = args;
2288
+ return text(await client.patch(`/v1/customers/${id}`, body));
2289
+ });
2290
+ server.registerTool("list_subscriptions", {
2291
+ title: "List Subscriptions",
2292
+ description: "List subscriptions with optional filters.",
2293
+ inputSchema: {
2294
+ status: z.string().optional().describe("Filter by status (active, canceled, past_due, trialing)"),
2295
+ customer_id: z.string().optional().describe("Filter by customer ID"),
2296
+ product_id: z.string().optional().describe("Filter by product ID"),
2297
+ limit: z.string().optional().describe("Max results"),
2298
+ starting_after: z.string().optional().describe("Cursor for pagination")
2299
+ },
2300
+ annotations: READ_ONLY
2301
+ }, async (args) => {
2302
+ return text(await client.get("/v1/subscriptions", {
2303
+ status: args.status,
2304
+ customerId: args.customer_id,
2305
+ productId: args.product_id,
2306
+ limit: args.limit,
2307
+ startingAfter: args.starting_after
2308
+ }));
2309
+ });
2310
+ server.registerTool("get_subscription", {
2311
+ title: "Get Subscription",
2312
+ description: "Get a subscription by ID.",
2313
+ inputSchema: { id: z.string().describe("Subscription ID (CUID)") },
2314
+ annotations: READ_ONLY
2315
+ }, async (args) => {
2316
+ return text(await client.get(`/v1/subscriptions/${args.id}`));
2317
+ });
2318
+ server.registerTool("cancel_subscription", {
2319
+ title: "Cancel Subscription",
2320
+ description: "Cancel a subscription. By default cancels at period end. Use immediately=true to cancel now.",
2321
+ inputSchema: {
2322
+ id: z.string().describe("Subscription ID (CUID)"),
2323
+ immediately: z.boolean().optional().describe("Cancel immediately instead of at period end (default: false)"),
2324
+ reason: z.string().optional().describe("Cancellation reason")
2325
+ },
2326
+ annotations: DESTRUCTIVE
2327
+ }, async (args) => {
2328
+ const { id, ...body } = args;
2329
+ return text(await client.post(`/v1/subscriptions/${id}/cancel`, body));
2330
+ });
2331
+ server.registerTool("list_orders", {
2332
+ title: "List Orders",
2333
+ description: "List orders with optional filters.",
2334
+ inputSchema: {
2335
+ status: z.string().optional().describe("Filter by status"),
2336
+ customer_id: z.string().optional().describe("Filter by customer ID"),
2337
+ limit: z.string().optional().describe("Max results"),
2338
+ starting_after: z.string().optional().describe("Cursor for pagination")
2339
+ },
2340
+ annotations: READ_ONLY
2341
+ }, async (args) => {
2342
+ return text(await client.get("/v1/orders", {
2343
+ status: args.status,
2344
+ customerId: args.customer_id,
2345
+ limit: args.limit,
2346
+ startingAfter: args.starting_after
2347
+ }));
2348
+ });
2349
+ server.registerTool("get_order", {
2350
+ title: "Get Order",
2351
+ description: "Get an order by ID.",
2352
+ inputSchema: { id: z.string().describe("Order ID (CUID)") },
2353
+ annotations: READ_ONLY
2354
+ }, async (args) => {
2355
+ return text(await client.get(`/v1/orders/${args.id}`));
2356
+ });
2357
+ server.registerTool("list_invoices", {
2358
+ title: "List Invoices",
2359
+ description: "List invoices with optional filters.",
2360
+ inputSchema: {
2361
+ status: z.string().optional().describe("Filter by status"),
2362
+ customer_id: z.string().optional().describe("Filter by customer ID"),
2363
+ subscription_id: z.string().optional().describe("Filter by subscription ID"),
2364
+ limit: z.string().optional().describe("Max results"),
2365
+ starting_after: z.string().optional().describe("Cursor for pagination")
2366
+ },
2367
+ annotations: READ_ONLY
2368
+ }, async (args) => {
2369
+ return text(await client.get("/v1/invoices", {
2370
+ status: args.status,
2371
+ customerId: args.customer_id,
2372
+ subscriptionId: args.subscription_id,
2373
+ limit: args.limit,
2374
+ startingAfter: args.starting_after
2375
+ }));
2376
+ });
2377
+ server.registerTool("get_invoice", {
2378
+ title: "Get Invoice",
2379
+ description: "Get an invoice by ID.",
2380
+ inputSchema: { id: z.string().describe("Invoice ID (CUID)") },
2381
+ annotations: READ_ONLY
2382
+ }, async (args) => {
2383
+ return text(await client.get(`/v1/invoices/${args.id}`));
2384
+ });
2385
+ server.registerTool("list_webhooks", {
2386
+ title: "List Webhooks",
2387
+ description: "List all webhook endpoints.",
2388
+ inputSchema: { limit: z.string().optional().describe("Max results") },
2389
+ annotations: READ_ONLY
2390
+ }, async (args) => {
2391
+ return text(await client.get("/v1/webhooks", { limit: args.limit }));
2392
+ });
2393
+ server.registerTool("create_webhook", {
2394
+ title: "Create Webhook",
2395
+ description: "Create a new webhook endpoint.",
2396
+ inputSchema: {
2397
+ url: z.string().describe("Webhook endpoint URL (must be HTTPS)"),
2398
+ events: z.array(z.string()).optional().describe("Event types to subscribe to")
2399
+ },
2400
+ annotations: WRITE
2401
+ }, async (args) => {
2402
+ return text(await client.post("/v1/webhooks", args));
2403
+ });
2404
+ server.registerTool("test_webhook", {
2405
+ title: "Test Webhook",
2406
+ description: "Send a test event to a webhook endpoint.",
2407
+ inputSchema: {
2408
+ id: z.string().describe("Webhook ID (CUID)"),
2409
+ event_type: z.string().optional().describe("Event type to test (e.g. checkout.completed)")
2410
+ },
2411
+ annotations: WRITE
2412
+ }, async (args) => {
2413
+ return text(await client.post(`/v1/webhooks/${args.id}/test`, { eventType: args.event_type }));
2414
+ });
2415
+ server.registerTool("delete_webhook", {
2416
+ title: "Delete Webhook",
2417
+ description: "Delete a webhook endpoint.",
2418
+ inputSchema: { id: z.string().describe("Webhook ID (CUID)") },
2419
+ annotations: DESTRUCTIVE
2420
+ }, async (args) => {
2421
+ await client.delete(`/v1/webhooks/${args.id}`);
2422
+ return text({
2423
+ success: true,
2424
+ message: `Webhook ${args.id} deleted`
2425
+ });
2426
+ });
2427
+ server.registerTool("create_checkout", {
2428
+ title: "Create Checkout Session",
2429
+ description: "Create a checkout session for a product.",
2430
+ inputSchema: {
2431
+ product_id: z.string().describe("Product ID (CUID)"),
2432
+ customer_email: z.string().optional().describe("Customer email for pre-fill"),
2433
+ success_url: z.string().optional().describe("Redirect URL after successful payment"),
2434
+ cancel_url: z.string().optional().describe("Redirect URL if customer cancels")
2435
+ },
2436
+ annotations: WRITE
2437
+ }, async (args) => {
2438
+ return text(await client.post("/v1/checkouts", {
2439
+ productId: args.product_id,
2440
+ customerEmail: args.customer_email,
2441
+ successUrl: args.success_url,
2442
+ cancelUrl: args.cancel_url
2443
+ }));
2444
+ });
2445
+ server.registerTool("get_checkout", {
2446
+ title: "Get Checkout Session",
2447
+ description: "Get a checkout session by ID.",
2448
+ inputSchema: { id: z.string().describe("Checkout session ID") },
2449
+ annotations: READ_ONLY
2450
+ }, async (args) => {
2451
+ return text(await client.get(`/v1/checkouts/${args.id}`));
2452
+ });
2453
+ }
2454
+ function registerMcpCommand(program) {
2455
+ program.command("mcp").description("Start a local MCP server over stdio (for AI agents like Claude, Cursor, etc.)").addHelpText("after", `
2456
+ Starts a Model Context Protocol server that exposes Recur API operations as tools.
2457
+ Agents connect via stdio transport — no network required beyond API calls.
2458
+
2459
+ Setup in Claude Desktop (claude_desktop_config.json):
2460
+ {
2461
+ "mcpServers": {
2462
+ "recur": {
2463
+ "command": "npx",
2464
+ "args": ["recur-tw", "mcp"],
2465
+ "env": { "RECUR_SECRET_KEY": "sk_test_xxx" }
2466
+ }
2467
+ }
2468
+ }
2469
+
2470
+ Setup in Claude Code (.mcp.json):
2471
+ {
2472
+ "mcpServers": {
2473
+ "recur": {
2474
+ "command": "npx",
2475
+ "args": ["recur-tw", "mcp"],
2476
+ "env": { "RECUR_SECRET_KEY": "sk_test_xxx" }
2477
+ }
2478
+ }
2479
+ }
2480
+
2481
+ Requires a Secret Key (sk_test_* or sk_live_*) via --key, RECUR_SECRET_KEY, or recur login.
2482
+ `).action(async () => {
2483
+ try {
2484
+ const opts = program.opts();
2485
+ const secretKey = resolveSecretKey(opts);
2486
+ const client = new RecurClient({
2487
+ baseUrl: resolveBaseUrl(opts),
2488
+ secretKey
2489
+ });
2490
+ const server = new McpServer({
2491
+ name: "recur",
2492
+ version: "0.1.0"
2493
+ }, { capabilities: { tools: {} } });
2494
+ registerTools(server, client);
2495
+ const transport = new StdioServerTransport();
2496
+ await server.connect(transport);
2497
+ transport.onclose = () => {
2498
+ process.exit(0);
2499
+ };
2500
+ } catch (err) {
2501
+ handleError(err);
2502
+ }
2503
+ });
2504
+ }
2505
+ //#endregion
2506
+ //#region src/cli.ts
2507
+ const program = new Command();
2508
+ program.name("recur").description("Recur CLI — Taiwan subscription payment platform.\nManage products, customers, subscriptions, webhooks, and more.\nAll commands require a Secret Key (sk_test_* or sk_live_*).").version("0.1.0").option("--key <secret-key>", "API secret key (sk_test_* or sk_live_*)").option("--profile <name>", "Use a named profile from ~/.recur/credentials.json").option("--base-url <url>", "API base URL (default: https://api.recur.tw)").option("--output <format>", "Output format: json, table, csv, ndjson (default: json when piped, table otherwise)").hook("preAction", (thisCommand) => {
2509
+ const opts = thisCommand.opts();
2510
+ if (!opts.output) opts.output = process.stdout.isTTY ? "table" : "json";
2511
+ if (![
2512
+ "json",
2513
+ "table",
2514
+ "csv",
2515
+ "ndjson"
2516
+ ].includes(opts.output)) {
2517
+ console.error(`Error: unsupported output format "${opts.output}". Use json, table, csv, or ndjson.`);
2518
+ process.exit(1);
2519
+ }
2520
+ }).option("--fields <fields>", "Comma-separated fields to include (e.g. id,name,price)").option("--dry-run", "Validate locally without making API calls (write commands only)").option("--json <payload>", "Raw JSON request body, maps directly to API (e.g. '{\"name\":\"Pro\"}')");
2521
+ program.addHelpText("after", `
2522
+ Global options (--output, --fields, --json, --dry-run) work with ALL subcommands.
2523
+
2524
+ Examples:
2525
+ $ recur login Save API key interactively (recommended)
2526
+ $ recur products list List all products (table format)
2527
+ $ recur products list --output json List all products (JSON for piping)
2528
+ $ recur products list --fields name,price Show only name and price columns
2529
+ $ recur subscriptions list --status active
2530
+ $ recur customers list --email user@example.com
2531
+ $ recur products create --json '{"name":"Pro","price":299,"interval":"monthly"}'
2532
+ $ recur products create --dry-run --name "Test" --price 99
2533
+ $ recur schema products.create Show API schema for agents
2534
+
2535
+ Auth priority: --key flag > RECUR_SECRET_KEY env var > ~/.recur/credentials.json
2536
+ Tip: prefer "recur login" or RECUR_SECRET_KEY env var over --key to avoid exposing keys in shell history.
2537
+ `);
2538
+ registerLoginCommand(program);
2539
+ registerProductsCommand(program);
2540
+ registerCustomersCommand(program);
2541
+ registerSubscriptionsCommand(program);
2542
+ registerWebhooksCommand(program);
2543
+ registerOrdersCommand(program);
2544
+ registerInvoicesCommand(program);
2545
+ registerCheckoutsCommand(program);
2546
+ registerSchemaCommand(program);
2547
+ registerMcpCommand(program);
2548
+ program.parse();
2549
+ //#endregion
2550
+ export {};
2551
+
2552
+ //# sourceMappingURL=cli.mjs.map