@stage5/lumine 0.2.17 → 0.2.19

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/lib/admin.js ADDED
@@ -0,0 +1,770 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { assertAuthScope, resolveAuth } from "./auth.js";
3
+ import { requestJson } from "./http.js";
4
+
5
+ export async function adminCommand(options) {
6
+ const operation = parseAdminOperation(options);
7
+ const auth = await resolveAuth(options);
8
+ await assertAuthScope({
9
+ options,
10
+ auth,
11
+ scope: operation.mutates ? "build:write" : "build:read",
12
+ });
13
+ let runId = 0;
14
+ if (adminOperationRequiresRun(operation)) {
15
+ const runStatus = await requestJson({
16
+ url: `${options.apiUrl}/cli/admin/daily-runs/status`,
17
+ authToken: auth.token,
18
+ timeoutMs: options.timeoutMs,
19
+ });
20
+ const activeRun = runStatus?.data?.run || null;
21
+ const retryableFinishedRun = [
22
+ "daily-run.complete",
23
+ "daily-run.fail",
24
+ ].includes(operation.name)
25
+ ? runStatus?.data?.lastRun || null
26
+ : null;
27
+ const selectedRun = activeRun || retryableFinishedRun;
28
+ runId = Number(selectedRun?.id || 0);
29
+ if (!runId) throw noActiveRunError();
30
+ if (options.adminIdentity) {
31
+ const requestedIdentity = parseIdentity(options.adminIdentity);
32
+ if (
33
+ requestedIdentity !== "auto" &&
34
+ requestedIdentity !== selectedRun?.identity?.key
35
+ ) {
36
+ throw cliValidationError(
37
+ `--identity ${requestedIdentity} does not match active run #${runId} (${selectedRun?.identity?.key || "unknown"}).`,
38
+ );
39
+ }
40
+ }
41
+ }
42
+ const requestId =
43
+ options.idempotencyKey || (operation.mutates ? `cli:${randomUUID()}` : "");
44
+ let result;
45
+ try {
46
+ result = await requestJson({
47
+ method: operation.method,
48
+ url: `${options.apiUrl}${operation.path}`,
49
+ authToken: auth.token,
50
+ body: operation.body,
51
+ headers: {
52
+ ...(runId ? { "x-lumine-admin-run-id": String(runId) } : {}),
53
+ ...(requestId ? { "x-lumine-idempotency-key": requestId } : {}),
54
+ },
55
+ timeoutMs: options.timeoutMs,
56
+ });
57
+ } catch (error) {
58
+ if (operation.mutates && requestId) {
59
+ const retryInstruction = `Retry with --idempotency-key ${requestId}.`;
60
+ error.data = error.data || {
61
+ ok: false,
62
+ status: "error",
63
+ error: {
64
+ code: error.code || "LUMINE_ADMIN_REQUEST_FAILED",
65
+ message: String(error.message || "The administrator request failed."),
66
+ details: null,
67
+ },
68
+ };
69
+ if (error.data?.error) {
70
+ const details =
71
+ error.data.error.details &&
72
+ typeof error.data.error.details === "object" &&
73
+ !Array.isArray(error.data.error.details)
74
+ ? error.data.error.details
75
+ : {};
76
+ error.data.error.details = {
77
+ ...details,
78
+ retryIdempotencyKey: requestId,
79
+ };
80
+ }
81
+ error.message = `${error.message} ${retryInstruction}`;
82
+ }
83
+ throw error;
84
+ }
85
+ if (options.json) {
86
+ console.log(JSON.stringify(result));
87
+ return result;
88
+ }
89
+ printAdminResult({ operation, result });
90
+ return result;
91
+ }
92
+
93
+ function adminOperationRequiresRun(operation) {
94
+ return ![
95
+ "identity.list",
96
+ "identity.status",
97
+ "identity.use",
98
+ "daily-run.start",
99
+ "daily-run.status",
100
+ ].includes(operation.name);
101
+ }
102
+
103
+ function noActiveRunError() {
104
+ const error = new Error(
105
+ "Start a delegated administrator daily run before using this command.",
106
+ );
107
+ error.code = "CLI_ADMIN_NO_ACTIVE_RUN";
108
+ error.data = {
109
+ ok: false,
110
+ status: "validation_error",
111
+ error: {
112
+ code: error.code,
113
+ message: error.message,
114
+ details: null,
115
+ },
116
+ };
117
+ return error;
118
+ }
119
+
120
+ export function parseAdminOperation(options) {
121
+ const [namespace = "", action = "", target = "", extra = ""] =
122
+ options.positional;
123
+
124
+ if (namespace === "identity") {
125
+ if (action === "list") {
126
+ return readOperation("identity.list", "/cli/admin/identities");
127
+ }
128
+ if (action === "status") {
129
+ return readOperation("identity.status", "/cli/admin/identity/status");
130
+ }
131
+ if (action === "use") {
132
+ return writeOperation(
133
+ "identity.use",
134
+ "PUT",
135
+ "/cli/admin/identity/preference",
136
+ { identity: parseIdentity(target) },
137
+ );
138
+ }
139
+ }
140
+
141
+ if (namespace === "daily-run") {
142
+ if (action === "start") {
143
+ return writeOperation(
144
+ "daily-run.start",
145
+ "POST",
146
+ "/cli/admin/daily-runs/start",
147
+ {
148
+ identity: options.adminIdentity
149
+ ? parseIdentity(options.adminIdentity)
150
+ : undefined,
151
+ commentMode: parseCommentMode(options.commentMode || "off"),
152
+ runKey: options.runKey || defaultDailyRunKey(),
153
+ },
154
+ );
155
+ }
156
+ if (action === "status") {
157
+ return readOperation("daily-run.status", "/cli/admin/daily-runs/status");
158
+ }
159
+ if (action === "complete" || action === "fail") {
160
+ return writeOperation(
161
+ `daily-run.${action}`,
162
+ "POST",
163
+ `/cli/admin/daily-runs/${action}`,
164
+ action === "fail" ? { reason: options.adminReason || undefined } : {},
165
+ );
166
+ }
167
+ }
168
+
169
+ if (
170
+ (namespace === "recommend-queue" && (!action || action === "list")) ||
171
+ (namespace === "recommendations" && action === "list")
172
+ ) {
173
+ const kind = String(options.adminKind || "recommend").toLowerCase();
174
+ if (kind !== "recommend") {
175
+ throw cliValidationError("--kind currently supports only recommend.");
176
+ }
177
+ return readOperation(
178
+ "recommendations.list",
179
+ withQuery("/cli/admin/recommendations", {
180
+ kind,
181
+ cursor: options.adminCursor,
182
+ limit: options.limit,
183
+ }),
184
+ );
185
+ }
186
+
187
+ if (
188
+ namespace === "subjects" &&
189
+ (action === "list" || action === "candidates")
190
+ ) {
191
+ return subjectsListOperation(options);
192
+ }
193
+ if (namespace === "subjects" && action === "featured") {
194
+ return readOperation("featured.list", "/cli/admin/subjects/featured");
195
+ }
196
+ if (namespace === "subjects") {
197
+ return legacySubjectsOperation({ action, target, options });
198
+ }
199
+
200
+ if (namespace === "subject") {
201
+ if (action === "get") return subjectGetOperation(target, options);
202
+ if (action === "comments") {
203
+ const subjectId = parseSubjectId(target);
204
+ return readOperation(
205
+ "subject.comments",
206
+ withQuery(`/cli/admin/subjects/${subjectId}/comments`, {
207
+ cursor: options.adminCursor,
208
+ limit: options.limit,
209
+ }),
210
+ );
211
+ }
212
+ if (action === "reveal") {
213
+ return subjectWrite("subject.reveal", "POST", target, "/reveal");
214
+ }
215
+ if (action === "effort" && target === "set") {
216
+ const subjectId = parseSubjectId(extra);
217
+ const level = parseRequiredInteger(options.adminLevel, "--level", 1, 3);
218
+ return writeOperation(
219
+ "subject.effort.set",
220
+ "PUT",
221
+ `/cli/admin/subjects/${subjectId}/effort`,
222
+ { level },
223
+ );
224
+ }
225
+ if (action === "creator" && target === "set-made-by-poster") {
226
+ return subjectWrite(
227
+ "subject.creator.set-made-by-poster",
228
+ "PUT",
229
+ extra,
230
+ "/created-by-author",
231
+ );
232
+ }
233
+ if (action === "feature" || action === "unfeature") {
234
+ return subjectWrite(
235
+ `subject.${action}`,
236
+ action === "feature" ? "POST" : "DELETE",
237
+ target,
238
+ "/featured",
239
+ );
240
+ }
241
+ }
242
+
243
+ if (namespace === "featured") {
244
+ if (action === "list") {
245
+ return readOperation("featured.list", "/cli/admin/subjects/featured");
246
+ }
247
+ if (action === "reorder") {
248
+ return featuredReorderOperation(options);
249
+ }
250
+ }
251
+
252
+ if (namespace === "comments" && action === "get") {
253
+ const commentId = parseRequiredInteger(target, "comment ID", 1);
254
+ return readOperation("comments.get", `/cli/admin/comments/${commentId}`);
255
+ }
256
+
257
+ if (namespace === "post") {
258
+ if (action === "get") return postGetOperation(target, options);
259
+ if (action === "comments") {
260
+ const parsedTarget = parseRecommendationTarget({
261
+ target,
262
+ explicitType: options.adminType,
263
+ });
264
+ if (parsedTarget.type === "comment") {
265
+ throw cliValidationError("A comment cannot contain a comment list.");
266
+ }
267
+ const path =
268
+ parsedTarget.type === "subject"
269
+ ? `/cli/admin/subjects/${parsedTarget.id}/comments`
270
+ : `/cli/admin/posts/${parsedTarget.type}/${parsedTarget.id}/comments`;
271
+ return readOperation(
272
+ "post.comments",
273
+ withQuery(path, {
274
+ cursor: options.adminCursor,
275
+ limit: options.limit,
276
+ }),
277
+ );
278
+ }
279
+ if (action === "recommend") {
280
+ return recommendOperation(target, options);
281
+ }
282
+ if (action === "reward") {
283
+ const parsedTarget = parseRecommendationTarget({
284
+ target,
285
+ explicitType: options.adminType,
286
+ });
287
+ const twinkles = parseRequiredInteger(
288
+ options.twinkles,
289
+ "--twinkles",
290
+ 3,
291
+ 3,
292
+ );
293
+ return writeOperation(
294
+ "post.reward",
295
+ "POST",
296
+ `/cli/admin/rewards/${parsedTarget.type}/${parsedTarget.id}`,
297
+ { twinkles },
298
+ );
299
+ }
300
+ }
301
+
302
+ if (namespace === "recommend" && action) {
303
+ return recommendOperation(action, options);
304
+ }
305
+
306
+ if (namespace === "comment") {
307
+ if (action === "draft") {
308
+ const subjectId = parseSubjectId(target);
309
+ return writeOperation(
310
+ "comment.draft",
311
+ "POST",
312
+ "/cli/admin/comment-drafts",
313
+ {
314
+ subjectId,
315
+ identity: options.adminIdentity
316
+ ? parseIdentity(options.adminIdentity)
317
+ : undefined,
318
+ },
319
+ );
320
+ }
321
+ if (action === "post") {
322
+ const draftId = parseRequiredInteger(
323
+ options.draftId || target,
324
+ "--draft-id",
325
+ 1,
326
+ );
327
+ return writeOperation(
328
+ "comment.post",
329
+ "POST",
330
+ `/cli/admin/comment-drafts/${draftId}/publish`,
331
+ {},
332
+ );
333
+ }
334
+ }
335
+
336
+ throw cliValidationError(
337
+ "Usage: lumine admin identity|daily-run|recommendations|post|subjects|subject|featured|comment ...",
338
+ );
339
+ }
340
+
341
+ function subjectsListOperation(options) {
342
+ return readOperation(
343
+ "subjects.candidates",
344
+ withQuery("/cli/admin/subjects", {
345
+ after: options.adminAfter,
346
+ cursor: options.adminCursor,
347
+ effort: options.adminEffort,
348
+ limit: options.limit,
349
+ }),
350
+ );
351
+ }
352
+
353
+ function subjectGetOperation(target, options) {
354
+ const subjectId = parseSubjectId(target);
355
+ return readOperation(
356
+ "subject.get",
357
+ withQuery(`/cli/admin/subjects/${subjectId}`, {
358
+ includeComments: options.includeComments ? "true" : "",
359
+ }),
360
+ );
361
+ }
362
+
363
+ function postGetOperation(target, options) {
364
+ const parsedTarget = parseRecommendationTarget({
365
+ target,
366
+ explicitType: options.adminType,
367
+ });
368
+ if (parsedTarget.type === "subject") {
369
+ return subjectGetOperation(parsedTarget.id, options);
370
+ }
371
+ if (parsedTarget.type === "comment") {
372
+ return readOperation("post.get", `/cli/admin/comments/${parsedTarget.id}`);
373
+ }
374
+ return readOperation(
375
+ "post.get",
376
+ `/cli/admin/posts/${parsedTarget.type}/${parsedTarget.id}`,
377
+ );
378
+ }
379
+
380
+ function legacySubjectsOperation({ action, target, options }) {
381
+ if (action === "get") return subjectGetOperation(target, options);
382
+ if (action === "set-effort") {
383
+ const subjectId = parseSubjectId(target);
384
+ const level = parseRequiredInteger(options.adminLevel, "--level", 1, 3);
385
+ return writeOperation(
386
+ "subject.effort.set",
387
+ "PUT",
388
+ `/cli/admin/subjects/${subjectId}/effort`,
389
+ { level },
390
+ );
391
+ }
392
+ if (action === "mark-created-by-author") {
393
+ return subjectWrite(
394
+ "subject.creator.set-made-by-poster",
395
+ "PUT",
396
+ target,
397
+ "/created-by-author",
398
+ );
399
+ }
400
+ if (action === "feature" || action === "unfeature") {
401
+ return subjectWrite(
402
+ `subject.${action}`,
403
+ action === "feature" ? "POST" : "DELETE",
404
+ target,
405
+ "/featured",
406
+ );
407
+ }
408
+ if (action === "reorder") return featuredReorderOperation(options);
409
+ throw cliValidationError(
410
+ `Unknown subjects action: ${action || "(missing)"}.`,
411
+ );
412
+ }
413
+
414
+ function subjectWrite(name, method, target, suffix) {
415
+ const subjectId = parseSubjectId(target);
416
+ return writeOperation(
417
+ name,
418
+ method,
419
+ `/cli/admin/subjects/${subjectId}${suffix}`,
420
+ {},
421
+ );
422
+ }
423
+
424
+ function featuredReorderOperation(options) {
425
+ return writeOperation(
426
+ "featured.reorder",
427
+ "PUT",
428
+ "/cli/admin/subjects/featured/order",
429
+ { ids: parseOrderedIds(options.adminIds) },
430
+ );
431
+ }
432
+
433
+ function recommendOperation(target, options) {
434
+ const recommendationTarget = parseRecommendationTarget({
435
+ target,
436
+ explicitType: options.adminType,
437
+ });
438
+ const rewardTwinkles = options.rewardTwinkles
439
+ ? parseRequiredInteger(options.rewardTwinkles, "--reward-twinkles", 3, 3)
440
+ : 0;
441
+ if (rewardTwinkles === 3 && !options.anyoneCanReward) {
442
+ throw cliValidationError(
443
+ "--reward-twinkles 3 requires --anyone-can-reward.",
444
+ );
445
+ }
446
+ return writeOperation(
447
+ "post.recommend",
448
+ "POST",
449
+ `/cli/admin/recommendations/${recommendationTarget.type}/${recommendationTarget.id}`,
450
+ {
451
+ anyoneCanReward: options.anyoneCanReward,
452
+ rewardTwinkles,
453
+ },
454
+ );
455
+ }
456
+
457
+ export function parseSubjectId(value) {
458
+ const normalized = String(value || "").trim();
459
+ const match = normalized.match(/(?:^subject:|\/subjects\/)(\d+)(?:[/?#]|$)/i);
460
+ return parseRequiredInteger(match?.[1] || normalized, "subject ID", 1);
461
+ }
462
+
463
+ export function parseRecommendationTarget({ target, explicitType = "" }) {
464
+ const normalized = String(target || "").trim();
465
+ const prefixed = normalized.match(
466
+ /^(subject|comment|aistory|dailyreflection):(\d+)$/i,
467
+ );
468
+ const subjectUrl = normalized.match(/\/subjects\/(\d+)(?:[/?#]|$)/i);
469
+ const commentUrl = normalized.match(/\/comments\/(\d+)(?:[/?#]|$)/i);
470
+ const aiStoryUrl = normalized.match(/\/ai-stories\/(\d+)(?:[/?#]|$)/i);
471
+ const dailyReflectionUrl = normalized.match(
472
+ /\/daily-reflections\/(\d+)(?:[/?#]|$)/i,
473
+ );
474
+ const inferredType =
475
+ normalizeRecommendationTargetType(prefixed?.[1]) ||
476
+ (subjectUrl
477
+ ? "subject"
478
+ : commentUrl
479
+ ? "comment"
480
+ : aiStoryUrl
481
+ ? "aiStory"
482
+ : dailyReflectionUrl
483
+ ? "dailyReflection"
484
+ : "");
485
+ const explicitNormalizedType =
486
+ normalizeRecommendationTargetType(explicitType);
487
+ if (explicitType && !explicitNormalizedType) {
488
+ throw cliValidationError(
489
+ "--type must be subject, comment, aiStory, or dailyReflection.",
490
+ );
491
+ }
492
+ const type = explicitNormalizedType || inferredType || "subject";
493
+ if (!["subject", "comment", "aiStory", "dailyReflection"].includes(type)) {
494
+ throw cliValidationError(
495
+ "--type must be subject, comment, aiStory, or dailyReflection.",
496
+ );
497
+ }
498
+ if (explicitType && inferredType && type !== inferredType) {
499
+ throw cliValidationError(
500
+ "The target and --type identify different content types.",
501
+ );
502
+ }
503
+ const id = parseRequiredInteger(
504
+ prefixed?.[2] ||
505
+ subjectUrl?.[1] ||
506
+ commentUrl?.[1] ||
507
+ aiStoryUrl?.[1] ||
508
+ dailyReflectionUrl?.[1] ||
509
+ normalized,
510
+ `${type} ID`,
511
+ 1,
512
+ );
513
+ return { type, id };
514
+ }
515
+
516
+ function normalizeRecommendationTargetType(value) {
517
+ const normalized = String(value || "")
518
+ .trim()
519
+ .toLowerCase();
520
+ if (normalized === "subject" || normalized === "comment") return normalized;
521
+ if (normalized === "aistory" || normalized === "ai-story") return "aiStory";
522
+ if (normalized === "dailyreflection" || normalized === "daily-reflection") {
523
+ return "dailyReflection";
524
+ }
525
+ return "";
526
+ }
527
+
528
+ export function isAdminJsonInvocation(args) {
529
+ return (
530
+ args[0] === "admin" &&
531
+ args.some((arg) => /^--json(?:=(?:1|true|yes|on))?$/i.test(arg))
532
+ );
533
+ }
534
+
535
+ export function formatAdminJsonError(error) {
536
+ const serverResult = error?.data;
537
+ if (serverResult?.ok === false && serverResult?.error) return serverResult;
538
+ return {
539
+ ok: false,
540
+ status:
541
+ error?.code === "CLI_ADMIN_CLI_VALIDATION" ? "validation_error" : "error",
542
+ error: {
543
+ code: error?.code || "LUMINE_ADMIN_ERROR",
544
+ message: String(error?.message || "The administrator command failed."),
545
+ details: null,
546
+ },
547
+ };
548
+ }
549
+
550
+ function readOperation(name, path) {
551
+ return { name, method: "GET", path, body: undefined, mutates: false };
552
+ }
553
+
554
+ function writeOperation(name, method, path, body) {
555
+ return { name, method, path, body, mutates: true };
556
+ }
557
+
558
+ function withQuery(path, values) {
559
+ const query = new URLSearchParams();
560
+ for (const [key, value] of Object.entries(values)) {
561
+ if (value !== "" && value !== null && value !== undefined) {
562
+ query.set(key, String(value));
563
+ }
564
+ }
565
+ const encoded = query.toString();
566
+ return encoded ? `${path}?${encoded}` : path;
567
+ }
568
+
569
+ function parseIdentity(value) {
570
+ const identity = String(value || "")
571
+ .trim()
572
+ .toLowerCase();
573
+ if (!["zero", "ciel", "auto"].includes(identity)) {
574
+ throw cliValidationError("Identity must be zero, ciel, or auto.");
575
+ }
576
+ return identity;
577
+ }
578
+
579
+ function parseCommentMode(value) {
580
+ const mode = String(value || "off")
581
+ .trim()
582
+ .toLowerCase();
583
+ if (!["off", "draft", "post"].includes(mode)) {
584
+ throw cliValidationError("--comment-mode must be off, draft, or post.");
585
+ }
586
+ return mode;
587
+ }
588
+
589
+ function parseOrderedIds(value) {
590
+ const ids = String(value || "")
591
+ .split(",")
592
+ .map((part) => part.trim())
593
+ .filter(Boolean)
594
+ .map((part) => parseRequiredInteger(part, "Featured subject ID", 1));
595
+ if (!String(value || "").trim()) {
596
+ throw cliValidationError(
597
+ "Pass the complete ordered list with --subject-ids <id,id,...>.",
598
+ );
599
+ }
600
+ if (new Set(ids).size !== ids.length) {
601
+ throw cliValidationError("Featured subject IDs must be unique.");
602
+ }
603
+ return ids;
604
+ }
605
+
606
+ function parseRequiredInteger(
607
+ value,
608
+ label,
609
+ minimum,
610
+ maximum = Number.MAX_SAFE_INTEGER,
611
+ ) {
612
+ const raw = String(value ?? "").trim();
613
+ const number = /^\d+$/.test(raw) ? Number(raw) : NaN;
614
+ if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
615
+ const range =
616
+ maximum === Number.MAX_SAFE_INTEGER
617
+ ? `at least ${minimum}`
618
+ : `${minimum}-${maximum}`;
619
+ throw cliValidationError(`${label} must be an integer ${range}.`);
620
+ }
621
+ return number;
622
+ }
623
+
624
+ function defaultDailyRunKey() {
625
+ const parts = new Intl.DateTimeFormat("en-CA", {
626
+ timeZone: "Asia/Bangkok",
627
+ year: "numeric",
628
+ month: "2-digit",
629
+ day: "2-digit",
630
+ }).formatToParts(new Date());
631
+ const value = Object.fromEntries(
632
+ parts.map((part) => [part.type, part.value]),
633
+ );
634
+ return `daily:${value.year}-${value.month}-${value.day}`;
635
+ }
636
+
637
+ function cliValidationError(message) {
638
+ const error = new Error(message);
639
+ error.code = "CLI_ADMIN_CLI_VALIDATION";
640
+ return error;
641
+ }
642
+
643
+ function printAdminResult({ operation, result }) {
644
+ const data = result?.data || {};
645
+ if (data.run !== undefined) {
646
+ if (!data.run) {
647
+ console.log("No active delegated administrator daily run.");
648
+ if (data.lastRun) {
649
+ console.log(
650
+ `Last run #${data.lastRun.id}: ${data.lastRun.status}; identity ${data.lastRun.identity.key}; comments ${data.lastRun.commentMode}.`,
651
+ );
652
+ }
653
+ return;
654
+ }
655
+ console.log(
656
+ `Run #${data.run.id}: ${data.run.status}; identity ${data.run.identity.key}; comments ${data.run.commentMode}.`,
657
+ );
658
+ return;
659
+ }
660
+ if (Array.isArray(data.identities)) {
661
+ console.log(
662
+ `Approved identities: ${data.identities.map((v) => v.key).join(", ")}.`,
663
+ );
664
+ console.log(`Preferred: ${data.preferredIdentity}.`);
665
+ return;
666
+ }
667
+ if (Object.hasOwn(data, "activeRun")) {
668
+ console.log(`Preferred identity: ${data.preferredIdentity}.`);
669
+ console.log(
670
+ `Last completed identity: ${data.lastCompletedIdentity || "none"}.`,
671
+ );
672
+ console.log(
673
+ data.activeRun
674
+ ? `Active run #${data.activeRun.id}: ${data.activeRun.identity.key}; comments ${data.activeRun.commentMode}.`
675
+ : "No active delegated administrator daily run.",
676
+ );
677
+ return;
678
+ }
679
+ if (Array.isArray(data.subjects)) {
680
+ console.log(`${data.subjects.length} subject(s):`);
681
+ for (const subject of data.subjects) {
682
+ console.log(
683
+ `#${subject.id} ${subject.title || "(untitled)"} — ${subject.author?.username || "unknown"} — effort ${subject.effortLevel ?? "unknown"}`,
684
+ );
685
+ console.log(` ${subject.url}`);
686
+ }
687
+ printPagination(data.pagination);
688
+ return;
689
+ }
690
+ if (Array.isArray(data.items)) {
691
+ console.log(`${data.items.length} recommendation candidate(s):`);
692
+ for (const item of data.items) {
693
+ console.log(`#${item.contentId} ${item.contentType}`);
694
+ if (item.url || item.subjectUrl)
695
+ console.log(` ${item.url || item.subjectUrl}`);
696
+ }
697
+ printPagination(data.pagination);
698
+ return;
699
+ }
700
+ if (Array.isArray(data.comments)) {
701
+ console.log(`${data.comments.length} comment(s) for ${data.subject?.url}:`);
702
+ for (const comment of data.comments) {
703
+ console.log(
704
+ `#${comment.id} ${comment.author?.username || "unknown"}: ${comment.content || "(empty)"}`,
705
+ );
706
+ }
707
+ printPagination(data.pagination);
708
+ return;
709
+ }
710
+ if (data.published) {
711
+ console.log(
712
+ `${result.status || "success"}: comment #${data.published.commentId}.`,
713
+ );
714
+ console.log(data.published.commentUrl || data.published.subjectUrl);
715
+ return;
716
+ }
717
+ if (data.draft) {
718
+ console.log(
719
+ `Draft #${data.draft.id}: ${data.draft.decision} (${data.draft.status}).`,
720
+ );
721
+ if (data.draft.content) console.log(data.draft.content);
722
+ if (data.draft.subjectUrl) console.log(data.draft.subjectUrl);
723
+ return;
724
+ }
725
+ if (data.subject) {
726
+ if (data.pairing) {
727
+ console.log(
728
+ `${result.status}: recommendation #${data.pairing.recommendationId}; reward ${data.pairing.rewardStatus}.`,
729
+ );
730
+ } else if (data.rewardOperation) {
731
+ console.log(
732
+ `${result.status}: reward ${data.rewardOperation.status || "confirmed"}.`,
733
+ );
734
+ } else if (data.reveal) {
735
+ console.log(`${result.status}: ${data.reveal.status}.`);
736
+ }
737
+ console.log(`#${data.subject.id} ${data.subject.title || "(untitled)"}`);
738
+ console.log(data.subject.url);
739
+ return;
740
+ }
741
+ if (data.comment) {
742
+ if (data.pairing) {
743
+ console.log(
744
+ `${result.status}: recommendation #${data.pairing.recommendationId}; reward ${data.pairing.rewardStatus}.`,
745
+ );
746
+ } else if (data.rewardOperation) {
747
+ console.log(
748
+ `${result.status}: reward ${data.rewardOperation.status || "confirmed"}.`,
749
+ );
750
+ }
751
+ console.log(
752
+ `#${data.comment.id} by ${data.comment.author?.username || "unknown"}`,
753
+ );
754
+ if (data.comment.subjectUrl) console.log(data.comment.subjectUrl);
755
+ if (data.comment.content) console.log(data.comment.content);
756
+ return;
757
+ }
758
+ console.log(
759
+ `${result.status || "success"}${result.changed === false ? " (no change)" : ""}.`,
760
+ );
761
+ }
762
+
763
+ function printPagination(pagination) {
764
+ if (!pagination) return;
765
+ console.log(
766
+ pagination.exhausted
767
+ ? "End of canonical snapshot."
768
+ : `Next cursor: ${pagination.nextCursor}`,
769
+ );
770
+ }