@siteoshq/cli 1.9.0 → 1.11.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 +46 -1
- package/dist/cli.js +777 -111
- package/dist/cli.js.map +1 -1
- package/package.json +18 -20
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: {
|
|
@@ -1918,6 +1918,26 @@ var ScheduledConfigSchema = z6.object({
|
|
|
1918
1918
|
});
|
|
1919
1919
|
var ManualConfigSchema = z6.object({ mode: z6.literal("manual") });
|
|
1920
1920
|
var CheckConfigSchema = z6.object({
|
|
1921
|
+
environmentVariables: z6.array(
|
|
1922
|
+
z6.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/).refine(
|
|
1923
|
+
(name) => !/^(?:SITEOS_|PLAYWRIGHT_|DOCKER_|NODE_|NPM_|PNPM_|LD_|DYLD_)/.test(
|
|
1924
|
+
name
|
|
1925
|
+
) && ![
|
|
1926
|
+
"PATH",
|
|
1927
|
+
"HOME",
|
|
1928
|
+
"TMPDIR",
|
|
1929
|
+
"TMP",
|
|
1930
|
+
"TEMP",
|
|
1931
|
+
"CI",
|
|
1932
|
+
"DATABASE_URL",
|
|
1933
|
+
"RUNNER_SHARED_SECRET"
|
|
1934
|
+
].includes(name) && !name.endsWith("_DATABASE_URL") && !name.endsWith("_R2_ACCESS_KEY_ID") && !name.endsWith("_R2_SECRET_ACCESS_KEY"),
|
|
1935
|
+
"This name is reserved by Pulse."
|
|
1936
|
+
)
|
|
1937
|
+
).max(50).refine(
|
|
1938
|
+
(names) => new Set(names).size === names.length,
|
|
1939
|
+
"Variable names must be unique."
|
|
1940
|
+
).optional(),
|
|
1921
1941
|
slug,
|
|
1922
1942
|
name: displayName,
|
|
1923
1943
|
include: z6.array(safeRelativePath).min(1).max(100),
|
|
@@ -2374,7 +2394,10 @@ var AUTH_HELP = `Usage:
|
|
|
2374
2394
|
siteos auth select --organization <organization-id> [--json]
|
|
2375
2395
|
siteos auth logout [--json]
|
|
2376
2396
|
|
|
2377
|
-
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.`;
|
|
2378
2401
|
async function runAuthCommand(options) {
|
|
2379
2402
|
if (options.args.length === 0 || options.args.some((arg) => HELP_FLAGS.has(arg))) {
|
|
2380
2403
|
return { exitCode: 0, stdout: AUTH_HELP };
|
|
@@ -6146,7 +6169,7 @@ var BUILTINS = /* @__PURE__ */ new Set([
|
|
|
6146
6169
|
...builtinModules,
|
|
6147
6170
|
...builtinModules.map((name) => `node:${name}`)
|
|
6148
6171
|
]);
|
|
6149
|
-
var ENVIRONMENT_PATTERN = /\bprocess\.env
|
|
6172
|
+
var ENVIRONMENT_PATTERN = /\bprocess\.env(?:\.([A-Z][A-Z0-9_]*)\b|\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\])/g;
|
|
6150
6173
|
function packageName(importPath) {
|
|
6151
6174
|
if (importPath.startsWith("@"))
|
|
6152
6175
|
return importPath.split("/").slice(0, 2).join("/");
|
|
@@ -6227,8 +6250,9 @@ async function analyzeTestImports(input) {
|
|
|
6227
6250
|
() => ""
|
|
6228
6251
|
);
|
|
6229
6252
|
for (const match of source.matchAll(ENVIRONMENT_PATTERN)) {
|
|
6230
|
-
|
|
6231
|
-
|
|
6253
|
+
const name = match[1] ?? match[2];
|
|
6254
|
+
if (name && name !== "PLAYWRIGHT_BASE_URL") {
|
|
6255
|
+
environmentVariables.add(name);
|
|
6232
6256
|
}
|
|
6233
6257
|
}
|
|
6234
6258
|
if (/playwright\.config\.[^.]+$/.test(file) && /\bwebServer\s*:/.test(source)) {
|
|
@@ -7155,7 +7179,8 @@ async function runDeployCommand(options, args2) {
|
|
|
7155
7179
|
},
|
|
7156
7180
|
strict: true
|
|
7157
7181
|
});
|
|
7158
|
-
const
|
|
7182
|
+
const projectRoot = path25.resolve(options.cwd ?? process.cwd());
|
|
7183
|
+
const repositoryRoot = await findRepositoryRoot(projectRoot);
|
|
7159
7184
|
const common = await commonServiceContext(options, "pulse");
|
|
7160
7185
|
if (common && (!common.environmentBinding?.slug || !common.environment.url))
|
|
7161
7186
|
throw new CliError(
|
|
@@ -7171,14 +7196,14 @@ async function runDeployCommand(options, args2) {
|
|
|
7171
7196
|
}
|
|
7172
7197
|
} : {},
|
|
7173
7198
|
output: values.output,
|
|
7174
|
-
projectRoot
|
|
7199
|
+
projectRoot
|
|
7175
7200
|
});
|
|
7176
7201
|
if (values["dry-run"]) {
|
|
7177
7202
|
return success(
|
|
7178
7203
|
values.json,
|
|
7179
7204
|
{ dryRun: true, manifest },
|
|
7180
7205
|
[
|
|
7181
|
-
`Bundle: ${path25.relative(
|
|
7206
|
+
`Bundle: ${path25.relative(projectRoot, manifest.archivePath)}`,
|
|
7182
7207
|
`Checksum: ${manifest.checksum}`,
|
|
7183
7208
|
`Files: ${manifest.fileCount}`,
|
|
7184
7209
|
"Dry run complete; nothing was uploaded."
|
|
@@ -7186,7 +7211,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7186
7211
|
);
|
|
7187
7212
|
}
|
|
7188
7213
|
const apiOrigin = await resolvePulseApiOrigin({
|
|
7189
|
-
cwd:
|
|
7214
|
+
cwd: projectRoot,
|
|
7190
7215
|
env: options.env,
|
|
7191
7216
|
requireConfig: true
|
|
7192
7217
|
});
|
|
@@ -7195,7 +7220,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7195
7220
|
apiOrigin,
|
|
7196
7221
|
fetchImpl: options.fetchImpl
|
|
7197
7222
|
});
|
|
7198
|
-
const snapshot = await loadChecksConfigSnapshot(
|
|
7223
|
+
const snapshot = await loadChecksConfigSnapshot(projectRoot);
|
|
7199
7224
|
const selected = await requireSelectedProject({
|
|
7200
7225
|
options,
|
|
7201
7226
|
apiOrigin,
|
|
@@ -7221,7 +7246,7 @@ async function runDeployCommand(options, args2) {
|
|
|
7221
7246
|
values.json,
|
|
7222
7247
|
{ deployment, dryRun: false, manifest },
|
|
7223
7248
|
[
|
|
7224
|
-
`Bundle: ${path25.relative(
|
|
7249
|
+
`Bundle: ${path25.relative(projectRoot, manifest.archivePath)}`,
|
|
7225
7250
|
`Checksum: ${manifest.checksum}`,
|
|
7226
7251
|
`Files: ${manifest.fileCount}`,
|
|
7227
7252
|
`Project ${deployment.projectId} deployed.`,
|
|
@@ -10010,8 +10035,8 @@ var ANALYTICS_HELP = `Set up website Analytics in the selected Project environme
|
|
|
10010
10035
|
Usage:
|
|
10011
10036
|
siteos analytics status [--environment <slug>] [--json]
|
|
10012
10037
|
siteos analytics installation [--environment <slug>] [--json]
|
|
10013
|
-
siteos analytics report [--days <1|7|28>] [--event <name>] [--country <ISO|unknown>] [--campaign <id>] [--environment <slug>] [--json]
|
|
10014
|
-
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]
|
|
10015
10040
|
siteos analytics settings show [--environment <slug>] [--json]
|
|
10016
10041
|
siteos analytics settings set --revision <number> [--enabled <true|false>] [--cookie-events <true|false>] [--minimal-realtime <true|false>] [--environment <slug>] [--json]
|
|
10017
10042
|
siteos analytics events list [--environment <slug>] [--json]
|
|
@@ -10114,6 +10139,7 @@ async function runAnalyticsCommand(options) {
|
|
|
10114
10139
|
event: { type: "string" },
|
|
10115
10140
|
country: { type: "string" },
|
|
10116
10141
|
campaign: { type: "string" },
|
|
10142
|
+
filters: { type: "string" },
|
|
10117
10143
|
revision: { type: "string" },
|
|
10118
10144
|
enabled: { type: "string" },
|
|
10119
10145
|
"cookie-events": { type: "string" },
|
|
@@ -10125,8 +10151,11 @@ async function runAnalyticsCommand(options) {
|
|
|
10125
10151
|
const operations = {
|
|
10126
10152
|
status: { args: 1, flags: [] },
|
|
10127
10153
|
installation: { args: 1, flags: [] },
|
|
10128
|
-
report: {
|
|
10129
|
-
|
|
10154
|
+
report: {
|
|
10155
|
+
args: 1,
|
|
10156
|
+
flags: ["days", "event", "country", "campaign", "filters"]
|
|
10157
|
+
},
|
|
10158
|
+
realtime: { args: 1, flags: ["country", "campaign", "filters"] },
|
|
10130
10159
|
"settings show": { args: 2, flags: [] },
|
|
10131
10160
|
"settings set": {
|
|
10132
10161
|
args: 2,
|
|
@@ -10240,7 +10269,13 @@ async function runAnalyticsCommand(options) {
|
|
|
10240
10269
|
message: "The Analytics grant does not match this Project and operation."
|
|
10241
10270
|
});
|
|
10242
10271
|
const query = new URLSearchParams();
|
|
10243
|
-
for (const key of [
|
|
10272
|
+
for (const key of [
|
|
10273
|
+
"days",
|
|
10274
|
+
"event",
|
|
10275
|
+
"country",
|
|
10276
|
+
"campaign",
|
|
10277
|
+
"filters"
|
|
10278
|
+
])
|
|
10244
10279
|
if (values[key]) query.set(key, values[key]);
|
|
10245
10280
|
const suffix = action === "realtime" ? `/realtime?${query}` : setting ? "/settings" : creating || archive ? `/${action}` : route === "monitoring prepare" ? "/monitoring" : `?${query}`;
|
|
10246
10281
|
if (!options.fetchImpl)
|
|
@@ -10358,18 +10393,643 @@ async function runAnalyticsCommand(options) {
|
|
|
10358
10393
|
}
|
|
10359
10394
|
|
|
10360
10395
|
// src/services/seo-command.ts
|
|
10361
|
-
import { writeFile as
|
|
10362
|
-
import
|
|
10363
|
-
import { parseArgs as
|
|
10364
|
-
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";
|
|
10365
10400
|
|
|
10366
|
-
// src/services/seo-
|
|
10401
|
+
// src/services/seo-research-command.ts
|
|
10367
10402
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
10368
|
-
import { writeFile as writeFile8 } from "fs/promises";
|
|
10369
|
-
import path30 from "path";
|
|
10370
10403
|
import { setTimeout as setTimeout2 } from "timers/promises";
|
|
10371
|
-
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";
|
|
10372
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, rankings, competitors, backlinks, brand, ai-visibility.
|
|
10524
|
+
Request JSON: {"kind":"keywords","target":"example.com","keywords":["website analytics"],"country":"US","language":"en"}.
|
|
10525
|
+
Rankings accepts up to 10 keywords and 5 competitors. AI visibility requires brand, prompt and platforms.
|
|
10526
|
+
Markets: US, GB, ES, DE, FR, CA, AU. Languages: en, es, de, fr, pt, it, nl, ru.
|
|
10527
|
+
Platforms: chat_gpt, claude, gemini, perplexity. Brand lookup uses brandPlatform chat_gpt|google and brandMatch domain|brand.
|
|
10528
|
+
Plan validates the request and shows planned parts and available research credits without enqueueing work.
|
|
10529
|
+
Run consumes Organization research credits through the same worker as the interface. Preserve the retry key after an uncertain response.
|
|
10530
|
+
History returns the latest 30 checks per kind; show/export can address older retained run IDs. Saved checks are limited to 100 per resource.
|
|
10531
|
+
Wait defaults to 120 seconds (maximum 900); exit 3 means still pending, exit 4 means failed or cancelled. Partial evidence stays accessible.
|
|
10532
|
+
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.
|
|
10533
|
+
`;
|
|
10534
|
+
var kinds = [
|
|
10535
|
+
"keywords",
|
|
10536
|
+
"rankings",
|
|
10537
|
+
"competitors",
|
|
10538
|
+
"backlinks",
|
|
10539
|
+
"brand",
|
|
10540
|
+
"ai-visibility"
|
|
10541
|
+
];
|
|
10542
|
+
var requestSchema = z19.object({ kind: z19.enum(kinds), target: z19.string().min(1).max(253) }).passthrough();
|
|
10543
|
+
var runStates = z19.enum([
|
|
10544
|
+
"queued",
|
|
10545
|
+
"running",
|
|
10546
|
+
"completed",
|
|
10547
|
+
"partial",
|
|
10548
|
+
"failed",
|
|
10549
|
+
"cancelled"
|
|
10550
|
+
]);
|
|
10551
|
+
var csvCell = (value) => {
|
|
10552
|
+
let text = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
10553
|
+
if (/^[\s]*[=+@-]/u.test(text) || /^[\t\r\n]/u.test(text)) text = `'${text}`;
|
|
10554
|
+
return `"${text.replaceAll('"', '""')}"`;
|
|
10555
|
+
};
|
|
10556
|
+
async function runSeoResearchCommand(options) {
|
|
10557
|
+
let idempotencyKey;
|
|
10558
|
+
try {
|
|
10559
|
+
const { values, positionals } = parseArgs5({
|
|
10560
|
+
args: options.args.slice(1),
|
|
10561
|
+
strict: true,
|
|
10562
|
+
allowPositionals: true,
|
|
10563
|
+
options: {
|
|
10564
|
+
json: { type: "boolean" },
|
|
10565
|
+
environment: { type: "string" },
|
|
10566
|
+
kind: { type: "string" },
|
|
10567
|
+
input: { type: "string" },
|
|
10568
|
+
name: { type: "string" },
|
|
10569
|
+
id: { type: "string" },
|
|
10570
|
+
"idempotency-key": { type: "string" },
|
|
10571
|
+
timeout: { type: "string" },
|
|
10572
|
+
format: { type: "string" },
|
|
10573
|
+
output: { type: "string" }
|
|
10574
|
+
}
|
|
10575
|
+
});
|
|
10576
|
+
const action = positionals[0] === "saved" ? positionals.slice(0, 2).join(" ") : positionals[0] ?? "";
|
|
10577
|
+
const id = positionals[action.startsWith("saved ") ? 2 : 1];
|
|
10578
|
+
const operations = {
|
|
10579
|
+
summary: { args: 1, flags: [] },
|
|
10580
|
+
status: { args: 1, flags: ["kind"] },
|
|
10581
|
+
history: { args: 1, flags: ["kind"] },
|
|
10582
|
+
show: { args: 2, flags: [] },
|
|
10583
|
+
plan: { args: 1, flags: ["input"] },
|
|
10584
|
+
run: { args: 1, flags: ["input", "idempotency-key"] },
|
|
10585
|
+
wait: { args: 2, flags: ["timeout"] },
|
|
10586
|
+
cancel: { args: 2, flags: [] },
|
|
10587
|
+
export: { args: 2, flags: ["format", "output"] },
|
|
10588
|
+
"saved list": { args: 2, flags: ["kind"] },
|
|
10589
|
+
"saved save": { args: 2, flags: ["input", "name", "id"] },
|
|
10590
|
+
"saved remove": { args: 3, flags: [] }
|
|
10591
|
+
};
|
|
10592
|
+
const operation = operations[action];
|
|
10593
|
+
if (!operation || operation.args !== positionals.length || Object.keys(values).some(
|
|
10594
|
+
(key) => !["json", "environment", ...operation.flags].includes(key)
|
|
10595
|
+
))
|
|
10596
|
+
throw new Error(
|
|
10597
|
+
"Invalid research operation or flags. Run siteos seo --help."
|
|
10598
|
+
);
|
|
10599
|
+
if (operation.flags.includes("kind") && !kinds.includes(values.kind))
|
|
10600
|
+
throw new Error("Choose a documented --kind.");
|
|
10601
|
+
if (operation.flags.includes("input") && !values.input)
|
|
10602
|
+
throw new Error("Provide --input with a research request JSON file.");
|
|
10603
|
+
if (action === "saved save" && (!values.name?.trim() || values.name.length > 100))
|
|
10604
|
+
throw new Error("Use --name with 1 to 100 characters.");
|
|
10605
|
+
if (values.id && values.id.length > 100)
|
|
10606
|
+
throw new Error("The saved ID is too long.");
|
|
10607
|
+
const timeout = Number(values.timeout ?? 120);
|
|
10608
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 900)
|
|
10609
|
+
throw new Error("Use a wait timeout between 1 and 900 seconds.");
|
|
10610
|
+
if (action === "export" && (!values.output || !["json", "csv"].includes(values.format ?? "")))
|
|
10611
|
+
throw new Error(
|
|
10612
|
+
"Export requires --format json|csv and --output for a new file."
|
|
10613
|
+
);
|
|
10614
|
+
let input;
|
|
10615
|
+
if (values.input) {
|
|
10616
|
+
input = await readSeoInput(options.cwd, values.input);
|
|
10617
|
+
if (!requestSchema.safeParse(input).success)
|
|
10618
|
+
throw new Error(
|
|
10619
|
+
"The request file needs a research kind and target domain. Run siteos seo --help."
|
|
10620
|
+
);
|
|
10621
|
+
}
|
|
10622
|
+
if (action === "run") {
|
|
10623
|
+
idempotencyKey = values["idempotency-key"] ?? randomUUID6();
|
|
10624
|
+
if (!/^[a-zA-Z0-9_-]{8,100}$/u.test(idempotencyKey))
|
|
10625
|
+
throw new Error(
|
|
10626
|
+
"Use an idempotency key of 8 to 100 letters, digits, underscores or hyphens."
|
|
10627
|
+
);
|
|
10628
|
+
}
|
|
10629
|
+
const client = await seoReportClient(options, values.environment);
|
|
10630
|
+
const resourceSchema = z19.object({
|
|
10631
|
+
id: z19.literal(client.resourceId),
|
|
10632
|
+
organizationId: z19.literal(client.organizationId)
|
|
10633
|
+
}).passthrough();
|
|
10634
|
+
const runSchema = z19.object({
|
|
10635
|
+
id: z19.string(),
|
|
10636
|
+
resourceId: z19.literal(client.resourceId),
|
|
10637
|
+
organizationId: z19.literal(client.organizationId),
|
|
10638
|
+
websiteUrl: z19.string(),
|
|
10639
|
+
request: requestSchema,
|
|
10640
|
+
state: runStates,
|
|
10641
|
+
createdAt: z19.string(),
|
|
10642
|
+
finishedAt: z19.string().nullable(),
|
|
10643
|
+
parts: z19.array(
|
|
10644
|
+
z19.object({
|
|
10645
|
+
key: z19.string(),
|
|
10646
|
+
state: z19.enum(["completed", "failed"]),
|
|
10647
|
+
error: z19.string().nullable(),
|
|
10648
|
+
dataset: z19.record(z19.unknown()).nullable(),
|
|
10649
|
+
observedAt: z19.string()
|
|
10650
|
+
}).passthrough()
|
|
10651
|
+
)
|
|
10652
|
+
}).passthrough();
|
|
10653
|
+
const get = async (suffix, scope = "read", body) => versionedReport.parse(
|
|
10654
|
+
await (await client.request(
|
|
10655
|
+
`/research${suffix}`,
|
|
10656
|
+
`seo:research:${scope}`,
|
|
10657
|
+
body
|
|
10658
|
+
)).json()
|
|
10659
|
+
);
|
|
10660
|
+
const query = `?kind=${encodeURIComponent(values.kind ?? "rankings")}`;
|
|
10661
|
+
let record;
|
|
10662
|
+
if (action === "plan" || action === "run" || action === "saved save") {
|
|
10663
|
+
const plan = await get("/plan", "read", input);
|
|
10664
|
+
resourceSchema.parse(plan.resource);
|
|
10665
|
+
const canonical = requestSchema.parse(plan.request);
|
|
10666
|
+
if (canonical.kind !== requestSchema.parse(input).kind)
|
|
10667
|
+
throw new Error("The plan does not match the requested research kind.");
|
|
10668
|
+
if (action === "run") {
|
|
10669
|
+
record = await get("/runs", "run", { request: input, idempotencyKey });
|
|
10670
|
+
const run = runSchema.parse(record.run);
|
|
10671
|
+
if (!isDeepStrictEqual(run.request, plan.request))
|
|
10672
|
+
throw new Error(
|
|
10673
|
+
"The admitted check does not match the validated research request."
|
|
10674
|
+
);
|
|
10675
|
+
record = { ...record, idempotencyKey };
|
|
10676
|
+
} else if (action === "saved save") {
|
|
10677
|
+
record = await get("/saved", "write", {
|
|
10678
|
+
request: input,
|
|
10679
|
+
name: values.name.trim(),
|
|
10680
|
+
...values.id ? { id: values.id } : {}
|
|
10681
|
+
});
|
|
10682
|
+
const saved = z19.object({
|
|
10683
|
+
id: z19.string(),
|
|
10684
|
+
name: z19.literal(values.name.trim()),
|
|
10685
|
+
request: requestSchema
|
|
10686
|
+
}).parse(record.saved);
|
|
10687
|
+
if (values.id && saved.id !== values.id || !isDeepStrictEqual(saved.request, plan.request))
|
|
10688
|
+
throw new Error(
|
|
10689
|
+
"The saved check does not match the requested settings."
|
|
10690
|
+
);
|
|
10691
|
+
} else record = plan;
|
|
10692
|
+
} else if (action === "cancel" || action === "saved remove") {
|
|
10693
|
+
record = await get(
|
|
10694
|
+
`/${action === "cancel" ? "runs" : "saved"}/${encodeURIComponent(id)}/${action === "cancel" ? "cancel" : "remove"}`,
|
|
10695
|
+
action === "cancel" ? "run" : "write",
|
|
10696
|
+
{}
|
|
10697
|
+
);
|
|
10698
|
+
z19.literal(true).parse(
|
|
10699
|
+
record[action === "cancel" ? "cancelled" : "removed"]
|
|
10700
|
+
);
|
|
10701
|
+
} else if (action === "summary") {
|
|
10702
|
+
record = await get("/summary");
|
|
10703
|
+
resourceSchema.parse(record.resource);
|
|
10704
|
+
z19.array(z19.object({ kind: z19.enum(kinds), summary: z19.unknown() })).parse(
|
|
10705
|
+
record.checks
|
|
10706
|
+
);
|
|
10707
|
+
} else if (["status", "history", "saved list"].includes(action)) {
|
|
10708
|
+
const view = await get(query);
|
|
10709
|
+
resourceSchema.parse(view.resource);
|
|
10710
|
+
const runs = z19.array(runSchema).parse(view.runs);
|
|
10711
|
+
const saved = z19.array(
|
|
10712
|
+
z19.object({
|
|
10713
|
+
id: z19.string(),
|
|
10714
|
+
name: z19.string(),
|
|
10715
|
+
request: requestSchema
|
|
10716
|
+
}).passthrough()
|
|
10717
|
+
).parse(view.saved);
|
|
10718
|
+
if (runs.some((r) => r.request.kind !== values.kind) || saved.some((r) => r.request.kind !== values.kind))
|
|
10719
|
+
throw new Error(
|
|
10720
|
+
"The response does not match the requested research kind."
|
|
10721
|
+
);
|
|
10722
|
+
if (view.run != null) {
|
|
10723
|
+
const selected = runSchema.parse(view.run);
|
|
10724
|
+
if (selected.request.kind !== values.kind)
|
|
10725
|
+
throw new Error("The selected report has a different research kind.");
|
|
10726
|
+
}
|
|
10727
|
+
record = {
|
|
10728
|
+
contractVersion: 1,
|
|
10729
|
+
resource: view.resource,
|
|
10730
|
+
kind: values.kind,
|
|
10731
|
+
...action === "history" ? { runs, limit: 30 } : action === "saved list" ? { saved, limit: 100 } : { connection: view.connection, run: view.run }
|
|
10732
|
+
};
|
|
10733
|
+
} else {
|
|
10734
|
+
const deadline = Date.now() + timeout * 1e3;
|
|
10735
|
+
while (true) {
|
|
10736
|
+
record = await get(`/runs/${encodeURIComponent(id)}`);
|
|
10737
|
+
resourceSchema.parse(record.resource);
|
|
10738
|
+
const run = runSchema.parse(record.run);
|
|
10739
|
+
if (run.id !== id)
|
|
10740
|
+
throw new Error(
|
|
10741
|
+
"The response does not match the selected research check."
|
|
10742
|
+
);
|
|
10743
|
+
if (record.previous) runSchema.parse(record.previous);
|
|
10744
|
+
if (action !== "wait" || !["queued", "running"].includes(run.state))
|
|
10745
|
+
break;
|
|
10746
|
+
if (Date.now() >= deadline)
|
|
10747
|
+
return {
|
|
10748
|
+
exitCode: 3,
|
|
10749
|
+
stdout: JSON.stringify({ ...record, timedOut: true }, null, 2)
|
|
10750
|
+
};
|
|
10751
|
+
await setTimeout2(Math.min(3e3, Math.max(0, deadline - Date.now())));
|
|
10752
|
+
}
|
|
10753
|
+
if (action === "export") {
|
|
10754
|
+
const run = runSchema.parse(record.run);
|
|
10755
|
+
const parts = run.parts.length ? run.parts : [
|
|
10756
|
+
{
|
|
10757
|
+
key: null,
|
|
10758
|
+
state: null,
|
|
10759
|
+
observedAt: null,
|
|
10760
|
+
error: run.error,
|
|
10761
|
+
dataset: null
|
|
10762
|
+
}
|
|
10763
|
+
];
|
|
10764
|
+
const rows = parts.flatMap((part) => {
|
|
10765
|
+
const dataset = part.dataset;
|
|
10766
|
+
const entries = dataset && Array.isArray(dataset.rows) ? dataset.rows : [dataset];
|
|
10767
|
+
return (entries.length ? entries : [null]).map((row) => [
|
|
10768
|
+
run.id,
|
|
10769
|
+
run.websiteUrl,
|
|
10770
|
+
run.request.kind,
|
|
10771
|
+
run.request.target,
|
|
10772
|
+
run.createdAt,
|
|
10773
|
+
run.state,
|
|
10774
|
+
run.error,
|
|
10775
|
+
part.key,
|
|
10776
|
+
part.state,
|
|
10777
|
+
part.observedAt,
|
|
10778
|
+
part.error,
|
|
10779
|
+
dataset?.type,
|
|
10780
|
+
run.request,
|
|
10781
|
+
dataset ? Object.fromEntries(
|
|
10782
|
+
Object.entries(dataset).filter(([key]) => key !== "rows")
|
|
10783
|
+
) : null,
|
|
10784
|
+
row
|
|
10785
|
+
]);
|
|
10786
|
+
});
|
|
10787
|
+
const content = values.format === "json" ? JSON.stringify(record, null, 2) : [
|
|
10788
|
+
[
|
|
10789
|
+
"run_id",
|
|
10790
|
+
"website_url",
|
|
10791
|
+
"kind",
|
|
10792
|
+
"target",
|
|
10793
|
+
"created_at",
|
|
10794
|
+
"run_state",
|
|
10795
|
+
"run_error",
|
|
10796
|
+
"part",
|
|
10797
|
+
"part_state",
|
|
10798
|
+
"observed_at",
|
|
10799
|
+
"error",
|
|
10800
|
+
"dataset_type",
|
|
10801
|
+
"request_json",
|
|
10802
|
+
"dataset_metadata_json",
|
|
10803
|
+
"data_json"
|
|
10804
|
+
],
|
|
10805
|
+
...rows
|
|
10806
|
+
].map((row) => row.map(csvCell).join(",")).join("\r\n");
|
|
10807
|
+
const output = await writeSeoReport(options, values.output, content);
|
|
10808
|
+
record = {
|
|
10809
|
+
contractVersion: 1,
|
|
10810
|
+
runId: id,
|
|
10811
|
+
state: run.state,
|
|
10812
|
+
format: values.format,
|
|
10813
|
+
output
|
|
10814
|
+
};
|
|
10815
|
+
}
|
|
10816
|
+
if (action === "wait" && ["failed", "cancelled"].includes(runSchema.parse(record.run).state))
|
|
10817
|
+
return { exitCode: 4, stdout: JSON.stringify(record, null, 2) };
|
|
10818
|
+
}
|
|
10819
|
+
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
10820
|
+
} catch (cause) {
|
|
10821
|
+
return seoReportFailure(
|
|
10822
|
+
cause,
|
|
10823
|
+
options.args.includes("--json"),
|
|
10824
|
+
idempotencyKey
|
|
10825
|
+
);
|
|
10826
|
+
}
|
|
10827
|
+
}
|
|
10828
|
+
|
|
10829
|
+
// src/services/seo-gsc-command.ts
|
|
10830
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
10831
|
+
import { z as z20 } from "zod";
|
|
10832
|
+
var GSC_HELP = `
|
|
10833
|
+
siteos seo gsc status [--environment <slug>] [--json]
|
|
10834
|
+
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]
|
|
10835
|
+
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]
|
|
10836
|
+
siteos seo gsc sync [--environment <slug>] [--json]
|
|
10837
|
+
siteos seo gsc bind --connection <id> --property <site-url> --website <environment-url> --revision <number> [--environment <slug>] [--json]
|
|
10838
|
+
siteos seo gsc disconnect --revision <number> [--environment <slug>] [--json]
|
|
10839
|
+
|
|
10840
|
+
GSC Insights reads saved Google Search Console reports; it requires a connected property for this environment.
|
|
10841
|
+
Status lists the property directory, binding, latest synchronization and report freshness. Connect Google OAuth interactively in SiteOS first.
|
|
10842
|
+
Report defaults to all pages and shows 25 rows per page, measured totals and coverage. --url includes the selected page and its query evidence.
|
|
10843
|
+
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.
|
|
10844
|
+
Sync queues the existing background worker and does not wait for completion. Read status until the returned run finishes.
|
|
10845
|
+
Bind/disconnect require a current revision and separate settings authority. Disconnect removes only this environment binding, not the Google account.
|
|
10846
|
+
`;
|
|
10847
|
+
async function runSeoGscCommand(options) {
|
|
10848
|
+
try {
|
|
10849
|
+
const { values, positionals } = parseArgs6({
|
|
10850
|
+
args: options.args.slice(1),
|
|
10851
|
+
strict: true,
|
|
10852
|
+
allowPositionals: true,
|
|
10853
|
+
options: {
|
|
10854
|
+
json: { type: "boolean" },
|
|
10855
|
+
environment: { type: "string" },
|
|
10856
|
+
dataset: { type: "string" },
|
|
10857
|
+
query: { type: "string" },
|
|
10858
|
+
filter: { type: "string" },
|
|
10859
|
+
sort: { type: "string" },
|
|
10860
|
+
page: { type: "string" },
|
|
10861
|
+
url: { type: "string" },
|
|
10862
|
+
format: { type: "string" },
|
|
10863
|
+
output: { type: "string" },
|
|
10864
|
+
connection: { type: "string" },
|
|
10865
|
+
property: { type: "string" },
|
|
10866
|
+
website: { type: "string" },
|
|
10867
|
+
revision: { type: "string" }
|
|
10868
|
+
}
|
|
10869
|
+
});
|
|
10870
|
+
const action = positionals[0] ?? "";
|
|
10871
|
+
const operations = {
|
|
10872
|
+
status: [],
|
|
10873
|
+
report: ["dataset", "query", "filter", "sort", "page", "url"],
|
|
10874
|
+
export: ["dataset", "query", "filter", "sort", "format", "output"],
|
|
10875
|
+
sync: [],
|
|
10876
|
+
bind: ["connection", "property", "website", "revision"],
|
|
10877
|
+
disconnect: ["revision"]
|
|
10878
|
+
};
|
|
10879
|
+
if (!operations[action] || positionals.length !== 1 || Object.keys(values).some(
|
|
10880
|
+
(key) => !["json", "environment", ...operations[action]].includes(key)
|
|
10881
|
+
))
|
|
10882
|
+
throw new Error("Invalid GSC operation or flags. Run siteos seo --help.");
|
|
10883
|
+
if (values.dataset && !["pages", "queries"].includes(values.dataset))
|
|
10884
|
+
throw new Error("Use --dataset pages or queries.");
|
|
10885
|
+
if (values.filter && !["all", "issues", "declining"].includes(values.filter))
|
|
10886
|
+
throw new Error("Use --filter all, issues or declining.");
|
|
10887
|
+
if (values.sort && !["clicks", "change", "impressions"].includes(values.sort))
|
|
10888
|
+
throw new Error("Use --sort clicks, change or impressions.");
|
|
10889
|
+
if (values.page && (!/^[1-9]\d{0,3}$/u.test(values.page) || Number(values.page) > 4e3))
|
|
10890
|
+
throw new Error("Use a page number between 1 and 4000.");
|
|
10891
|
+
if ((values.query?.length ?? 0) > 160 || (values.url?.length ?? 0) > 2048)
|
|
10892
|
+
throw new Error("The report filter is too long.");
|
|
10893
|
+
if (action === "export" && (!values.output || !["csv", "json"].includes(values.format ?? "")))
|
|
10894
|
+
throw new Error(
|
|
10895
|
+
"Export requires --format csv|json and --output for a new file."
|
|
10896
|
+
);
|
|
10897
|
+
if (["bind", "disconnect"].includes(action) && (!/^\d{1,9}$/u.test(values.revision ?? "") || action === "disconnect" && Number(values.revision) < 1))
|
|
10898
|
+
throw new Error(
|
|
10899
|
+
"Use the current binding --revision (0 only before the first binding)."
|
|
10900
|
+
);
|
|
10901
|
+
if (action === "bind" && (!values.connection || !values.property || !values.website))
|
|
10902
|
+
throw new Error(
|
|
10903
|
+
"Binding requires --connection, --property, --website and --revision."
|
|
10904
|
+
);
|
|
10905
|
+
const client = await seoReportClient(options, values.environment);
|
|
10906
|
+
const query = new URLSearchParams({
|
|
10907
|
+
dataset: values.dataset ?? "pages",
|
|
10908
|
+
filter: values.filter ?? "all"
|
|
10909
|
+
});
|
|
10910
|
+
for (const [flag, key] of [
|
|
10911
|
+
["query", "q"],
|
|
10912
|
+
["sort", "sort"],
|
|
10913
|
+
["page", "page"],
|
|
10914
|
+
["url", "pageUrl"],
|
|
10915
|
+
["format", "format"]
|
|
10916
|
+
])
|
|
10917
|
+
if (values[flag]) query.set(key, values[flag]);
|
|
10918
|
+
const writing = ["sync", "bind", "disconnect"].includes(action);
|
|
10919
|
+
const suffix = action === "bind" ? "/binding" : writing ? `/${action}` : action === "export" ? `/export?${query}` : `?${query}`;
|
|
10920
|
+
const body = action === "bind" ? {
|
|
10921
|
+
connectionId: values.connection,
|
|
10922
|
+
siteUrl: values.property,
|
|
10923
|
+
websiteUrl: values.website,
|
|
10924
|
+
expectedRevision: Number(values.revision)
|
|
10925
|
+
} : action === "disconnect" ? { expectedRevision: Number(values.revision) } : writing ? {} : void 0;
|
|
10926
|
+
const response = await client.request(
|
|
10927
|
+
`/search-console${suffix}`,
|
|
10928
|
+
`seo:search:${action === "sync" ? "sync" : writing ? "write" : "read"}`,
|
|
10929
|
+
body
|
|
10930
|
+
);
|
|
10931
|
+
const runSchema = z20.object({
|
|
10932
|
+
id: z20.string(),
|
|
10933
|
+
resourceId: z20.literal(client.resourceId),
|
|
10934
|
+
organizationId: z20.literal(client.organizationId),
|
|
10935
|
+
websiteUrl: z20.string(),
|
|
10936
|
+
siteUrl: z20.string(),
|
|
10937
|
+
state: z20.enum([
|
|
10938
|
+
"queued",
|
|
10939
|
+
"running",
|
|
10940
|
+
"completed",
|
|
10941
|
+
"partial",
|
|
10942
|
+
"failed",
|
|
10943
|
+
"cancelled"
|
|
10944
|
+
]),
|
|
10945
|
+
dates: z20.unknown()
|
|
10946
|
+
}).passthrough();
|
|
10947
|
+
const bindingSchema = z20.object({
|
|
10948
|
+
resourceId: z20.literal(client.resourceId),
|
|
10949
|
+
organizationId: z20.literal(client.organizationId),
|
|
10950
|
+
websiteUrl: z20.string(),
|
|
10951
|
+
siteUrl: z20.string(),
|
|
10952
|
+
revision: z20.number().int().positive()
|
|
10953
|
+
}).passthrough();
|
|
10954
|
+
const metrics = z20.object({
|
|
10955
|
+
clicks: z20.number(),
|
|
10956
|
+
impressions: z20.number(),
|
|
10957
|
+
ctr: z20.number(),
|
|
10958
|
+
position: z20.number()
|
|
10959
|
+
}).passthrough().nullable();
|
|
10960
|
+
const validateView = (data) => {
|
|
10961
|
+
const record2 = versionedReport.parse(data);
|
|
10962
|
+
z20.literal(client.resourceId).parse(record2.resourceId);
|
|
10963
|
+
bindingSchema.nullable().parse(record2.binding);
|
|
10964
|
+
runSchema.nullable().parse(record2.latest);
|
|
10965
|
+
runSchema.nullable().parse(record2.report);
|
|
10966
|
+
z20.array(
|
|
10967
|
+
z20.object({ key: z20.string(), current: metrics, previous: metrics }).passthrough()
|
|
10968
|
+
).parse(record2.rows);
|
|
10969
|
+
z20.number().int().nonnegative().parse(record2.total);
|
|
10970
|
+
z20.literal(values.dataset ?? "pages").parse(record2.dataset);
|
|
10971
|
+
return record2;
|
|
10972
|
+
};
|
|
10973
|
+
if (action === "export") {
|
|
10974
|
+
const rows = Number(response.headers.get("X-SEO-Export-Rows"));
|
|
10975
|
+
const reportId = response.headers.get("X-SEO-Search-Id");
|
|
10976
|
+
const truncated = response.headers.get("X-SEO-Export-Truncated");
|
|
10977
|
+
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(
|
|
10978
|
+
values.format === "csv" ? "text/csv" : "application/json"
|
|
10979
|
+
))
|
|
10980
|
+
throw new Error(
|
|
10981
|
+
"The export response does not match this SEO resource."
|
|
10982
|
+
);
|
|
10983
|
+
const text = await response.text();
|
|
10984
|
+
if (values.format === "json") {
|
|
10985
|
+
const report = validateView(JSON.parse(text));
|
|
10986
|
+
z20.literal(reportId).parse(runSchema.parse(report.report).id);
|
|
10987
|
+
z20.array(z20.unknown()).length(rows).parse(report.rows);
|
|
10988
|
+
z20.literal(truncated === "true").parse(report.truncated);
|
|
10989
|
+
}
|
|
10990
|
+
const output = await writeSeoReport(options, values.output, text);
|
|
10991
|
+
return {
|
|
10992
|
+
exitCode: 0,
|
|
10993
|
+
stdout: JSON.stringify(
|
|
10994
|
+
{
|
|
10995
|
+
contractVersion: 1,
|
|
10996
|
+
resourceId: client.resourceId,
|
|
10997
|
+
reportId,
|
|
10998
|
+
rows,
|
|
10999
|
+
truncated: truncated === "true",
|
|
11000
|
+
format: values.format,
|
|
11001
|
+
output
|
|
11002
|
+
},
|
|
11003
|
+
null,
|
|
11004
|
+
2
|
|
11005
|
+
)
|
|
11006
|
+
};
|
|
11007
|
+
}
|
|
11008
|
+
const record = versionedReport.parse(await response.json());
|
|
11009
|
+
if (action === "disconnect") z20.literal(true).parse(record.unbound);
|
|
11010
|
+
else if (writing) {
|
|
11011
|
+
runSchema.parse(record.run);
|
|
11012
|
+
if (action === "bind") {
|
|
11013
|
+
const binding = bindingSchema.parse(record.binding);
|
|
11014
|
+
if (binding.siteUrl !== values.property || binding.websiteUrl !== values.website || binding.revision !== Number(values.revision) + 1)
|
|
11015
|
+
throw new Error(
|
|
11016
|
+
"The saved binding does not match the requested property and revision."
|
|
11017
|
+
);
|
|
11018
|
+
}
|
|
11019
|
+
} else validateView(record);
|
|
11020
|
+
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
11021
|
+
} catch (cause) {
|
|
11022
|
+
return seoReportFailure(cause, options.args.includes("--json"));
|
|
11023
|
+
}
|
|
11024
|
+
}
|
|
11025
|
+
|
|
11026
|
+
// src/services/seo-performance-command.ts
|
|
11027
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
11028
|
+
import { writeFile as writeFile9 } from "fs/promises";
|
|
11029
|
+
import path31 from "path";
|
|
11030
|
+
import { setTimeout as setTimeout3 } from "timers/promises";
|
|
11031
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
11032
|
+
import { z as z21 } from "zod";
|
|
10373
11033
|
var PERFORMANCE_HELP = `
|
|
10374
11034
|
siteos seo performance run --audit <id> --url <url> [--url <url>...] [--device <mobile|desktop>] [--idempotency-key <key>] [--environment <slug>] [--json]
|
|
10375
11035
|
siteos seo performance list [--device <mobile|desktop>] [--environment <slug>] [--json]
|
|
@@ -10386,7 +11046,7 @@ Reuse the returned idempotency key after an uncertain run response. Wait default
|
|
|
10386
11046
|
async function runSeoPerformanceCommand(options) {
|
|
10387
11047
|
let idempotencyKey;
|
|
10388
11048
|
try {
|
|
10389
|
-
const { values, positionals } =
|
|
11049
|
+
const { values, positionals } = parseArgs7({
|
|
10390
11050
|
args: options.args.slice(1),
|
|
10391
11051
|
strict: true,
|
|
10392
11052
|
allowPositionals: true,
|
|
@@ -10441,7 +11101,7 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10441
11101
|
"Export requires --format csv|json and --output for a new file."
|
|
10442
11102
|
);
|
|
10443
11103
|
if (action === "run") {
|
|
10444
|
-
idempotencyKey = values["idempotency-key"] ??
|
|
11104
|
+
idempotencyKey = values["idempotency-key"] ?? randomUUID7();
|
|
10445
11105
|
if (!/^[a-zA-Z0-9_-]{16,100}$/u.test(idempotencyKey))
|
|
10446
11106
|
throw new Error(
|
|
10447
11107
|
"Use an idempotency key of 16 to 100 letters, digits, underscores or hyphens."
|
|
@@ -10457,12 +11117,12 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10457
11117
|
const runtime = commonProjectRuntime(options);
|
|
10458
11118
|
const writing = ["run", "cancel"].includes(action);
|
|
10459
11119
|
const scope = writing ? "seo:audits:write" : "seo:workspace:read";
|
|
10460
|
-
const batchSchema =
|
|
10461
|
-
id:
|
|
10462
|
-
resourceId:
|
|
10463
|
-
organizationId:
|
|
10464
|
-
sourceAuditId:
|
|
10465
|
-
state:
|
|
11120
|
+
const batchSchema = z21.object({
|
|
11121
|
+
id: z21.string(),
|
|
11122
|
+
resourceId: z21.literal(context.resourceId),
|
|
11123
|
+
organizationId: z21.literal(context.overview.project.organizationId),
|
|
11124
|
+
sourceAuditId: z21.string(),
|
|
11125
|
+
state: z21.enum([
|
|
10466
11126
|
"queued",
|
|
10467
11127
|
"running",
|
|
10468
11128
|
"completed",
|
|
@@ -10470,8 +11130,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10470
11130
|
"failed",
|
|
10471
11131
|
"cancelled"
|
|
10472
11132
|
]),
|
|
10473
|
-
device:
|
|
10474
|
-
urls:
|
|
11133
|
+
device: z21.enum(["mobile", "desktop"]),
|
|
11134
|
+
urls: z21.array(z21.string()).min(1).max(10)
|
|
10475
11135
|
}).passthrough();
|
|
10476
11136
|
const query = new URLSearchParams();
|
|
10477
11137
|
if (values.device) query.set("device", values.device);
|
|
@@ -10519,21 +11179,21 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10519
11179
|
);
|
|
10520
11180
|
const text = await response.text();
|
|
10521
11181
|
if (values.format === "json") {
|
|
10522
|
-
const parsed =
|
|
10523
|
-
contractVersion:
|
|
11182
|
+
const parsed = z21.object({
|
|
11183
|
+
contractVersion: z21.literal(1),
|
|
10524
11184
|
batch: batchSchema,
|
|
10525
|
-
pages:
|
|
11185
|
+
pages: z21.array(z21.unknown())
|
|
10526
11186
|
}).parse(JSON.parse(text));
|
|
10527
11187
|
if (parsed.batch.id !== id)
|
|
10528
11188
|
throw new Error(
|
|
10529
11189
|
"The export response does not match the selected check."
|
|
10530
11190
|
);
|
|
10531
11191
|
}
|
|
10532
|
-
const output =
|
|
11192
|
+
const output = path31.resolve(
|
|
10533
11193
|
options.cwd ?? process.cwd(),
|
|
10534
11194
|
values.output
|
|
10535
11195
|
);
|
|
10536
|
-
await
|
|
11196
|
+
await writeFile9(output, text, { flag: "wx", mode: 384 });
|
|
10537
11197
|
return {
|
|
10538
11198
|
exitCode: 0,
|
|
10539
11199
|
stdout: JSON.stringify(
|
|
@@ -10545,8 +11205,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10545
11205
|
}
|
|
10546
11206
|
const data = await response.json();
|
|
10547
11207
|
if (!response.ok) {
|
|
10548
|
-
const error =
|
|
10549
|
-
error:
|
|
11208
|
+
const error = z21.object({
|
|
11209
|
+
error: z21.object({ code: z21.string(), message: z21.string().max(500) })
|
|
10550
11210
|
}).safeParse(data);
|
|
10551
11211
|
throw new SiteOSAuthApiError({
|
|
10552
11212
|
code: error.success ? error.data.error.code : "SEO_REQUEST_FAILED",
|
|
@@ -10554,8 +11214,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10554
11214
|
status: response.status
|
|
10555
11215
|
});
|
|
10556
11216
|
}
|
|
10557
|
-
const record =
|
|
10558
|
-
if (action === "cancel")
|
|
11217
|
+
const record = z21.object({ contractVersion: z21.literal(1) }).passthrough().parse(data);
|
|
11218
|
+
if (action === "cancel") z21.literal(true).parse(record.cancelled);
|
|
10559
11219
|
else if (action === "run") {
|
|
10560
11220
|
const accepted = batchSchema.parse(record.batch);
|
|
10561
11221
|
const normalize = (url) => {
|
|
@@ -10568,8 +11228,8 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10568
11228
|
"The queued check does not match the requested source, device and URLs."
|
|
10569
11229
|
);
|
|
10570
11230
|
} else {
|
|
10571
|
-
|
|
10572
|
-
|
|
11231
|
+
z21.literal(context.resourceId).parse(record.resourceId);
|
|
11232
|
+
z21.array(batchSchema).parse(record.batches);
|
|
10573
11233
|
const selected = batchSchema.nullable().parse(record.batch);
|
|
10574
11234
|
if (id && selected?.id !== id)
|
|
10575
11235
|
throw new Error(
|
|
@@ -10591,12 +11251,12 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10591
11251
|
exitCode: 3,
|
|
10592
11252
|
stdout: JSON.stringify({ ...record, timedOut: true }, null, 2)
|
|
10593
11253
|
};
|
|
10594
|
-
await
|
|
11254
|
+
await setTimeout3(Math.min(3e3, Math.max(0, deadline - Date.now())));
|
|
10595
11255
|
}
|
|
10596
11256
|
} catch (cause) {
|
|
10597
11257
|
const error = {
|
|
10598
11258
|
code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
|
|
10599
|
-
message: cause instanceof
|
|
11259
|
+
message: cause instanceof z21.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The performance command failed."
|
|
10600
11260
|
};
|
|
10601
11261
|
return {
|
|
10602
11262
|
exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
|
|
@@ -10613,7 +11273,7 @@ async function runSeoPerformanceCommand(options) {
|
|
|
10613
11273
|
}
|
|
10614
11274
|
|
|
10615
11275
|
// src/services/seo-command.ts
|
|
10616
|
-
var SEO_HELP = `Audit
|
|
11276
|
+
var SEO_HELP = `Audit SEO and work with search and AI research in the selected Project environment.
|
|
10617
11277
|
|
|
10618
11278
|
Usage:
|
|
10619
11279
|
siteos seo status [--environment <slug>] [--json]
|
|
@@ -10634,6 +11294,8 @@ Usage:
|
|
|
10634
11294
|
siteos seo notifications set --enabled <true|false> [--destination <candidate-id>] --severity <error|warning> --failures <true|false> --revision <number> [--environment <slug>] [--json]
|
|
10635
11295
|
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]
|
|
10636
11296
|
${PERFORMANCE_HELP.trim().split("\n\n")[0]}
|
|
11297
|
+
${RESEARCH_HELP.trim().split("\n\n")[0]}
|
|
11298
|
+
${GSC_HELP.trim().split("\n\n")[0]}
|
|
10637
11299
|
|
|
10638
11300
|
Schedule and notification writes require an owner/admin and the saved revision (initially 0).
|
|
10639
11301
|
Export writes all matching rows to a new file; existing files are never overwritten.
|
|
@@ -10641,15 +11303,19 @@ Runs are queued. Read audit show until terminal; an accepted run is not a comple
|
|
|
10641
11303
|
Recheck accepts a URL observed in the source audit. Cross-page rules require a full audit.
|
|
10642
11304
|
Read the current disposition revision before ignore/restore; use 0 if no decision exists.
|
|
10643
11305
|
Setup: siteos project connect seo. No crawl runs during setup.
|
|
10644
|
-
${PERFORMANCE_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11306
|
+
${PERFORMANCE_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11307
|
+
${RESEARCH_HELP.trim().split("\n\n").slice(1).join("\n\n")}
|
|
11308
|
+
${GSC_HELP.trim().split("\n\n").slice(1).join("\n\n")}`;
|
|
10645
11309
|
async function runSeoCommand(options) {
|
|
10646
11310
|
if (!options.args.length || options.args.some((arg) => ["--help", "-h"].includes(arg)))
|
|
10647
11311
|
return { exitCode: 0, stdout: SEO_HELP };
|
|
10648
11312
|
if (options.args[0] === "performance")
|
|
10649
11313
|
return runSeoPerformanceCommand(options);
|
|
11314
|
+
if (options.args[0] === "research") return runSeoResearchCommand(options);
|
|
11315
|
+
if (options.args[0] === "gsc") return runSeoGscCommand(options);
|
|
10650
11316
|
const json = options.args.includes("--json");
|
|
10651
11317
|
try {
|
|
10652
|
-
const { positionals, values } =
|
|
11318
|
+
const { positionals, values } = parseArgs8({
|
|
10653
11319
|
args: options.args,
|
|
10654
11320
|
strict: true,
|
|
10655
11321
|
allowPositionals: true,
|
|
@@ -10862,18 +11528,18 @@ async function runSeoCommand(options) {
|
|
|
10862
11528
|
);
|
|
10863
11529
|
const text = await response.text();
|
|
10864
11530
|
if (values.format === "json")
|
|
10865
|
-
|
|
10866
|
-
contractVersion:
|
|
10867
|
-
audit:
|
|
10868
|
-
id:
|
|
10869
|
-
resourceId:
|
|
11531
|
+
z22.object({
|
|
11532
|
+
contractVersion: z22.literal(1),
|
|
11533
|
+
audit: z22.object({
|
|
11534
|
+
id: z22.literal(values.audit),
|
|
11535
|
+
resourceId: z22.literal(context.resourceId)
|
|
10870
11536
|
}),
|
|
10871
|
-
kind:
|
|
10872
|
-
totalRows:
|
|
10873
|
-
rows:
|
|
11537
|
+
kind: z22.literal(values.kind),
|
|
11538
|
+
totalRows: z22.literal(rows),
|
|
11539
|
+
rows: z22.array(z22.unknown()).length(rows)
|
|
10874
11540
|
}).parse(JSON.parse(text));
|
|
10875
|
-
const output =
|
|
10876
|
-
await
|
|
11541
|
+
const output = path32.resolve(options.cwd ?? process.cwd(), values.output);
|
|
11542
|
+
await writeFile10(output, text, { flag: "wx", mode: 384 });
|
|
10877
11543
|
return {
|
|
10878
11544
|
exitCode: 0,
|
|
10879
11545
|
stdout: JSON.stringify(
|
|
@@ -10891,8 +11557,8 @@ async function runSeoCommand(options) {
|
|
|
10891
11557
|
}
|
|
10892
11558
|
const data = await response.json();
|
|
10893
11559
|
if (!response.ok) {
|
|
10894
|
-
const result =
|
|
10895
|
-
error:
|
|
11560
|
+
const result = z22.object({
|
|
11561
|
+
error: z22.object({ code: z22.string(), message: z22.string().max(500) })
|
|
10896
11562
|
}).safeParse(data);
|
|
10897
11563
|
throw new SiteOSAuthApiError({
|
|
10898
11564
|
code: result.success ? result.data.error.code : "SEO_REQUEST_FAILED",
|
|
@@ -10900,34 +11566,34 @@ async function runSeoCommand(options) {
|
|
|
10900
11566
|
status: response.status
|
|
10901
11567
|
});
|
|
10902
11568
|
}
|
|
10903
|
-
const record =
|
|
11569
|
+
const record = z22.object({ contractVersion: z22.literal(1) }).passthrough().parse(data);
|
|
10904
11570
|
if (automation) {
|
|
10905
|
-
|
|
10906
|
-
const schedule =
|
|
10907
|
-
enabled:
|
|
10908
|
-
weekday:
|
|
10909
|
-
time:
|
|
10910
|
-
timeZone:
|
|
10911
|
-
revision:
|
|
10912
|
-
nextRunAt:
|
|
11571
|
+
z22.literal(context.resourceId).parse(record.resourceId);
|
|
11572
|
+
const schedule = z22.object({
|
|
11573
|
+
enabled: z22.boolean(),
|
|
11574
|
+
weekday: z22.number().int().min(1).max(7),
|
|
11575
|
+
time: z22.string(),
|
|
11576
|
+
timeZone: z22.string(),
|
|
11577
|
+
revision: z22.number().int().min(0),
|
|
11578
|
+
nextRunAt: z22.string().nullable()
|
|
10913
11579
|
});
|
|
10914
|
-
const notificationRoute =
|
|
10915
|
-
enabled:
|
|
10916
|
-
minimumSeverity:
|
|
10917
|
-
includeFailures:
|
|
10918
|
-
revision:
|
|
10919
|
-
destinationId:
|
|
11580
|
+
const notificationRoute = z22.object({
|
|
11581
|
+
enabled: z22.boolean(),
|
|
11582
|
+
minimumSeverity: z22.enum(["error", "warning"]),
|
|
11583
|
+
includeFailures: z22.boolean(),
|
|
11584
|
+
revision: z22.number().int().min(0),
|
|
11585
|
+
destinationId: z22.string().nullable()
|
|
10920
11586
|
});
|
|
10921
11587
|
if (retryNotification) {
|
|
10922
|
-
|
|
10923
|
-
|
|
11588
|
+
z22.literal(true).parse(record.retryQueued);
|
|
11589
|
+
z22.literal(positionals[2]).parse(record.notificationId);
|
|
10924
11590
|
} else if (route === "notifications destinations")
|
|
10925
|
-
|
|
10926
|
-
candidates:
|
|
10927
|
-
|
|
10928
|
-
candidateId:
|
|
10929
|
-
label:
|
|
10930
|
-
availability:
|
|
11591
|
+
z22.object({
|
|
11592
|
+
candidates: z22.array(
|
|
11593
|
+
z22.object({
|
|
11594
|
+
candidateId: z22.string(),
|
|
11595
|
+
label: z22.string(),
|
|
11596
|
+
availability: z22.literal("available")
|
|
10931
11597
|
})
|
|
10932
11598
|
)
|
|
10933
11599
|
}).parse(record);
|
|
@@ -10940,46 +11606,46 @@ async function runSeoCommand(options) {
|
|
|
10940
11606
|
notificationRoute.parse(record.route);
|
|
10941
11607
|
}
|
|
10942
11608
|
} else if (!writing) {
|
|
10943
|
-
const validated =
|
|
10944
|
-
resource:
|
|
10945
|
-
id:
|
|
10946
|
-
organizationId:
|
|
11609
|
+
const validated = z22.object({
|
|
11610
|
+
resource: z22.object({
|
|
11611
|
+
id: z22.literal(context.resourceId),
|
|
11612
|
+
organizationId: z22.literal(context.overview.project.organizationId)
|
|
10947
11613
|
}),
|
|
10948
|
-
audits:
|
|
10949
|
-
audit:
|
|
10950
|
-
id:
|
|
10951
|
-
resourceId:
|
|
11614
|
+
audits: z22.array(z22.object({ id: z22.string() }).passthrough()),
|
|
11615
|
+
audit: z22.object({
|
|
11616
|
+
id: z22.string(),
|
|
11617
|
+
resourceId: z22.literal(context.resourceId)
|
|
10952
11618
|
}).passthrough().nullable(),
|
|
10953
|
-
pages:
|
|
10954
|
-
issues:
|
|
10955
|
-
changes:
|
|
10956
|
-
totalChanges:
|
|
10957
|
-
dispositions:
|
|
11619
|
+
pages: z22.array(z22.unknown()),
|
|
11620
|
+
issues: z22.array(z22.unknown()),
|
|
11621
|
+
changes: z22.array(z22.unknown()),
|
|
11622
|
+
totalChanges: z22.number(),
|
|
11623
|
+
dispositions: z22.array(z22.unknown())
|
|
10958
11624
|
}).passthrough().parse(record);
|
|
10959
11625
|
const selected = query.get("audit");
|
|
10960
11626
|
if (selected && validated.audit?.id !== selected)
|
|
10961
11627
|
throw new Error("The SEO response does not match the requested audit.");
|
|
10962
11628
|
} else if (record.audit)
|
|
10963
|
-
|
|
10964
|
-
id:
|
|
10965
|
-
resourceId:
|
|
10966
|
-
organizationId:
|
|
10967
|
-
state:
|
|
11629
|
+
z22.object({
|
|
11630
|
+
id: z22.string(),
|
|
11631
|
+
resourceId: z22.literal(context.resourceId),
|
|
11632
|
+
organizationId: z22.literal(context.overview.project.organizationId),
|
|
11633
|
+
state: z22.literal("queued")
|
|
10968
11634
|
}).parse(record.audit);
|
|
10969
|
-
else if (route === "audit cancel")
|
|
11635
|
+
else if (route === "audit cancel") z22.literal(true).parse(record.cancelled);
|
|
10970
11636
|
else if (action === "issue")
|
|
10971
|
-
|
|
10972
|
-
url:
|
|
10973
|
-
ruleId:
|
|
10974
|
-
ignored:
|
|
10975
|
-
revision:
|
|
11637
|
+
z22.object({
|
|
11638
|
+
url: z22.literal(values.url),
|
|
11639
|
+
ruleId: z22.literal(values.rule),
|
|
11640
|
+
ignored: z22.literal(positionals[1] === "ignore"),
|
|
11641
|
+
revision: z22.literal(Number(values.revision) + 1)
|
|
10976
11642
|
}).parse(record.disposition);
|
|
10977
11643
|
else throw new Error("The SEO service returned an invalid response.");
|
|
10978
11644
|
return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
|
|
10979
11645
|
} catch (cause) {
|
|
10980
11646
|
const error = {
|
|
10981
11647
|
code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
|
|
10982
|
-
message: cause instanceof
|
|
11648
|
+
message: cause instanceof z22.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The SEO command failed."
|
|
10983
11649
|
};
|
|
10984
11650
|
return {
|
|
10985
11651
|
exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
|