@siteoshq/cli 1.4.0 → 1.5.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 CHANGED
@@ -267,3 +267,17 @@ answers from the working database, retaining only a reserved-key tombstone. That
267
267
  reused. Shared Environment credentials remain active for other forms. Sync refuses archived/deleted
268
268
  keys and never removes forms missing from a local manifest. Backups follow deployment retention;
269
269
  they are not an in-app restore mechanism.
270
+
271
+ ### Automatic Forms deployment
272
+
273
+ Generate Forms JSON from the shared host validation schema during build. Install a release-only
274
+ key once with `siteos forms deployment-key issue --environment production --install --json`.
275
+ Store `SITEOS_FORMS_DEPLOYMENT_KEY` in the CI secret store and configure explicit
276
+ `SITEOS_FORMS_PUBLIC_URL`. Keep the submission credential on the website server.
277
+
278
+ Run `siteos forms deploy --manifest .siteos/forms/manifest.json --json` before routing traffic to a
279
+ new release. This needs no interactive Auth or local Project state and publishes atomically.
280
+ Definitions include a generated SHA-256 `sourceExportId`; runtime sends it as `contractVersion`.
281
+ A changed field requires a normal build/release, not a second hand-edited validation schema.
282
+ `forms deployment-key list|revoke` manages release keys; submission keys cannot publish.
283
+ See the SiteOS Forms skill for the portable generator and version serialization contract.
package/dist/cli.js CHANGED
@@ -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
  };