@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/index.mjs ADDED
@@ -0,0 +1,1095 @@
1
+ import pc from "picocolors";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ //#region src/errors.ts
6
+ var CLIError = class extends Error {
7
+ constructor(message, statusCode) {
8
+ super(message);
9
+ this.statusCode = statusCode;
10
+ this.name = "CLIError";
11
+ }
12
+ };
13
+ //#endregion
14
+ //#region src/client.ts
15
+ var RecurClient = class {
16
+ baseUrl;
17
+ secretKey;
18
+ constructor(opts) {
19
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
20
+ this.secretKey = opts.secretKey;
21
+ }
22
+ async request(method, path, opts) {
23
+ const url = new URL(path, this.baseUrl);
24
+ if (opts?.params) {
25
+ for (const [key, value] of Object.entries(opts.params)) if (value !== void 0) url.searchParams.set(key, value);
26
+ }
27
+ const headers = {
28
+ Authorization: `Bearer ${this.secretKey}`,
29
+ "User-Agent": `@recur-tw/cli/${__CLI_VERSION__}`
30
+ };
31
+ const hasBody = opts?.body !== void 0;
32
+ if (hasBody) headers["Content-Type"] = "application/json";
33
+ const response = await fetch(url.toString(), {
34
+ method,
35
+ headers,
36
+ body: hasBody ? JSON.stringify(opts.body) : void 0,
37
+ signal: AbortSignal.timeout(3e4)
38
+ });
39
+ let data;
40
+ if ((response.headers.get("content-type") ?? "").includes("application/json")) data = await response.json();
41
+ else data = await response.text();
42
+ data = sanitizeResponse(data);
43
+ if (!response.ok) {
44
+ const errorBody = data;
45
+ const msg = errorBody?.error?.message ?? `HTTP ${response.status}`;
46
+ let detail = `[${errorBody?.error?.code ?? "unknown"}] ${msg}`;
47
+ if (response.status === 404) {
48
+ const resource = path.split("/").filter(Boolean).pop() ?? "";
49
+ detail += `\nThe resource "${resource}" was not found. Check the ID or slug is correct.`;
50
+ }
51
+ throw new CLIError(detail, response.status);
52
+ }
53
+ return data;
54
+ }
55
+ get(path, params) {
56
+ return this.request("GET", path, { params });
57
+ }
58
+ post(path, body) {
59
+ return this.request("POST", path, { body });
60
+ }
61
+ patch(path, body) {
62
+ return this.request("PATCH", path, { body });
63
+ }
64
+ delete(path) {
65
+ return this.request("DELETE", path);
66
+ }
67
+ };
68
+ /**
69
+ * Strip a single string of dangerous characters:
70
+ * - ASCII control chars (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) — keep \t \n \r
71
+ * - DEL (0x7F)
72
+ * - Zero-width / invisible Unicode (U+200B-200F, U+FEFF)
73
+ * - Line/paragraph separators (U+2028-2029)
74
+ * - ANSI escape sequences (CSI + OSC patterns)
75
+ */
76
+ function sanitizeString(str) {
77
+ 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, "");
78
+ }
79
+ /**
80
+ * Recursively sanitize all string values (and keys) in API responses.
81
+ * Defends against prompt injection via invisible characters or ANSI
82
+ * escape sequences embedded in user-generated data.
83
+ */
84
+ function sanitizeResponse(data) {
85
+ if (typeof data === "string") return sanitizeString(data);
86
+ if (Array.isArray(data)) return data.map(sanitizeResponse);
87
+ if (data !== null && typeof data === "object") {
88
+ const obj = data;
89
+ const result = {};
90
+ for (const [key, value] of Object.entries(obj)) result[sanitizeString(key)] = sanitizeResponse(value);
91
+ return result;
92
+ }
93
+ return data;
94
+ }
95
+ //#endregion
96
+ //#region src/config.ts
97
+ const CONFIG_DIR = path.join(os.homedir(), ".recur");
98
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
99
+ function ensureConfigDir() {
100
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, {
101
+ mode: 448,
102
+ recursive: true
103
+ });
104
+ else if ((fs.statSync(CONFIG_DIR).mode & 511) !== 448) fs.chmodSync(CONFIG_DIR, 448);
105
+ }
106
+ function readCredentials() {
107
+ if (!fs.existsSync(CREDENTIALS_FILE)) return {
108
+ profiles: {},
109
+ activeProfile: "default"
110
+ };
111
+ const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
112
+ try {
113
+ return JSON.parse(raw);
114
+ } catch {
115
+ throw new Error(`Credentials file is corrupted: ${CREDENTIALS_FILE}\nDelete it and run "recur login" to re-authenticate.`);
116
+ }
117
+ }
118
+ function writeCredentials(creds) {
119
+ ensureConfigDir();
120
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
121
+ }
122
+ function getProfile(profileName) {
123
+ const creds = readCredentials();
124
+ const name = profileName ?? creds.activeProfile ?? "default";
125
+ return creds.profiles[name] ?? null;
126
+ }
127
+ function saveProfile(name, profile) {
128
+ const creds = readCredentials();
129
+ creds.profiles[name] = profile;
130
+ if (!creds.activeProfile) creds.activeProfile = name;
131
+ writeCredentials(creds);
132
+ }
133
+ /**
134
+ * Resolve the secret key from (in priority order):
135
+ * 1. --key flag
136
+ * 2. RECUR_SECRET_KEY env var
137
+ * 3. Active profile in ~/.recur/credentials.json
138
+ *
139
+ * Validates format before returning.
140
+ */
141
+ function resolveSecretKey(opts) {
142
+ let key;
143
+ if (opts.key) key = opts.key;
144
+ else {
145
+ const envKey = process.env["RECUR_SECRET_KEY"];
146
+ if (envKey) key = envKey;
147
+ else {
148
+ const profile = getProfile(opts.profile);
149
+ if (profile?.secretKey) key = profile.secretKey;
150
+ }
151
+ }
152
+ 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");
153
+ if (!/^sk_(test|live)_[a-zA-Z0-9]+$/.test(key)) {
154
+ 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.");
155
+ throw new Error("Invalid API key format.\nExpected sk_test_* or sk_live_*");
156
+ }
157
+ return key;
158
+ }
159
+ /**
160
+ * Validate a URL uses HTTPS (or HTTP for localhost in development).
161
+ * Rejects non-http(s) schemes like file://, javascript://, data://.
162
+ */
163
+ function validateUrl(url, label) {
164
+ let parsed;
165
+ try {
166
+ parsed = new URL(url);
167
+ } catch {
168
+ throw new Error(`Invalid ${label}: "${url}" is not a valid URL`);
169
+ }
170
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`Invalid ${label}: only http/https allowed, got ${parsed.protocol}`);
171
+ 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.`);
172
+ return url;
173
+ }
174
+ function isLocalhost(hostname) {
175
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
176
+ }
177
+ /**
178
+ * Resolve the base URL for API calls.
179
+ */
180
+ function resolveBaseUrl(opts) {
181
+ if (opts.baseUrl) return validateUrl(opts.baseUrl, "base URL");
182
+ const envUrl = process.env["RECUR_BASE_URL"];
183
+ if (envUrl) return validateUrl(envUrl, "RECUR_BASE_URL");
184
+ const profile = getProfile(opts.profile);
185
+ if (profile?.baseUrl) return validateUrl(profile.baseUrl, "profile base URL");
186
+ return "https://api.recur.tw";
187
+ }
188
+ //#endregion
189
+ //#region src/output.ts
190
+ /**
191
+ * Render data in the specified format.
192
+ * Agent-optimized: --output json produces clean, parseable JSON to stdout.
193
+ * Human-optimized: table format with colors to stderr-safe stdout.
194
+ */
195
+ function render(data, opts = { format: "table" }) {
196
+ switch (opts.format) {
197
+ case "json":
198
+ renderJson(data);
199
+ break;
200
+ case "ndjson":
201
+ renderNdjson(data);
202
+ break;
203
+ case "csv":
204
+ renderCsv(data, opts);
205
+ break;
206
+ default:
207
+ renderTable(data, opts);
208
+ break;
209
+ }
210
+ }
211
+ function renderJson(data) {
212
+ console.log(JSON.stringify(data, null, 2));
213
+ }
214
+ /**
215
+ * NDJSON: one JSON object per line, no array wrapper.
216
+ * Enables stream processing without buffering entire response.
217
+ */
218
+ function renderNdjson(data) {
219
+ const rows = Array.isArray(data) ? data : [data];
220
+ for (const row of rows) console.log(JSON.stringify(row));
221
+ }
222
+ function renderCsv(data, opts) {
223
+ const rows = Array.isArray(data) ? data : [data];
224
+ if (rows.length === 0) return;
225
+ const fields = opts.fields ?? Object.keys(rows[0]);
226
+ if (!opts.noHeaders) console.log(fields.join(","));
227
+ for (const row of rows) {
228
+ const record = row;
229
+ const values = fields.map((f) => {
230
+ const val = record[f];
231
+ if (val === null || val === void 0) return "";
232
+ const str = typeof val === "object" ? JSON.stringify(val) : String(val);
233
+ if (str.includes(",") || str.includes("\"") || str.includes("\n") || /^[=+\-@]/.test(str)) return `"${str.replace(/"/g, "\"\"")}"`;
234
+ return str;
235
+ });
236
+ console.log(values.join(","));
237
+ }
238
+ }
239
+ function renderTable(data, opts) {
240
+ const rows = Array.isArray(data) ? data : [data];
241
+ if (rows.length === 0) {
242
+ console.log(pc.dim("No results"));
243
+ return;
244
+ }
245
+ const fields = opts.fields ?? Object.keys(rows[0]);
246
+ const widths = {};
247
+ for (const field of fields) widths[field] = field.length;
248
+ for (const row of rows) {
249
+ const record = row;
250
+ for (const field of fields) {
251
+ const val = formatValue(record[field]);
252
+ widths[field] = Math.min(Math.max(widths[field] ?? 0, val.length), 50);
253
+ }
254
+ }
255
+ const header = fields.map((f) => pc.bold(f.padEnd(widths[f] ?? 0))).join(" ");
256
+ console.log(header);
257
+ console.log(fields.map((f) => "─".repeat(widths[f] ?? 0)).join(" "));
258
+ for (const row of rows) {
259
+ const record = row;
260
+ const line = fields.map((f) => {
261
+ const val = formatValue(record[f]);
262
+ const maxWidth = widths[f] ?? 50;
263
+ const padded = val.padEnd(maxWidth);
264
+ return padded.length > maxWidth ? padded.slice(0, maxWidth - 1) + "…" : padded;
265
+ }).join(" ");
266
+ console.log(line);
267
+ }
268
+ console.log(pc.dim(`\n${rows.length} result${rows.length === 1 ? "" : "s"}`));
269
+ }
270
+ function formatValue(val) {
271
+ if (val === null || val === void 0) return pc.dim("—");
272
+ if (typeof val === "boolean") return val ? pc.green("true") : pc.dim("false");
273
+ if (typeof val === "object") return sanitizeString(JSON.stringify(val));
274
+ return sanitizeString(String(val));
275
+ }
276
+ /**
277
+ * Pick specific fields from data (for --fields flag).
278
+ */
279
+ function pickFields(data, fields) {
280
+ warnUnknownFields(data, fields);
281
+ if (Array.isArray(data)) return data.map((item) => pick(item, fields));
282
+ return pick(data, fields);
283
+ }
284
+ function pick(obj, fields) {
285
+ const result = {};
286
+ for (const field of fields) if (field in obj) result[field] = obj[field];
287
+ return result;
288
+ }
289
+ /**
290
+ * Warn about --fields that don't exist in the data.
291
+ * Called once per render to avoid repeated warnings.
292
+ */
293
+ function warnUnknownFields(data, fields) {
294
+ const sample = Array.isArray(data) ? data[0] : data;
295
+ if (!sample || typeof sample !== "object") return;
296
+ const available = Object.keys(sample);
297
+ const unknown = fields.filter((f) => !available.includes(f));
298
+ if (unknown.length > 0) console.error(pc.yellow(`Warning: unknown fields: ${unknown.map(sanitizeString).join(", ")}. Available: ${available.map(sanitizeString).join(", ")}`));
299
+ }
300
+ //#endregion
301
+ //#region ../core/src/api/resources.ts
302
+ /** Shorthand constructors for response field definitions */
303
+ const f = {
304
+ str: (name, description, nullable) => ({
305
+ name,
306
+ type: "string",
307
+ description,
308
+ nullable
309
+ }),
310
+ num: (name, description, nullable) => ({
311
+ name,
312
+ type: "number",
313
+ description,
314
+ nullable
315
+ }),
316
+ bool: (name, description) => ({
317
+ name,
318
+ type: "boolean",
319
+ description
320
+ }),
321
+ dt: (name, description, nullable) => ({
322
+ name,
323
+ type: "datetime",
324
+ description,
325
+ nullable
326
+ }),
327
+ obj: (name, description, nullable) => ({
328
+ name,
329
+ type: "object",
330
+ description,
331
+ nullable
332
+ }),
333
+ arr: (name, description) => ({
334
+ name,
335
+ type: "array",
336
+ description
337
+ })
338
+ };
339
+ /** Extract field names from ResponseFieldDef[] (for --fields validation) */
340
+ function fieldNames(fields) {
341
+ return fields.map((f) => f.name);
342
+ }
343
+ const ProductType = [
344
+ "SUBSCRIPTION",
345
+ "ONE_TIME",
346
+ "CREDITS",
347
+ "DONATION"
348
+ ];
349
+ const ProductStatus = ["active", "archived"];
350
+ const BillingInterval = ["monthly", "yearly"];
351
+ const SubscriptionStatus = [
352
+ "active",
353
+ "canceled",
354
+ "expired",
355
+ "past_due",
356
+ "trialing"
357
+ ];
358
+ const OrderStatus = [
359
+ "pending",
360
+ "paid",
361
+ "failed",
362
+ "refunded"
363
+ ];
364
+ const InvoiceStatus = [
365
+ "draft",
366
+ "open",
367
+ "paid",
368
+ "void",
369
+ "uncollectible"
370
+ ];
371
+ const cursorPagination = {
372
+ cursor: "starting_after",
373
+ defaultLimit: 10,
374
+ maxLimit: 100
375
+ };
376
+ /** All API resources in registration order */
377
+ const allResources = [
378
+ {
379
+ resource: "products",
380
+ description: "Manage subscription and one-time products",
381
+ actions: {
382
+ list: {
383
+ method: "GET",
384
+ path: "/v1/products",
385
+ description: "List all products",
386
+ params: {
387
+ status: {
388
+ type: "string",
389
+ description: "Filter by status",
390
+ enum: ProductStatus
391
+ },
392
+ limit: {
393
+ type: "number",
394
+ description: "Max results",
395
+ default: 20
396
+ }
397
+ },
398
+ responseFields: [
399
+ f.str("id", "Product ID (CUID)"),
400
+ f.str("name", "Product name"),
401
+ f.str("slug", "URL-friendly slug", true),
402
+ f.str("description", "Product description", true),
403
+ f.str("type", "SUBSCRIPTION, ONE_TIME, CREDITS, or DONATION"),
404
+ f.str("interval", "Billing interval: month or year", true),
405
+ f.num("interval_count", "Billing interval multiplier", true),
406
+ f.num("price", "Price in TWD (integer, e.g. 299 = NT$299)"),
407
+ f.str("currency", "Always TWD"),
408
+ f.num("trial_days", "Free trial days", true),
409
+ f.num("display_order", "Sort order for UI", true),
410
+ f.obj("metadata", "Custom key-value data", true),
411
+ f.bool("active", "Whether the product is active"),
412
+ f.dt("created_at", "Creation timestamp")
413
+ ]
414
+ },
415
+ get: {
416
+ method: "GET",
417
+ path: "/v1/products/:id",
418
+ description: "Get a product by ID (CUID) or slug (contains hyphen)",
419
+ responseFields: [
420
+ f.str("id"),
421
+ f.str("name"),
422
+ f.str("slug", void 0, true),
423
+ f.str("description", void 0, true),
424
+ f.str("type"),
425
+ f.str("interval", void 0, true),
426
+ f.num("interval_count", void 0, true),
427
+ f.num("price", "Price in TWD (integer)"),
428
+ f.str("currency"),
429
+ f.num("trial_days", void 0, true),
430
+ f.num("display_order", void 0, true),
431
+ f.obj("metadata", void 0, true),
432
+ f.bool("active"),
433
+ f.dt("created_at"),
434
+ f.dt("updated_at")
435
+ ]
436
+ },
437
+ create: {
438
+ method: "POST",
439
+ path: "/v1/products",
440
+ description: "Create a new product",
441
+ bodySchema: {
442
+ name: {
443
+ type: "string",
444
+ description: "Product name",
445
+ required: true
446
+ },
447
+ price: {
448
+ type: "number",
449
+ description: "Price in TWD (integer, e.g. 299 = NT$299)",
450
+ required: true
451
+ },
452
+ interval: {
453
+ type: "string",
454
+ description: "Billing interval",
455
+ enum: BillingInterval
456
+ },
457
+ type: {
458
+ type: "string",
459
+ description: "Product type",
460
+ enum: ProductType
461
+ },
462
+ description: {
463
+ type: "string",
464
+ description: "Product description"
465
+ }
466
+ },
467
+ responseFields: [
468
+ f.str("id"),
469
+ f.str("name"),
470
+ f.str("slug", void 0, true),
471
+ f.num("price"),
472
+ f.str("interval", void 0, true),
473
+ f.str("type"),
474
+ f.bool("active"),
475
+ f.dt("created_at")
476
+ ],
477
+ supportsDryRun: true
478
+ },
479
+ update: {
480
+ method: "PATCH",
481
+ path: "/v1/products/:id",
482
+ description: "Update a product",
483
+ bodySchema: {
484
+ name: {
485
+ type: "string",
486
+ description: "Product name"
487
+ },
488
+ description: {
489
+ type: "string",
490
+ description: "Product description"
491
+ },
492
+ price: {
493
+ type: "number",
494
+ description: "Price in TWD (integer)"
495
+ }
496
+ },
497
+ responseFields: [
498
+ f.str("id"),
499
+ f.str("name"),
500
+ f.str("slug", void 0, true),
501
+ f.num("price"),
502
+ f.dt("updated_at")
503
+ ],
504
+ supportsDryRun: true
505
+ },
506
+ archive: {
507
+ method: "POST",
508
+ path: "/v1/products/:id/archive",
509
+ description: "Archive a product",
510
+ responseFields: [f.str("id"), f.bool("active")],
511
+ supportsDryRun: true
512
+ }
513
+ }
514
+ },
515
+ {
516
+ resource: "customers",
517
+ description: "Manage customers",
518
+ actions: {
519
+ list: {
520
+ method: "GET",
521
+ path: "/v1/customers",
522
+ description: "List all customers",
523
+ params: {
524
+ email: {
525
+ type: "string",
526
+ description: "Filter by email"
527
+ },
528
+ status: {
529
+ type: "string",
530
+ description: "Filter by status",
531
+ enum: [
532
+ "ACTIVE",
533
+ "SUSPENDED",
534
+ "BANNED"
535
+ ]
536
+ },
537
+ limit: {
538
+ type: "number",
539
+ description: "Max results (1-100)",
540
+ default: 10
541
+ },
542
+ startingAfter: {
543
+ type: "string",
544
+ description: "Cursor: ID of last item from previous page",
545
+ cliFlag: "--starting-after"
546
+ }
547
+ },
548
+ responseFields: [
549
+ f.str("id", "Customer ID (CUID)"),
550
+ f.str("email", "Customer email"),
551
+ f.str("name", "Customer name", true),
552
+ f.str("external_id", "External system ID", true),
553
+ f.bool("email_verified", "Whether email is verified"),
554
+ f.str("status", "ACTIVE, SUSPENDED, or BANNED"),
555
+ f.dt("created_at", "Creation timestamp"),
556
+ f.dt("updated_at", "Last update timestamp"),
557
+ f.num("subscriptions_count", "Number of subscriptions"),
558
+ f.num("orders_count", "Number of orders")
559
+ ],
560
+ pagination: cursorPagination
561
+ },
562
+ get: {
563
+ method: "GET",
564
+ path: "/v1/customers/:id",
565
+ description: "Get a customer by ID",
566
+ responseFields: [
567
+ f.str("id"),
568
+ f.str("email"),
569
+ f.str("name", void 0, true),
570
+ f.str("external_id", void 0, true),
571
+ f.bool("email_verified"),
572
+ f.str("status"),
573
+ f.arr("subscriptions", "Customer subscriptions"),
574
+ f.dt("created_at"),
575
+ f.dt("updated_at")
576
+ ]
577
+ },
578
+ update: {
579
+ method: "PATCH",
580
+ path: "/v1/customers/:id",
581
+ description: "Update a customer",
582
+ bodySchema: {
583
+ name: {
584
+ type: "string",
585
+ description: "Customer name"
586
+ },
587
+ externalId: {
588
+ type: "string",
589
+ description: "External system ID",
590
+ cliFlag: "--external-id"
591
+ }
592
+ },
593
+ responseFields: [
594
+ f.str("id"),
595
+ f.str("email"),
596
+ f.str("name", void 0, true),
597
+ f.str("external_id", void 0, true)
598
+ ],
599
+ supportsDryRun: true
600
+ }
601
+ }
602
+ },
603
+ {
604
+ resource: "subscriptions",
605
+ description: "Manage subscriptions",
606
+ actions: {
607
+ list: {
608
+ method: "GET",
609
+ path: "/v1/subscriptions",
610
+ description: "List subscriptions",
611
+ params: {
612
+ status: {
613
+ type: "string",
614
+ description: "Filter by status",
615
+ enum: SubscriptionStatus
616
+ },
617
+ customerId: {
618
+ type: "string",
619
+ description: "Filter by customer ID",
620
+ cliFlag: "--customer-id"
621
+ },
622
+ email: {
623
+ type: "string",
624
+ description: "Filter by customer email"
625
+ },
626
+ limit: {
627
+ type: "number",
628
+ description: "Max results (1-100)",
629
+ default: 10
630
+ },
631
+ startingAfter: {
632
+ type: "string",
633
+ description: "Cursor: ID of last item from previous page",
634
+ cliFlag: "--starting-after"
635
+ }
636
+ },
637
+ responseFields: [
638
+ f.str("id", "Subscription ID"),
639
+ f.str("status", "active, canceled, expired, past_due, or trialing"),
640
+ f.str("product_id", "Product ID"),
641
+ f.str("product_slug", "Product slug", true),
642
+ f.str("product_name", "Product name"),
643
+ f.num("amount", "Billing amount in TWD (integer)"),
644
+ f.str("interval", "Billing interval: month or year"),
645
+ f.num("interval_count", "Interval multiplier"),
646
+ f.dt("current_period_start", "Current billing period start"),
647
+ f.dt("current_period_end", "Current billing period end"),
648
+ f.dt("canceled_at", "When subscription was canceled", true),
649
+ f.dt("started_at", "When subscription started", true),
650
+ f.obj("customer", "Customer object (id, email, name)", true)
651
+ ],
652
+ pagination: cursorPagination
653
+ },
654
+ get: {
655
+ method: "GET",
656
+ path: "/v1/subscriptions/:id",
657
+ description: "Get subscription details",
658
+ responseFields: [
659
+ f.str("id"),
660
+ f.str("status"),
661
+ f.obj("product", "Product details (id, slug, name, price, interval)"),
662
+ f.obj("customer", "Customer details (id, email, name, external_id)"),
663
+ f.num("amount", "Billing amount in TWD"),
664
+ f.str("interval"),
665
+ f.num("interval_count"),
666
+ f.dt("current_period_start"),
667
+ f.dt("current_period_end"),
668
+ f.bool("cancel_at_period_end", "Whether subscription cancels at period end"),
669
+ f.dt("canceled_at", void 0, true),
670
+ f.dt("started_at", void 0, true),
671
+ f.dt("trial_start", void 0, true),
672
+ f.dt("trial_end", void 0, true),
673
+ f.arr("invoices", "Invoice history"),
674
+ f.obj("metadata", void 0, true),
675
+ f.dt("created_at")
676
+ ]
677
+ },
678
+ cancel: {
679
+ method: "POST",
680
+ path: "/v1/subscriptions/:id/cancel",
681
+ description: "Cancel a subscription",
682
+ bodySchema: { immediately: {
683
+ type: "boolean",
684
+ description: "Cancel immediately (flag, no value). Omit for safe period-end cancel. Do NOT pass --immediately false.",
685
+ default: false
686
+ } },
687
+ responseFields: [
688
+ f.str("id"),
689
+ f.str("status"),
690
+ f.bool("cancel_at_period_end"),
691
+ f.dt("canceled_at", void 0, true)
692
+ ],
693
+ supportsDryRun: true
694
+ }
695
+ }
696
+ },
697
+ {
698
+ resource: "orders",
699
+ description: "View orders",
700
+ actions: {
701
+ list: {
702
+ method: "GET",
703
+ path: "/v1/orders",
704
+ description: "List orders",
705
+ params: {
706
+ status: {
707
+ type: "string",
708
+ description: "Filter by status",
709
+ enum: OrderStatus
710
+ },
711
+ customerId: {
712
+ type: "string",
713
+ description: "Filter by customer ID",
714
+ cliFlag: "--customer-id"
715
+ },
716
+ limit: {
717
+ type: "number",
718
+ description: "Max results (1-100)",
719
+ default: 10
720
+ },
721
+ startingAfter: {
722
+ type: "string",
723
+ description: "Cursor: ID of last item from previous page",
724
+ cliFlag: "--starting-after"
725
+ }
726
+ },
727
+ responseFields: [
728
+ f.str("id", "Order ID"),
729
+ f.str("status", "pending, paid, failed, or refunded"),
730
+ f.num("total", "Total amount in TWD (integer)"),
731
+ f.num("subtotal", "Subtotal before discounts"),
732
+ f.str("currency", "Always TWD"),
733
+ f.str("customer_id", "Customer ID", true),
734
+ f.num("items_count", "Number of line items"),
735
+ f.dt("created_at", "Creation timestamp")
736
+ ],
737
+ pagination: cursorPagination
738
+ },
739
+ get: {
740
+ method: "GET",
741
+ path: "/v1/orders/:id",
742
+ description: "Get order details",
743
+ responseFields: [
744
+ f.str("id"),
745
+ f.str("status"),
746
+ f.num("total"),
747
+ f.num("subtotal"),
748
+ f.num("discount_amount", "Discount applied"),
749
+ f.str("currency"),
750
+ f.obj("customer", "Customer details (id, email, name)"),
751
+ f.arr("items", "Line items (id, productId, productName, price, quantity)"),
752
+ f.dt("created_at")
753
+ ]
754
+ }
755
+ }
756
+ },
757
+ {
758
+ resource: "invoices",
759
+ description: "View invoices",
760
+ actions: {
761
+ list: {
762
+ method: "GET",
763
+ path: "/v1/invoices",
764
+ description: "List invoices",
765
+ params: {
766
+ subscriptionId: {
767
+ type: "string",
768
+ description: "Filter by subscription ID",
769
+ cliFlag: "--subscription-id"
770
+ },
771
+ customerId: {
772
+ type: "string",
773
+ description: "Filter by customer ID",
774
+ cliFlag: "--customer-id"
775
+ },
776
+ status: {
777
+ type: "string",
778
+ description: "Filter by status",
779
+ enum: InvoiceStatus
780
+ },
781
+ limit: {
782
+ type: "number",
783
+ description: "Max results (1-100)",
784
+ default: 10
785
+ },
786
+ startingAfter: {
787
+ type: "string",
788
+ description: "Cursor: ID of last item from previous page",
789
+ cliFlag: "--starting-after"
790
+ }
791
+ },
792
+ responseFields: [
793
+ f.str("id", "Invoice ID"),
794
+ f.str("status", "draft, open, paid, void, or uncollectible"),
795
+ f.num("amount", "Invoice amount in TWD (integer)"),
796
+ f.str("currency", "Always TWD"),
797
+ f.str("subscription_id", "Associated subscription ID"),
798
+ f.str("customer_id", "Customer ID"),
799
+ f.dt("period_start", "Billing period start"),
800
+ f.dt("period_end", "Billing period end"),
801
+ f.dt("paid_at", "When invoice was paid", true),
802
+ f.dt("created_at", "Creation timestamp")
803
+ ],
804
+ pagination: cursorPagination
805
+ },
806
+ get: {
807
+ method: "GET",
808
+ path: "/v1/invoices/:id",
809
+ description: "Get invoice details",
810
+ responseFields: [
811
+ f.str("id"),
812
+ f.str("status"),
813
+ f.num("amount"),
814
+ f.str("currency"),
815
+ f.str("subscription_id"),
816
+ f.obj("subscription", "Subscription details (id, productId, status)"),
817
+ f.str("customer_id"),
818
+ f.dt("period_start"),
819
+ f.dt("period_end"),
820
+ f.dt("paid_at", void 0, true),
821
+ f.dt("created_at")
822
+ ]
823
+ }
824
+ }
825
+ },
826
+ {
827
+ resource: "webhooks",
828
+ description: "Manage webhook endpoints",
829
+ actions: {
830
+ list: {
831
+ method: "GET",
832
+ path: "/v1/webhooks",
833
+ description: "List webhook endpoints",
834
+ params: { limit: {
835
+ type: "number",
836
+ description: "Max results",
837
+ default: 20
838
+ } },
839
+ responseFields: [
840
+ f.str("id", "Webhook ID"),
841
+ f.str("url", "Endpoint URL"),
842
+ f.arr("events", "Subscribed event types"),
843
+ f.bool("is_active", "Whether the webhook is active"),
844
+ f.dt("created_at", "Creation timestamp")
845
+ ]
846
+ },
847
+ create: {
848
+ method: "POST",
849
+ path: "/v1/webhooks",
850
+ description: "Create a webhook endpoint",
851
+ bodySchema: {
852
+ url: {
853
+ type: "string",
854
+ description: "Webhook URL",
855
+ required: true
856
+ },
857
+ events: {
858
+ type: "string",
859
+ description: "Comma-separated event types (default: essential events)"
860
+ }
861
+ },
862
+ responseFields: [
863
+ f.str("id"),
864
+ f.str("url"),
865
+ f.str("secret", "Signing secret (shown once)"),
866
+ f.arr("events"),
867
+ f.bool("is_active")
868
+ ],
869
+ supportsDryRun: true
870
+ },
871
+ test: {
872
+ method: "POST",
873
+ path: "/v1/webhooks/:id/test",
874
+ description: "Send a test event to verify webhook endpoint",
875
+ bodySchema: { eventType: {
876
+ type: "string",
877
+ description: "Event type to send",
878
+ default: "checkout.completed",
879
+ cliFlag: "--event"
880
+ } },
881
+ responseFields: [
882
+ f.bool("success", "Whether the test delivery succeeded"),
883
+ f.num("status_code", "HTTP status code from endpoint"),
884
+ f.num("response_time", "Response time in ms")
885
+ ]
886
+ },
887
+ delete: {
888
+ method: "DELETE",
889
+ path: "/v1/webhooks/:id",
890
+ description: "Delete a webhook endpoint",
891
+ responseFields: [f.str("id")],
892
+ supportsDryRun: true
893
+ }
894
+ }
895
+ },
896
+ {
897
+ resource: "checkouts",
898
+ description: "Create checkout sessions",
899
+ actions: {
900
+ create: {
901
+ method: "POST",
902
+ path: "/v1/checkouts",
903
+ description: "Create a checkout session",
904
+ bodySchema: {
905
+ productId: {
906
+ type: "string",
907
+ description: "Product ID",
908
+ required: true,
909
+ cliFlag: "--product-id"
910
+ },
911
+ customerEmail: {
912
+ type: "string",
913
+ description: "Customer email",
914
+ cliFlag: "--customer-email"
915
+ },
916
+ successUrl: {
917
+ type: "string",
918
+ description: "Redirect URL on success",
919
+ cliFlag: "--success-url"
920
+ },
921
+ cancelUrl: {
922
+ type: "string",
923
+ description: "Redirect URL on cancel",
924
+ cliFlag: "--cancel-url"
925
+ }
926
+ },
927
+ responseFields: [
928
+ f.str("id", "Checkout session ID"),
929
+ f.str("url", "Hosted checkout URL"),
930
+ f.str("status", "Session status"),
931
+ f.dt("expires_at", "Session expiration")
932
+ ],
933
+ supportsDryRun: true
934
+ },
935
+ get: {
936
+ method: "GET",
937
+ path: "/v1/checkouts/:id",
938
+ description: "Get checkout session status",
939
+ responseFields: [
940
+ f.str("id"),
941
+ f.str("url", void 0, true),
942
+ f.str("status"),
943
+ f.str("customer_id", void 0, true),
944
+ f.str("product_id"),
945
+ f.dt("created_at")
946
+ ]
947
+ }
948
+ }
949
+ }
950
+ ];
951
+ //#endregion
952
+ //#region src/schema.ts
953
+ /**
954
+ * Schema registry for runtime introspection.
955
+ * Resource definitions are imported from @workspace/core/api (canonical source).
956
+ * Enables `recur schema <resource>.<action>` for agents to discover
957
+ * available parameters, fields, and request bodies.
958
+ */
959
+ var SchemaError = class extends CLIError {
960
+ available;
961
+ constructor(message, available) {
962
+ super(`${message}\nAvailable: ${available.join(", ")}`);
963
+ this.available = available;
964
+ }
965
+ };
966
+ const registry = {};
967
+ for (const def of allResources) registry[def.resource] = def;
968
+ function getResource(name) {
969
+ return registry[name];
970
+ }
971
+ function getAllResources() {
972
+ return Object.values(registry);
973
+ }
974
+ function getAction(resourceAction) {
975
+ const [resource, action] = resourceAction.split(".");
976
+ if (!resource || !action) return void 0;
977
+ return registry[resource]?.actions[action];
978
+ }
979
+ /**
980
+ * Dump schema as machine-readable JSON for agents.
981
+ * Throws CLIError for unknown resources/actions (non-zero exit).
982
+ */
983
+ function dumpSchema(resourceAction) {
984
+ if (resourceAction) {
985
+ const [resourceName, actionName] = resourceAction.split(".");
986
+ if (!resourceName) throw new SchemaError("Invalid format. Use: <resource> or <resource>.<action>", Object.keys(registry));
987
+ const resource = registry[resourceName];
988
+ if (!resource) throw new SchemaError(`Unknown resource: ${resourceName}`, Object.keys(registry));
989
+ if (!actionName) return resource;
990
+ const action = resource.actions[actionName];
991
+ if (!action) throw new SchemaError(`Unknown action: ${actionName} on ${resourceName}`, Object.keys(resource.actions));
992
+ return {
993
+ resource: resourceName,
994
+ action: actionName,
995
+ ...action
996
+ };
997
+ }
998
+ return Object.values(registry).map((r) => ({
999
+ resource: r.resource,
1000
+ description: r.description,
1001
+ actions: Object.entries(r.actions).map(([name, def]) => ({
1002
+ name,
1003
+ method: def.method,
1004
+ path: def.path,
1005
+ description: def.description
1006
+ }))
1007
+ }));
1008
+ }
1009
+ //#endregion
1010
+ //#region src/validator.ts
1011
+ /**
1012
+ * Validate a resource ID or slug against dangerous input patterns.
1013
+ * Rejects path traversals, embedded query params, control chars.
1014
+ *
1015
+ * Does NOT enforce ID prefixes — Recur uses CUID-format IDs (e.g.
1016
+ * "ro91zsticf41uwq8bungmklk") without a prefix like "cus_".
1017
+ */
1018
+ function validateResourceId(id) {
1019
+ if (!id || typeof id !== "string") throw new CLIError("Resource ID is required");
1020
+ if (id.includes("..") || id.includes("/") || id.includes("\\")) throw new CLIError(`Invalid resource ID: path traversal detected in "${id}"`);
1021
+ if (id.includes("?") || id.includes("#") || id.includes("%")) throw new CLIError(`Invalid resource ID: special characters not allowed in "${id}"`);
1022
+ for (let i = 0; i < id.length; i++) {
1023
+ const code = id.charCodeAt(i);
1024
+ if (code < 32 || code === 127) throw new CLIError(`Invalid resource ID: control character at position ${i}`);
1025
+ }
1026
+ if (/[\u200B-\u200F\u2028\u2029\uFEFF]/.test(id)) throw new CLIError("Invalid resource ID: invisible Unicode characters detected");
1027
+ return id;
1028
+ }
1029
+ /**
1030
+ * Parse and validate a raw JSON payload from --json flag.
1031
+ */
1032
+ function parseJsonPayload(raw) {
1033
+ try {
1034
+ const parsed = JSON.parse(raw);
1035
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CLIError("JSON payload must be an object");
1036
+ return parsed;
1037
+ } catch (err) {
1038
+ if (err instanceof CLIError) throw err;
1039
+ throw new CLIError(`Invalid JSON payload: ${err.message}`);
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Validate that a string looks like a valid API key.
1044
+ */
1045
+ function validateApiKey(key) {
1046
+ if (!/^sk_(test|live)_[a-zA-Z0-9]+$/.test(key)) {
1047
+ 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.");
1048
+ throw new CLIError("Invalid API key format. Expected sk_test_* or sk_live_*");
1049
+ }
1050
+ return key;
1051
+ }
1052
+ //#endregion
1053
+ //#region src/extract.ts
1054
+ /**
1055
+ * Extract list data from API responses.
1056
+ *
1057
+ * All list endpoints return a standard envelope: { object: 'list', data: [...] }.
1058
+ * This function also handles legacy shapes and direct arrays as fallback.
1059
+ */
1060
+ function extractList(response) {
1061
+ if (Array.isArray(response)) return response;
1062
+ if (typeof response === "object" && response !== null) {
1063
+ const obj = response;
1064
+ if (Array.isArray(obj["data"])) return obj["data"];
1065
+ for (const value of Object.values(obj)) if (Array.isArray(value)) return value;
1066
+ }
1067
+ return [response];
1068
+ }
1069
+ /**
1070
+ * Extract list data with pagination metadata.
1071
+ *
1072
+ * API responses include:
1073
+ * - has_more: boolean — whether more pages exist
1074
+ * - next_cursor: string | null — ID to pass as starting_after for next page
1075
+ */
1076
+ function extractPaginatedList(response) {
1077
+ const data = extractList(response);
1078
+ if (typeof response === "object" && response !== null) {
1079
+ const obj = response;
1080
+ return {
1081
+ data,
1082
+ hasMore: obj["has_more"] === true,
1083
+ nextCursor: typeof obj["next_cursor"] === "string" ? obj["next_cursor"] : null
1084
+ };
1085
+ }
1086
+ return {
1087
+ data,
1088
+ hasMore: false,
1089
+ nextCursor: null
1090
+ };
1091
+ }
1092
+ //#endregion
1093
+ export { CLIError, RecurClient, dumpSchema, extractList, extractPaginatedList, fieldNames, getAction, getAllResources, getProfile, getResource, parseJsonPayload, pickFields, render, resolveBaseUrl, resolveSecretKey, sanitizeString, saveProfile, validateApiKey, validateResourceId, validateUrl };
1094
+
1095
+ //# sourceMappingURL=index.mjs.map