@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/AGENT.md +268 -0
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2552 -0
- package/dist/index.d.mts +196 -0
- package/dist/index.mjs +1095 -0
- package/package.json +71 -0
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
|