@movermarketingai/searchlight-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.
@@ -0,0 +1,1471 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { chmod, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+
8
+ const defaultBaseUrl = "https://searchlight.movermarketing.ai";
9
+ const defaultTokenFilePath = join(homedir(), ".config", "searchlight", "token");
10
+ const scopeLabels = {
11
+ all: "All pages",
12
+ content: "Content group",
13
+ specific: "Specific pages",
14
+ };
15
+ const booleanArgKeys = new Set(["dryRun", "includeNoWebsite", "json", "tokenStdin", "yes"]);
16
+
17
+ const rootAnnotationCommands = new Set([
18
+ "sites",
19
+ "list",
20
+ "add",
21
+ "add-from-annotate",
22
+ "update",
23
+ "update-from-annotate",
24
+ "delete",
25
+ ]);
26
+
27
+ function usage(exitCode = 0) {
28
+ console.log(`Usage:
29
+ mmai-searchlight help
30
+ mmai-searchlight setup
31
+ npm run searchlight -- help
32
+
33
+ Authentication:
34
+ mmai-searchlight setup
35
+ mmai-searchlight auth store --token-stdin [--json]
36
+ mmai-searchlight auth check [--json]
37
+ mmai-searchlight auth path [--json]
38
+ mmai-searchlight auth logout [--yes] [--json]
39
+
40
+ Clients:
41
+ mmai-searchlight clients list [--json]
42
+ mmai-searchlight clients add --name NAME --gsc-property PROPERTY [--site-slug SLUG] [--ga4-property PROPERTY] [--json]
43
+ mmai-searchlight clients import-report-portal [--dry-run] [--yes] [--json] [--match TEXT] [--limit N] [--property-mode auto|url-prefix|domain] [--ga4-mode auto|off] [--refresh-mode none|gsc|all]
44
+
45
+ Content runs:
46
+ mmai-searchlight content-runs list --site SITE [--json]
47
+ mmai-searchlight content-runs add --site SITE --file content-pipeline/runs/RUN/manifest.json [--dry-run] [--json]
48
+ mmai-searchlight content-runs ingest --site SITE --file content-pipeline/runs/RUN/manifest.json [--dry-run] [--json]
49
+
50
+ Annotations:
51
+ mmai-searchlight annotations sites [--json]
52
+ mmai-searchlight annotations list --site SITE [--json]
53
+ mmai-searchlight annotations add --site SITE --title TITLE --summary TEXT [--date YYYY-MM-DD] [--scope all|specific|content] [--page URL_OR_PATH ...] [--dry-run]
54
+ mmai-searchlight annotations add-from-annotate --site SITE --file FILE|- [--date YYYY-MM-DD] [--dry-run]
55
+ mmai-searchlight annotations update --site SITE --id ID [--title TITLE] [--summary TEXT] [--date YYYY-MM-DD] [--scope all|specific|content] [--page URL_OR_PATH ...] [--dry-run]
56
+ mmai-searchlight annotations update-from-annotate --site SITE --id ID --file FILE|- [--date YYYY-MM-DD] [--dry-run]
57
+ mmai-searchlight annotations delete --site SITE --id ID --yes
58
+
59
+ SEO tests:
60
+ mmai-searchlight tests list --site SITE [--json]
61
+ mmai-searchlight tests refresh --site SITE [--json]
62
+ mmai-searchlight tests run --site SITE [--json]
63
+
64
+ Legacy annotation shortcuts are still supported:
65
+ npm run annotations -- sites
66
+ npm run annotations -- add-from-annotate --site SITE --file annotation.txt
67
+
68
+ Environment:
69
+ SEARCHLIGHT_TOKEN Preferred explicit bearer token override.
70
+ SEARCHLIGHT_WORKER_TOKEN Scoped worker bearer token for internal workers.
71
+ SEARCHLIGHT_TOKEN_FILE Optional token file path; defaults to ${defaultTokenFilePath}.
72
+ SEARCHLIGHT_BASE_URL Optional; defaults to ${defaultBaseUrl}.
73
+ `);
74
+ process.exit(exitCode);
75
+ }
76
+
77
+ function parseArgs(argv) {
78
+ const [command, ...tokens] = argv;
79
+ const options = { _: [], command, page: [] };
80
+
81
+ for (let index = 0; index < tokens.length; index += 1) {
82
+ const token = tokens[index];
83
+
84
+ if (!token.startsWith("--")) {
85
+ options._.push(token);
86
+ continue;
87
+ }
88
+
89
+ const [rawKey, inlineValue] = token.slice(2).split("=", 2);
90
+ const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
91
+
92
+ if (booleanArgKeys.has(key)) {
93
+ options[key] = inlineValue === undefined ? true : inlineValue !== "false";
94
+ continue;
95
+ }
96
+
97
+ const value = inlineValue ?? tokens[index + 1];
98
+
99
+ if (typeof value !== "string" || value.startsWith("--")) {
100
+ throw new Error(`--${rawKey} requires a value.`);
101
+ }
102
+
103
+ if (inlineValue === undefined) {
104
+ index += 1;
105
+ }
106
+
107
+ if (key === "page") {
108
+ options.page.push(value);
109
+ } else {
110
+ options[key] = value;
111
+ }
112
+ }
113
+
114
+ return options;
115
+ }
116
+
117
+ function requireOption(options, key) {
118
+ const value = options[key];
119
+
120
+ if (typeof value !== "string" || value.trim().length === 0) {
121
+ throw new Error(`--${key} is required.`);
122
+ }
123
+
124
+ return value.trim();
125
+ }
126
+
127
+ function optionalString(value) {
128
+ if (typeof value !== "string") {
129
+ return undefined;
130
+ }
131
+
132
+ const trimmed = value.trim();
133
+ return trimmed.length > 0 ? trimmed : undefined;
134
+ }
135
+
136
+ function getBaseUrl(options) {
137
+ return (options.baseUrl ?? process.env.SEARCHLIGHT_BASE_URL ?? defaultBaseUrl).replace(/\/$/, "");
138
+ }
139
+
140
+ function getTokenFilePath() {
141
+ return process.env.SEARCHLIGHT_TOKEN_FILE ?? defaultTokenFilePath;
142
+ }
143
+
144
+ async function readStoredToken() {
145
+ const tokenFilePath = getTokenFilePath();
146
+
147
+ try {
148
+ const [contents, fileStats] = await Promise.all([
149
+ readFile(tokenFilePath, "utf8"),
150
+ stat(tokenFilePath),
151
+ ]);
152
+ const unsafeModeBits = fileStats.mode & 0o077;
153
+
154
+ if (unsafeModeBits !== 0) {
155
+ console.error(
156
+ `Warning: ${tokenFilePath} is readable by group/other users. Run: chmod 600 ${tokenFilePath}`,
157
+ );
158
+ }
159
+
160
+ return contents.trim() || undefined;
161
+ } catch (error) {
162
+ if (error?.code === "ENOENT") {
163
+ return undefined;
164
+ }
165
+
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async function getAuthToken() {
171
+ const token =
172
+ process.env.SEARCHLIGHT_TOKEN ??
173
+ process.env.SEARCHLIGHT_WORKER_TOKEN ??
174
+ (await readStoredToken());
175
+
176
+ if (!token) {
177
+ throw new Error(
178
+ `No Searchlight CLI token found. Run "mmai-searchlight setup" for safe storage instructions, set SEARCHLIGHT_TOKEN, or store a token at ${getTokenFilePath()}.`,
179
+ );
180
+ }
181
+
182
+ return token;
183
+ }
184
+
185
+ async function hasConfiguredAuthToken() {
186
+ return Boolean(
187
+ process.env.SEARCHLIGHT_TOKEN ??
188
+ process.env.SEARCHLIGHT_WORKER_TOKEN ??
189
+ (await readStoredToken()),
190
+ );
191
+ }
192
+
193
+ function todayApiDate() {
194
+ return new Date().toISOString().slice(0, 10);
195
+ }
196
+
197
+ function normalizeScope(value = "specific") {
198
+ const normalized = value.toLowerCase().replace(/\s+/g, "_");
199
+
200
+ if (normalized === "all" || normalized === "all_pages") {
201
+ return scopeLabels.all;
202
+ }
203
+
204
+ if (normalized === "content" || normalized === "content_group") {
205
+ return scopeLabels.content;
206
+ }
207
+
208
+ return scopeLabels.specific;
209
+ }
210
+
211
+ function getPagesForScope(scope, pages) {
212
+ if (scope === scopeLabels.all) {
213
+ return ["All pages"];
214
+ }
215
+
216
+ return pages.length > 0 ? pages : ["/"];
217
+ }
218
+
219
+ function createFormData(input) {
220
+ const formData = new FormData();
221
+
222
+ for (const [key, value] of Object.entries(input)) {
223
+ if (Array.isArray(value)) {
224
+ formData.set(key, value.join("\n"));
225
+ } else if (value !== undefined) {
226
+ formData.set(key, String(value));
227
+ }
228
+ }
229
+
230
+ return formData;
231
+ }
232
+
233
+ function parseResponseBody(text) {
234
+ if (!text) {
235
+ return {};
236
+ }
237
+
238
+ try {
239
+ return JSON.parse(text);
240
+ } catch {
241
+ return { rawText: text };
242
+ }
243
+ }
244
+
245
+ function getResponseErrorMessage(response, body) {
246
+ if (typeof body.error === "string" && body.error.trim()) {
247
+ return body.error;
248
+ }
249
+
250
+ if (typeof body.rawText === "string" && body.rawText.trim()) {
251
+ const excerpt = body.rawText.replace(/\s+/g, " ").trim().slice(0, 240);
252
+
253
+ return `Searchlight API returned ${response.status}: ${excerpt}`;
254
+ }
255
+
256
+ return `Searchlight API returned ${response.status}.`;
257
+ }
258
+
259
+ async function requestJson(options, path, init = {}) {
260
+ const response = await fetch(`${getBaseUrl(options)}${path}`, {
261
+ ...init,
262
+ headers: {
263
+ accept: "application/json",
264
+ authorization: `Bearer ${await getAuthToken()}`,
265
+ ...(init.headers ?? {}),
266
+ },
267
+ });
268
+ const text = await response.text();
269
+ const body = parseResponseBody(text);
270
+
271
+ if (!response.ok) {
272
+ throw new Error(getResponseErrorMessage(response, body));
273
+ }
274
+
275
+ if (body.rawText) {
276
+ throw new Error("Searchlight API returned a non-JSON response.");
277
+ }
278
+
279
+ return body;
280
+ }
281
+
282
+ function formatMappingRow(mapping, fallback = {}) {
283
+ return [
284
+ mapping.siteSlug,
285
+ mapping.siteName,
286
+ mapping.gscPropertyId ?? fallback.gscPropertyId ?? "",
287
+ mapping.ga4PropertyId ?? fallback.ga4PropertyId ?? "",
288
+ ].join("\t");
289
+ }
290
+
291
+ function formatContentRunRow(contentRun) {
292
+ return `${contentRun.runId}\t${contentRun.status}\t${contentRun.affectedUrls?.join(",") ?? ""}`;
293
+ }
294
+
295
+ function formatAnnotationTestRow(test) {
296
+ return [
297
+ test.annotationId,
298
+ test.status,
299
+ test.primaryMetric,
300
+ test.testRange,
301
+ test.title,
302
+ ].join("\t");
303
+ }
304
+
305
+ function printResult(options, value) {
306
+ if (options.json) {
307
+ console.log(JSON.stringify(value, null, 2));
308
+ return;
309
+ }
310
+
311
+ if (Array.isArray(value.sites)) {
312
+ for (const site of value.sites) {
313
+ console.log(`${site.slug}\t${site.name}\t${site.propertyId}`);
314
+ }
315
+ return;
316
+ }
317
+
318
+ if (Array.isArray(value.annotations)) {
319
+ for (const annotation of value.annotations) {
320
+ console.log(`${annotation.annotationId}\t${annotation.date}\t${annotation.title}`);
321
+ }
322
+ return;
323
+ }
324
+
325
+ if (Array.isArray(value.contentRuns)) {
326
+ for (const run of value.contentRuns) {
327
+ console.log(formatContentRunRow(run));
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (Array.isArray(value.tests)) {
333
+ for (const test of value.tests) {
334
+ console.log(formatAnnotationTestRow(test));
335
+ }
336
+ return;
337
+ }
338
+
339
+ if (Array.isArray(value.mappings)) {
340
+ for (const mapping of value.mappings) {
341
+ console.log(formatMappingRow(mapping));
342
+ }
343
+ return;
344
+ }
345
+
346
+ console.log(JSON.stringify(value, null, 2));
347
+ }
348
+
349
+ async function listSites(options) {
350
+ printResult(options, await requestJson(options, "/api/sites"));
351
+ }
352
+
353
+ async function listAnnotations(options) {
354
+ const site = requireOption(options, "site");
355
+
356
+ printResult(options, await requestJson(options, `/api/sites/${site}/annotations`));
357
+ }
358
+
359
+ async function listTests(options) {
360
+ const site = requireOption(options, "site");
361
+
362
+ printResult(options, await requestJson(options, `/api/sites/${site}/annotations/tests`));
363
+ }
364
+
365
+ async function refreshTests(options) {
366
+ const site = requireOption(options, "site");
367
+
368
+ printResult(
369
+ options,
370
+ await postJson(options, `/api/sites/${site}/annotations/tests/run`),
371
+ );
372
+ }
373
+
374
+ async function listContentRuns(options) {
375
+ const site = requireOption(options, "site");
376
+
377
+ printResult(options, await requestJson(options, `/api/sites/${site}/content-runs`));
378
+ }
379
+
380
+ async function addContentRun(options) {
381
+ const site = requireOption(options, "site");
382
+ const file = requireOption(options, "file");
383
+ const manifest = JSON.parse(await readTextInput(file));
384
+
385
+ if (options.dryRun) {
386
+ printResult(options, {
387
+ manifest,
388
+ site,
389
+ status: "dry-run",
390
+ });
391
+ return;
392
+ }
393
+
394
+ printResult(
395
+ options,
396
+ await postJson(options, `/api/sites/${site}/content-runs`, { manifest }),
397
+ );
398
+ }
399
+
400
+ function annotationInputFromOptions(options, existing = {}) {
401
+ const scope = normalizeScope(options.scope ?? existing.scope);
402
+ const pages = options.page.length > 0 ? options.page : existing.pages ?? [];
403
+
404
+ return {
405
+ date: options.date ?? existing.dateInput ?? todayApiDate(),
406
+ pages: getPagesForScope(scope, pages),
407
+ scope,
408
+ summary: options.summary ?? existing.summary,
409
+ title: options.title ?? existing.title,
410
+ };
411
+ }
412
+
413
+ function validateAnnotationInput(input) {
414
+ if (!input.title) {
415
+ throw new Error("--title is required.");
416
+ }
417
+
418
+ if (!input.summary) {
419
+ throw new Error("--summary is required.");
420
+ }
421
+
422
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) {
423
+ throw new Error("--date must be YYYY-MM-DD.");
424
+ }
425
+ }
426
+
427
+ async function submitAnnotation(options, action, input, annotationId) {
428
+ const site = requireOption(options, "site");
429
+ validateAnnotationInput(input);
430
+
431
+ if (options.dryRun) {
432
+ return {
433
+ action,
434
+ annotation: input,
435
+ annotationId,
436
+ site,
437
+ status: "dry-run",
438
+ };
439
+ }
440
+
441
+ const formData = createFormData({
442
+ _action: action,
443
+ annotationId,
444
+ ...input,
445
+ });
446
+
447
+ return requestJson(options, `/api/sites/${site}/annotations`, {
448
+ body: formData,
449
+ method: "POST",
450
+ });
451
+ }
452
+
453
+ async function addAnnotation(options) {
454
+ const input = annotationInputFromOptions(options);
455
+ const result = await submitAnnotation(options, "create", input);
456
+
457
+ printResult(options, result);
458
+ }
459
+
460
+ function parseAnnotateText(text) {
461
+ const titleMatch = text.match(/Annotation title:\s*([\s\S]*?)(?:\n\s*Summary of changes:|$)/i);
462
+ const summaryMatch = text.match(/Summary of changes:\s*([\s\S]*?)(?:\n\s*URLs affected:|$)/i);
463
+ const urlsMatch = text.match(/URLs affected:\s*(?:```\w*\s*)?([\s\S]*?)(?:```|$)/i);
464
+ const pages = (urlsMatch?.[1] ?? "")
465
+ .split(/\r?\n/)
466
+ .map((line) => line.replace(/^[-*]\s*/, "").trim())
467
+ .filter(Boolean);
468
+
469
+ return {
470
+ pages,
471
+ summary: (summaryMatch?.[1] ?? "").trim(),
472
+ title: (titleMatch?.[1] ?? "").trim(),
473
+ };
474
+ }
475
+
476
+ async function readTextInput(file) {
477
+ if (!file || file === "-") {
478
+ const chunks = [];
479
+
480
+ for await (const chunk of process.stdin) {
481
+ chunks.push(chunk);
482
+ }
483
+
484
+ return Buffer.concat(chunks).toString("utf8");
485
+ }
486
+
487
+ return readFile(file, "utf8");
488
+ }
489
+
490
+ function printSetupInstructions() {
491
+ const tokenFilePath = getTokenFilePath();
492
+
493
+ console.log(`Searchlight CLI setup
494
+
495
+ 1. Get a provisioned CLI token from Searchlight Workspace Settings or from your Searchlight admin.
496
+ 2. Store it without putting it in shell history:
497
+
498
+ read -rsp "Searchlight CLI token: " SEARCHLIGHT_TOKEN && echo && \\
499
+ printf '%s' "$SEARCHLIGHT_TOKEN" | mmai-searchlight auth store --token-stdin && \\
500
+ unset SEARCHLIGHT_TOKEN
501
+
502
+ 3. Validate access:
503
+
504
+ mmai-searchlight auth check
505
+ mmai-searchlight annotations sites --json
506
+
507
+ Token file: ${tokenFilePath}
508
+ Production API: ${defaultBaseUrl}
509
+
510
+ Anyone can install this CLI, but Searchlight only honors server-issued, scoped, unrevoked tokens.`);
511
+ }
512
+
513
+ async function storeAuthToken(options) {
514
+ if (!options.tokenStdin) {
515
+ throw new Error("Use --token-stdin so the raw token is read from stdin instead of command history.");
516
+ }
517
+
518
+ const token = (await readTextInput("-")).trim();
519
+
520
+ if (!token) {
521
+ throw new Error("No token was provided on stdin.");
522
+ }
523
+
524
+ const tokenFilePath = getTokenFilePath();
525
+
526
+ await mkdir(dirname(tokenFilePath), { mode: 0o700, recursive: true });
527
+ await writeFile(tokenFilePath, `${token}\n`, { mode: 0o600 });
528
+ await chmod(tokenFilePath, 0o600);
529
+
530
+ if (options.json) {
531
+ console.log(JSON.stringify({ status: "stored", tokenFile: tokenFilePath }, null, 2));
532
+ return;
533
+ }
534
+
535
+ console.log(`Stored Searchlight CLI token at ${tokenFilePath}`);
536
+ console.log("Run: mmai-searchlight auth check");
537
+ }
538
+
539
+ async function checkAuth(options) {
540
+ const result = await requestJson(options, "/api/sites");
541
+ const siteCount = result.sites?.length ?? 0;
542
+
543
+ if (options.json) {
544
+ console.log(JSON.stringify({ baseUrl: getBaseUrl(options), siteCount, status: "ok" }, null, 2));
545
+ return;
546
+ }
547
+
548
+ console.log(`Authenticated against ${getBaseUrl(options)}. Sites available: ${siteCount}`);
549
+ }
550
+
551
+ async function printAuthPath(options) {
552
+ const tokenFilePath = getTokenFilePath();
553
+
554
+ if (options.json) {
555
+ console.log(JSON.stringify({ tokenFile: tokenFilePath }, null, 2));
556
+ return;
557
+ }
558
+
559
+ console.log(tokenFilePath);
560
+ }
561
+
562
+ async function logoutAuth(options) {
563
+ const tokenFilePath = getTokenFilePath();
564
+
565
+ if (!options.yes) {
566
+ throw new Error("Refusing to remove the stored token without --yes.");
567
+ }
568
+
569
+ try {
570
+ await unlink(tokenFilePath);
571
+ } catch (error) {
572
+ if (error?.code !== "ENOENT") {
573
+ throw error;
574
+ }
575
+ }
576
+
577
+ if (options.json) {
578
+ console.log(JSON.stringify({ status: "removed", tokenFile: tokenFilePath }, null, 2));
579
+ return;
580
+ }
581
+
582
+ console.log(`Removed stored Searchlight CLI token from ${tokenFilePath}`);
583
+ }
584
+
585
+ async function runAuthCommand(command, options) {
586
+ switch (command) {
587
+ case "store":
588
+ await storeAuthToken(options);
589
+ break;
590
+ case "check":
591
+ case "status":
592
+ await checkAuth(options);
593
+ break;
594
+ case "path":
595
+ await printAuthPath(options);
596
+ break;
597
+ case "logout":
598
+ await logoutAuth(options);
599
+ break;
600
+ case "help":
601
+ case "--help":
602
+ case undefined:
603
+ usage(0);
604
+ break;
605
+ default:
606
+ throw new Error(`Unknown auth command: ${command}`);
607
+ }
608
+ }
609
+
610
+ function annotateOptions(options, parsed, existing = {}) {
611
+ return {
612
+ ...options,
613
+ page: parsed.pages.length > 0 ? parsed.pages : existing.pages ?? [],
614
+ scope: parsed.pages.length > 0 ? scopeLabels.specific : existing.scope ?? scopeLabels.all,
615
+ summary: parsed.summary || existing.summary,
616
+ title: parsed.title || existing.title,
617
+ };
618
+ }
619
+
620
+ async function addFromAnnotate(options) {
621
+ const parsed = parseAnnotateText(await readTextInput(requireOption(options, "file")));
622
+ const input = annotationInputFromOptions(annotateOptions(options, parsed));
623
+ const result = await submitAnnotation(options, "create", input);
624
+
625
+ printResult(options, result);
626
+ }
627
+
628
+ function toInputDate(value) {
629
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
630
+ return value;
631
+ }
632
+
633
+ const parsed = Date.parse(`${value} UTC`);
634
+
635
+ if (Number.isNaN(parsed)) {
636
+ return todayApiDate();
637
+ }
638
+
639
+ return new Date(parsed).toISOString().slice(0, 10);
640
+ }
641
+
642
+ async function getExistingAnnotation(options) {
643
+ const site = requireOption(options, "site");
644
+ const annotationId = requireOption(options, "id");
645
+ const annotations = (await requestJson(options, `/api/sites/${site}/annotations`)).annotations;
646
+ const existing = annotations.find((annotation) => annotation.annotationId === annotationId);
647
+
648
+ if (!existing) {
649
+ throw new Error(`Annotation not found: ${annotationId}`);
650
+ }
651
+
652
+ return { annotationId, existing };
653
+ }
654
+
655
+ async function updateAnnotation(options) {
656
+ const { annotationId, existing } = await getExistingAnnotation(options);
657
+ const input = annotationInputFromOptions(options, {
658
+ ...existing,
659
+ dateInput: toInputDate(existing.date),
660
+ });
661
+ const result = await submitAnnotation(options, "update", input, annotationId);
662
+
663
+ printResult(options, result);
664
+ }
665
+
666
+ async function updateFromAnnotate(options) {
667
+ const { annotationId, existing } = await getExistingAnnotation(options);
668
+ const parsed = parseAnnotateText(await readTextInput(requireOption(options, "file")));
669
+ const input = annotationInputFromOptions(annotateOptions(options, parsed, existing), {
670
+ ...existing,
671
+ dateInput: toInputDate(existing.date),
672
+ });
673
+ const result = await submitAnnotation(options, "update", input, annotationId);
674
+
675
+ printResult(options, result);
676
+ }
677
+
678
+ async function deleteAnnotation(options) {
679
+ const site = requireOption(options, "site");
680
+ const annotationId = requireOption(options, "id");
681
+
682
+ if (options.dryRun) {
683
+ printResult(options, {
684
+ action: "delete",
685
+ annotationId,
686
+ site,
687
+ status: "dry-run",
688
+ });
689
+ return;
690
+ }
691
+
692
+ if (!options.yes) {
693
+ throw new Error("Refusing to delete without --yes.");
694
+ }
695
+
696
+ const result = await requestJson(options, `/api/sites/${site}/annotations`, {
697
+ body: createFormData({ _action: "delete", annotationId }),
698
+ method: "POST",
699
+ });
700
+
701
+ printResult(options, result);
702
+ }
703
+
704
+ function slugify(value) {
705
+ return value
706
+ .toLowerCase()
707
+ .replace(/[^a-z0-9]+/g, "-")
708
+ .replace(/^-+|-+$/g, "")
709
+ .slice(0, 64);
710
+ }
711
+
712
+ function normalizeWebsiteUrl(value) {
713
+ const raw = optionalString(value);
714
+
715
+ if (!raw) {
716
+ return null;
717
+ }
718
+
719
+ try {
720
+ const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
721
+ const url = new URL(withScheme);
722
+ url.hash = "";
723
+ url.search = "";
724
+
725
+ if (!url.pathname || url.pathname === "") {
726
+ url.pathname = "/";
727
+ }
728
+
729
+ return url;
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
734
+
735
+ function domainPropertyFromUrl(url) {
736
+ return `sc-domain:${url.hostname.replace(/^www\./i, "")}`;
737
+ }
738
+
739
+ function urlPrefixPropertyFromUrl(url) {
740
+ if (url.pathname === "/") {
741
+ return `${url.origin}/`;
742
+ }
743
+
744
+ return url.href;
745
+ }
746
+
747
+ function getAlternateHost(hostname) {
748
+ if (hostname.startsWith("www.")) {
749
+ return hostname.replace(/^www\./, "");
750
+ }
751
+
752
+ return `www.${hostname}`;
753
+ }
754
+
755
+ function urlPrefixPropertyCandidatesFromUrl(url) {
756
+ const primaryProperty = urlPrefixPropertyFromUrl(url);
757
+
758
+ if (url.pathname !== "/") {
759
+ return [primaryProperty];
760
+ }
761
+
762
+ const alternateUrl = new URL(url.href);
763
+ alternateUrl.hostname = getAlternateHost(url.hostname);
764
+
765
+ return [primaryProperty, urlPrefixPropertyFromUrl(alternateUrl)];
766
+ }
767
+
768
+ function getChoiceOption(options, key, fallback, allowedValues) {
769
+ const value = options[key] ?? fallback;
770
+
771
+ if (allowedValues.includes(value)) {
772
+ return value;
773
+ }
774
+
775
+ const flag = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
776
+ throw new Error(`--${flag} must be ${allowedValues.join(", ")}.`);
777
+ }
778
+
779
+ function getPropertyMode(options) {
780
+ return getChoiceOption(
781
+ options,
782
+ "propertyMode",
783
+ "auto",
784
+ ["auto", "domain", "url-prefix"],
785
+ );
786
+ }
787
+
788
+ function getGa4Mode(options) {
789
+ return getChoiceOption(options, "ga4Mode", "auto", ["auto", "off"]);
790
+ }
791
+
792
+ function getRefreshMode(options) {
793
+ return getChoiceOption(options, "refreshMode", "gsc", ["none", "gsc", "all"]);
794
+ }
795
+
796
+ function getGscOwnersForProperty(inventory, propertyId) {
797
+ if (!propertyId) {
798
+ return [];
799
+ }
800
+
801
+ return [...(inventory.gscOwnersByProperty.get(propertyId) ?? new Set())].sort();
802
+ }
803
+
804
+ function propertyFromWebsiteUrl(url, propertyMode, inventory) {
805
+ const domainProperty = domainPropertyFromUrl(url);
806
+ const urlPrefixCandidates = urlPrefixPropertyCandidatesFromUrl(url);
807
+ const availableProperties = inventory.gscOwnersByProperty;
808
+
809
+ if (propertyMode === "domain") {
810
+ return domainProperty;
811
+ }
812
+
813
+ if (propertyMode === "auto") {
814
+ if (availableProperties.has(domainProperty)) {
815
+ return domainProperty;
816
+ }
817
+
818
+ return (
819
+ urlPrefixCandidates.find((property) => availableProperties.has(property)) ??
820
+ urlPrefixCandidates[0]
821
+ );
822
+ }
823
+
824
+ return urlPrefixCandidates[0];
825
+ }
826
+
827
+ function normalizeDomain(value) {
828
+ return value.toLowerCase().replace(/^www\./, "");
829
+ }
830
+
831
+ function getDomainFromUri(value) {
832
+ const url = normalizeWebsiteUrl(value);
833
+
834
+ return url ? normalizeDomain(url.hostname) : null;
835
+ }
836
+
837
+ function ga4MatchesWebsite(ga4Property, websiteUrl) {
838
+ if (!websiteUrl) {
839
+ return false;
840
+ }
841
+
842
+ const domain = normalizeDomain(websiteUrl.hostname);
843
+ const streamUris = ga4Property.streamUris?.length
844
+ ? ga4Property.streamUris
845
+ : [ga4Property.defaultUri];
846
+
847
+ return streamUris.some((uri) => getDomainFromUri(uri) === domain);
848
+ }
849
+
850
+ function normalizeMatchName(value) {
851
+ return optionalString(value)
852
+ ?.toLowerCase()
853
+ .replace(/&/g, " and ")
854
+ .replace(/[^a-z0-9]+/g, " ")
855
+ .trim()
856
+ .replace(/\s+/g, " ");
857
+ }
858
+
859
+ function scoreGa4Candidate(property, siteName) {
860
+ const normalizedSiteName = normalizeMatchName(siteName);
861
+ const normalizedDisplayName = normalizeMatchName(property.displayName);
862
+
863
+ if (!normalizedSiteName || !normalizedDisplayName) {
864
+ return 0;
865
+ }
866
+
867
+ if (normalizedDisplayName === normalizedSiteName) {
868
+ return 2;
869
+ }
870
+
871
+ if (normalizedDisplayName.startsWith(`${normalizedSiteName} `)) {
872
+ return 1;
873
+ }
874
+
875
+ return 0;
876
+ }
877
+
878
+ function findGa4PropertyMatch({ ga4Mode, gscPropertyId, inventory, siteName, websiteUrl }) {
879
+ if (ga4Mode === "off" || !websiteUrl || !gscPropertyId) {
880
+ return null;
881
+ }
882
+
883
+ const gscOwners = new Set(getGscOwnersForProperty(inventory, gscPropertyId));
884
+ const candidates = inventory.ga4Properties
885
+ .filter((property) => gscOwners.has(property.email))
886
+ .filter((property) => ga4MatchesWebsite(property, websiteUrl));
887
+
888
+ candidates.sort((left, right) => {
889
+ const scoreDelta = scoreGa4Candidate(right, siteName) - scoreGa4Candidate(left, siteName);
890
+
891
+ if (scoreDelta !== 0) {
892
+ return scoreDelta;
893
+ }
894
+
895
+ return `${left.email}:${left.property}`.localeCompare(`${right.email}:${right.property}`);
896
+ });
897
+
898
+ const match = candidates[0];
899
+
900
+ if (!match) {
901
+ return null;
902
+ }
903
+
904
+ return {
905
+ connectionEmail: match.email,
906
+ ga4PropertyId: match.property,
907
+ matchType: "web_stream_domain",
908
+ };
909
+ }
910
+
911
+ function canQuerySearchConsoleProperty(property) {
912
+ return property.permissionLevel !== "siteUnverifiedUser";
913
+ }
914
+
915
+ function createEmptyGooglePropertyInventory() {
916
+ return {
917
+ errors: [],
918
+ ga4Properties: [],
919
+ gscOwnersByProperty: new Map(),
920
+ };
921
+ }
922
+
923
+ function addGscPropertyOwner(inventory, propertyId, email) {
924
+ if (!propertyId || !email) {
925
+ return;
926
+ }
927
+
928
+ const owners = inventory.gscOwnersByProperty.get(propertyId) ?? new Set();
929
+ owners.add(email);
930
+ inventory.gscOwnersByProperty.set(propertyId, owners);
931
+ }
932
+
933
+ async function readAvailableGoogleProperties(options) {
934
+ const inventory = createEmptyGooglePropertyInventory();
935
+
936
+ if (!(await hasConfiguredAuthToken())) {
937
+ return inventory;
938
+ }
939
+
940
+ try {
941
+ const { accounts = [] } = await requestJson(options, "/api/ops/google-properties");
942
+
943
+ for (const account of accounts) {
944
+ if (account.errors?.length) {
945
+ inventory.errors.push({ email: account.email, errors: account.errors });
946
+ }
947
+
948
+ for (const property of account.searchConsoleProperties ?? []) {
949
+ if (canQuerySearchConsoleProperty(property)) {
950
+ addGscPropertyOwner(inventory, property.siteUrl, account.email);
951
+ }
952
+ }
953
+
954
+ for (const property of account.ga4Properties ?? []) {
955
+ if (property.property && account.email) {
956
+ inventory.ga4Properties.push({ ...property, email: account.email });
957
+ }
958
+ }
959
+ }
960
+ } catch (error) {
961
+ inventory.errors.push({
962
+ error: error instanceof Error ? error.message : "Unable to read Google properties.",
963
+ });
964
+ }
965
+
966
+ return inventory;
967
+ }
968
+
969
+ function getReportPortalClients() {
970
+ const output = execFileSync(
971
+ "mmai-report-portal",
972
+ ["clients", "list", "--active-only", "true", "--json"],
973
+ { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 },
974
+ );
975
+ const parsed = JSON.parse(output);
976
+
977
+ if (Array.isArray(parsed)) {
978
+ return parsed;
979
+ }
980
+
981
+ return parsed.clients ?? parsed.data ?? parsed.items ?? [];
982
+ }
983
+
984
+ function getClientName(client, websiteUrl) {
985
+ return (
986
+ optionalString(client.business_name) ??
987
+ optionalString(client.businessName) ??
988
+ optionalString(client.name) ??
989
+ websiteUrl?.hostname.replace(/^www\./i, "") ??
990
+ "Unnamed client"
991
+ );
992
+ }
993
+
994
+ function matchesFilter(client, mapping, match) {
995
+ const normalizedMatch = optionalString(match)?.toLowerCase();
996
+
997
+ if (!normalizedMatch) {
998
+ return true;
999
+ }
1000
+
1001
+ return [
1002
+ client.business_name,
1003
+ client.businessName,
1004
+ client.name,
1005
+ client.website_url,
1006
+ client.websiteUrl,
1007
+ mapping.siteName,
1008
+ mapping.siteSlug,
1009
+ mapping.gscPropertyId,
1010
+ mapping.ga4PropertyId,
1011
+ ]
1012
+ .filter(Boolean)
1013
+ .some((value) => String(value).toLowerCase().includes(normalizedMatch));
1014
+ }
1015
+
1016
+ function createReportPortalMappings(options, inventory = createEmptyGooglePropertyInventory()) {
1017
+ const clients = getReportPortalClients();
1018
+ const ga4Mode = getGa4Mode(options);
1019
+ const propertyMode = getPropertyMode(options);
1020
+ const seenSlugs = new Set();
1021
+ const skipped = [];
1022
+ const mappings = [];
1023
+
1024
+ for (const client of clients) {
1025
+ const websiteUrl = normalizeWebsiteUrl(client.website_url ?? client.websiteUrl);
1026
+
1027
+ if (!websiteUrl && !options.includeNoWebsite) {
1028
+ skipped.push({ reason: "missing_website_url", reportPortalClientId: client.id, name: getClientName(client) });
1029
+ continue;
1030
+ }
1031
+
1032
+ const siteName = getClientName(client, websiteUrl);
1033
+ const fallbackSlug = websiteUrl ? slugify(websiteUrl.hostname.replace(/^www\./i, "")) : "client";
1034
+ let siteSlug = slugify(siteName) || fallbackSlug;
1035
+
1036
+ if (seenSlugs.has(siteSlug)) {
1037
+ siteSlug = slugify(`${siteSlug}-${fallbackSlug}`) || `${siteSlug}-${mappings.length + 1}`;
1038
+ }
1039
+
1040
+ seenSlugs.add(siteSlug);
1041
+
1042
+ const gscPropertyId = websiteUrl
1043
+ ? propertyFromWebsiteUrl(websiteUrl, propertyMode, inventory)
1044
+ : undefined;
1045
+ const ga4Match = findGa4PropertyMatch({
1046
+ ga4Mode,
1047
+ gscPropertyId,
1048
+ inventory,
1049
+ siteName,
1050
+ websiteUrl,
1051
+ });
1052
+ const connectionEmail = ga4Match?.connectionEmail
1053
+ ?? getGscOwnersForProperty(inventory, gscPropertyId)[0]
1054
+ ?? options.connectionEmail;
1055
+ const mapping = {
1056
+ connectionEmail,
1057
+ ga4MatchType: ga4Match?.matchType,
1058
+ ga4PropertyId: ga4Match?.ga4PropertyId,
1059
+ gscPropertyId,
1060
+ isCustom: true,
1061
+ reportPortalClientId: client.id,
1062
+ siteName,
1063
+ siteSlug,
1064
+ websiteUrl: websiteUrl?.href,
1065
+ };
1066
+
1067
+ if (!matchesFilter(client, mapping, options.match)) {
1068
+ continue;
1069
+ }
1070
+
1071
+ mappings.push(mapping);
1072
+ }
1073
+
1074
+ const limitedMappings = applyLimit(mappings, options.limit);
1075
+
1076
+ return {
1077
+ availableGa4PropertyCount: inventory.ga4Properties.length,
1078
+ availableGscPropertyCount: inventory.gscOwnersByProperty.size,
1079
+ ga4MatchedCount: limitedMappings.filter((mapping) => mapping.ga4PropertyId).length,
1080
+ googlePropertyErrors: inventory.errors,
1081
+ mappings: limitedMappings,
1082
+ skipped,
1083
+ totalActiveClients: clients.length,
1084
+ };
1085
+ }
1086
+
1087
+ function applyLimit(items, rawLimit) {
1088
+ if (!rawLimit) {
1089
+ return items;
1090
+ }
1091
+
1092
+ const limit = Number.parseInt(rawLimit, 10);
1093
+
1094
+ if (!Number.isInteger(limit) || limit < 1) {
1095
+ throw new Error("--limit must be a positive integer.");
1096
+ }
1097
+
1098
+ return items.slice(0, limit);
1099
+ }
1100
+
1101
+ function getPropertyMatchKeys(propertyId) {
1102
+ const property = optionalString(propertyId);
1103
+
1104
+ if (!property) {
1105
+ return [];
1106
+ }
1107
+
1108
+ if (property.startsWith("sc-domain:")) {
1109
+ return [property, property.replace("sc-domain:", "domain:").replace(/^domain:www\./i, "domain:")];
1110
+ }
1111
+
1112
+ try {
1113
+ const url = new URL(property);
1114
+ return [property, `domain:${url.hostname.replace(/^www\./i, "")}`];
1115
+ } catch {
1116
+ return [property];
1117
+ }
1118
+ }
1119
+
1120
+ async function reuseExistingSiteSlugs(options, plan) {
1121
+ if (!(await hasConfiguredAuthToken())) {
1122
+ return plan;
1123
+ }
1124
+
1125
+ try {
1126
+ const existingSites = (await requestJson(options, "/api/sites")).sites ?? [];
1127
+ const sitesByKey = new Map();
1128
+
1129
+ for (const site of existingSites) {
1130
+ for (const key of getPropertyMatchKeys(site.propertyId)) {
1131
+ sitesByKey.set(key, site);
1132
+ }
1133
+ }
1134
+
1135
+ const reused = [];
1136
+ const mappings = plan.mappings.map((mapping) => {
1137
+ const existingSite = getPropertyMatchKeys(mapping.gscPropertyId)
1138
+ .map((key) => sitesByKey.get(key))
1139
+ .find(Boolean);
1140
+
1141
+ if (!existingSite || existingSite.slug === mapping.siteSlug) {
1142
+ return mapping;
1143
+ }
1144
+
1145
+ reused.push({
1146
+ from: mapping.siteSlug,
1147
+ propertyId: mapping.gscPropertyId,
1148
+ siteName: mapping.siteName,
1149
+ to: existingSite.slug,
1150
+ });
1151
+
1152
+ return { ...mapping, siteSlug: existingSite.slug };
1153
+ });
1154
+
1155
+ return { ...plan, mappings, reused };
1156
+ } catch {
1157
+ return plan;
1158
+ }
1159
+ }
1160
+
1161
+ function printImportPlan(options, plan) {
1162
+ if (options.json) {
1163
+ console.log(JSON.stringify(plan, null, 2));
1164
+ return;
1165
+ }
1166
+
1167
+ console.log(`Report Portal active clients: ${plan.totalActiveClients}`);
1168
+ console.log(`Ready to onboard: ${plan.mappings.length}`);
1169
+ console.log(`Skipped: ${plan.skipped.length}`);
1170
+ console.log(`Available GSC properties checked: ${plan.availableGscPropertyCount ?? 0}`);
1171
+ console.log(`Available GA4 properties checked: ${plan.availableGa4PropertyCount ?? 0}`);
1172
+ console.log(`GA4 properties auto-attached: ${plan.ga4MatchedCount ?? 0}`);
1173
+ console.log(`Reusing existing Searchlight slugs: ${plan.reused?.length ?? 0}`);
1174
+
1175
+ for (const mapping of plan.mappings) {
1176
+ console.log(formatMappingRow(mapping, {
1177
+ ga4PropertyId: "no GA4 property",
1178
+ gscPropertyId: "no GSC property",
1179
+ }));
1180
+ }
1181
+
1182
+ if (!options.yes) {
1183
+ console.log("\nDry run only. Re-run with --yes to write these mappings to Searchlight.");
1184
+ }
1185
+ }
1186
+
1187
+ async function saveClientMappings(options, mappings) {
1188
+ return requestJson(options, "/api/ops/client-mappings", {
1189
+ body: JSON.stringify({ mappings }),
1190
+ headers: { "content-type": "application/json" },
1191
+ method: "POST",
1192
+ });
1193
+ }
1194
+
1195
+ async function addClient(options) {
1196
+ const siteName = requireOption(options, "name");
1197
+ const gscPropertyId = requireOption(options, "gscProperty");
1198
+ const mapping = {
1199
+ connectionEmail: options.connectionEmail,
1200
+ ga4PropertyId: optionalString(options.ga4Property),
1201
+ gscPropertyId,
1202
+ isCustom: true,
1203
+ siteName,
1204
+ siteSlug: optionalString(options.siteSlug),
1205
+ };
1206
+ const result = await saveClientMappings(options, [mapping]);
1207
+
1208
+ printResult(options, result);
1209
+ }
1210
+
1211
+ async function postJson(options, path, body = {}) {
1212
+ return requestJson(options, path, {
1213
+ body: JSON.stringify(body),
1214
+ headers: { "content-type": "application/json" },
1215
+ method: "POST",
1216
+ });
1217
+ }
1218
+
1219
+ function refreshFailure(siteSlug, source, error, fallbackMessage) {
1220
+ return {
1221
+ error: error instanceof Error ? error.message : fallbackMessage,
1222
+ siteSlug,
1223
+ source,
1224
+ status: "failed",
1225
+ };
1226
+ }
1227
+
1228
+ async function refreshGscMapping(options, mapping) {
1229
+ if (!mapping.gscPropertyId) {
1230
+ return null;
1231
+ }
1232
+
1233
+ try {
1234
+ const result = await postJson(
1235
+ options,
1236
+ `/api/sites/${mapping.siteSlug}/gsc/ensure-fresh`,
1237
+ );
1238
+
1239
+ return {
1240
+ siteSlug: mapping.siteSlug,
1241
+ source: "gsc",
1242
+ status: result.status ?? "requested",
1243
+ };
1244
+ } catch (error) {
1245
+ return refreshFailure(
1246
+ mapping.siteSlug,
1247
+ "gsc",
1248
+ error,
1249
+ "Search Console refresh failed.",
1250
+ );
1251
+ }
1252
+ }
1253
+
1254
+ async function refreshGa4Mapping(options, mapping) {
1255
+ if (!mapping.ga4PropertyId) {
1256
+ return null;
1257
+ }
1258
+
1259
+ try {
1260
+ const result = await postJson(
1261
+ options,
1262
+ `/api/sites/${mapping.siteSlug}/ga4/refresh`,
1263
+ );
1264
+
1265
+ return {
1266
+ siteSlug: mapping.siteSlug,
1267
+ source: "ga4",
1268
+ status: result.syncStatus?.status ?? "refreshed",
1269
+ };
1270
+ } catch (error) {
1271
+ return refreshFailure(mapping.siteSlug, "ga4", error, "GA4 refresh failed.");
1272
+ }
1273
+ }
1274
+
1275
+ async function refreshImportedMappings(options, mappings) {
1276
+ const mode = getRefreshMode(options);
1277
+
1278
+ if (mode === "none") {
1279
+ return { mode, results: [] };
1280
+ }
1281
+
1282
+ const results = [];
1283
+
1284
+ for (const mapping of mappings) {
1285
+ const gscResult = await refreshGscMapping(options, mapping);
1286
+ if (gscResult) results.push(gscResult);
1287
+
1288
+ if (mode === "all") {
1289
+ const ga4Result = await refreshGa4Mapping(options, mapping);
1290
+ if (ga4Result) results.push(ga4Result);
1291
+ }
1292
+ }
1293
+
1294
+ return { mode, results };
1295
+ }
1296
+
1297
+ async function importReportPortalClients(options) {
1298
+ const inventory = await readAvailableGoogleProperties(options);
1299
+ const plan = await reuseExistingSiteSlugs(
1300
+ options,
1301
+ createReportPortalMappings(options, inventory),
1302
+ );
1303
+
1304
+ if (!options.yes || options.dryRun) {
1305
+ printImportPlan(options, plan);
1306
+ return;
1307
+ }
1308
+
1309
+ if (plan.mappings.length === 0) {
1310
+ printImportPlan(options, plan);
1311
+ return;
1312
+ }
1313
+
1314
+ const result = await saveClientMappings(options, plan.mappings);
1315
+ const refresh = await refreshImportedMappings(options, plan.mappings);
1316
+ const output = {
1317
+ ...result,
1318
+ availableGa4PropertyCount: plan.availableGa4PropertyCount,
1319
+ availableGscPropertyCount: plan.availableGscPropertyCount,
1320
+ ga4MatchedCount: plan.ga4MatchedCount,
1321
+ googlePropertyErrors: plan.googlePropertyErrors,
1322
+ refresh,
1323
+ skipped: plan.skipped,
1324
+ totalActiveClients: plan.totalActiveClients,
1325
+ };
1326
+
1327
+ printResult(options, output);
1328
+ }
1329
+
1330
+ async function runAnnotationCommand(command, options) {
1331
+ switch (command) {
1332
+ case "sites":
1333
+ await listSites(options);
1334
+ break;
1335
+ case "list":
1336
+ await listAnnotations(options);
1337
+ break;
1338
+ case "add":
1339
+ await addAnnotation(options);
1340
+ break;
1341
+ case "add-from-annotate":
1342
+ await addFromAnnotate(options);
1343
+ break;
1344
+ case "update":
1345
+ await updateAnnotation(options);
1346
+ break;
1347
+ case "update-from-annotate":
1348
+ await updateFromAnnotate(options);
1349
+ break;
1350
+ case "delete":
1351
+ await deleteAnnotation(options);
1352
+ break;
1353
+ case "help":
1354
+ case "--help":
1355
+ case undefined:
1356
+ usage(0);
1357
+ break;
1358
+ default:
1359
+ throw new Error(`Unknown annotations command: ${command}`);
1360
+ }
1361
+ }
1362
+
1363
+ async function runContentRunCommand(command, options) {
1364
+ switch (command) {
1365
+ case "list":
1366
+ await listContentRuns(options);
1367
+ break;
1368
+ case "add":
1369
+ case "ingest":
1370
+ await addContentRun(options);
1371
+ break;
1372
+ case "help":
1373
+ case "--help":
1374
+ case undefined:
1375
+ usage(0);
1376
+ break;
1377
+ default:
1378
+ throw new Error(`Unknown content-runs command: ${command}`);
1379
+ }
1380
+ }
1381
+
1382
+ async function runTestCommand(command, options) {
1383
+ switch (command) {
1384
+ case "list":
1385
+ await listTests(options);
1386
+ break;
1387
+ case "refresh":
1388
+ case "run":
1389
+ await refreshTests(options);
1390
+ break;
1391
+ case "help":
1392
+ case "--help":
1393
+ case undefined:
1394
+ usage(0);
1395
+ break;
1396
+ default:
1397
+ throw new Error(`Unknown tests command: ${command}`);
1398
+ }
1399
+ }
1400
+
1401
+ async function runClientCommand(command, options) {
1402
+ switch (command) {
1403
+ case "list":
1404
+ await listSites(options);
1405
+ break;
1406
+ case "add":
1407
+ await addClient(options);
1408
+ break;
1409
+ case "import-report-portal":
1410
+ case "import-rp":
1411
+ await importReportPortalClients(options);
1412
+ break;
1413
+ case "help":
1414
+ case "--help":
1415
+ case undefined:
1416
+ usage(0);
1417
+ break;
1418
+ default:
1419
+ throw new Error(`Unknown clients command: ${command}`);
1420
+ }
1421
+ }
1422
+
1423
+ async function main() {
1424
+ const [scope, maybeCommand, ...rest] = process.argv.slice(2);
1425
+
1426
+ if (!scope || scope === "help" || scope === "--help") {
1427
+ usage(0);
1428
+ }
1429
+
1430
+ if (scope === "setup") {
1431
+ printSetupInstructions();
1432
+ return;
1433
+ }
1434
+
1435
+ if (scope === "auth") {
1436
+ await runAuthCommand(maybeCommand, parseArgs([maybeCommand, ...rest]));
1437
+ return;
1438
+ }
1439
+
1440
+ if (scope === "annotations") {
1441
+ await runAnnotationCommand(maybeCommand, parseArgs([maybeCommand, ...rest]));
1442
+ return;
1443
+ }
1444
+
1445
+ if (scope === "clients") {
1446
+ await runClientCommand(maybeCommand, parseArgs([maybeCommand, ...rest]));
1447
+ return;
1448
+ }
1449
+
1450
+ if (scope === "content-runs") {
1451
+ await runContentRunCommand(maybeCommand, parseArgs([maybeCommand, ...rest]));
1452
+ return;
1453
+ }
1454
+
1455
+ if (scope === "tests") {
1456
+ await runTestCommand(maybeCommand, parseArgs([maybeCommand, ...rest]));
1457
+ return;
1458
+ }
1459
+
1460
+ if (rootAnnotationCommands.has(scope)) {
1461
+ await runAnnotationCommand(scope, parseArgs([scope, maybeCommand, ...rest].filter(Boolean)));
1462
+ return;
1463
+ }
1464
+
1465
+ throw new Error(`Unknown command group: ${scope}`);
1466
+ }
1467
+
1468
+ main().catch((error) => {
1469
+ console.error(error instanceof Error ? error.message : error);
1470
+ process.exit(1);
1471
+ });