zalo-agent-cli 1.0.31 → 1.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.
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Official Account (OA) commands — manage Zalo OA via REST API v3.0.
3
+ * Independent from zca-js personal account; uses separate OA access token.
4
+ */
5
+
6
+ import { resolve } from "node:path";
7
+ import { registerOAListenCommand } from "./oa-listen.js";
8
+ import { registerOAInitCommand } from "./oa-init.js";
9
+ import {
10
+ saveOAToken,
11
+ saveOACreds,
12
+ loadOACreds,
13
+ loadOAToken,
14
+ getOAuthUrl,
15
+ exchangeCode,
16
+ refreshAccessToken,
17
+ sendText,
18
+ sendImage,
19
+ sendFile,
20
+ sendList,
21
+ getMessageStatus,
22
+ getOAProfile,
23
+ getFollowerInfo,
24
+ getFollowers,
25
+ updateFollowerInfo,
26
+ getTags,
27
+ assignTag,
28
+ removeTag,
29
+ removeFollowerFromTag,
30
+ uploadImage,
31
+ uploadFile,
32
+ getRecentChat,
33
+ getConversation,
34
+ updateMenu,
35
+ createArticle,
36
+ getArticleList,
37
+ getArticleDetail,
38
+ createProduct,
39
+ getProductList,
40
+ getProductInfo,
41
+ createCategory,
42
+ getCategoryList,
43
+ createOrder,
44
+ } from "../core/oa-client.js";
45
+ import { success, error, info, output } from "../utils/output.js";
46
+
47
+ export function registerOACommands(program) {
48
+ const oa = program.command("oa").description("Zalo Official Account API v3.0 commands");
49
+ const json = () => program.opts().json;
50
+
51
+ // ─── Login & Auth ───────────────────────────────────────────────
52
+
53
+ oa.command("login")
54
+ .description("Login to Zalo OA via OAuth (opens browser, starts callback server)")
55
+ .requiredOption("--app-id <id>", "Zalo App ID from developers.zalo.me")
56
+ .requiredOption("--secret <key>", "Zalo App Secret Key")
57
+ .option("-p, --port <port>", "Callback server port", "3456")
58
+ .option("--callback-host <host>", "Callback host for VPS (e.g. https://your-vps.com)")
59
+ .option("--oa-id <id>", "OA identifier for multi-OA support", "default")
60
+ .action(async (opts) => {
61
+ const { createServer } = await import("node:http");
62
+ const callbackBase = opts.callbackHost || `http://localhost:${opts.port}`;
63
+ const redirectUri = `${callbackBase}/callback`;
64
+ const authUrl = getOAuthUrl(opts.appId, redirectUri);
65
+
66
+ if (!json()) {
67
+ if (opts.callbackHost) {
68
+ info("VPS mode — open this URL in your local browser:");
69
+ } else {
70
+ info("Opening browser for Zalo OA authorization...");
71
+ }
72
+ info(`Auth URL:\n ${authUrl}\n`);
73
+ }
74
+
75
+ // Open browser (skip on VPS/headless — user copies URL manually)
76
+ if (!opts.callbackHost) {
77
+ const { exec } = await import("node:child_process");
78
+ const openCmd =
79
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
80
+ exec(`${openCmd} "${authUrl}"`);
81
+ }
82
+
83
+ // Start callback server to receive authorization code
84
+ const server = createServer(async (req, res) => {
85
+ if (!req.url?.startsWith("/callback")) {
86
+ res.writeHead(404);
87
+ res.end();
88
+ return;
89
+ }
90
+ const url = new URL(req.url, `http://localhost:${opts.port}`);
91
+ const code = url.searchParams.get("code");
92
+ const oaId = url.searchParams.get("oa_id");
93
+
94
+ if (!code) {
95
+ const errMsg = url.searchParams.get("error_description") || "No authorization code received";
96
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
97
+ res.end(`<h2>Login failed</h2><p>${errMsg}</p>`);
98
+ if (!json()) error(errMsg);
99
+ server.close();
100
+ return;
101
+ }
102
+
103
+ try {
104
+ // Exchange code for tokens
105
+ const tokenData = await exchangeCode(code, opts.appId, opts.secret, redirectUri);
106
+
107
+ if (tokenData.error) {
108
+ throw new Error(
109
+ `Token error ${tokenData.error}: ${tokenData.error_description || tokenData.error_name}`,
110
+ );
111
+ }
112
+
113
+ // Save all credentials
114
+ saveOACreds(
115
+ {
116
+ appId: opts.appId,
117
+ secretKey: opts.secret,
118
+ accessToken: tokenData.access_token,
119
+ refreshToken: tokenData.refresh_token,
120
+ expiresIn: tokenData.expires_in,
121
+ oaId: oaId || opts.oaId,
122
+ },
123
+ opts.oaId,
124
+ );
125
+
126
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
127
+ res.end("<h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p>");
128
+
129
+ output({ ok: true, oaId: oaId || opts.oaId, expires_in: tokenData.expires_in }, json(), () => {
130
+ success(`OA logged in successfully (OA ID: ${oaId || opts.oaId})`);
131
+ info(
132
+ `Token expires in ${Math.round((tokenData.expires_in || 86400) / 3600)}h — use 'oa refresh' to renew`,
133
+ );
134
+ });
135
+ } catch (e) {
136
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
137
+ res.end(`<h2>Login failed</h2><p>${e.message}</p>`);
138
+ error(e.message);
139
+ } finally {
140
+ server.close();
141
+ }
142
+ });
143
+
144
+ // Bind to 0.0.0.0 on VPS so external traffic can reach callback
145
+ const bindHost = opts.callbackHost ? "0.0.0.0" : "127.0.0.1";
146
+ server.listen(Number(opts.port), bindHost, () => {
147
+ if (!json()) info(`Waiting for callback on ${bindHost}:${opts.port}...`);
148
+ });
149
+
150
+ // Timeout after 2 minutes
151
+ setTimeout(() => {
152
+ if (!json()) warning("Login timed out (2 min). Try again.");
153
+ server.close();
154
+ process.exit(1);
155
+ }, 120_000);
156
+ });
157
+
158
+ oa.command("refresh")
159
+ .description("Refresh OA access token using stored refresh token")
160
+ .option("--oa-id <id>", "OA identifier", "default")
161
+ .action(async (opts) => {
162
+ try {
163
+ const creds = loadOACreds(opts.oaId);
164
+ if (!creds?.refreshToken || !creds?.appId || !creds?.secretKey) {
165
+ throw new Error("Missing credentials. Run: zalo-agent oa login --app-id <id> --secret <key>");
166
+ }
167
+ const tokenData = await refreshAccessToken(creds.refreshToken, creds.appId, creds.secretKey);
168
+ if (tokenData.error) {
169
+ throw new Error(
170
+ `Refresh error ${tokenData.error}: ${tokenData.error_description || tokenData.error_name}`,
171
+ );
172
+ }
173
+ saveOACreds(
174
+ {
175
+ accessToken: tokenData.access_token,
176
+ refreshToken: tokenData.refresh_token,
177
+ expiresIn: tokenData.expires_in,
178
+ },
179
+ opts.oaId,
180
+ );
181
+ output({ ok: true, expires_in: tokenData.expires_in }, json(), () =>
182
+ success(`Token refreshed. Expires in ${Math.round((tokenData.expires_in || 86400) / 3600)}h`),
183
+ );
184
+ } catch (e) {
185
+ error(e.message);
186
+ }
187
+ });
188
+
189
+ oa.command("setup <access-token>")
190
+ .description("Manually set OA access token (skip OAuth)")
191
+ .option("--oa-id <id>", "OA identifier for multi-OA support", "default")
192
+ .action(async (accessToken, opts) => {
193
+ try {
194
+ saveOAToken(accessToken, opts.oaId);
195
+ output({ ok: true, oaId: opts.oaId }, json(), () => success(`OA token saved for "${opts.oaId}"`));
196
+ } catch (e) {
197
+ error(e.message);
198
+ }
199
+ });
200
+
201
+ oa.command("whoami")
202
+ .description("Show current OA profile info")
203
+ .option("--oa-id <id>", "OA identifier", "default")
204
+ .action(async (opts) => {
205
+ try {
206
+ const result = await getOAProfile(opts.oaId);
207
+ output(result, json(), () => {
208
+ const d = result.data || result;
209
+ info(`OA: ${d.name || "N/A"} (ID: ${d.oa_id || "N/A"})`);
210
+ if (d.description) info(`Description: ${d.description}`);
211
+ if (d.num_follower != null) info(`Followers: ${d.num_follower}`);
212
+ });
213
+ } catch (e) {
214
+ error(e.message);
215
+ }
216
+ });
217
+
218
+ // ─── Messaging ───────────────────────────────────────────────────
219
+
220
+ const msg = oa.command("msg").description("Send messages to OA followers");
221
+
222
+ msg.command("text <user-id> <text>")
223
+ .description("Send text message to a follower")
224
+ .option("-m, --msg-type <type>", "Message type: cs | transaction | promotion", "cs")
225
+ .option("--oa-id <id>", "OA identifier", "default")
226
+ .action(async (userId, text, opts) => {
227
+ try {
228
+ const result = await sendText(userId, text, opts.msgType, opts.oaId);
229
+ output(result, json(), () => success("Text message sent"));
230
+ } catch (e) {
231
+ error(e.message);
232
+ }
233
+ });
234
+
235
+ msg.command("image <user-id>")
236
+ .description("Send image message to a follower")
237
+ .option("--image-url <url>", "Image URL")
238
+ .option("--image-id <id>", "Pre-uploaded image attachment_id")
239
+ .option("-m, --msg-type <type>", "Message type: cs | transaction | promotion", "cs")
240
+ .option("--oa-id <id>", "OA identifier", "default")
241
+ .action(async (userId, opts) => {
242
+ try {
243
+ const result = await sendImage(
244
+ userId,
245
+ { imageUrl: opts.imageUrl, imageId: opts.imageId },
246
+ opts.msgType,
247
+ opts.oaId,
248
+ );
249
+ output(result, json(), () => success("Image message sent"));
250
+ } catch (e) {
251
+ error(e.message);
252
+ }
253
+ });
254
+
255
+ msg.command("file <user-id> <file-id>")
256
+ .description("Send file message (requires pre-uploaded file token)")
257
+ .option("-m, --msg-type <type>", "Message type: cs | transaction | promotion", "cs")
258
+ .option("--oa-id <id>", "OA identifier", "default")
259
+ .action(async (userId, fileId, opts) => {
260
+ try {
261
+ const result = await sendFile(userId, fileId, opts.msgType, opts.oaId);
262
+ output(result, json(), () => success("File message sent"));
263
+ } catch (e) {
264
+ error(e.message);
265
+ }
266
+ });
267
+
268
+ msg.command("list <user-id> <elements-json>")
269
+ .description('Send list message. elements-json: \'[{"title":"...", "subtitle":"..."}]\'')
270
+ .option("-m, --msg-type <type>", "Message type: cs | transaction | promotion", "cs")
271
+ .option("--oa-id <id>", "OA identifier", "default")
272
+ .action(async (userId, elementsJson, opts) => {
273
+ try {
274
+ const elements = JSON.parse(elementsJson);
275
+ const result = await sendList(userId, elements, opts.msgType, opts.oaId);
276
+ output(result, json(), () => success("List message sent"));
277
+ } catch (e) {
278
+ error(e.message);
279
+ }
280
+ });
281
+
282
+ msg.command("status <message-id>")
283
+ .description("Check message delivery status")
284
+ .option("--oa-id <id>", "OA identifier", "default")
285
+ .action(async (messageId, opts) => {
286
+ try {
287
+ const result = await getMessageStatus(messageId, opts.oaId);
288
+ output(result, json(), () => {
289
+ const d = result.data || result;
290
+ info(`Status: ${d.status || JSON.stringify(d)}`);
291
+ });
292
+ } catch (e) {
293
+ error(e.message);
294
+ }
295
+ });
296
+
297
+ // ─── Followers ───────────────────────────────────────────────────
298
+
299
+ const follower = oa.command("follower").description("Manage OA followers");
300
+
301
+ follower
302
+ .command("info <user-id>")
303
+ .description("Get follower details")
304
+ .option("--oa-id <id>", "OA identifier", "default")
305
+ .action(async (userId, opts) => {
306
+ try {
307
+ const result = await getFollowerInfo(userId, opts.oaId);
308
+ output(result, json(), () => {
309
+ const d = result.data || result;
310
+ info(`Name: ${d.display_name || d.name || "N/A"}`);
311
+ if (d.user_id) info(`User ID: ${d.user_id}`);
312
+ if (d.user_id_by_app) info(`App User ID: ${d.user_id_by_app}`);
313
+ });
314
+ } catch (e) {
315
+ error(e.message);
316
+ }
317
+ });
318
+
319
+ follower
320
+ .command("list")
321
+ .description("List OA followers (paginated)")
322
+ .option("--offset <n>", "Offset", "0")
323
+ .option("--count <n>", "Count per page", "50")
324
+ .option("--oa-id <id>", "OA identifier", "default")
325
+ .action(async (opts) => {
326
+ try {
327
+ const result = await getFollowers(Number(opts.offset), Number(opts.count), opts.oaId);
328
+ output(result, json(), () => {
329
+ const d = result.data || result;
330
+ const users = d.users || d.followers || [];
331
+ info(`Followers: ${d.total || users.length}`);
332
+ users.forEach((u) => console.log(` - ${u.user_id}: ${u.display_name || "N/A"}`));
333
+ });
334
+ } catch (e) {
335
+ error(e.message);
336
+ }
337
+ });
338
+
339
+ follower
340
+ .command("update <user-id> <updates-json>")
341
+ .description('Update follower info. updates-json: \'{"name":"...","phone":"..."}\'')
342
+ .option("--oa-id <id>", "OA identifier", "default")
343
+ .action(async (userId, updatesJson, opts) => {
344
+ try {
345
+ const updates = JSON.parse(updatesJson);
346
+ const result = await updateFollowerInfo(userId, updates, opts.oaId);
347
+ output(result, json(), () => success("Follower info updated"));
348
+ } catch (e) {
349
+ error(e.message);
350
+ }
351
+ });
352
+
353
+ // ─── Tags ────────────────────────────────────────────────────────
354
+
355
+ const tag = oa.command("tag").description("Manage OA tags");
356
+
357
+ tag.command("list")
358
+ .description("List all tags")
359
+ .option("--oa-id <id>", "OA identifier", "default")
360
+ .action(async (opts) => {
361
+ try {
362
+ const result = await getTags(opts.oaId);
363
+ output(result, json(), () => {
364
+ const tags = result.data?.tags || result.tags || [];
365
+ info(`Tags (${tags.length}):`);
366
+ tags.forEach((t) => console.log(` - ${t.name} (${t.count || 0} followers)`));
367
+ });
368
+ } catch (e) {
369
+ error(e.message);
370
+ }
371
+ });
372
+
373
+ tag.command("assign <user-id> <tag-name>")
374
+ .description("Assign tag to a follower")
375
+ .option("--oa-id <id>", "OA identifier", "default")
376
+ .action(async (userId, tagName, opts) => {
377
+ try {
378
+ const result = await assignTag(userId, tagName, opts.oaId);
379
+ output(result, json(), () => success(`Tag "${tagName}" assigned`));
380
+ } catch (e) {
381
+ error(e.message);
382
+ }
383
+ });
384
+
385
+ tag.command("remove <tag-name>")
386
+ .description("Delete a tag")
387
+ .option("--oa-id <id>", "OA identifier", "default")
388
+ .action(async (tagName, opts) => {
389
+ try {
390
+ const result = await removeTag(tagName, opts.oaId);
391
+ output(result, json(), () => success(`Tag "${tagName}" removed`));
392
+ } catch (e) {
393
+ error(e.message);
394
+ }
395
+ });
396
+
397
+ tag.command("untag <user-id> <tag-name>")
398
+ .description("Remove follower from a tag")
399
+ .option("--oa-id <id>", "OA identifier", "default")
400
+ .action(async (userId, tagName, opts) => {
401
+ try {
402
+ const result = await removeFollowerFromTag(userId, tagName, opts.oaId);
403
+ output(result, json(), () => success(`Removed "${tagName}" from user`));
404
+ } catch (e) {
405
+ error(e.message);
406
+ }
407
+ });
408
+
409
+ // ─── Media Upload ────────────────────────────────────────────────
410
+
411
+ const upload = oa.command("upload").description("Upload media to OA");
412
+
413
+ upload
414
+ .command("image <file-path>")
415
+ .description("Upload image (returns attachment_id for sending)")
416
+ .option("--oa-id <id>", "OA identifier", "default")
417
+ .action(async (filePath, opts) => {
418
+ try {
419
+ const result = await uploadImage(resolve(filePath), opts.oaId);
420
+ output(result, json(), () => {
421
+ const d = result.data || result;
422
+ success(`Image uploaded: ${d.attachment_id || JSON.stringify(d)}`);
423
+ });
424
+ } catch (e) {
425
+ error(e.message);
426
+ }
427
+ });
428
+
429
+ upload
430
+ .command("file <file-path>")
431
+ .description("Upload file (returns token for sending)")
432
+ .option("--oa-id <id>", "OA identifier", "default")
433
+ .action(async (filePath, opts) => {
434
+ try {
435
+ const result = await uploadFile(resolve(filePath), opts.oaId);
436
+ output(result, json(), () => {
437
+ const d = result.data || result;
438
+ success(`File uploaded: ${d.token || JSON.stringify(d)}`);
439
+ });
440
+ } catch (e) {
441
+ error(e.message);
442
+ }
443
+ });
444
+
445
+ // ─── Conversations ───────────────────────────────────────────────
446
+
447
+ const conv = oa.command("conv").description("OA conversations");
448
+
449
+ conv.command("recent")
450
+ .description("List recent conversations")
451
+ .option("--offset <n>", "Offset", "0")
452
+ .option("--count <n>", "Count", "10")
453
+ .option("--oa-id <id>", "OA identifier", "default")
454
+ .action(async (opts) => {
455
+ try {
456
+ const result = await getRecentChat(Number(opts.offset), Number(opts.count), opts.oaId);
457
+ output(result, json(), () => {
458
+ const chats = result.data || [];
459
+ info(`Recent conversations: ${Array.isArray(chats) ? chats.length : "see JSON"}`);
460
+ });
461
+ } catch (e) {
462
+ error(e.message);
463
+ }
464
+ });
465
+
466
+ conv.command("history <user-id>")
467
+ .description("Get conversation history with a follower")
468
+ .option("--offset <n>", "Offset", "0")
469
+ .option("--count <n>", "Count", "10")
470
+ .option("--oa-id <id>", "OA identifier", "default")
471
+ .action(async (userId, opts) => {
472
+ try {
473
+ const result = await getConversation(userId, Number(opts.offset), Number(opts.count), opts.oaId);
474
+ output(result, json(), () => {
475
+ const msgs = result.data || [];
476
+ info(`Messages: ${Array.isArray(msgs) ? msgs.length : "see JSON"}`);
477
+ });
478
+ } catch (e) {
479
+ error(e.message);
480
+ }
481
+ });
482
+
483
+ // ─── Menu ────────────────────────────────────────────────────────
484
+
485
+ oa.command("menu <menu-json>")
486
+ .description("Update OA menu. menu-json: '{\"buttons\":[...]}'")
487
+ .option("--oa-id <id>", "OA identifier", "default")
488
+ .action(async (menuJson, opts) => {
489
+ try {
490
+ const menuData = JSON.parse(menuJson);
491
+ const result = await updateMenu(menuData, opts.oaId);
492
+ output(result, json(), () => success("OA menu updated"));
493
+ } catch (e) {
494
+ error(e.message);
495
+ }
496
+ });
497
+
498
+ // ─── Articles ────────────────────────────────────────────────────
499
+
500
+ const article = oa.command("article").description("Manage OA articles/broadcasts");
501
+
502
+ article
503
+ .command("create <article-json>")
504
+ .description("Create article (broadcast)")
505
+ .option("--oa-id <id>", "OA identifier", "default")
506
+ .action(async (articleJson, opts) => {
507
+ try {
508
+ const data = JSON.parse(articleJson);
509
+ const result = await createArticle(data, opts.oaId);
510
+ output(result, json(), () => success("Article created"));
511
+ } catch (e) {
512
+ error(e.message);
513
+ }
514
+ });
515
+
516
+ article
517
+ .command("list")
518
+ .description("List articles")
519
+ .option("--offset <n>", "Offset", "0")
520
+ .option("--limit <n>", "Limit", "10")
521
+ .option("--oa-id <id>", "OA identifier", "default")
522
+ .action(async (opts) => {
523
+ try {
524
+ const result = await getArticleList(Number(opts.offset), Number(opts.limit), opts.oaId);
525
+ output(result, json(), () => {
526
+ const articles = result.data?.articles || [];
527
+ info(`Articles: ${articles.length}`);
528
+ articles.forEach((a) => console.log(` - ${a.id}: ${a.title || "N/A"}`));
529
+ });
530
+ } catch (e) {
531
+ error(e.message);
532
+ }
533
+ });
534
+
535
+ article
536
+ .command("detail <article-id>")
537
+ .description("Get article details")
538
+ .option("--oa-id <id>", "OA identifier", "default")
539
+ .action(async (articleId, opts) => {
540
+ try {
541
+ const result = await getArticleDetail(articleId, opts.oaId);
542
+ output(result, json(), () => {
543
+ const d = result.data || result;
544
+ info(`Title: ${d.title || "N/A"}`);
545
+ if (d.status) info(`Status: ${d.status}`);
546
+ });
547
+ } catch (e) {
548
+ error(e.message);
549
+ }
550
+ });
551
+
552
+ // ─── Store ───────────────────────────────────────────────────────
553
+
554
+ const store = oa.command("store").description("Manage OA store (products, categories, orders)");
555
+
556
+ store
557
+ .command("product-create <product-json>")
558
+ .description("Create product in OA store")
559
+ .option("--oa-id <id>", "OA identifier", "default")
560
+ .action(async (productJson, opts) => {
561
+ try {
562
+ const data = JSON.parse(productJson);
563
+ const result = await createProduct(data, opts.oaId);
564
+ output(result, json(), () => success("Product created"));
565
+ } catch (e) {
566
+ error(e.message);
567
+ }
568
+ });
569
+
570
+ store
571
+ .command("product-list")
572
+ .description("List products")
573
+ .option("--offset <n>", "Offset", "0")
574
+ .option("--limit <n>", "Limit", "10")
575
+ .option("--oa-id <id>", "OA identifier", "default")
576
+ .action(async (opts) => {
577
+ try {
578
+ const result = await getProductList(Number(opts.offset), Number(opts.limit), opts.oaId);
579
+ output(result, json(), () => {
580
+ const products = result.data?.products || [];
581
+ info(`Products: ${products.length}`);
582
+ products.forEach((p) => console.log(` - ${p.id}: ${p.name || "N/A"}`));
583
+ });
584
+ } catch (e) {
585
+ error(e.message);
586
+ }
587
+ });
588
+
589
+ store
590
+ .command("product-info <product-id>")
591
+ .description("Get product details")
592
+ .option("--oa-id <id>", "OA identifier", "default")
593
+ .action(async (productId, opts) => {
594
+ try {
595
+ const result = await getProductInfo(productId, opts.oaId);
596
+ output(result, json(), () => {
597
+ const d = result.data || result;
598
+ info(`Product: ${d.name || "N/A"} — ${d.price || "N/A"}`);
599
+ });
600
+ } catch (e) {
601
+ error(e.message);
602
+ }
603
+ });
604
+
605
+ store
606
+ .command("category-create <category-json>")
607
+ .description("Create store category")
608
+ .option("--oa-id <id>", "OA identifier", "default")
609
+ .action(async (categoryJson, opts) => {
610
+ try {
611
+ const data = JSON.parse(categoryJson);
612
+ const result = await createCategory(data, opts.oaId);
613
+ output(result, json(), () => success("Category created"));
614
+ } catch (e) {
615
+ error(e.message);
616
+ }
617
+ });
618
+
619
+ store
620
+ .command("category-list")
621
+ .description("List store categories")
622
+ .option("--oa-id <id>", "OA identifier", "default")
623
+ .action(async (opts) => {
624
+ try {
625
+ const result = await getCategoryList(opts.oaId);
626
+ output(result, json(), () => {
627
+ const cats = result.data?.categories || [];
628
+ info(`Categories: ${cats.length}`);
629
+ cats.forEach((c) => console.log(` - ${c.id}: ${c.name || "N/A"}`));
630
+ });
631
+ } catch (e) {
632
+ error(e.message);
633
+ }
634
+ });
635
+
636
+ store
637
+ .command("order-create <order-json>")
638
+ .description("Create order")
639
+ .option("--oa-id <id>", "OA identifier", "default")
640
+ .action(async (orderJson, opts) => {
641
+ try {
642
+ const data = JSON.parse(orderJson);
643
+ const result = await createOrder(data, opts.oaId);
644
+ output(result, json(), () => success("Order created"));
645
+ } catch (e) {
646
+ error(e.message);
647
+ }
648
+ });
649
+
650
+ // ─── Webhook Listener ────────────────────────────────────────────
651
+
652
+ registerOAListenCommand(oa, program);
653
+
654
+ // ─── Guided Setup ────────────────────────────────────────────────
655
+
656
+ registerOAInitCommand(oa, program);
657
+ }