@siteoshq/cli 1.4.0 → 1.6.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/dist/cli.js CHANGED
@@ -1499,7 +1499,7 @@ var OverviewSchema = z4.object({
1499
1499
  });
1500
1500
  function createProjectApi(input) {
1501
1501
  const origin = resolveSiteOSAuthBaseUrl(input.env);
1502
- async function request(path30, schema, body, method) {
1502
+ async function request(path31, schema, body, method) {
1503
1503
  const scope = body === void 0 ? "projects:workspace:read" : "projects:workspace:write";
1504
1504
  const grant = await input.grants.acquire({
1505
1505
  audience: "siteos-projects",
@@ -1513,7 +1513,7 @@ function createProjectApi(input) {
1513
1513
  message: "SiteOS API access is unavailable."
1514
1514
  });
1515
1515
  const response = await input.fetchImpl(
1516
- `${origin}/api/projects/v1/projects${path30}`,
1516
+ `${origin}/api/projects/v1/projects${path31}`,
1517
1517
  {
1518
1518
  method: method ?? (body === void 0 ? "GET" : "POST"),
1519
1519
  headers: {
@@ -3133,11 +3133,33 @@ function resolveSiteOSFormsBaseUrl(env = process.env) {
3133
3133
  env[SITEOS_FORMS_BASE_URL_ENV]?.trim() || DEFAULT_SITEOS_FORMS_BASE_URL
3134
3134
  );
3135
3135
  }
3136
+ var deploymentResponseSchema = z9.object({
3137
+ environmentId: opaqueIdentifierSchema,
3138
+ forms: z9.array(
3139
+ z9.object({
3140
+ formKey: opaqueIdentifierSchema,
3141
+ formId: opaqueIdentifierSchema,
3142
+ contractVersion: z9.string().regex(/^sha256:[a-f0-9]{64}$/),
3143
+ versionId: opaqueIdentifierSchema,
3144
+ version: z9.number().int().positive()
3145
+ })
3146
+ )
3147
+ });
3136
3148
  function createSiteOSFormsApiClient(options) {
3137
3149
  const apiBaseUrl = normalizeFormsOrigin(options.apiBaseUrl);
3138
3150
  const inboxPath = (input) => `/api/v1/forms/environments/${encodeURIComponent(input.environmentId)}/forms/${encodeURIComponent(input.formId)}/submissions`;
3139
3151
  const definitionPath = (input) => `/api/v1/forms/environments/${encodeURIComponent(input.environmentId)}/forms/${encodeURIComponent(input.formId)}`;
3152
+ const keyCollection = (input) => options.deploymentKeys ? `${environmentItemPath(input)}/deployment-keys` : credentialCollectionPath(input);
3140
3153
  return {
3154
+ publishDefinitions: (input) => requestJson2({
3155
+ apiBaseUrl,
3156
+ fetchImpl: options.fetchImpl,
3157
+ method: "POST",
3158
+ path: "/api/forms/deployments",
3159
+ body: { definitions: input.definitions },
3160
+ headers: { "x-siteos-forms-deployment-key": input.credential },
3161
+ responseSchema: deploymentResponseSchema
3162
+ }),
3141
3163
  listDefinitions: (input) => requestJson2({
3142
3164
  apiBaseUrl,
3143
3165
  fetchImpl: options.fetchImpl,
@@ -3212,8 +3234,10 @@ function createSiteOSFormsApiClient(options) {
3212
3234
  fetchImpl: options.fetchImpl,
3213
3235
  headers: managementHeaders(input),
3214
3236
  method: "POST",
3215
- path: credentialCollectionPath(input),
3216
- responseSchema: formsCredentialExchangeSchema
3237
+ path: keyCollection(input),
3238
+ responseSchema: options.deploymentKeys ? formsCredentialExchangeSchema.extend({
3239
+ token: z9.string().regex(/^pfd_[A-Za-z0-9_-]{43}$/)
3240
+ }) : formsCredentialExchangeSchema
3217
3241
  }),
3218
3242
  listEnvironments: (input) => requestJson2({
3219
3243
  apiBaseUrl,
@@ -3236,7 +3260,7 @@ function createSiteOSFormsApiClient(options) {
3236
3260
  fetchImpl: options.fetchImpl,
3237
3261
  headers: managementHeaders(input),
3238
3262
  method: "GET",
3239
- path: credentialCollectionPath(input),
3263
+ path: keyCollection(input),
3240
3264
  responseSchema: formsCredentialListSchema
3241
3265
  }),
3242
3266
  revokeCredential: (input) => requestJson2({
@@ -3244,7 +3268,7 @@ function createSiteOSFormsApiClient(options) {
3244
3268
  fetchImpl: options.fetchImpl,
3245
3269
  headers: managementHeaders(input),
3246
3270
  method: "DELETE",
3247
- path: credentialItemPath(input),
3271
+ path: options.deploymentKeys ? `${keyCollection(input)}/${encodeURIComponent(input.credentialId)}` : credentialItemPath(input),
3248
3272
  responseSchema: formsCredentialMetadataSchema
3249
3273
  }),
3250
3274
  rotateCredential: (input) => requestJson2({
@@ -3254,7 +3278,9 @@ function createSiteOSFormsApiClient(options) {
3254
3278
  headers: managementHeaders(input),
3255
3279
  method: "POST",
3256
3280
  path: credentialRotatePath(input),
3257
- responseSchema: formsCredentialExchangeSchema
3281
+ responseSchema: options.deploymentKeys ? formsCredentialExchangeSchema.extend({
3282
+ token: z9.string().regex(/^pfd_[A-Za-z0-9_-]{43}$/)
3283
+ }) : formsCredentialExchangeSchema
3258
3284
  }),
3259
3285
  submitForm: (input) => requestJson2({
3260
3286
  apiBaseUrl,
@@ -3288,7 +3314,9 @@ async function requestJson2(options) {
3288
3314
  ...options.body === void 0 ? {} : { "Content-Type": "application/json" },
3289
3315
  ...options.headers
3290
3316
  },
3291
- method: options.method
3317
+ method: options.method,
3318
+ redirect: "error",
3319
+ signal: AbortSignal.timeout(3e4)
3292
3320
  });
3293
3321
  } catch {
3294
3322
  throw new SiteOSFormsApiError({
@@ -3381,7 +3409,7 @@ import {
3381
3409
  } from "fs/promises";
3382
3410
  import path11 from "path";
3383
3411
  var SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV = "SITEOS_FORMS_SUBMISSION_CREDENTIAL";
3384
- var credentialPattern = /^pfs_[A-Za-z0-9_-]{22}$/;
3412
+ var SITEOS_FORMS_DEPLOYMENT_KEY_ENV = "SITEOS_FORMS_DEPLOYMENT_KEY";
3385
3413
  var SiteOSFormsCredentialStorageError = class extends Error {
3386
3414
  code;
3387
3415
  constructor(code) {
@@ -3393,6 +3421,8 @@ var SiteOSFormsCredentialStorageError = class extends Error {
3393
3421
  }
3394
3422
  };
3395
3423
  async function prepareSiteOSFormsCredentialInstallation(options) {
3424
+ const variable = options.deployment ? SITEOS_FORMS_DEPLOYMENT_KEY_ENV : SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV;
3425
+ const credentialPattern = options.deployment ? /^pfd_[A-Za-z0-9_-]{43}$/ : /^pfs_[A-Za-z0-9_-]{22}$/;
3396
3426
  const projectRoot = path11.resolve(options.projectRoot);
3397
3427
  const dotenvPath = path11.join(projectRoot, ".env");
3398
3428
  try {
@@ -3419,7 +3449,7 @@ async function prepareSiteOSFormsCredentialInstallation(options) {
3419
3449
  }
3420
3450
  const currentContent = await readSafeDotenv(dotenvPath);
3421
3451
  await writeCredentialAtomically({
3422
- content: setCredential(currentContent, credential),
3452
+ content: setCredential(currentContent, credential, variable),
3423
3453
  dotenvPath,
3424
3454
  projectRoot
3425
3455
  });
@@ -3457,7 +3487,10 @@ async function readSafeDotenv(dotenvPath) {
3457
3487
  );
3458
3488
  }
3459
3489
  const content = dotenvStats ? await readFile4(dotenvPath, "utf8") : "";
3460
- if (countCredentialEntries(content) > 1) {
3490
+ if ([
3491
+ SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV,
3492
+ SITEOS_FORMS_DEPLOYMENT_KEY_ENV
3493
+ ].some((variable) => countCredentialEntries(content, variable) > 1)) {
3461
3494
  throw new SiteOSFormsCredentialStorageError(
3462
3495
  "FORMS_CREDENTIAL_STORAGE_INVALID"
3463
3496
  );
@@ -3485,21 +3518,16 @@ async function writeCredentialAtomically(options) {
3485
3518
  await rm3(temporaryPath, { force: true }).catch(() => void 0);
3486
3519
  }
3487
3520
  }
3488
- function setCredential(content, credential) {
3489
- const assignment = `${SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV}=${credential}`;
3490
- const existing = new RegExp(
3491
- `^${SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV}=[^\\r\\n]*(?=\\r?$)`,
3492
- "m"
3493
- );
3521
+ function setCredential(content, credential, variable) {
3522
+ const assignment = `${variable}=${credential}`;
3523
+ const existing = new RegExp(`^${variable}=[^\\r\\n]*(?=\\r?$)`, "m");
3494
3524
  if (existing.test(content)) return content.replace(existing, assignment);
3495
3525
  const newline = content.includes("\r\n") ? "\r\n" : "\n";
3496
3526
  if (content.length === 0) return `${assignment}${newline}`;
3497
3527
  return `${content}${content.endsWith("\n") ? "" : newline}${assignment}${newline}`;
3498
3528
  }
3499
- function countCredentialEntries(content) {
3500
- return content.split(/\r?\n/u).filter(
3501
- (line) => line.startsWith(`${SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV}=`)
3502
- ).length;
3529
+ function countCredentialEntries(content, variable) {
3530
+ return content.split(/\r?\n/u).filter((line) => line.startsWith(`${variable}=`)).length;
3503
3531
  }
3504
3532
  function isDotenvIgnored(projectRoot) {
3505
3533
  return new Promise((resolve, reject) => {
@@ -3670,6 +3698,10 @@ var FORMS_HELP = `Usage:
3670
3698
  siteos forms submissions list --environment <slug> --form <form-id> [--query <text>] [--status <status>] [--from <ISO>] [--to <ISO>] [--limit <1-100>] [--cursor <cursor>] [--json]
3671
3699
  siteos forms submissions read --environment <slug> --form <form-id> --submission <id> [--json]
3672
3700
  siteos forms submissions status --environment <slug> --form <form-id> --submission <id> --status <new|read|archived|spam> --expected-status <status> [--json]
3701
+ siteos forms deploy --manifest <path> [--json]
3702
+ siteos forms deployment-key issue --environment <slug> --install [--name <name>] [--json]
3703
+ siteos forms deployment-key list --environment <slug> [--json]
3704
+ siteos forms deployment-key revoke --environment <slug> --credential <id> [--json]
3673
3705
  siteos forms submit --input <path> [--json]
3674
3706
 
3675
3707
  Manage SiteOS Forms Environments, definitions, credentials, and submission smoke tests.`;
@@ -3681,6 +3713,8 @@ async function runFormsCommand(options) {
3681
3713
  stdout: FORMS_HELP
3682
3714
  };
3683
3715
  }
3716
+ if (args2[0] === "deploy")
3717
+ return runDeployment({ ...options, args: args2.slice(1) });
3684
3718
  if (args2[0] !== "submit" && args2[0] !== "project") {
3685
3719
  try {
3686
3720
  args2 = await commonEnvironmentArguments(options, "forms", args2);
@@ -3717,6 +3751,13 @@ async function runFormsCommand(options) {
3717
3751
  args: args2.slice(1)
3718
3752
  });
3719
3753
  }
3754
+ if (args2[0] === "deployment-key") {
3755
+ return runCredentialCommand({
3756
+ ...options,
3757
+ args: args2.slice(1),
3758
+ deployment: true
3759
+ });
3760
+ }
3720
3761
  if (args2[0] === "credential") {
3721
3762
  return runCredentialCommand({
3722
3763
  ...options,
@@ -4052,6 +4093,11 @@ async function runCredentialCommand(options) {
4052
4093
  if (!action || !["issue", "list", "revoke", "rotate"].includes(action)) {
4053
4094
  return usageError2("Unknown SiteOS forms credential command.");
4054
4095
  }
4096
+ if (options.deployment && action === "rotate")
4097
+ return usageError2(
4098
+ "Issue a new deployment key, update the release secret, then revoke the old key."
4099
+ );
4100
+ const variable = options.deployment ? SITEOS_FORMS_DEPLOYMENT_KEY_ENV : SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV;
4055
4101
  const parsed = parseCredentialFlags(action, options.args.slice(1));
4056
4102
  if (!parsed.ok) return parsed.error;
4057
4103
  const context = await loadCredentialManagementContext({
@@ -4123,7 +4169,7 @@ Environment: ${parsed.environmentSlug}`
4123
4169
  credential,
4124
4170
  environment: parsed.environmentSlug,
4125
4171
  installed: {
4126
- variable: SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV
4172
+ variable
4127
4173
  },
4128
4174
  operation: action === "issue" ? "issued" : "rotated"
4129
4175
  };
@@ -4133,7 +4179,7 @@ Environment: ${parsed.environmentSlug}`
4133
4179
  `SiteOS Forms credential ${output.operation} and installed.`,
4134
4180
  `Credential: ${credential.id}`,
4135
4181
  `Environment: ${parsed.environmentSlug}`,
4136
- `Variable: ${SITEOS_FORMS_SUBMISSION_CREDENTIAL_ENV}`
4182
+ `Variable: ${variable}`
4137
4183
  ].join("\n")
4138
4184
  };
4139
4185
  } catch (error) {
@@ -4144,14 +4190,21 @@ async function loadCredentialManagementContext(options) {
4144
4190
  try {
4145
4191
  const local = await readLocalCommonProject(options);
4146
4192
  const rootDir = local?.rootDir ?? (await requireFormsProjectReference(options.cwd ?? process.cwd())).rootDir;
4147
- const installation = options.install ? await (options.prepareCredentialInstallation ?? prepareSiteOSFormsCredentialInstallation)({ projectRoot: rootDir }) : void 0;
4193
+ const installation = options.install ? await (options.prepareCredentialInstallation ?? prepareSiteOSFormsCredentialInstallation)({
4194
+ projectRoot: rootDir,
4195
+ ...options.deployment ? { deployment: true } : {}
4196
+ }) : void 0;
4148
4197
  const context = await loadFormsManagementContext(
4149
4198
  options,
4150
4199
  "forms:credential:manage"
4151
4200
  );
4152
4201
  if (!context.ok) return context;
4153
4202
  return {
4154
- client: context.client,
4203
+ client: options.deployment ? createSiteOSFormsApiClient({
4204
+ apiBaseUrl: context.apiBaseUrl,
4205
+ fetchImpl: options.fetchImpl ?? fetch,
4206
+ deploymentKeys: true
4207
+ }) : context.client,
4155
4208
  grant: context.grant,
4156
4209
  ...installation ? { installation } : {},
4157
4210
  ok: true,
@@ -5255,6 +5308,55 @@ function createFormsUiOutput(input) {
5255
5308
  url
5256
5309
  };
5257
5310
  }
5311
+ async function runDeployment(options) {
5312
+ const parsed = parseDefinitionSourceFlags(options.args, {
5313
+ environmentRequired: false,
5314
+ usage: "Usage: siteos forms deploy --manifest <path> [--json]"
5315
+ });
5316
+ if (!parsed.ok) return parsed.error;
5317
+ const loaded = await loadFormDefinitions({
5318
+ cwd: options.cwd,
5319
+ source: parsed.source
5320
+ });
5321
+ if (!loaded.ok) return loaded.error;
5322
+ const env = options.env ?? process.env;
5323
+ const credential = env[SITEOS_FORMS_DEPLOYMENT_KEY_ENV]?.trim();
5324
+ const apiBaseUrl = env.SITEOS_FORMS_PUBLIC_URL?.trim();
5325
+ if (!credential || !/^pfd_[A-Za-z0-9_-]{43}$/.test(credential) || !apiBaseUrl)
5326
+ return {
5327
+ exitCode: 1,
5328
+ stderr: "Configure SITEOS_FORMS_PUBLIC_URL and SITEOS_FORMS_DEPLOYMENT_KEY in the release environment."
5329
+ };
5330
+ try {
5331
+ const client = createSiteOSFormsApiClient({
5332
+ apiBaseUrl,
5333
+ fetchImpl: options.fetchImpl ?? fetch
5334
+ });
5335
+ const result = await client.publishDefinitions({
5336
+ credential,
5337
+ definitions: loaded.definitions.map((item) => item.value)
5338
+ });
5339
+ const expected = new Map(
5340
+ loaded.definitions.map((item) => [
5341
+ item.value.formKey,
5342
+ item.value.sourceExportId
5343
+ ])
5344
+ );
5345
+ if (result.forms.length !== expected.size || new Set(result.forms.map((item) => item.formKey)).size !== expected.size || result.forms.some(
5346
+ (item) => !expected.has(item.formKey) || expected.get(item.formKey) !== item.contractVersion
5347
+ ))
5348
+ return {
5349
+ exitCode: 1,
5350
+ stderr: "The published Forms versions do not match this build."
5351
+ };
5352
+ return {
5353
+ exitCode: 0,
5354
+ stdout: parsed.json ? stringifySafeJson(result) : `Published ${result.forms.length} Forms contracts for Environment ${result.environmentId}.`
5355
+ };
5356
+ } catch (error) {
5357
+ return formatApiError(error);
5358
+ }
5359
+ }
5258
5360
 
5259
5361
  // src/package-metadata.ts
5260
5362
  import { readFileSync } from "fs";
@@ -5299,7 +5401,7 @@ var projectSchema = z11.object({
5299
5401
  }).strict();
5300
5402
  var projectListSchema = z11.object({ projects: z11.array(projectSchema) }).strict();
5301
5403
  var projectResponseSchema = z11.object({ project: projectSchema }).strict();
5302
- var deploymentResponseSchema = z11.object({
5404
+ var deploymentResponseSchema2 = z11.object({
5303
5405
  checks: z11.array(
5304
5406
  z11.object({
5305
5407
  checkId: z11.string().min(1),
@@ -5404,7 +5506,7 @@ function createPulseApiClient(input) {
5404
5506
  grant: options.grant,
5405
5507
  method: "POST",
5406
5508
  path: "/api/cli/v1/deployments",
5407
- schema: deploymentResponseSchema
5509
+ schema: deploymentResponseSchema2
5408
5510
  });
5409
5511
  }
5410
5512
  };
@@ -9735,10 +9837,261 @@ async function runServiceCommand(service, options) {
9735
9837
  }
9736
9838
 
9737
9839
  // src/services/seo-command.ts
9840
+ import { writeFile as writeFile9 } from "fs/promises";
9841
+ import path30 from "path";
9842
+ import { parseArgs as parseArgs5 } from "util";
9843
+ import { z as z17 } from "zod";
9844
+
9845
+ // src/services/seo-performance-command.ts
9846
+ import { randomUUID as randomUUID6 } from "crypto";
9738
9847
  import { writeFile as writeFile8 } from "fs/promises";
9739
9848
  import path29 from "path";
9849
+ import { setTimeout as setTimeout2 } from "timers/promises";
9740
9850
  import { parseArgs as parseArgs4 } from "util";
9741
9851
  import { z as z16 } from "zod";
9852
+ var PERFORMANCE_HELP = `
9853
+ siteos seo performance run --audit <id> --url <url> [--url <url>...] [--device <mobile|desktop>] [--idempotency-key <key>] [--environment <slug>] [--json]
9854
+ siteos seo performance list [--device <mobile|desktop>] [--environment <slug>] [--json]
9855
+ siteos seo performance show <id> [--url <url>] [--environment <slug>] [--json]
9856
+ siteos seo performance wait <id> [--timeout <seconds>] [--environment <slug>] [--json]
9857
+ siteos seo performance cancel <id> [--environment <slug>] [--json]
9858
+ siteos seo performance history --url <url> [--device <mobile|desktop>] [--environment <slug>] [--json]
9859
+ siteos seo performance export <id> --format <csv|json> --output <new-file> [--environment <slug>] [--json]
9860
+
9861
+ Performance checks use up to 10 explicitly selected URLs from a saved full HTML audit.
9862
+ Mobile is the default for a new run. Scores are Lighthouse lab data, not real-user Core Web Vitals.
9863
+ Reuse the returned idempotency key after an uncertain run response. Wait defaults to 120 seconds (maximum 900).
9864
+ `;
9865
+ async function runSeoPerformanceCommand(options) {
9866
+ let idempotencyKey;
9867
+ try {
9868
+ const { values, positionals } = parseArgs4({
9869
+ args: options.args.slice(1),
9870
+ strict: true,
9871
+ allowPositionals: true,
9872
+ options: {
9873
+ json: { type: "boolean" },
9874
+ environment: { type: "string" },
9875
+ audit: { type: "string" },
9876
+ url: { type: "string", multiple: true },
9877
+ device: { type: "string" },
9878
+ "idempotency-key": { type: "string" },
9879
+ timeout: { type: "string" },
9880
+ format: { type: "string" },
9881
+ output: { type: "string" }
9882
+ }
9883
+ });
9884
+ const action = positionals[0] ?? "";
9885
+ const id = positionals[1];
9886
+ const operations = {
9887
+ run: { args: 1, flags: ["audit", "url", "device", "idempotency-key"] },
9888
+ list: { args: 1, flags: ["device"] },
9889
+ show: { args: 2, flags: ["url"] },
9890
+ wait: { args: 2, flags: ["timeout"] },
9891
+ cancel: { args: 2, flags: [] },
9892
+ history: { args: 1, flags: ["url", "device"] },
9893
+ export: { args: 2, flags: ["format", "output"] }
9894
+ };
9895
+ const operation = operations[action];
9896
+ if (!operation || positionals.length !== operation.args || Object.keys(values).some(
9897
+ (key) => !["environment", "json", ...operation.flags].includes(key)
9898
+ ))
9899
+ throw new Error(
9900
+ "Invalid performance operation or flags. Run siteos seo --help."
9901
+ );
9902
+ if (values.device && !["mobile", "desktop"].includes(values.device))
9903
+ throw new Error("Use --device mobile or desktop.");
9904
+ if (action === "run" && (!values.audit || !values.url?.length || values.url.length > 10))
9905
+ throw new Error(
9906
+ "Choose a source --audit and between 1 and 10 --url values."
9907
+ );
9908
+ if (action === "history" && values.url?.length !== 1 || action === "show" && values.url && values.url.length !== 1)
9909
+ throw new Error("Use one --url for page evidence or history.");
9910
+ for (const value of values.url ?? []) {
9911
+ const url = new URL(value);
9912
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port || value.length > 2048)
9913
+ throw new Error("Use eligible public HTTP(S) URLs.");
9914
+ }
9915
+ const timeout = Number(values.timeout ?? 120);
9916
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 900)
9917
+ throw new Error("Use a wait timeout between 1 and 900 seconds.");
9918
+ if (action === "export" && (!values.output || !["csv", "json"].includes(values.format ?? "")))
9919
+ throw new Error(
9920
+ "Export requires --format csv|json and --output for a new file."
9921
+ );
9922
+ if (action === "run") {
9923
+ idempotencyKey = values["idempotency-key"] ?? randomUUID6();
9924
+ if (!/^[a-zA-Z0-9_-]{16,100}$/u.test(idempotencyKey))
9925
+ throw new Error(
9926
+ "Use an idempotency key of 16 to 100 letters, digits, underscores or hyphens."
9927
+ );
9928
+ }
9929
+ const context = await commonServiceContext(
9930
+ options,
9931
+ "seo",
9932
+ values.environment
9933
+ );
9934
+ if (!context)
9935
+ throw new Error("Select a SiteOS Project with siteos project use first.");
9936
+ const runtime = commonProjectRuntime(options);
9937
+ const writing = ["run", "cancel"].includes(action);
9938
+ const scope = writing ? "seo:audits:write" : "seo:workspace:read";
9939
+ const batchSchema = z16.object({
9940
+ id: z16.string(),
9941
+ resourceId: z16.literal(context.resourceId),
9942
+ organizationId: z16.literal(context.overview.project.organizationId),
9943
+ sourceAuditId: z16.string(),
9944
+ state: z16.enum([
9945
+ "queued",
9946
+ "running",
9947
+ "completed",
9948
+ "partial",
9949
+ "failed",
9950
+ "cancelled"
9951
+ ]),
9952
+ device: z16.enum(["mobile", "desktop"]),
9953
+ urls: z16.array(z16.string()).min(1).max(10)
9954
+ }).passthrough();
9955
+ const query = new URLSearchParams();
9956
+ if (values.device) query.set("device", values.device);
9957
+ if (!writing && values.url?.[0]) query.set("pageUrl", values.url[0]);
9958
+ const suffix = id ? `/${encodeURIComponent(id)}${action === "cancel" ? "/cancel" : action === "export" ? "/export" : ""}` : "";
9959
+ if (action === "export") query.set("format", values.format);
9960
+ const deadline = Date.now() + timeout * 1e3;
9961
+ while (true) {
9962
+ const grant = await runtime.grants.acquire({
9963
+ audience: "siteos-seo",
9964
+ scopes: [scope]
9965
+ });
9966
+ if (grant.grant.audience !== "siteos-seo" || grant.grant.organizationId !== context.overview.project.organizationId || grant.grant.scopes.length !== 1 || grant.grant.scopes[0] !== scope)
9967
+ throw new Error(
9968
+ "The SEO grant does not match this Project and operation."
9969
+ );
9970
+ if (!options.fetchImpl)
9971
+ throw new Error("SiteOS API access is unavailable.");
9972
+ const response = await options.fetchImpl(
9973
+ `${runtime.api.origin}/api/seo/v1/resources/${encodeURIComponent(context.resourceId)}/browser-checks${suffix}?${query}`,
9974
+ {
9975
+ method: writing ? "POST" : "GET",
9976
+ headers: {
9977
+ Accept: "application/json",
9978
+ Authorization: `Bearer ${grant.accessToken}`,
9979
+ ...action === "run" ? { "Content-Type": "application/json" } : {}
9980
+ },
9981
+ ...action === "run" ? {
9982
+ body: JSON.stringify({
9983
+ auditId: values.audit,
9984
+ urls: values.url,
9985
+ device: values.device ?? "mobile",
9986
+ idempotencyKey
9987
+ })
9988
+ } : {},
9989
+ signal: AbortSignal.timeout(3e4)
9990
+ }
9991
+ );
9992
+ if (action === "export" && response.ok) {
9993
+ if (!(response instanceof Response) || response.headers.get("X-SEO-Browser-Id") !== id || !response.headers.get("Content-Type")?.startsWith(
9994
+ values.format === "csv" ? "text/csv" : "application/json"
9995
+ ))
9996
+ throw new Error(
9997
+ "The export response does not match the selected check."
9998
+ );
9999
+ const text = await response.text();
10000
+ if (values.format === "json") {
10001
+ const parsed = z16.object({
10002
+ contractVersion: z16.literal(1),
10003
+ batch: batchSchema,
10004
+ pages: z16.array(z16.unknown())
10005
+ }).parse(JSON.parse(text));
10006
+ if (parsed.batch.id !== id)
10007
+ throw new Error(
10008
+ "The export response does not match the selected check."
10009
+ );
10010
+ }
10011
+ const output = path29.resolve(
10012
+ options.cwd ?? process.cwd(),
10013
+ values.output
10014
+ );
10015
+ await writeFile8(output, text, { flag: "wx", mode: 384 });
10016
+ return {
10017
+ exitCode: 0,
10018
+ stdout: JSON.stringify(
10019
+ { batchId: id, output, format: values.format },
10020
+ null,
10021
+ 2
10022
+ )
10023
+ };
10024
+ }
10025
+ const data = await response.json();
10026
+ if (!response.ok) {
10027
+ const error = z16.object({
10028
+ error: z16.object({ code: z16.string(), message: z16.string().max(500) })
10029
+ }).safeParse(data);
10030
+ throw new SiteOSAuthApiError({
10031
+ code: error.success ? error.data.error.code : "SEO_REQUEST_FAILED",
10032
+ message: error.success ? error.data.error.message : "The performance request failed.",
10033
+ status: response.status
10034
+ });
10035
+ }
10036
+ const record = z16.object({ contractVersion: z16.literal(1) }).passthrough().parse(data);
10037
+ if (action === "cancel") z16.literal(true).parse(record.cancelled);
10038
+ else if (action === "run") {
10039
+ const accepted = batchSchema.parse(record.batch);
10040
+ const normalize = (url) => {
10041
+ const value = new URL(url);
10042
+ value.hash = "";
10043
+ return value.href;
10044
+ };
10045
+ if (accepted.sourceAuditId !== values.audit || accepted.device !== (values.device ?? "mobile") || JSON.stringify([...new Set(accepted.urls.map(normalize))].sort()) !== JSON.stringify([...new Set(values.url.map(normalize))].sort()))
10046
+ throw new Error(
10047
+ "The queued check does not match the requested source, device and URLs."
10048
+ );
10049
+ } else {
10050
+ z16.literal(context.resourceId).parse(record.resourceId);
10051
+ z16.array(batchSchema).parse(record.batches);
10052
+ const selected = batchSchema.nullable().parse(record.batch);
10053
+ if (id && selected?.id !== id)
10054
+ throw new Error(
10055
+ "The response does not match the selected performance check."
10056
+ );
10057
+ }
10058
+ const batch = record.batch ? batchSchema.parse(record.batch) : null;
10059
+ if (action !== "wait" || !batch || !["queued", "running"].includes(batch.state))
10060
+ return {
10061
+ exitCode: 0,
10062
+ stdout: JSON.stringify(
10063
+ { ...record, ...idempotencyKey ? { idempotencyKey } : {} },
10064
+ null,
10065
+ 2
10066
+ )
10067
+ };
10068
+ if (Date.now() >= deadline)
10069
+ return {
10070
+ exitCode: 3,
10071
+ stdout: JSON.stringify({ ...record, timedOut: true }, null, 2)
10072
+ };
10073
+ await setTimeout2(Math.min(3e3, Math.max(0, deadline - Date.now())));
10074
+ }
10075
+ } catch (cause) {
10076
+ const error = {
10077
+ code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
10078
+ message: cause instanceof z16.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The performance command failed."
10079
+ };
10080
+ return {
10081
+ exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,
10082
+ ...options.args.includes("--json") ? {
10083
+ stdout: JSON.stringify({
10084
+ error,
10085
+ ...idempotencyKey ? { idempotencyKey } : {}
10086
+ })
10087
+ } : {
10088
+ stderr: `${error.message}${idempotencyKey ? ` Retry key: ${idempotencyKey}` : ""}`
10089
+ }
10090
+ };
10091
+ }
10092
+ }
10093
+
10094
+ // src/services/seo-command.ts
9742
10095
  var SEO_HELP = `Audit public HTML in the selected Project environment.
9743
10096
 
9744
10097
  Usage:
@@ -9759,19 +10112,23 @@ Usage:
9759
10112
  siteos seo notifications destinations [--environment <slug>] [--json]
9760
10113
  siteos seo notifications set --enabled <true|false> [--destination <candidate-id>] --severity <error|warning> --failures <true|false> --revision <number> [--environment <slug>] [--json]
9761
10114
  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]
10115
+ ${PERFORMANCE_HELP.trim().split("\n\n")[0]}
9762
10116
 
9763
10117
  Schedule and notification writes require an owner/admin and the saved revision (initially 0).
9764
10118
  Export writes all matching rows to a new file; existing files are never overwritten.
9765
10119
  Runs are queued. Read audit show until terminal; an accepted run is not a completed check.
9766
10120
  Recheck accepts a URL observed in the source audit. Cross-page rules require a full audit.
9767
10121
  Read the current disposition revision before ignore/restore; use 0 if no decision exists.
9768
- Setup: siteos project connect seo. No crawl runs during setup.`;
10122
+ Setup: siteos project connect seo. No crawl runs during setup.
10123
+ ${PERFORMANCE_HELP.trim().split("\n\n").slice(1).join("\n\n")}`;
9769
10124
  async function runSeoCommand(options) {
9770
10125
  if (!options.args.length || options.args.some((arg) => ["--help", "-h"].includes(arg)))
9771
10126
  return { exitCode: 0, stdout: SEO_HELP };
10127
+ if (options.args[0] === "performance")
10128
+ return runSeoPerformanceCommand(options);
9772
10129
  const json = options.args.includes("--json");
9773
10130
  try {
9774
- const { positionals, values } = parseArgs4({
10131
+ const { positionals, values } = parseArgs5({
9775
10132
  args: options.args,
9776
10133
  strict: true,
9777
10134
  allowPositionals: true,
@@ -9984,18 +10341,18 @@ async function runSeoCommand(options) {
9984
10341
  );
9985
10342
  const text = await response.text();
9986
10343
  if (values.format === "json")
9987
- z16.object({
9988
- contractVersion: z16.literal(1),
9989
- audit: z16.object({
9990
- id: z16.literal(values.audit),
9991
- resourceId: z16.literal(context.resourceId)
10344
+ z17.object({
10345
+ contractVersion: z17.literal(1),
10346
+ audit: z17.object({
10347
+ id: z17.literal(values.audit),
10348
+ resourceId: z17.literal(context.resourceId)
9992
10349
  }),
9993
- kind: z16.literal(values.kind),
9994
- totalRows: z16.literal(rows),
9995
- rows: z16.array(z16.unknown()).length(rows)
10350
+ kind: z17.literal(values.kind),
10351
+ totalRows: z17.literal(rows),
10352
+ rows: z17.array(z17.unknown()).length(rows)
9996
10353
  }).parse(JSON.parse(text));
9997
- const output = path29.resolve(options.cwd ?? process.cwd(), values.output);
9998
- await writeFile8(output, text, { flag: "wx", mode: 384 });
10354
+ const output = path30.resolve(options.cwd ?? process.cwd(), values.output);
10355
+ await writeFile9(output, text, { flag: "wx", mode: 384 });
9999
10356
  return {
10000
10357
  exitCode: 0,
10001
10358
  stdout: JSON.stringify(
@@ -10013,8 +10370,8 @@ async function runSeoCommand(options) {
10013
10370
  }
10014
10371
  const data = await response.json();
10015
10372
  if (!response.ok) {
10016
- const result = z16.object({
10017
- error: z16.object({ code: z16.string(), message: z16.string().max(500) })
10373
+ const result = z17.object({
10374
+ error: z17.object({ code: z17.string(), message: z17.string().max(500) })
10018
10375
  }).safeParse(data);
10019
10376
  throw new SiteOSAuthApiError({
10020
10377
  code: result.success ? result.data.error.code : "SEO_REQUEST_FAILED",
@@ -10022,34 +10379,34 @@ async function runSeoCommand(options) {
10022
10379
  status: response.status
10023
10380
  });
10024
10381
  }
10025
- const record = z16.object({ contractVersion: z16.literal(1) }).passthrough().parse(data);
10382
+ const record = z17.object({ contractVersion: z17.literal(1) }).passthrough().parse(data);
10026
10383
  if (automation) {
10027
- z16.literal(context.resourceId).parse(record.resourceId);
10028
- const schedule = z16.object({
10029
- enabled: z16.boolean(),
10030
- weekday: z16.number().int().min(1).max(7),
10031
- time: z16.string(),
10032
- timeZone: z16.string(),
10033
- revision: z16.number().int().min(0),
10034
- nextRunAt: z16.string().nullable()
10384
+ z17.literal(context.resourceId).parse(record.resourceId);
10385
+ const schedule = z17.object({
10386
+ enabled: z17.boolean(),
10387
+ weekday: z17.number().int().min(1).max(7),
10388
+ time: z17.string(),
10389
+ timeZone: z17.string(),
10390
+ revision: z17.number().int().min(0),
10391
+ nextRunAt: z17.string().nullable()
10035
10392
  });
10036
- const notificationRoute = z16.object({
10037
- enabled: z16.boolean(),
10038
- minimumSeverity: z16.enum(["error", "warning"]),
10039
- includeFailures: z16.boolean(),
10040
- revision: z16.number().int().min(0),
10041
- destinationId: z16.string().nullable()
10393
+ const notificationRoute = z17.object({
10394
+ enabled: z17.boolean(),
10395
+ minimumSeverity: z17.enum(["error", "warning"]),
10396
+ includeFailures: z17.boolean(),
10397
+ revision: z17.number().int().min(0),
10398
+ destinationId: z17.string().nullable()
10042
10399
  });
10043
10400
  if (retryNotification) {
10044
- z16.literal(true).parse(record.retryQueued);
10045
- z16.literal(positionals[2]).parse(record.notificationId);
10401
+ z17.literal(true).parse(record.retryQueued);
10402
+ z17.literal(positionals[2]).parse(record.notificationId);
10046
10403
  } else if (route === "notifications destinations")
10047
- z16.object({
10048
- candidates: z16.array(
10049
- z16.object({
10050
- candidateId: z16.string(),
10051
- label: z16.string(),
10052
- availability: z16.literal("available")
10404
+ z17.object({
10405
+ candidates: z17.array(
10406
+ z17.object({
10407
+ candidateId: z17.string(),
10408
+ label: z17.string(),
10409
+ availability: z17.literal("available")
10053
10410
  })
10054
10411
  )
10055
10412
  }).parse(record);
@@ -10062,46 +10419,46 @@ async function runSeoCommand(options) {
10062
10419
  notificationRoute.parse(record.route);
10063
10420
  }
10064
10421
  } else if (!writing) {
10065
- const validated = z16.object({
10066
- resource: z16.object({
10067
- id: z16.literal(context.resourceId),
10068
- organizationId: z16.literal(context.overview.project.organizationId)
10422
+ const validated = z17.object({
10423
+ resource: z17.object({
10424
+ id: z17.literal(context.resourceId),
10425
+ organizationId: z17.literal(context.overview.project.organizationId)
10069
10426
  }),
10070
- audits: z16.array(z16.object({ id: z16.string() }).passthrough()),
10071
- audit: z16.object({
10072
- id: z16.string(),
10073
- resourceId: z16.literal(context.resourceId)
10427
+ audits: z17.array(z17.object({ id: z17.string() }).passthrough()),
10428
+ audit: z17.object({
10429
+ id: z17.string(),
10430
+ resourceId: z17.literal(context.resourceId)
10074
10431
  }).passthrough().nullable(),
10075
- pages: z16.array(z16.unknown()),
10076
- issues: z16.array(z16.unknown()),
10077
- changes: z16.array(z16.unknown()),
10078
- totalChanges: z16.number(),
10079
- dispositions: z16.array(z16.unknown())
10432
+ pages: z17.array(z17.unknown()),
10433
+ issues: z17.array(z17.unknown()),
10434
+ changes: z17.array(z17.unknown()),
10435
+ totalChanges: z17.number(),
10436
+ dispositions: z17.array(z17.unknown())
10080
10437
  }).passthrough().parse(record);
10081
10438
  const selected = query.get("audit");
10082
10439
  if (selected && validated.audit?.id !== selected)
10083
10440
  throw new Error("The SEO response does not match the requested audit.");
10084
10441
  } else if (record.audit)
10085
- z16.object({
10086
- id: z16.string(),
10087
- resourceId: z16.literal(context.resourceId),
10088
- organizationId: z16.literal(context.overview.project.organizationId),
10089
- state: z16.literal("queued")
10442
+ z17.object({
10443
+ id: z17.string(),
10444
+ resourceId: z17.literal(context.resourceId),
10445
+ organizationId: z17.literal(context.overview.project.organizationId),
10446
+ state: z17.literal("queued")
10090
10447
  }).parse(record.audit);
10091
- else if (route === "audit cancel") z16.literal(true).parse(record.cancelled);
10448
+ else if (route === "audit cancel") z17.literal(true).parse(record.cancelled);
10092
10449
  else if (action === "issue")
10093
- z16.object({
10094
- url: z16.literal(values.url),
10095
- ruleId: z16.literal(values.rule),
10096
- ignored: z16.literal(positionals[1] === "ignore"),
10097
- revision: z16.literal(Number(values.revision) + 1)
10450
+ z17.object({
10451
+ url: z17.literal(values.url),
10452
+ ruleId: z17.literal(values.rule),
10453
+ ignored: z17.literal(positionals[1] === "ignore"),
10454
+ revision: z17.literal(Number(values.revision) + 1)
10098
10455
  }).parse(record.disposition);
10099
10456
  else throw new Error("The SEO service returned an invalid response.");
10100
10457
  return { exitCode: 0, stdout: JSON.stringify(record, null, 2) };
10101
10458
  } catch (cause) {
10102
10459
  const error = {
10103
10460
  code: cause instanceof SiteOSAuthApiError ? cause.code : "SEO_COMMAND_FAILED",
10104
- message: cause instanceof z16.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The SEO command failed."
10461
+ message: cause instanceof z17.ZodError ? "The SEO service returned an invalid response." : cause instanceof Error ? cause.message : "The SEO command failed."
10105
10462
  };
10106
10463
  return {
10107
10464
  exitCode: cause instanceof SiteOSAuthApiError ? 1 : 2,