@siteoshq/cli 1.10.0 → 1.12.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/README.md +51 -6
- package/dist/cli.js +768 -109
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1502,7 +1502,7 @@ var OverviewSchema = z4.object({
|
|
|
1502
1502
|
});
|
|
1503
1503
|
function createProjectApi(input) {
|
|
1504
1504
|
const origin = resolveSiteOSAuthBaseUrl(input.env);
|
|
1505
|
-
async function request(
|
|
1505
|
+
async function request(path33, schema, body, method) {
|
|
1506
1506
|
const scope = body === void 0 ? "projects:workspace:read" : "projects:workspace:write";
|
|
1507
1507
|
const grant = await input.grants.acquire({
|
|
1508
1508
|
audience: "siteos-projects",
|
|
@@ -1516,7 +1516,7 @@ function createProjectApi(input) {
|
|
|
1516
1516
|
message: "SiteOS API access is unavailable."
|
|
1517
1517
|
});
|
|
1518
1518
|
const response = await input.fetchImpl(
|
|
1519
|
-
`${origin}/api/projects/v1/projects${
|
|
1519
|
+
`${origin}/api/projects/v1/projects${path33}`,
|
|
1520
1520
|
{
|
|
1521
1521
|
method: method ?? (body === void 0 ? "GET" : "POST"),
|
|
1522
1522
|
headers: {
|
|
@@ -2394,7 +2394,10 @@ var AUTH_HELP = `Usage:
|
|
|
2394
2394
|
siteos auth select --organization <organization-id> [--json]
|
|
2395
2395
|
siteos auth logout [--json]
|
|
2396
2396
|
|
|
2397
|
-
Authenticate the SiteOS CLI with a user-level session
|
|
2397
|
+
Authenticate the SiteOS CLI with a user-level session.
|
|
2398
|
+
A personal organization is set up automatically on first sign-in.
|
|
2399
|
+
|
|
2400
|
+
Manual organization creation is team administration, available only to verified @pixelpoint.io accounts.`;
|
|
2398
2401
|
async function runAuthCommand(options) {
|
|
2399
2402
|
if (options.args.length === 0 || options.args.some((arg) => HELP_FLAGS.has(arg))) {
|
|
2400
2403
|
return { exitCode: 0, stdout: AUTH_HELP };
|
|
@@ -7176,7 +7179,8 @@ async function runDeployCommand(options, args2) {
|
|
|
7176
7179
|
},
|
|
7177
7180
|
strict: true
|
|
7178
7181
|
});
|
|
7179
|
-
const
|
|
7182
|
+
const projectRoot = path25.resolve(options.cwd ?? process.cwd());
|
|
7183
|
+
const repositoryRoot = await findRepositoryRoot(projectRoot);
|
|
7180
7184
|
const common = await commonServiceContext(options, "pulse");
|
|
7181
7185
|
if (common && (!common.environmentBinding?.slug || !common.environment.url))
|
|
7182
7186
|
throw new CliError(
|
|
@@ -7192,14 +7196,14 @@ async function runDeployCommand(options, args2) {
|
|
|
7192
7196
|
}
|
|
7193
7197
|
} : {},
|
|
7194
7198
|
output: values.output,
|
|
7195
|
-
projectRoot
|
|
7199
|
+
projectRoot
|
|
7196
7200
|
});
|
|
7197
7201
|
if (values["dry-run"]) {
|
|
7198
7202
|
return success(
|
|
7199
7203
|
values.json,
|
|
7200
7204
|
{ dryRun: true, manifest },
|
|
7201
7205
|
[
|
|
7202
|
-
`Bundle: ${path25.relative(
|
|
7206
|
+
`Bundle: ${path25.relative(projectRoot, manifest.archivePath)}`,
|
|
7203
7207
|
`Checksum: ${manifest.checksum}`,
|
|
7204
7208
|
`Files: ${manifest.fileCount}`,
|
|
7205
7209
|
"Dry run complete; nothing was uploaded."
|
|
@@ -7207,7 +7211,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7207
7211
|
);
|
|
7208
7212
|
}
|
|
7209
7213
|
const apiOrigin = await resolvePulseApiOrigin({
|
|
7210
|
-
cwd:
|
|
7214
|
+
cwd: projectRoot,
|
|
7211
7215
|
env: options.env,
|
|
7212
7216
|
requireConfig: true
|
|
7213
7217
|
});
|
|
@@ -7216,7 +7220,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7216
7220
|
apiOrigin,
|
|
7217
7221
|
fetchImpl: options.fetchImpl
|
|
7218
7222
|
});
|
|
7219
|
-
const snapshot = await loadChecksConfigSnapshot(
|
|
7223
|
+
const snapshot = await loadChecksConfigSnapshot(projectRoot);
|
|
7220
7224
|
const selected = await requireSelectedProject({
|
|
7221
7225
|
options,
|
|
7222
7226
|
apiOrigin,
|
|
@@ -7242,7 +7246,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7242
7246
|
values.json,
|
|
7243
7247
|
{ deployment, dryRun: false, manifest },
|
|
7244
7248
|
[
|
|
7245
|
-
`Bundle: ${path25.relative(
|
|
7249
|
+
`Bundle: ${path25.relative(projectRoot, manifest.archivePath)}`,
|
|
7246
7250
|
`Checksum: ${manifest.checksum}`,
|
|
7247
7251
|
`Files: ${manifest.fileCount}`,
|
|
7248
7252
|
`Project ${deployment.projectId} deployed.`,
|
|
@@ -10031,8 +10035,8 @@ var ANALYTICS_HELP = `Set up website Analytics in the selected Project environme
|
|
|
10031
10035
|
Usage:
|
|
10032
10036
|
siteos analytics status [--environment <slug>] [--json]
|
|
10033
10037
|
siteos analytics installation [--environment <slug>] [--json]
|
|
10034
|
-
siteos analytics report [--days <1|7|28>] [--event <name>] [--country <ISO|unknown>] [--campaign <id>] [--environment <slug>] [--json]
|
|
10035
|
-
siteos analytics realtime [--country <ISO|unknown>] [--campaign <id>] [--environment <slug>] [--json]
|
|
10038
|
+
siteos analytics report [--days <1|7|28>] [--event <name>] [--country <ISO|unknown>] [--campaign <id>] [--filters <json>] [--environment <slug>] [--json]
|
|
10039
|
+
siteos analytics realtime [--country <ISO|unknown>] [--campaign <id>] [--filters <json>] [--environment <slug>] [--json]
|
|
10036
10040
|
siteos analytics settings show [--environment <slug>] [--json]
|
|
10037
10041
|
siteos analytics settings set --revision <number> [--enabled <true|false>] [--cookie-events <true|false>] [--minimal-realtime <true|false>] [--environment <slug>] [--json]
|
|
10038
10042
|
siteos analytics events list [--environment <slug>] [--json]
|
|
@@ -10135,6 +10139,7 @@ async function runAnalyticsCommand(options) {
|
|
|
10135
10139
|
event: { type: "string" },
|
|
10136
10140
|
country: { type: "string" },
|
|
10137
10141
|
campaign: { type: "string" },
|
|
10142
|
+
filters: { type: "string" },
|
|
10138
10143
|
revision: { type: "string" },
|
|
10139
10144
|
enabled: { type: "string" },
|
|
10140
10145
|
"cookie-events": { type: "string" },
|
|
@@ -10146,8 +10151,11 @@ async function runAnalyticsCommand(options) {
|
|
|
10146
10151
|
const operations = {
|
|
10147
10152
|
status: { args: 1, flags: [] },
|
|
10148
10153
|
installation: { args: 1, flags: [] },
|
|
10149
|
-
report: {
|
|
10150
|
-
|
|
10154
|
+
report: {
|
|
10155
|
+
args: 1,
|
|
10156
|
+
flags: ["days", "event", "country", "campaign", "filters"]
|
|
10157
|
+
},
|
|
10158
|
+
realtime: { args: 1, flags: ["country", "campaign", "filters"] },
|
|
10151
10159
|
"settings show": { args: 2, flags: [] },
|
|
10152
10160
|
"settings set": {
|
|
10153
10161
|
args: 2,
|
|
@@ -10261,7 +10269,13 @@ async function runAnalyticsCommand(options) {
|
|
|
10261
10269
|
message: "The Analytics grant does not match this Project and operation."
|
|
10262
10270
|
});
|
|
10263
10271
|
const query = new URLSearchParams();
|
|
10264
|
-
for (const key of [
|
|
10272
|
+
for (const key of [
|
|
10273
|
+
"days",
|
|
10274
|
+
"event",
|
|
10275
|
+
"country",
|
|
10276
|
+
"campaign",
|
|
10277
|
+
"filters"
|
|
10278
|
+
])
|
|
10265
10279
|
if (values[key]) query.set(key, values[key]);
|
|
10266
10280
|
const suffix = action === "realtime" ? `/realtime?${query}` : setting ? "/settings" : creating || archive ? `/${action}` : route === "monitoring prepare" ? "/monitoring" : `?${query}`;
|
|
10267
10281
|
if (!options.fetchImpl)
|
|
@@ -10379,18 +10393,649 @@ async function runAnalyticsCommand(options) {
|
|
|
10379
10393
|
}
|
|
10380
10394
|
|
|
10381
10395
|
// src/services/seo-command.ts
|
|
10382
|
-
import { writeFile as
|
|
10383
|
-
import
|
|
10384
|
-
import { parseArgs as
|
|
10385
|
-
import { z as
|
|
10396
|
+
import { writeFile as writeFile10 } from "fs/promises";
|
|
10397
|
+
import path32 from "path";
|
|
10398
|
+
import { parseArgs as parseArgs8 } from "util";
|
|
10399
|
+
import { z as z22 } from "zod";
|
|
10386
10400
|
|
|
10387
|
-
// src/services/seo-
|
|
10401
|
+
// src/services/seo-research-command.ts
|
|
10388
10402
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
10389
|
-
import { writeFile as writeFile8 } from "fs/promises";
|
|
10390
|
-
import path30 from "path";
|
|
10391
10403
|
import { setTimeout as setTimeout2 } from "timers/promises";
|
|
10392
|
-
import { parseArgs as parseArgs5 } from "util";
|
|
10404
|
+
import { isDeepStrictEqual, parseArgs as parseArgs5 } from "util";
|
|
10405
|
+
import { z as z19 } from "zod";
|
|
10406
|
+
|
|
10407
|
+
// src/services/seo-report-client.ts
|
|
10408
|
+
import { open, writeFile as writeFile8 } from "fs/promises";
|
|
10409
|
+
import path30 from "path";
|
|
10393
10410
|
import { z as z18 } from "zod";
|
|
10411
|
+
async function seoReportClient(options, environment) {
|
|
10412
|
+
const context = await commonServiceContext(options, "seo", environment);
|
|
10413
|
+
if (!context)
|
|
10414
|
+
throw new Error("Select a SiteOS Project with siteos project use first.");
|
|
10415
|
+
const runtime = commonProjectRuntime(options);
|
|
10416
|
+
return {
|
|
10417
|
+
resourceId: context.resourceId,
|
|
10418
|
+
organizationId: context.overview.project.organizationId,
|
|
10419
|
+
async request(suffix, scope, body) {
|
|
10420
|
+
const grant = await runtime.grants.acquire({
|
|
10421
|
+
audience: "siteos-seo",
|
|
10422
|
+
scopes: [scope]
|
|
10423
|
+
});
|
|
10424
|
+
if (grant.grant.audience !== "siteos-seo" || grant.grant.organizationId !== context.overview.project.organizationId || grant.grant.scopes.length !== 1 || grant.grant.scopes[0] !== scope)
|
|
10425
|
+
throw new SiteOSAuthApiError({
|
|
10426
|
+
code: "AUTH_INVALID_RESPONSE",
|
|
10427
|
+
message: "The SEO grant does not match this Project and operation."
|
|
10428
|
+
});
|
|
10429
|
+
if (!options.fetchImpl)
|
|
10430
|
+
throw new Error("SiteOS API access is unavailable.");
|
|
10431
|
+
const response = await options.fetchImpl(
|
|
10432
|
+
`${runtime.api.origin}/api/seo/v1/resources/${encodeURIComponent(context.resourceId)}${suffix}`,
|
|
10433
|
+
{
|
|
10434
|
+
method: body === void 0 ? "GET" : "POST",
|
|
10435
|
+
headers: {
|
|
10436
|
+
Accept: "application/json",
|
|
10437
|
+
Authorization: `Bearer ${grant.accessToken}`,
|
|
10438
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
10439
|
+
},
|
|
10440
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
10441
|
+
signal: AbortSignal.timeout(3e4)
|
|
10442
|
+
}
|
|
10443
|
+
);
|
|
10444
|
+
if (!response.ok) {
|
|
10445
|
+
const result = z18.object({
|
|
10446
|
+
error: z18.object({ code: z18.string(), message: z18.string().max(500) })
|
|
10447
|
+
}).safeParse(await response.json());
|
|
10448
|
+
throw new SiteOSAuthApiError({
|
|
10449
|
+
code: result.success ? result.data.error.code : "SEO_REQUEST_FAILED",
|
|
10450
|
+
message: result.success ? result.data.error.message : "The SEO request failed.",
|
|
10451
|
+
status: response.status
|
|
10452
|
+
});
|
|
10453
|
+
}
|
|
10454
|
+
if (!(response instanceof Response))
|
|
10455
|
+
throw new Error("The API transport does not support SEO reports.");
|
|
10456
|
+
return response;
|
|
10457
|
+
}
|
|
10458
|
+
};
|
|
10459
|
+
}
|
|
10460
|
+
var versionedReport = z18.object({ contractVersion: z18.literal(1) }).passthrough();
|
|
10461
|
+
async function readSeoInput(cwd, filename) {
|
|
10462
|
+
const file = await open(path30.resolve(cwd ?? process.cwd(), filename), "r");
|
|
10463
|
+
try {
|
|
10464
|
+
const stat3 = await file.stat();
|
|
10465
|
+
if (!stat3.isFile() || stat3.size > 32e3)
|
|
10466
|
+
throw new Error("Use a JSON request file of at most 32,000 bytes.");
|
|
10467
|
+
const buffer = Buffer.alloc(32001);
|
|
10468
|
+
let bytes = 0;
|
|
10469
|
+
while (bytes < buffer.length) {
|
|
10470
|
+
const read = await file.read(buffer, bytes, buffer.length - bytes, null);
|
|
10471
|
+
if (!read.bytesRead) break;
|
|
10472
|
+
bytes += read.bytesRead;
|
|
10473
|
+
}
|
|
10474
|
+
if (bytes > 32e3)
|
|
10475
|
+
throw new Error("Use a JSON request file of at most 32,000 bytes.");
|
|
10476
|
+
try {
|
|
10477
|
+
return JSON.parse(buffer.subarray(0, bytes).toString("utf8"));
|
|
10478
|
+
} catch {
|
|
10479
|
+
throw new Error("The request file must contain valid JSON.");
|
|
10480
|
+
}
|
|
10481
|
+
} finally {
|
|
10482
|
+
await file.close();
|
|
10483
|
+
}
|
|
10484
|
+
}
|
|
10485
|
+
async function writeSeoReport(options, filename, content) {
|
|
10486
|
+
const output = path30.resolve(options.cwd ?? process.cwd(), filename);
|
|
10487
|
+
await writeFile8(output, content, { flag: "wx", mode: 384 });
|
|
10488
|
+
return output;
|
|
10489
|
+
}
|
|
10490
|
+
function seoReportFailure(cause, json, idempotencyKey) {
|
|
10491
|
+
const error = {
|
|
10492
|
+
code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
|
|
10493
|
+
message: cause instanceof z18.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The SEO command failed."
|
|
10494
|
+
};
|
|
10495
|
+
return {
|
|
10496
|
+
exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
|
|
10497
|
+
...json ? {
|
|
10498
|
+
stdout: JSON.stringify({
|
|
10499
|
+
error,
|
|
10500
|
+
...idempotencyKey ? { idempotencyKey } : {}
|
|
10501
|
+
})
|
|
10502
|
+
} : {
|
|
10503
|
+
stderr: `${error.message}${idempotencyKey ? ` Retry key: ${idempotencyKey}` : ""}`
|
|
10504
|
+
}
|
|
10505
|
+
};
|
|
10506
|
+
}
|
|
10507
|
+
|
|
10508
|
+
// src/services/seo-research-command.ts
|
|
10509
|
+
var RESEARCH_HELP = `
|
|
10510
|
+
siteos seo research summary [--environment <slug>] [--json]
|
|
10511
|
+
siteos seo research status --kind <kind> [--environment <slug>] [--json]
|
|
10512
|
+
siteos seo research history --kind <kind> [--environment <slug>] [--json]
|
|
10513
|
+
siteos seo research show <run-id> [--environment <slug>] [--json]
|
|
10514
|
+
siteos seo research plan --input <request.json> [--environment <slug>] [--json]
|
|
10515
|
+
siteos seo research run --input <request.json> [--idempotency-key <key>] [--environment <slug>] [--json]
|
|
10516
|
+
siteos seo research wait <run-id> [--timeout <seconds>] [--environment <slug>] [--json]
|
|
10517
|
+
siteos seo research cancel <run-id> [--environment <slug>] [--json]
|
|
10518
|
+
siteos seo research export <run-id> --format <json|csv> --output <new-file> [--environment <slug>] [--json]
|
|
10519
|
+
siteos seo research saved list --kind <kind> [--environment <slug>] [--json]
|
|
10520
|
+
siteos seo research saved save --input <request.json> --name <name> [--id <saved-id>] [--environment <slug>] [--json]
|
|
10521
|
+
siteos seo research saved remove <saved-id> [--environment <slug>] [--json]
|
|
10522
|
+
|
|
10523
|
+
Research kinds: keywords, domain, rankings, backlinks, brand, ai-visibility.
|
|
10524
|
+
Domain Overview: {"kind":"domain","target":"example.com","country":"US","language":"en"}.
|
|
10525
|
+
Domain collects summary, ranking keywords, top pages and competitors. The legacy competitors kind retains its two-part plan; domain history includes those reports.
|
|
10526
|
+
Request JSON: {"kind":"keywords","target":"example.com","keywords":["website analytics"],"country":"US","language":"en"}.
|
|
10527
|
+
Rankings accepts up to 10 keywords and 5 competitors. AI visibility requires brand, prompt and platforms.
|
|
10528
|
+
Markets: US, GB, ES, DE, FR, CA, AU. Languages: en, es, de, fr, pt, it, nl, ru.
|
|
10529
|
+
Platforms: chat_gpt, claude, gemini, perplexity. Brand lookup uses brandPlatform chat_gpt|google and brandMatch domain|brand.
|
|
10530
|
+
Plan validates the request and shows planned parts and available research credits without enqueueing work.
|
|
10531
|
+
Run consumes Organization research credits through the same worker as the interface. Preserve the retry key after an uncertain response.
|
|
10532
|
+
History returns the latest 30 checks per kind; show/export can address older retained run IDs. Saved checks are limited to 100 per resource.
|
|
10533
|
+
Wait defaults to 120 seconds (maximum 900); exit 3 means still pending, exit 4 means failed or cancelled. Partial evidence stays accessible.
|
|
10534
|
+
Exports preserve scope, dates and partial state. CSV stores one dataset row as JSON per row, including failed parts; it never converts missing metrics to zero.
|
|
10535
|
+
`;
|
|
10536
|
+
var kinds = [
|
|
10537
|
+
"keywords",
|
|
10538
|
+
"rankings",
|
|
10539
|
+
"competitors",
|
|
10540
|
+
"backlinks",
|
|
10541
|
+
"brand",
|
|
10542
|
+
"ai-visibility"
|
|
10543
|
+
];
|
|
10544
|
+
var requestSchema = z19.object({
|
|
10545
|
+
kind: z19.enum(["domain", ...kinds]).transform((kind) => kind === "domain" ? "competitors" : kind),
|
|
10546
|
+
target: z19.string().min(1).max(253)
|
|
10547
|
+
}).passthrough();
|
|
10548
|
+
var runStates = z19.enum([
|
|
10549
|
+
"queued",
|
|
10550
|
+
"running",
|
|
10551
|
+
"completed",
|
|
10552
|
+
"partial",
|
|
10553
|
+
"failed",
|
|
10554
|
+
"cancelled"
|
|
10555
|
+
]);
|
|
10556
|
+
var csvCell = (value) => {
|
|
10557
|
+
let text = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
10558
|
+
if (/^[\s]*[=+@-]/u.test(text) || /^[\t\r\n]/u.test(text)) text = `'${text}`;
|
|
10559
|
+
return `"${text.replaceAll('"', '""')}"`;
|
|
10560
|
+
};
|
|
10561
|
+
async function runSeoResearchCommand(options) {
|
|
10562
|
+
let idempotencyKey;
|
|
10563
|
+
try {
|
|
10564
|
+
const { values, positionals } = parseArgs5({
|
|
10565
|
+
args: options.args.slice(1),
|
|
10566
|
+
strict: true,
|
|
10567
|
+
allowPositionals: true,
|
|
10568
|
+
options: {
|
|
10569
|
+
json: { type: "boolean" },
|
|
10570
|
+
environment: { type: "string" },
|
|
10571
|
+
kind: { type: "string" },
|
|
10572
|
+
input: { type: "string" },
|
|
10573
|
+
name: { type: "string" },
|
|
10574
|
+
id: { type: "string" },
|
|
10575
|
+
"idempotency-key": { type: "string" },
|
|
10576
|
+
timeout: { type: "string" },
|
|
10577
|
+
format: { type: "string" },
|
|
10578
|
+
output: { type: "string" }
|
|
10579
|
+
}
|
|
10580
|
+
});
|
|
10581
|
+
const action = positionals[0] === "saved" ? positionals.slice(0, 2).join(" ") : positionals[0] ?? "";
|
|
10582
|
+
const id = positionals[action.startsWith("saved ") ? 2 : 1];
|
|
10583
|
+
const operations = {
|
|
10584
|
+
summary: { args: 1, flags: [] },
|
|
10585
|
+
status: { args: 1, flags: ["kind"] },
|
|
10586
|
+
history: { args: 1, flags: ["kind"] },
|
|
10587
|
+
show: { args: 2, flags: [] },
|
|
10588
|
+
plan: { args: 1, flags: ["input"] },
|
|
10589
|
+
run: { args: 1, flags: ["input", "idempotency-key"] },
|
|
10590
|
+
wait: { args: 2, flags: ["timeout"] },
|
|
10591
|
+
cancel: { args: 2, flags: [] },
|
|
10592
|
+
export: { args: 2, flags: ["format", "output"] },
|
|
10593
|
+
"saved list": { args: 2, flags: ["kind"] },
|
|
10594
|
+
"saved save": { args: 2, flags: ["input", "name", "id"] },
|
|
10595
|
+
"saved remove": { args: 3, flags: [] }
|
|
10596
|
+
};
|
|
10597
|
+
const operation = operations[action];
|
|
10598
|
+
if (!operation || operation.args !== positionals.length || Object.keys(values).some(
|
|
10599
|
+
(key) => !["json", "environment", ...operation.flags].includes(key)
|
|
10600
|
+
))
|
|
10601
|
+
throw new Error(
|
|
10602
|
+
"Invalid research operation or flags. Run siteos seo --help."
|
|
10603
|
+
);
|
|
10604
|
+
if (values.kind === "domain") values.kind = "competitors";
|
|
10605
|
+
if (operation.flags.includes("kind") && !kinds.includes(values.kind))
|
|
10606
|
+
throw new Error("Choose a documented --kind.");
|
|
10607
|
+
if (operation.flags.includes("input") && !values.input)
|
|
10608
|
+
throw new Error("Provide --input with a research request JSON file.");
|
|
10609
|
+
if (action === "saved save" && (!values.name?.trim() || values.name.length > 100))
|
|
10610
|
+
throw new Error("Use --name with 1 to 100 characters.");
|
|
10611
|
+
if (values.id && values.id.length > 100)
|
|
10612
|
+
throw new Error("The saved ID is too long.");
|
|
10613
|
+
const timeout = Number(values.timeout ?? 120);
|
|
10614
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 900)
|
|
10615
|
+
throw new Error("Use a wait timeout between 1 and 900 seconds.");
|
|
10616
|
+
if (action === "export" && (!values.output || !["json", "csv"].includes(values.format ?? "")))
|
|
10617
|
+
throw new Error(
|
|
10618
|
+
"Export requires --format json|csv and --output for a new file."
|
|
10619
|
+
);
|
|
10620
|
+
let input;
|
|
10621
|
+
if (values.input) {
|
|
10622
|
+
input = await readSeoInput(options.cwd, values.input);
|
|
10623
|
+
if (!requestSchema.safeParse(input).success)
|
|
10624
|
+
throw new Error(
|
|
10625
|
+
"The request file needs a research kind and target domain. Run siteos seo --help."
|
|
10626
|
+
);
|
|
10627
|
+
}
|
|
10628
|
+
if (action === "run") {
|
|
10629
|
+
idempotencyKey = values["idempotency-key"] ?? randomUUID6();
|
|
10630
|
+
if (!/^[a-zA-Z0-9_-]{8,100}$/u.test(idempotencyKey))
|
|
10631
|
+
throw new Error(
|
|
10632
|
+
"Use an idempotency key of 8 to 100 letters, digits, underscores or hyphens."
|
|
10633
|
+
);
|
|
10634
|
+
}
|
|
10635
|
+
const client = await seoReportClient(options, values.environment);
|
|
10636
|
+
const resourceSchema = z19.object({
|
|
10637
|
+
id: z19.literal(client.resourceId),
|
|
10638
|
+
organizationId: z19.literal(client.organizationId)
|
|
10639
|
+
}).passthrough();
|
|
10640
|
+
const runSchema = z19.object({
|
|
10641
|
+
id: z19.string(),
|
|
10642
|
+
resourceId: z19.literal(client.resourceId),
|
|
10643
|
+
organizationId: z19.literal(client.organizationId),
|
|
10644
|
+
websiteUrl: z19.string(),
|
|
10645
|
+
request: requestSchema,
|
|
10646
|
+
state: runStates,
|
|
10647
|
+
createdAt: z19.string(),
|
|
10648
|
+
finishedAt: z19.string().nullable(),
|
|
10649
|
+
parts: z19.array(
|
|
10650
|
+
z19.object({
|
|
10651
|
+
key: z19.string(),
|
|
10652
|
+
state: z19.enum(["completed", "failed"]),
|
|
10653
|
+
error: z19.string().nullable(),
|
|
10654
|
+
dataset: z19.record(z19.unknown()).nullable(),
|
|
10655
|
+
observedAt: z19.string()
|
|
10656
|
+
}).passthrough()
|
|
10657
|
+
)
|
|
10658
|
+
}).passthrough();
|
|
10659
|
+
const get = async (suffix, scope = "read", body) => versionedReport.parse(
|
|
10660
|
+
await (await client.request(
|
|
10661
|
+
`/research${suffix}`,
|
|
10662
|
+
`seo:research:${scope}`,
|
|
10663
|
+
body
|
|
10664
|
+
)).json()
|
|
10665
|
+
);
|
|
10666
|
+
const query = `?kind=${encodeURIComponent(values.kind ?? "rankings")}`;
|
|
10667
|
+
let record;
|
|
10668
|
+
if (action === "plan" || action === "run" || action === "saved save") {
|
|
10669
|
+
const plan = await get("/plan", "read", input);
|
|
10670
|
+
resourceSchema.parse(plan.resource);
|
|
10671
|
+
const canonical = requestSchema.parse(plan.request);
|
|
10672
|
+
if (canonical.kind !== requestSchema.parse(input).kind)
|
|
10673
|
+
throw new Error("The plan does not match the requested research kind.");
|
|
10674
|
+
if (action === "run") {
|
|
10675
|
+
record = await get("/runs", "run", { request: input, idempotencyKey });
|
|
10676
|
+
const run = runSchema.parse(record.run);
|
|
10677
|
+
if (!isDeepStrictEqual(run.request, plan.request))
|
|
10678
|
+
throw new Error(
|
|
10679
|
+
"The admitted check does not match the validated research request."
|
|
10680
|
+
);
|
|
10681
|
+
record = { ...record, idempotencyKey };
|
|
10682
|
+
} else if (action === "saved save") {
|
|
10683
|
+
record = await get("/saved", "write", {
|
|
10684
|
+
request: input,
|
|
10685
|
+
name: values.name.trim(),
|
|
10686
|
+
...values.id ? { id: values.id } : {}
|
|
10687
|
+
});
|
|
10688
|
+
const saved = z19.object({
|
|
10689
|
+
id: z19.string(),
|
|
10690
|
+
name: z19.literal(values.name.trim()),
|
|
10691
|
+
request: requestSchema
|
|
10692
|
+
}).parse(record.saved);
|
|
10693
|
+
if (values.id && saved.id !== values.id || !isDeepStrictEqual(saved.request, plan.request))
|
|
10694
|
+
throw new Error(
|
|
10695
|
+
"The saved check does not match the requested settings."
|
|
10696
|
+
);
|
|
10697
|
+
} else record = plan;
|
|
10698
|
+
} else if (action === "cancel" || action === "saved remove") {
|
|
10699
|
+
record = await get(
|
|
10700
|
+
`/${action === "cancel" ? "runs" : "saved"}/${encodeURIComponent(id)}/${action === "cancel" ? "cancel" : "remove"}`,
|
|
10701
|
+
action === "cancel" ? "run" : "write",
|
|
10702
|
+
{}
|
|
10703
|
+
);
|
|
10704
|
+
z19.literal(true).parse(
|
|
10705
|
+
record[action === "cancel" ? "cancelled" : "removed"]
|
|
10706
|
+
);
|
|
10707
|
+
} else if (action === "summary") {
|
|
10708
|
+
record = await get("/summary");
|
|
10709
|
+
resourceSchema.parse(record.resource);
|
|
10710
|
+
z19.array(z19.object({ kind: z19.enum(kinds), summary: z19.unknown() })).parse(
|
|
10711
|
+
record.checks
|
|
10712
|
+
);
|
|
10713
|
+
} else if (["status", "history", "saved list"].includes(action)) {
|
|
10714
|
+
const view = await get(query);
|
|
10715
|
+
resourceSchema.parse(view.resource);
|
|
10716
|
+
const runs = z19.array(runSchema).parse(view.runs);
|
|
10717
|
+
const saved = z19.array(
|
|
10718
|
+
z19.object({
|
|
10719
|
+
id: z19.string(),
|
|
10720
|
+
name: z19.string(),
|
|
10721
|
+
request: requestSchema
|
|
10722
|
+
}).passthrough()
|
|
10723
|
+
).parse(view.saved);
|
|
10724
|
+
if (runs.some((r) => r.request.kind !== values.kind) || saved.some((r) => r.request.kind !== values.kind))
|
|
10725
|
+
throw new Error(
|
|
10726
|
+
"The response does not match the requested research kind."
|
|
10727
|
+
);
|
|
10728
|
+
if (view.run != null) {
|
|
10729
|
+
const selected = runSchema.parse(view.run);
|
|
10730
|
+
if (selected.request.kind !== values.kind)
|
|
10731
|
+
throw new Error("The selected report has a different research kind.");
|
|
10732
|
+
}
|
|
10733
|
+
record = {
|
|
10734
|
+
contractVersion: 1,
|
|
10735
|
+
resource: view.resource,
|
|
10736
|
+
kind: values.kind,
|
|
10737
|
+
...action === "history" ? { runs, limit: 30 } : action === "saved list" ? { saved, limit: 100 } : { connection: view.connection, run: view.run }
|
|
10738
|
+
};
|
|
10739
|
+
} else {
|
|
10740
|
+
const deadline = Date.now() + timeout * 1e3;
|
|
10741
|
+
while (true) {
|
|
10742
|
+
record = await get(`/runs/${encodeURIComponent(id)}`);
|
|
10743
|
+
resourceSchema.parse(record.resource);
|
|
10744
|
+
const run = runSchema.parse(record.run);
|
|
10745
|
+
if (run.id !== id)
|
|
10746
|
+
throw new Error(
|
|
10747
|
+
"The response does not match the selected research check."
|
|
10748
|
+
);
|
|
10749
|
+
if (record.previous) runSchema.parse(record.previous);
|
|
10750
|
+
if (action !== "wait" || !["queued", "running"].includes(run.state))
|
|
10751
|
+
break;
|
|
10752
|
+
if (Date.now() >= deadline)
|
|
10753
|
+
return {
|
|
10754
|
+
exitCode: 3,
|
|
10755
|
+
stdout: JSON.stringify({ ...record, timedOut: true }, null, 2)
|
|
10756
|
+
};
|
|
10757
|
+
await setTimeout2(Math.min(3e3, Math.max(0, deadline - Date.now())));
|
|
10758
|
+
}
|
|
10759
|
+
if (action === "export") {
|
|
10760
|
+
const run = runSchema.parse(record.run);
|
|
10761
|
+
const parts = run.parts.length ? run.parts : [
|
|
10762
|
+
{
|
|
10763
|
+
key: null,
|
|
10764
|
+
state: null,
|
|
10765
|
+
observedAt: null,
|
|
10766
|
+
error: run.error,
|
|
10767
|
+
dataset: null
|
|
10768
|
+
}
|
|
10769
|
+
];
|
|
10770
|
+
const rows = parts.flatMap((part) => {
|
|
10771
|
+
const dataset = part.dataset;
|
|
10772
|
+
const entries = dataset && Array.isArray(dataset.rows) ? dataset.rows : [dataset];
|
|
10773
|
+
return (entries.length ? entries : [null]).map((row) => [
|
|
10774
|
+
run.id,
|
|
10775
|
+
run.websiteUrl,
|
|
10776
|
+
run.request.kind,
|
|
10777
|
+
run.request.target,
|
|
10778
|
+
run.createdAt,
|
|
10779
|
+
run.state,
|
|
10780
|
+
run.error,
|
|
10781
|
+
part.key,
|
|
10782
|
+
part.state,
|
|
10783
|
+
part.observedAt,
|
|
10784
|
+
part.error,
|
|
10785
|
+
dataset?.type,
|
|
10786
|
+
run.request,
|
|
10787
|
+
dataset ? Object.fromEntries(
|
|
10788
|
+
Object.entries(dataset).filter(([key]) => key !== "rows")
|
|
10789
|
+
) : null,
|
|
10790
|
+
row
|
|
10791
|
+
]);
|
|
10792
|
+
});
|
|
10793
|
+
const content = values.format === "json" ? JSON.stringify(record, null, 2) : [
|
|
10794
|
+
[
|
|
10795
|
+
"run_id",
|
|
10796
|
+
"website_url",
|
|
10797
|
+
"kind",
|
|
10798
|
+
"target",
|
|
10799
|
+
"created_at",
|
|
10800
|
+
"run_state",
|
|
10801
|
+
"run_error",
|
|
10802
|
+
"part",
|
|
10803
|
+
"part_state",
|
|
10804
|
+
"observed_at",
|
|
10805
|
+
"error",
|
|
10806
|
+
"dataset_type",
|
|
10807
|
+
"request_json",
|
|
10808
|
+
"dataset_metadata_json",
|
|
10809
|
+
"data_json"
|
|
10810
|
+
],
|
|
10811
|
+
...rows
|
|
10812
|
+
].map((row) => row.map(csvCell).join(",")).join("\r\n");
|
|
10813
|
+
const output = await writeSeoReport(options, values.output, content);
|
|
10814
|
+
record = {
|
|
10815
|
+
contractVersion: 1,
|
|
10816
|
+
runId: id,
|
|
10817
|
+
state: run.state,
|
|
10818
|
+
format: values.format,
|
|
10819
|
+
output
|
|
10820
|
+
};
|
|
10821
|
+
}
|
|
10822
|
+
if (action === "wait" && ["failed", "cancelled"].includes(runSchema.parse(record.run).state))
|
|
10823
|
+
return { exitCode: 4, stdout: JSON.stringify(record, null, 2) };
|
|
10824
|
+
}
|
|
10825
|
+
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
10826
|
+
} catch (cause) {
|
|
10827
|
+
return seoReportFailure(
|
|
10828
|
+
cause,
|
|
10829
|
+
options.args.includes("--json"),
|
|
10830
|
+
idempotencyKey
|
|
10831
|
+
);
|
|
10832
|
+
}
|
|
10833
|
+
}
|
|
10834
|
+
|
|
10835
|
+
// src/services/seo-gsc-command.ts
|
|
10836
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
10837
|
+
import { z as z20 } from "zod";
|
|
10838
|
+
var GSC_HELP = `
|
|
10839
|
+
siteos seo gsc status [--environment <slug>] [--json]
|
|
10840
|
+
siteos seo gsc report [--dataset <pages|queries>] [--query <text>] [--filter <all|issues|declining>] [--sort <clicks|change|impressions>] [--page <number>] [--url <page-url>] [--environment <slug>] [--json]
|
|
10841
|
+
siteos seo gsc export --format <json|csv> --output <new-file> [--dataset <pages|queries>] [--query <text>] [--filter <all|issues|declining>] [--sort <clicks|change|impressions>] [--environment <slug>] [--json]
|
|
10842
|
+
siteos seo gsc sync [--environment <slug>] [--json]
|
|
10843
|
+
siteos seo gsc bind --connection <id> --property <site-url> --website <environment-url> --revision <number> [--environment <slug>] [--json]
|
|
10844
|
+
siteos seo gsc disconnect --revision <number> [--environment <slug>] [--json]
|
|
10845
|
+
|
|
10846
|
+
GSC Insights reads saved Google Search Console reports; it requires a connected property for this environment.
|
|
10847
|
+
Status lists the property directory, binding, latest synchronization and report freshness. Connect Google OAuth interactively in SiteOS first.
|
|
10848
|
+
Report defaults to all pages and shows 25 rows per page, measured totals and coverage. --url includes the selected page and its query evidence.
|
|
10849
|
+
Export includes every matching row of the selected saved comparison (up to 20,000), with report dates and truncation metadata; --page is not an export filter.
|
|
10850
|
+
Sync queues the existing background worker and does not wait for completion. Read status until the returned run finishes.
|
|
10851
|
+
Bind/disconnect require a current revision and separate settings authority. Disconnect removes only this environment binding, not the Google account.
|
|
10852
|
+
`;
|
|
10853
|
+
async function runSeoGscCommand(options) {
|
|
10854
|
+
try {
|
|
10855
|
+
const { values, positionals } = parseArgs6({
|
|
10856
|
+
args: options.args.slice(1),
|
|
10857
|
+
strict: true,
|
|
10858
|
+
allowPositionals: true,
|
|
10859
|
+
options: {
|
|
10860
|
+
json: { type: "boolean" },
|
|
10861
|
+
environment: { type: "string" },
|
|
10862
|
+
dataset: { type: "string" },
|
|
10863
|
+
query: { type: "string" },
|
|
10864
|
+
filter: { type: "string" },
|
|
10865
|
+
sort: { type: "string" },
|
|
10866
|
+
page: { type: "string" },
|
|
10867
|
+
url: { type: "string" },
|
|
10868
|
+
format: { type: "string" },
|
|
10869
|
+
output: { type: "string" },
|
|
10870
|
+
connection: { type: "string" },
|
|
10871
|
+
property: { type: "string" },
|
|
10872
|
+
website: { type: "string" },
|
|
10873
|
+
revision: { type: "string" }
|
|
10874
|
+
}
|
|
10875
|
+
});
|
|
10876
|
+
const action = positionals[0] ?? "";
|
|
10877
|
+
const operations = {
|
|
10878
|
+
status: [],
|
|
10879
|
+
report: ["dataset", "query", "filter", "sort", "page", "url"],
|
|
10880
|
+
export: ["dataset", "query", "filter", "sort", "format", "output"],
|
|
10881
|
+
sync: [],
|
|
10882
|
+
bind: ["connection", "property", "website", "revision"],
|
|
10883
|
+
disconnect: ["revision"]
|
|
10884
|
+
};
|
|
10885
|
+
if (!operations[action] || positionals.length !== 1 || Object.keys(values).some(
|
|
10886
|
+
(key) => !["json", "environment", ...operations[action]].includes(key)
|
|
10887
|
+
))
|
|
10888
|
+
throw new Error("Invalid GSC operation or flags. Run siteos seo --help.");
|
|
10889
|
+
if (values.dataset && !["pages", "queries"].includes(values.dataset))
|
|
10890
|
+
throw new Error("Use --dataset pages or queries.");
|
|
10891
|
+
if (values.filter && !["all", "issues", "declining"].includes(values.filter))
|
|
10892
|
+
throw new Error("Use --filter all, issues or declining.");
|
|
10893
|
+
if (values.sort && !["clicks", "change", "impressions"].includes(values.sort))
|
|
10894
|
+
throw new Error("Use --sort clicks, change or impressions.");
|
|
10895
|
+
if (values.page && (!/^[1-9]\d{0,3}$/u.test(values.page) || Number(values.page) > 4e3))
|
|
10896
|
+
throw new Error("Use a page number between 1 and 4000.");
|
|
10897
|
+
if ((values.query?.length ?? 0) > 160 || (values.url?.length ?? 0) > 2048)
|
|
10898
|
+
throw new Error("The report filter is too long.");
|
|
10899
|
+
if (action === "export" && (!values.output || !["csv", "json"].includes(values.format ?? "")))
|
|
10900
|
+
throw new Error(
|
|
10901
|
+
"Export requires --format csv|json and --output for a new file."
|
|
10902
|
+
);
|
|
10903
|
+
if (["bind", "disconnect"].includes(action) && (!/^\d{1,9}$/u.test(values.revision ?? "") || action === "disconnect" && Number(values.revision) < 1))
|
|
10904
|
+
throw new Error(
|
|
10905
|
+
"Use the current binding --revision (0 only before the first binding)."
|
|
10906
|
+
);
|
|
10907
|
+
if (action === "bind" && (!values.connection || !values.property || !values.website))
|
|
10908
|
+
throw new Error(
|
|
10909
|
+
"Binding requires --connection, --property, --website and --revision."
|
|
10910
|
+
);
|
|
10911
|
+
const client = await seoReportClient(options, values.environment);
|
|
10912
|
+
const query = new URLSearchParams({
|
|
10913
|
+
dataset: values.dataset ?? "pages",
|
|
10914
|
+
filter: values.filter ?? "all"
|
|
10915
|
+
});
|
|
10916
|
+
for (const [flag, key] of [
|
|
10917
|
+
["query", "q"],
|
|
10918
|
+
["sort", "sort"],
|
|
10919
|
+
["page", "page"],
|
|
10920
|
+
["url", "pageUrl"],
|
|
10921
|
+
["format", "format"]
|
|
10922
|
+
])
|
|
10923
|
+
if (values[flag]) query.set(key, values[flag]);
|
|
10924
|
+
const writing = ["sync", "bind", "disconnect"].includes(action);
|
|
10925
|
+
const suffix = action === "bind" ? "/binding" : writing ? `/${action}` : action === "export" ? `/export?${query}` : `?${query}`;
|
|
10926
|
+
const body = action === "bind" ? {
|
|
10927
|
+
connectionId: values.connection,
|
|
10928
|
+
siteUrl: values.property,
|
|
10929
|
+
websiteUrl: values.website,
|
|
10930
|
+
expectedRevision: Number(values.revision)
|
|
10931
|
+
} : action === "disconnect" ? { expectedRevision: Number(values.revision) } : writing ? {} : void 0;
|
|
10932
|
+
const response = await client.request(
|
|
10933
|
+
`/search-console${suffix}`,
|
|
10934
|
+
`seo:search:${action === "sync" ? "sync" : writing ? "write" : "read"}`,
|
|
10935
|
+
body
|
|
10936
|
+
);
|
|
10937
|
+
const runSchema = z20.object({
|
|
10938
|
+
id: z20.string(),
|
|
10939
|
+
resourceId: z20.literal(client.resourceId),
|
|
10940
|
+
organizationId: z20.literal(client.organizationId),
|
|
10941
|
+
websiteUrl: z20.string(),
|
|
10942
|
+
siteUrl: z20.string(),
|
|
10943
|
+
state: z20.enum([
|
|
10944
|
+
"queued",
|
|
10945
|
+
"running",
|
|
10946
|
+
"completed",
|
|
10947
|
+
"partial",
|
|
10948
|
+
"failed",
|
|
10949
|
+
"cancelled"
|
|
10950
|
+
]),
|
|
10951
|
+
dates: z20.unknown()
|
|
10952
|
+
}).passthrough();
|
|
10953
|
+
const bindingSchema = z20.object({
|
|
10954
|
+
resourceId: z20.literal(client.resourceId),
|
|
10955
|
+
organizationId: z20.literal(client.organizationId),
|
|
10956
|
+
websiteUrl: z20.string(),
|
|
10957
|
+
siteUrl: z20.string(),
|
|
10958
|
+
revision: z20.number().int().positive()
|
|
10959
|
+
}).passthrough();
|
|
10960
|
+
const metrics = z20.object({
|
|
10961
|
+
clicks: z20.number(),
|
|
10962
|
+
impressions: z20.number(),
|
|
10963
|
+
ctr: z20.number(),
|
|
10964
|
+
position: z20.number()
|
|
10965
|
+
}).passthrough().nullable();
|
|
10966
|
+
const validateView = (data) => {
|
|
10967
|
+
const record2 = versionedReport.parse(data);
|
|
10968
|
+
z20.literal(client.resourceId).parse(record2.resourceId);
|
|
10969
|
+
bindingSchema.nullable().parse(record2.binding);
|
|
10970
|
+
runSchema.nullable().parse(record2.latest);
|
|
10971
|
+
runSchema.nullable().parse(record2.report);
|
|
10972
|
+
z20.array(
|
|
10973
|
+
z20.object({ key: z20.string(), current: metrics, previous: metrics }).passthrough()
|
|
10974
|
+
).parse(record2.rows);
|
|
10975
|
+
z20.number().int().nonnegative().parse(record2.total);
|
|
10976
|
+
z20.literal(values.dataset ?? "pages").parse(record2.dataset);
|
|
10977
|
+
return record2;
|
|
10978
|
+
};
|
|
10979
|
+
if (action === "export") {
|
|
10980
|
+
const rows = Number(response.headers.get("X-SEO-Export-Rows"));
|
|
10981
|
+
const reportId = response.headers.get("X-SEO-Search-Id");
|
|
10982
|
+
const truncated = response.headers.get("X-SEO-Export-Truncated");
|
|
10983
|
+
if (response.headers.get("X-SEO-Resource-Id") !== client.resourceId || !reportId || !response.headers.has("X-SEO-Export-Rows") || !Number.isSafeInteger(rows) || rows < 0 || !["true", "false"].includes(truncated ?? "") || !response.headers.get("Content-Type")?.startsWith(
|
|
10984
|
+
values.format === "csv" ? "text/csv" : "application/json"
|
|
10985
|
+
))
|
|
10986
|
+
throw new Error(
|
|
10987
|
+
"The export response does not match this SEO resource."
|
|
10988
|
+
);
|
|
10989
|
+
const text = await response.text();
|
|
10990
|
+
if (values.format === "json") {
|
|
10991
|
+
const report = validateView(JSON.parse(text));
|
|
10992
|
+
z20.literal(reportId).parse(runSchema.parse(report.report).id);
|
|
10993
|
+
z20.array(z20.unknown()).length(rows).parse(report.rows);
|
|
10994
|
+
z20.literal(truncated === "true").parse(report.truncated);
|
|
10995
|
+
}
|
|
10996
|
+
const output = await writeSeoReport(options, values.output, text);
|
|
10997
|
+
return {
|
|
10998
|
+
exitCode: 0,
|
|
10999
|
+
stdout: JSON.stringify(
|
|
11000
|
+
{
|
|
11001
|
+
contractVersion: 1,
|
|
11002
|
+
resourceId: client.resourceId,
|
|
11003
|
+
reportId,
|
|
11004
|
+
rows,
|
|
11005
|
+
truncated: truncated === "true",
|
|
11006
|
+
format: values.format,
|
|
11007
|
+
output
|
|
11008
|
+
},
|
|
11009
|
+
null,
|
|
11010
|
+
2
|
|
11011
|
+
)
|
|
11012
|
+
};
|
|
11013
|
+
}
|
|
11014
|
+
const record = versionedReport.parse(await response.json());
|
|
11015
|
+
if (action === "disconnect") z20.literal(true).parse(record.unbound);
|
|
11016
|
+
else if (writing) {
|
|
11017
|
+
runSchema.parse(record.run);
|
|
11018
|
+
if (action === "bind") {
|
|
11019
|
+
const binding = bindingSchema.parse(record.binding);
|
|
11020
|
+
if (binding.siteUrl !== values.property || binding.websiteUrl !== values.website || binding.revision !== Number(values.revision) + 1)
|
|
11021
|
+
throw new Error(
|
|
11022
|
+
"The saved binding does not match the requested property and revision."
|
|
11023
|
+
);
|
|
11024
|
+
}
|
|
11025
|
+
} else validateView(record);
|
|
11026
|
+
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
11027
|
+
} catch (cause) {
|
|
11028
|
+
return seoReportFailure(cause, options.args.includes("--json"));
|
|
11029
|
+
}
|
|
11030
|
+
}
|
|
11031
|
+
|
|
11032
|
+
// src/services/seo-performance-command.ts
|
|
11033
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
11034
|
+
import { writeFile as writeFile9 } from "fs/promises";
|
|
11035
|
+
import path31 from "path";
|
|
11036
|
+
import { setTimeout as setTimeout3 } from "timers/promises";
|
|
11037
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
11038
|
+
import { z as z21 } from "zod";
|
|
10394
11039
|
var PERFORMANCE_HELP = `
|
|
10395
11040
|
siteos seo performance run --audit <id> --url <url> [--url <url>...] [--device <mobile|desktop>] [--idempotency-key <key>] [--environment <slug>] [--json]
|
|
10396
11041
|
siteos seo performance list [--device <mobile|desktop>] [--environment <slug>] [--json]
|
|
@@ -10407,7 +11052,7 @@ Reuse the returned idempotency key after an uncertain run response. Wait default
|
|
|
10407
11052
|
async function runSeoPerformanceCommand(options) {
|
|
10408
11053
|
let idempotencyKey;
|
|
10409
11054
|
try {
|
|
10410
|
-
const { values, positionals } =
|
|
11055
|
+
const { values, positionals } = parseArgs7({
|
|
10411
11056
|
args: options.args.slice(1),
|
|
10412
11057
|
strict: true,
|
|
10413
11058
|
allowPositionals: true,
|
|
@@ -10462,7 +11107,7 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10462
11107
|
"Export requires --format csv|json and --output for a new file."
|
|
10463
11108
|
);
|
|
10464
11109
|
if (action === "run") {
|
|
10465
|
-
idempotencyKey = values["idempotency-key"] ??
|
|
11110
|
+
idempotencyKey = values["idempotency-key"] ?? randomUUID7();
|
|
10466
11111
|
if (!/^[a-zA-Z0-9_-]{16,100}$/u.test(idempotencyKey))
|
|
10467
11112
|
throw new Error(
|
|
10468
11113
|
"Use an idempotency key of 16 to 100 letters, digits, underscores or hyphens."
|
|
@@ -10478,12 +11123,12 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10478
11123
|
const runtime = commonProjectRuntime(options);
|
|
10479
11124
|
const writing = ["run", "cancel"].includes(action);
|
|
10480
11125
|
const scope = writing ? "seo:audits:write" : "seo:workspace:read";
|
|
10481
|
-
const batchSchema =
|
|
10482
|
-
id:
|
|
10483
|
-
resourceId:
|
|
10484
|
-
organizationId:
|
|
10485
|
-
sourceAuditId:
|
|
10486
|
-
state:
|
|
11126
|
+
const batchSchema = z21.object({
|
|
11127
|
+
id: z21.string(),
|
|
11128
|
+
resourceId: z21.literal(context.resourceId),
|
|
11129
|
+
organizationId: z21.literal(context.overview.project.organizationId),
|
|
11130
|
+
sourceAuditId: z21.string(),
|
|
11131
|
+
state: z21.enum([
|
|
10487
11132
|
"queued",
|
|
10488
11133
|
"running",
|
|
10489
11134
|
"completed",
|
|
@@ -10491,8 +11136,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10491
11136
|
"failed",
|
|
10492
11137
|
"cancelled"
|
|
10493
11138
|
]),
|
|
10494
|
-
device:
|
|
10495
|
-
urls:
|
|
11139
|
+
device: z21.enum(["mobile", "desktop"]),
|
|
11140
|
+
urls: z21.array(z21.string()).min(1).max(10)
|
|
10496
11141
|
}).passthrough();
|
|
10497
11142
|
const query = new URLSearchParams();
|
|
10498
11143
|
if (values.device) query.set("device", values.device);
|
|
@@ -10540,21 +11185,21 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10540
11185
|
);
|
|
10541
11186
|
const text = await response.text();
|
|
10542
11187
|
if (values.format === "json") {
|
|
10543
|
-
const parsed =
|
|
10544
|
-
contractVersion:
|
|
11188
|
+
const parsed = z21.object({
|
|
11189
|
+
contractVersion: z21.literal(1),
|
|
10545
11190
|
batch: batchSchema,
|
|
10546
|
-
pages:
|
|
11191
|
+
pages: z21.array(z21.unknown())
|
|
10547
11192
|
}).parse(JSON.parse(text));
|
|
10548
11193
|
if (parsed.batch.id !== id)
|
|
10549
11194
|
throw new Error(
|
|
10550
11195
|
"The export response does not match the selected check."
|
|
10551
11196
|
);
|
|
10552
11197
|
}
|
|
10553
|
-
const output =
|
|
11198
|
+
const output = path31.resolve(
|
|
10554
11199
|
options.cwd ?? process.cwd(),
|
|
10555
11200
|
values.output
|
|
10556
11201
|
);
|
|
10557
|
-
await
|
|
11202
|
+
await writeFile9(output, text, { flag: "wx", mode: 384 });
|
|
10558
11203
|
return {
|
|
10559
11204
|
exitCode: 0,
|
|
10560
11205
|
stdout: JSON.stringify(
|
|
@@ -10566,8 +11211,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10566
11211
|
}
|
|
10567
11212
|
const data = await response.json();
|
|
10568
11213
|
if (!response.ok) {
|
|
10569
|
-
const error =
|
|
10570
|
-
error:
|
|
11214
|
+
const error = z21.object({
|
|
11215
|
+
error: z21.object({ code: z21.string(), message: z21.string().max(500) })
|
|
10571
11216
|
}).safeParse(data);
|
|
10572
11217
|
throw new SiteOSAuthApiError({
|
|
10573
11218
|
code: error.success ? error.data.error.code : "SEO_REQUEST_FAILED",
|
|
@@ -10575,8 +11220,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10575
11220
|
status: response.status
|
|
10576
11221
|
});
|
|
10577
11222
|
}
|
|
10578
|
-
const record =
|
|
10579
|
-
if (action === "cancel")
|
|
11223
|
+
const record = z21.object({ contractVersion: z21.literal(1) }).passthrough().parse(data);
|
|
11224
|
+
if (action === "cancel") z21.literal(true).parse(record.cancelled);
|
|
10580
11225
|
else if (action === "run") {
|
|
10581
11226
|
const accepted = batchSchema.parse(record.batch);
|
|
10582
11227
|
const normalize = (url) => {
|
|
@@ -10589,8 +11234,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10589
11234
|
"The queued check does not match the requested source, device and URLs."
|
|
10590
11235
|
);
|
|
10591
11236
|
} else {
|
|
10592
|
-
|
|
10593
|
-
|
|
11237
|
+
z21.literal(context.resourceId).parse(record.resourceId);
|
|
11238
|
+
z21.array(batchSchema).parse(record.batches);
|
|
10594
11239
|
const selected = batchSchema.nullable().parse(record.batch);
|
|
10595
11240
|
if (id && selected?.id !== id)
|
|
10596
11241
|
throw new Error(
|
|
@@ -10612,12 +11257,12 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10612
11257
|
exitCode: 3,
|
|
10613
11258
|
stdout: JSON.stringify({ ...record, timedOut: true }, null, 2)
|
|
10614
11259
|
};
|
|
10615
|
-
await
|
|
11260
|
+
await setTimeout3(Math.min(3e3, Math.max(0, deadline - Date.now())));
|
|
10616
11261
|
}
|
|
10617
11262
|
} catch (cause) {
|
|
10618
11263
|
const error = {
|
|
10619
11264
|
code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
|
|
10620
|
-
message: cause instanceof
|
|
11265
|
+
message: cause instanceof z21.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The performance command failed."
|
|
10621
11266
|
};
|
|
10622
11267
|
return {
|
|
10623
11268
|
exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
|
|
@@ -10634,7 +11279,15 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10634
11279
|
}
|
|
10635
11280
|
|
|
10636
11281
|
// src/services/seo-command.ts
|
|
10637
|
-
var SEO_HELP = `
|
|
11282
|
+
var SEO_HELP = `SEO/GEO: audit your website and research search-engine and AI visibility in the selected Project environment.
|
|
11283
|
+
|
|
11284
|
+
Site Audit checks public HTML, canonical, robots.txt, sitemap and technical GEO signals.
|
|
11285
|
+
Performance measures selected pages with Lighthouse and compares response/rendered HTML.
|
|
11286
|
+
Research covers keywords, rankings, competitors, backlinks, Brand lookup and Prompt checks.
|
|
11287
|
+
GSC Insights reads your connected property's Google Search Console data; other research needs no GSC.
|
|
11288
|
+
Technical GEO checks crawler policies, content, snippets and structured-data syntax/shape.
|
|
11289
|
+
These checks do not measure autonomous agent journeys or guarantee AI inclusion or citations.
|
|
11290
|
+
The service is named SEO/GEO in the application; its command remains siteos seo.
|
|
10638
11291
|
|
|
10639
11292
|
Usage:
|
|
10640
11293
|
siteos seo status [--environment <slug>] [--json]
|
|
@@ -10655,6 +11308,8 @@ Usage:
|
|
|
10655
11308
|
siteos seo notifications set --enabled <true|false> [--destination <candidate-id>] --severity <error|warning> --failures <true|false> --revision <number> [--environment <slug>] [--json]
|
|
10656
11309
|
siteos seo export --audit <id> --kind <pages|issues|changes> --format <csv|json> --output <new-file> [--query <text>] [--rule <id>] [--severity <error|warning|notice>] [--state <page-or-change-state>] [--environment <slug>] [--json]
|
|
10657
11310
|
${PERFORMANCE_HELP.trim().split("\n\n")[0]}
|
|
11311
|
+
${RESEARCH_HELP.trim().split("\n\n")[0]}
|
|
11312
|
+
${GSC_HELP.trim().split("\n\n")[0]}
|
|
10658
11313
|
|
|
10659
11314
|
Schedule and notification writes require an owner/admin and the saved revision (initially 0).
|
|
10660
11315
|
Export writes all matching rows to a new file; existing files are never overwritten.
|
|
@@ -10662,15 +11317,19 @@ Runs are queued. Read audit show until terminal; an accepted run is not a comple
|
|
|
10662
11317
|
Recheck accepts a URL observed in the source audit. Cross-page rules require a full audit.
|
|
10663
11318
|
Read the current disposition revision before ignore/restore; use 0 if no decision exists.
|
|
10664
11319
|
Setup: siteos project connect seo. No crawl runs during setup.
|
|
10665
|
-
${PERFORMANCE_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11320
|
+
${PERFORMANCE_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11321
|
+
${RESEARCH_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11322
|
+
${GSC_HELP.trim().split("\n\n").slice(1).join("\n\n")}`;
|
|
10666
11323
|
async function runSeoCommand(options) {
|
|
10667
11324
|
if (!options.args.length || options.args.some((arg) => ["--help", "-h"].includes(arg)))
|
|
10668
11325
|
return { exitCode: 0, stdout: SEO_HELP };
|
|
10669
11326
|
if (options.args[0] === "performance")
|
|
10670
11327
|
return runSeoPerformanceCommand(options);
|
|
11328
|
+
if (options.args[0] === "research") return runSeoResearchCommand(options);
|
|
11329
|
+
if (options.args[0] === "gsc") return runSeoGscCommand(options);
|
|
10671
11330
|
const json = options.args.includes("--json");
|
|
10672
11331
|
try {
|
|
10673
|
-
const { positionals, values } =
|
|
11332
|
+
const { positionals, values } = parseArgs8({
|
|
10674
11333
|
args: options.args,
|
|
10675
11334
|
strict: true,
|
|
10676
11335
|
allowPositionals: true,
|
|
@@ -10883,18 +11542,18 @@ async function runSeoCommand(options) {
|
|
|
10883
11542
|
);
|
|
10884
11543
|
const text = await response.text();
|
|
10885
11544
|
if (values.format === "json")
|
|
10886
|
-
|
|
10887
|
-
contractVersion:
|
|
10888
|
-
audit:
|
|
10889
|
-
id:
|
|
10890
|
-
resourceId:
|
|
11545
|
+
z22.object({
|
|
11546
|
+
contractVersion: z22.literal(1),
|
|
11547
|
+
audit: z22.object({
|
|
11548
|
+
id: z22.literal(values.audit),
|
|
11549
|
+
resourceId: z22.literal(context.resourceId)
|
|
10891
11550
|
}),
|
|
10892
|
-
kind:
|
|
10893
|
-
totalRows:
|
|
10894
|
-
rows:
|
|
11551
|
+
kind: z22.literal(values.kind),
|
|
11552
|
+
totalRows: z22.literal(rows),
|
|
11553
|
+
rows: z22.array(z22.unknown()).length(rows)
|
|
10895
11554
|
}).parse(JSON.parse(text));
|
|
10896
|
-
const output =
|
|
10897
|
-
await
|
|
11555
|
+
const output = path32.resolve(options.cwd ?? process.cwd(), values.output);
|
|
11556
|
+
await writeFile10(output, text, { flag: "wx", mode: 384 });
|
|
10898
11557
|
return {
|
|
10899
11558
|
exitCode: 0,
|
|
10900
11559
|
stdout: JSON.stringify(
|
|
@@ -10912,8 +11571,8 @@ async function runSeoCommand(options) {
|
|
|
10912
11571
|
}
|
|
10913
11572
|
const data = await response.json();
|
|
10914
11573
|
if (!response.ok) {
|
|
10915
|
-
const result =
|
|
10916
|
-
error:
|
|
11574
|
+
const result = z22.object({
|
|
11575
|
+
error: z22.object({ code: z22.string(), message: z22.string().max(500) })
|
|
10917
11576
|
}).safeParse(data);
|
|
10918
11577
|
throw new SiteOSAuthApiError({
|
|
10919
11578
|
code: result.success ? result.data.error.code : "SEO_REQUEST_FAILED",
|
|
@@ -10921,34 +11580,34 @@ async function runSeoCommand(options) {
|
|
|
10921
11580
|
status: response.status
|
|
10922
11581
|
});
|
|
10923
11582
|
}
|
|
10924
|
-
const record =
|
|
11583
|
+
const record = z22.object({ contractVersion: z22.literal(1) }).passthrough().parse(data);
|
|
10925
11584
|
if (automation) {
|
|
10926
|
-
|
|
10927
|
-
const schedule =
|
|
10928
|
-
enabled:
|
|
10929
|
-
weekday:
|
|
10930
|
-
time:
|
|
10931
|
-
timeZone:
|
|
10932
|
-
revision:
|
|
10933
|
-
nextRunAt:
|
|
11585
|
+
z22.literal(context.resourceId).parse(record.resourceId);
|
|
11586
|
+
const schedule = z22.object({
|
|
11587
|
+
enabled: z22.boolean(),
|
|
11588
|
+
weekday: z22.number().int().min(1).max(7),
|
|
11589
|
+
time: z22.string(),
|
|
11590
|
+
timeZone: z22.string(),
|
|
11591
|
+
revision: z22.number().int().min(0),
|
|
11592
|
+
nextRunAt: z22.string().nullable()
|
|
10934
11593
|
});
|
|
10935
|
-
const notificationRoute =
|
|
10936
|
-
enabled:
|
|
10937
|
-
minimumSeverity:
|
|
10938
|
-
includeFailures:
|
|
10939
|
-
revision:
|
|
10940
|
-
destinationId:
|
|
11594
|
+
const notificationRoute = z22.object({
|
|
11595
|
+
enabled: z22.boolean(),
|
|
11596
|
+
minimumSeverity: z22.enum(["error", "warning"]),
|
|
11597
|
+
includeFailures: z22.boolean(),
|
|
11598
|
+
revision: z22.number().int().min(0),
|
|
11599
|
+
destinationId: z22.string().nullable()
|
|
10941
11600
|
});
|
|
10942
11601
|
if (retryNotification) {
|
|
10943
|
-
|
|
10944
|
-
|
|
11602
|
+
z22.literal(true).parse(record.retryQueued);
|
|
11603
|
+
z22.literal(positionals[2]).parse(record.notificationId);
|
|
10945
11604
|
} else if (route === "notifications destinations")
|
|
10946
|
-
|
|
10947
|
-
candidates:
|
|
10948
|
-
|
|
10949
|
-
candidateId:
|
|
10950
|
-
label:
|
|
10951
|
-
availability:
|
|
11605
|
+
z22.object({
|
|
11606
|
+
candidates: z22.array(
|
|
11607
|
+
z22.object({
|
|
11608
|
+
candidateId: z22.string(),
|
|
11609
|
+
label: z22.string(),
|
|
11610
|
+
availability: z22.literal("available")
|
|
10952
11611
|
})
|
|
10953
11612
|
)
|
|
10954
11613
|
}).parse(record);
|
|
@@ -10961,46 +11620,46 @@ async function runSeoCommand(options) {
|
|
|
10961
11620
|
notificationRoute.parse(record.route);
|
|
10962
11621
|
}
|
|
10963
11622
|
} else if (!writing) {
|
|
10964
|
-
const validated =
|
|
10965
|
-
resource:
|
|
10966
|
-
id:
|
|
10967
|
-
organizationId:
|
|
11623
|
+
const validated = z22.object({
|
|
11624
|
+
resource: z22.object({
|
|
11625
|
+
id: z22.literal(context.resourceId),
|
|
11626
|
+
organizationId: z22.literal(context.overview.project.organizationId)
|
|
10968
11627
|
}),
|
|
10969
|
-
audits:
|
|
10970
|
-
audit:
|
|
10971
|
-
id:
|
|
10972
|
-
resourceId:
|
|
11628
|
+
audits: z22.array(z22.object({ id: z22.string() }).passthrough()),
|
|
11629
|
+
audit: z22.object({
|
|
11630
|
+
id: z22.string(),
|
|
11631
|
+
resourceId: z22.literal(context.resourceId)
|
|
10973
11632
|
}).passthrough().nullable(),
|
|
10974
|
-
pages:
|
|
10975
|
-
issues:
|
|
10976
|
-
changes:
|
|
10977
|
-
totalChanges:
|
|
10978
|
-
dispositions:
|
|
11633
|
+
pages: z22.array(z22.unknown()),
|
|
11634
|
+
issues: z22.array(z22.unknown()),
|
|
11635
|
+
changes: z22.array(z22.unknown()),
|
|
11636
|
+
totalChanges: z22.number(),
|
|
11637
|
+
dispositions: z22.array(z22.unknown())
|
|
10979
11638
|
}).passthrough().parse(record);
|
|
10980
11639
|
const selected = query.get("audit");
|
|
10981
11640
|
if (selected && validated.audit?.id !== selected)
|
|
10982
11641
|
throw new Error("The SEO response does not match the requested audit.");
|
|
10983
11642
|
} else if (record.audit)
|
|
10984
|
-
|
|
10985
|
-
id:
|
|
10986
|
-
resourceId:
|
|
10987
|
-
organizationId:
|
|
10988
|
-
state:
|
|
11643
|
+
z22.object({
|
|
11644
|
+
id: z22.string(),
|
|
11645
|
+
resourceId: z22.literal(context.resourceId),
|
|
11646
|
+
organizationId: z22.literal(context.overview.project.organizationId),
|
|
11647
|
+
state: z22.literal("queued")
|
|
10989
11648
|
}).parse(record.audit);
|
|
10990
|
-
else if (route === "audit cancel")
|
|
11649
|
+
else if (route === "audit cancel") z22.literal(true).parse(record.cancelled);
|
|
10991
11650
|
else if (action === "issue")
|
|
10992
|
-
|
|
10993
|
-
url:
|
|
10994
|
-
ruleId:
|
|
10995
|
-
ignored:
|
|
10996
|
-
revision:
|
|
11651
|
+
z22.object({
|
|
11652
|
+
url: z22.literal(values.url),
|
|
11653
|
+
ruleId: z22.literal(values.rule),
|
|
11654
|
+
ignored: z22.literal(positionals[1] === "ignore"),
|
|
11655
|
+
revision: z22.literal(Number(values.revision) + 1)
|
|
10997
11656
|
}).parse(record.disposition);
|
|
10998
11657
|
else throw new Error("The SEO service returned an invalid response.");
|
|
10999
11658
|
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
11000
11659
|
} catch (cause) {
|
|
11001
11660
|
const error = {
|
|
11002
11661
|
code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
|
|
11003
|
-
message: cause instanceof
|
|
11662
|
+
message: cause instanceof z22.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The SEO command failed."
|
|
11004
11663
|
};
|
|
11005
11664
|
return {
|
|
11006
11665
|
exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
|
|
@@ -11175,7 +11834,7 @@ Commands:
|
|
|
11175
11834
|
cookie Configure, publish, and inspect the Project\u2019s cookie banner.
|
|
11176
11835
|
trace Configure analytics observation and inspect evidence.
|
|
11177
11836
|
analytics Configure website Analytics, events and realtime reports.
|
|
11178
|
-
seo
|
|
11837
|
+
seo SEO/GEO audits, keyword research and AI visibility reports.
|
|
11179
11838
|
integrations Manage Organization connections and destinations.
|
|
11180
11839
|
pulse Manage monitoring checks, tests, and deployments.
|
|
11181
11840
|
search Run SiteOS search operations for a project environment.
|