← Files WixARCHIVED FILE

skills/wix-headless-templates/forms/seed/seed-forms.mjs

28.5 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Forms seed — a BUILD-TIME script, never shipped in the app. Run from the project root
// (where wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/forms/seed/seed-forms.mjs plan.json
//
// It mints its own site token via the Wix CLI, installs the Wix Forms app if needed, expands
// each plan field into the nested Form Schemas v4 shape, creates the forms, and READS EACH ONE
// BACK to prove the fields survived (a create returns 200 even when a choice field silently
// degraded to a plain text box). Prints a JSON result to stdout.
//
// Plan shape (see SEED.md):
//   { "forms": [{ "name", "submitText"?, "nextText"?, "previousText"?, "steps"?, "rules"?,
//                 "thankYou"?, "thankYouSeconds"?, "redirect"?, "deadline"?, "maxSubmissions"?,
//                 "perVisitor"?, "requiredIndicator"?,
//                 "fields": [{ "label", "kind", "required"?, "placeholder"?, "description"?, "default"?,
//                              "choices"?, "other"?, "min"?, "max"?, "step"?, "minLength"?, "maxLength"?,
//                              "pattern"?, "patternMessage"?, "fileLimit"?, "formats"?, "countries"?,
//                              "country"?, "buttonText"?, "hidden"? }] }] }
//
// The plan is PLAIN DATA — labels and kinds. Everything the API demands and the docs bury is
// derived here: per-field UUIDs, the two-level options nesting, the validation block that must
// exist even when empty, the choice enum that must agree with the component's options, the
// per-component prefill key, the snake_case+suffix target, a `steps` layout referencing every
// field including the submit button (a field missing from `steps` never appears in the owner's
// dashboard), and rules keyed by field id.
//
// Seeding is ADDITIVE — never deletes or overwrites existing forms.
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const API = "https://www.wixapis.com";
const FORMS_APP_ID = "225dd912-7dea-4738-8688-4b8c6955ffc2";
const NAMESPACE = "wix.form_app.form";

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

async function req(ctx, path, { method = "POST", body } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${ctx.token}`,
      "wix-site-id": ctx.siteId,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
  return json;
}

/** Idempotent. Nothing here works until the app is installed. */
export async function installFormsApp(ctx) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", {
      body: {
        tenant: { tenantType: "SITE", id: ctx.siteId },
        appInstance: { appDefId: FORMS_APP_ID, enabled: true },
      },
    });
  } catch {
    /* already installed is fine */
  }
}

// ---- field expansion -------------------------------------------------------------------------

// kind → { inputType, componentType, identifier, format?, contact? }
// The two block names are what the API nests settings under, and they are named after the
// field's own enums — which is why they are looked up here rather than spelled at each call.
// `contact` is the per-field contactMapping: it is what makes a submission create or update the
// site's CRM contact (the older postSubmissionTriggers.upsertContact is ignored by v4).
const KINDS = {
  text:      { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "TEXT_INPUT" },
  textarea:  { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "TEXT_AREA" },
  email:     { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "CONTACTS_EMAIL", format: "EMAIL", contact: { contactField: "EMAIL", emailInfo: { tag: "UNTAGGED" } } },
  phone:     { inputType: "STRING", componentType: "PHONE_INPUT",    identifier: "CONTACTS_PHONE", format: "PHONE", contact: { contactField: "PHONE", phoneInfo: { tag: "UNTAGGED" } } },
  url:       { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "URL_INPUT", format: "URL" },
  firstName: { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "CONTACTS_FIRST_NAME", contact: { contactField: "FIRST_NAME" } },
  lastName:  { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "CONTACTS_LAST_NAME", contact: { contactField: "LAST_NAME" } },
  company:   { inputType: "STRING", componentType: "TEXT_INPUT",     identifier: "CONTACTS_COMPANY", contact: { contactField: "COMPANY" } },
  date:      { inputType: "STRING", componentType: "DATE_PICKER",    identifier: "DATE_PICKER", format: "DATE", dateBlock: "dateOptions" },
  time:      { inputType: "STRING", componentType: "TIME_INPUT",     identifier: "TIME_INPUT", format: "TIME", dateBlock: "timeOptions" },
  datetime:  { inputType: "STRING", componentType: "DATE_TIME",      identifier: "DATE_TIME_INPUT", format: "DATE_TIME", dateBlock: "dateTimeOptions" },
  number:    { inputType: "NUMBER", componentType: "NUMBER_INPUT",   identifier: "NUMBER_INPUT" },
  rating:    { inputType: "NUMBER", componentType: "RATING_INPUT",   identifier: "RATING_INPUT" },
  select:    { inputType: "STRING", componentType: "DROPDOWN",       identifier: "DROPDOWN" },
  radio:     { inputType: "STRING", componentType: "RADIO_GROUP",    identifier: "RADIO_GROUP" },
  multi:     { inputType: "ARRAY",  componentType: "CHECKBOX_GROUP", identifier: "CHECKBOX_GROUP" },
  tags:      { inputType: "ARRAY",  componentType: "TAGS",           identifier: "TAGS" },
  checkbox:  { inputType: "BOOLEAN", componentType: "CHECKBOX",      identifier: "CHECKBOX" },
  file:      { inputType: "WIX_FILE", componentType: "FILE_UPLOAD",  identifier: "FILE_UPLOAD" },
  address:   { inputType: "ADDRESS", componentType: "MULTILINE_ADDRESS", identifier: "MULTILINE_ADDRESS" },
};

const INPUT_BLOCK = {
  STRING: "stringOptions", NUMBER: "numberOptions", BOOLEAN: "booleanOptions",
  ARRAY: "arrayOptions", ADDRESS: "addressOptions", WIX_FILE: "wixFileOptions",
};
const COMPONENT_BLOCK = {
  TEXT_INPUT: "textInputOptions", PHONE_INPUT: "phoneInputOptions", NUMBER_INPUT: "numberInputOptions",
  RATING_INPUT: "ratingInputOptions", DATE_PICKER: "datePickerOptions", TIME_INPUT: "timeInputOptions",
  DATE_TIME: "dateTimeOptions", CHECKBOX: "checkboxOptions", CHECKBOX_GROUP: "checkboxGroupOptions",
  TAGS: "tagsOptions", RADIO_GROUP: "radioGroupOptions", DROPDOWN: "dropdownOptions",
  MULTILINE_ADDRESS: "multilineAddressOptions", FILE_UPLOAD: "fileUploadOptions",
};

// The address subfields the owner can require. `country` is always shown by the runtime; the
// others follow the country template. `validation.fields` only carries required-ness.
const ADDRESS_PARTS = ["country", "addressLine", "addressLine2", "city", "subdivision", "postalCode"];
const ADDRESS_REQUIRED_WHEN_REQUIRED = ["country", "addressLine", "city", "postalCode"];

/** One paragraph of Ricos rich content — the shape of a checkbox label, a thank-you message. */
function richText(text) {
  return {
    nodes: [
      {
        type: "PARAGRAPH",
        id: randomUUID().slice(0, 8),
        nodes: [{ type: "TEXT", id: "", nodes: [], textData: { text: String(text), decorations: [] } }],
        paragraphData: {},
      },
    ],
    metadata: { version: 1 },
  };
}

/**
 * `target` is the IMMUTABLE submission key: starts with a letter, letters/digits/underscore
 * only, no doubled underscore, unique within the form. The random suffix is what keeps two
 * fields with the same label apart.
 */
function targetFor(label, taken) {
  const base =
    String(label)
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "_")
      .replace(/_+/g, "_")
      .replace(/^_|_$/g, "")
      .replace(/^(?=\d)/, "f_")
      .slice(0, 40) || "field";
  let target;
  do {
    target = `${base}_${Math.random().toString(36).slice(2, 8)}`;
  } while (taken.has(target));
  taken.add(target);
  return target;
}

function buildField(spec, taken) {
  const kind = KINDS[spec.kind];
  if (!kind) {
    throw new Error(
      `field "${spec.label}": unknown kind "${spec.kind}" — one of ${Object.keys(KINDS).join(", ")}`,
    );
  }
  const isChoice = ["select", "radio", "multi", "tags"].includes(spec.kind);
  const choices = spec.choices ?? [];
  if (isChoice && !choices.length) {
    throw new Error(`field "${spec.label}": kind "${spec.kind}" needs a non-empty choices array`);
  }

  const target = targetFor(spec.label, taken);
  const defaults = [].concat(spec.default ?? []).map(String);
  const options = choices.map((c) => {
    const value = typeof c === "string" ? c : c.value;
    return {
      id: randomUUID(),
      label: typeof c === "string" ? c : (c.label ?? c.value),
      value,
      // A preselected choice is marked on the OPTION (`options[].default`), not on the component.
      ...(defaults.includes(String(value)) ? { default: true } : {}),
    };
  });
  const values = options.map((o) => o.value);
  const other = spec.other
    ? { label: typeof spec.other === "string" ? spec.other : "Other", ...(spec.otherPlaceholder ? { placeholder: spec.otherPlaceholder } : {}) }
    : null;

  // A choice field declares its options TWICE — here and in the validation enum — and the two
  // must agree. Disagree and the create still returns 200: the field is created as a plain
  // text box, losing its choices. Both are derived from the same list, so they cannot drift.
  // A free-text "Other" entry is anything outside the list, so no enum is written with it (the
  // validator ignores the enum when a customOption is defined).
  const validation = {};
  if (kind.format) validation.format = kind.format;
  if (kind.dateBlock) {
    // Date bounds live under the format's options block; `$now`, `$now+2d`, `$now-1M` are accepted.
    const bounds = { ...(spec.min != null ? { minimum: String(spec.min) } : {}), ...(spec.max != null ? { maximum: String(spec.max) } : {}) };
    if (Object.keys(bounds).length) validation[kind.dateBlock] = bounds;
  } else {
    if (spec.min != null) validation.minimum = spec.min;
    if (spec.max != null) validation.maximum = spec.max;
  }
  if (spec.step != null) validation.multipleOf = spec.step;
  if (spec.minLength != null) validation.minLength = spec.minLength;
  if (spec.maxLength != null) validation.maxLength = spec.maxLength;
  if (spec.pattern) validation.pattern = spec.pattern;
  if (spec.patternMessage) validation.validationMessages = { pattern: spec.patternMessage };
  if (spec.minItems != null) validation.minItems = spec.minItems;
  if (spec.maxItems != null) validation.maxItems = spec.maxItems;
  if (values.length) {
    if (kind.inputType === "ARRAY") {
      // `itemType` sits INSIDE `items`, beside the options block; one level up the create is a 400
      // whose message blames the options block ("itemType cannot be provided with ...").
      validation.items = { itemType: "STRING", stringOptions: other ? {} : { enum: values } };
    } else if (!other) {
      validation.enum = values;
    }
  }
  if (spec.kind === "phone" && spec.countries?.length) validation.phoneOptions = { allowedCountryCodes: spec.countries };
  if (spec.kind === "file") {
    validation.fileLimit = spec.fileLimit ?? 1;
    if (spec.formats?.length) validation.uploadFileFormats = spec.formats;
  }
  // "Must be ticked" is the boolean enum [true]; `required` alone only checks that a value is present.
  if (spec.kind === "checkbox" && spec.required) validation.enum = [true];
  if (spec.kind === "address") {
    if (spec.countries?.length) validation.allowedCountries = spec.countries;
    validation.fields = Object.fromEntries(
      ADDRESS_PARTS.map((sub) => [sub, { required: spec.parts?.[sub] ?? (Boolean(spec.required) && ADDRESS_REQUIRED_WHEN_REQUIRED.includes(sub)) }]),
    );
  }

  // A checkbox labels itself with rich content (its label may carry a link to the terms).
  const component = { label: spec.kind === "checkbox" ? richText(spec.label) : spec.label, showLabel: true };
  if (spec.placeholder?.length > 100) {
    throw new Error(`field "${spec.label}": placeholder is ${spec.placeholder.length} characters, the API allows 100`);
  }
  if (spec.placeholder) component.placeholder = spec.placeholder;
  if (spec.description) component.description = richText(spec.description);
  if (options.length) component.options = options;
  if (other) component.customOption = other;
  // The prefill key differs per component: a checkbox has `checked`, a rating `defaultValue`,
  // a choice field marks its option (above), everything else `default`.
  if (spec.default != null && !isChoice) {
    if (spec.kind === "checkbox") component.checked = Boolean(spec.default);
    else if (spec.kind === "rating") component.defaultValue = Number(spec.default);
    else if (spec.kind === "number") component.default = Number(spec.default);
    else component.default = String(spec.default);
  }
  if (spec.kind === "phone" && spec.country) component.defaultCountryCode = spec.country;
  if (spec.kind === "file" && spec.buttonText) component.buttonText = spec.buttonText;
  if (spec.kind === "address" && spec.parts && "addressLine2" in spec.parts) component.fieldSettings = { addressLine2: { show: spec.parts.addressLine2 !== false } };

  return {
    id: randomUUID(),
    identifier: kind.identifier,
    fieldType: "INPUT",
    // A field a rule shows starts hidden.
    hidden: Boolean(spec.hidden),
    inputOptions: {
      target,
      inputType: kind.inputType,
      // `required` lives HERE, never inside the validation block.
      required: spec.required ?? false,
      // Contact fields carry pii and the CRM mapping; without contactMapping a submission is
      // stored but no contact is created or updated.
      ...(kind.contact ? { pii: true, contactMapping: kind.contact } : {}),
      [INPUT_BLOCK[kind.inputType]]: {
        // `validation` is always present, even as {}, and nests under the INPUT-TYPE block —
        // not the component one. Absent, the target is not registered as an accepted value and
        // every submission comes back UNKNOWN_VALUE_ERROR on a key that IS in the schema.
        validation,
        componentType: kind.componentType,
        [COMPONENT_BLOCK[kind.componentType]]: component,
      },
    },
  };
}

// Plan rule → v4 `formRules[]` entry. The condition names a field by LABEL; the override names
// the fields it shows / hides / requires. `show` marks those fields hidden in the base schema
// and un-hides them while the condition holds — how the dashboard's own "show X when" works.
const RULE_OPERATORS = { is: "EQUAL", isNot: "NOT_EQUAL", in: "IN", includes: "CONTAINS", checked: "CHECKED", isEmpty: "EMPTY", isNotEmpty: "NOT_EMPTY" };
function buildRule(rule, fieldByLabel, formName) {
  const byLabel = (label) => {
    const f = fieldByLabel.get(label);
    if (!f) throw new Error(`form "${formName}": rule refers to a field labelled "${label}" that is not in the plan`);
    return f;
  };
  const when = rule.when ?? {};
  const opKey = Object.keys(RULE_OPERATORS).find((k) => k in when);
  if (!when.field || !opKey) throw new Error(`form "${formName}": a rule needs "when": { "field", and one of ${Object.keys(RULE_OPERATORS).join("/")} }`);
  const source = byLabel(when.field);
  const operator = RULE_OPERATORS[opKey];
  // EMPTY, NOT_EMPTY and CHECKED carry no value of their own, but the service still requires one
  // (RULE_CONDITION_VALUE_MISSING without it); `true` is what the dashboard sends.
  const value = ["checked", "isEmpty", "isNotEmpty"].includes(opKey) ? true : when[opKey];
  const override = (label, propertyType, options) => ({
    entityType: "FIELD",
    fieldOptions: { fieldId: byLabel(label).id, propertyType, ...options },
  });
  const overrides = [
    ...(rule.show ?? []).map((l) => override(l, "HIDDEN", { hiddenOptions: { hidden: false } })),
    ...(rule.hide ?? []).map((l) => override(l, "HIDDEN", { hiddenOptions: { hidden: true } })),
    ...(rule.require ?? []).map((l) => override(l, "REQUIRED", { requiredOptions: { required: true } })),
  ];
  if (!overrides.length) throw new Error(`form "${formName}": a rule needs at least one of show / hide / require`);
  for (const l of rule.show ?? []) byLabel(l).hidden = true;
  return {
    id: randomUUID(),
    name: rule.name ?? `${when.field} ${opKey} ${JSON.stringify(value ?? "")}`.slice(0, 100),
    // The root of a rule expression must be an and/or group, never a bare condition
    // (UNGROUPED_RULE_EXPRESSION_ROOT). One condition still goes inside an `and`.
    expression: { and: { conditions: [{ condition: { target: source.inputOptions.target, operator, value } }] } },
    overrides,
  };
}

function buildForm(planForm) {
  const taken = new Set();
  const fields = (planForm.fields ?? []).map((f) => buildField(f, taken));
  if (!fields.length) throw new Error(`form "${planForm.name}": no fields`);
  const fieldByLabel = new Map(fields.map((f, i) => [planForm.fields[i].label, f]));

  const submit = {
    id: randomUUID(),
    identifier: "SUBMIT_BUTTON",
    fieldType: "DISPLAY",
    displayOptions: {
      // The button's identifier is SUBMIT_BUTTON; its display type is PAGE_NAVIGATION (the enum
      // has no SUBMIT_BUTTON value — the create is a 400 with anything else). One button drives
      // both the page moves and the final submit, so all three wordings sit on it.
      displayFieldType: "PAGE_NAVIGATION",
      pageNavigationOptions: {
        submitText: planForm.submitText ?? "Submit",
        ...(planForm.nextText ? { nextPageText: planForm.nextText } : {}),
        ...(planForm.previousText ? { previousPageText: planForm.previousText } : {}),
      },
    },
  };

  // `steps` must reference EVERY field, the submit button included — a field missing from the
  // layout never appears in the owner's dashboard, so they cannot edit what the site renders.
  // A plan without steps is one page; with steps, a field not named on any step lands on the
  // last one, and the button always does.
  const planSteps = planForm.steps?.length ? planForm.steps : [{ name: "", fields: planForm.fields.map((f) => f.label) }];
  const placed = new Set();
  const stepFields = planSteps.map((s) =>
    (s.fields ?? []).map((label) => {
      const f = fieldByLabel.get(label);
      if (!f) throw new Error(`form "${planForm.name}": step "${s.name ?? ""}" names a field labelled "${label}" that is not in the plan`);
      placed.add(f.id);
      return f;
    }),
  );
  const unplaced = fields.filter((f) => !placed.has(f.id));
  stepFields[stepFields.length - 1].push(...unplaced, submit);
  const layoutOf = (list) => {
    const items = list.map((f, i) => ({ fieldId: f.id, row: i, column: 0, width: 12 }));
    return { large: { items }, medium: { items }, small: { items } };
  };
  const steps = planSteps.map((s, i) => ({ id: randomUUID(), ...(s.name ? { name: s.name } : {}), layout: layoutOf(stepFields[i]) }));

  const formRules = (planForm.rules ?? []).map((r) => buildRule(r, fieldByLabel, planForm.name));

  // What happens after a successful submit is the owner's setting, so it lives on the form:
  // the thank-you text (optionally auto-hidden) or a redirect. Absent, the page writes its own.
  const submitSettings = planForm.redirect
    ? { submitSuccessAction: "REDIRECT", redirectOptions: { redirectUrl: planForm.redirect, target: "SELF" } }
    : planForm.thankYou
      ? { submitSuccessAction: "THANK_YOU_MESSAGE", thankYouMessageOptions: { richContent: richText(planForm.thankYou), ...(planForm.thankYouSeconds ? { durationInSeconds: planForm.thankYouSeconds } : {}) } }
      : null;
  const limitationRule = {
    ...(planForm.deadline ? { dateTimeDeadline: new Date(planForm.deadline).toISOString() } : {}),
    ...(planForm.maxSubmissions != null ? { maxAllowedSubmissions: planForm.maxSubmissions } : {}),
    ...(planForm.perVisitor != null ? { submissionLimitPerUser: planForm.perVisitor } : {}),
  };

  return {
    name: planForm.name,
    namespace: NAMESPACE,
    formFields: [...fields, submit],
    steps,
    ...(formRules.length ? { formRules } : {}),
    ...(submitSettings ? { submitSettings } : {}),
    ...(Object.keys(limitationRule).length ? { limitationRule } : {}),
    ...(planForm.requiredIndicator
      ? { requiredIndicatorProperties: { requiredIndicator: planForm.requiredIndicator, requiredIndicatorPlacement: "AFTER_FIELD_TITLE" } }
      : {}),
  };
}

// ---- operations ------------------------------------------------------------------------------

/**
 * Read the form back and confirm each field kept its componentType, and that the steps and rules
 * arrived. A create returns 200 even when a choice field degraded to a plain text box, so this is
 * the only check that catches it.
 */
async function verifyForm(ctx, formId, expected, body) {
  const { form } = await req(ctx, `/form-schema-service/v4/forms/${formId}`, { method: "GET" });
  const live = new Map(
    (form?.formFields ?? [])
      .filter((f) => f.fieldType === "INPUT")
      .map((f) => {
        const block = f.inputOptions?.[INPUT_BLOCK[f.inputOptions?.inputType]] ?? {};
        return [f.inputOptions?.target, block.componentType];
      }),
  );
  const degraded = expected
    .filter((e) => live.get(e.target) !== e.componentType)
    .map((e) => `${e.target}: expected ${e.componentType}, got ${live.get(e.target) ?? "MISSING"}`);
  const stepsLive = (form?.steps ?? []).length;
  const rulesLive = (form?.formRules ?? []).length;
  if (stepsLive !== body.steps.length) degraded.push(`steps: expected ${body.steps.length}, got ${stepsLive}`);
  if (rulesLive !== (body.formRules ?? []).length) degraded.push(`rules: expected ${(body.formRules ?? []).length}, got ${rulesLive}`);
  return { fieldsLive: live.size, steps: stepsLive, rules: rulesLive, degraded };
}

// ---- plan limits -----------------------------------------------------------------------------

/**
 * The caps the site's plan puts on forms, read live (Get Restrictions). A free site: 4 forms,
 * 10 input fields per form, 3 steps, 3 rules. A paid plan lifts them, so they are never
 * hardcoded here. Null when the call fails; the create then reports the cap itself.
 */
async function readLimits(ctx) {
  try {
    const { restrictions, totalFormCount } = await req(ctx, "/form-app-service/v4/restrictions", { method: "GET" });
    const limit = (key) => restrictions?.[key]?.limit;
    return {
      forms: limit("formsLimit"),
      fields: limit("fieldsLimit"),
      steps: limit("stepsLimit"),
      rules: limit("rulesLimit"),
      totalFormCount: totalFormCount ?? 0,
    };
  } catch {
    return null;
  }
}

/**
 * Checks the whole plan against the site's caps BEFORE any create, so the agent fixes the plan
 * in one step instead of decoding the service's message one form at a time. The service rejects
 * the whole form (FIELDS_COUNT_RESTRICTIONS_ERROR and friends); nothing would be created either
 * way, but a local check costs no round trip.
 */
function checkPlanAgainstLimits(ctx, plan, byName, limits) {
  if (!limits) return;
  const over = (n, cap) => cap != null && n > cap;
  const problems = [];
  let newForms = 0;
  for (const f of plan.forms ?? []) {
    if (byName.has(f.name)) continue; // left as is, never counts twice
    newForms += 1;
    const fields = (f.fields ?? []).length;
    const steps = (f.steps ?? []).length;
    const rules = (f.rules ?? []).length;
    if (over(fields, limits.fields)) problems.push(`form "${f.name}": ${fields} fields, this site's plan allows ${limits.fields} per form`);
    if (over(steps, limits.steps)) problems.push(`form "${f.name}": ${steps} steps, this site's plan allows ${limits.steps} per form`);
    if (over(rules, limits.rules)) problems.push(`form "${f.name}": ${rules} rules, this site's plan allows ${limits.rules} per form`);
  }
  if (over(limits.totalFormCount + newForms, limits.forms)) {
    problems.push(
      `${newForms} new form(s) on top of the site's ${limits.totalFormCount} would exceed this site's plan limit of ${limits.forms} forms`,
    );
  }
  if (!problems.length) return;
  throw new Error(
    `The plan exceeds this site's plan limits; nothing was created:\n  - ${problems.join("\n  - ")}\n` +
      `Either trim the plan to the limits, or the owner upgrades the site's plan at ` +
      `https://www.wix.com/upgrade/website?metaSiteId=${ctx.siteId} and the same plan runs unchanged. ` +
      `Do not split one form in two to fit: it spends a form slot and splits a visitor's answers across two records. ` +
      `Say which way you went in the closing message.`,
  );
}

/**
 * Create every form in the plan. Existing forms are left alone — matching by name, since a
 * re-run must not create a second copy of the same form.
 */
export async function setupForms(ctx, plan) {
  await installFormsApp(ctx);

  const existing = await req(
    ctx,
    `/form-schema-service/v4/forms?namespace=${encodeURIComponent(NAMESPACE)}`,
    { method: "GET" },
  ).catch(() => ({ forms: [] }));
  const byName = new Map((existing.forms ?? []).map((f) => [f.name, f]));

  // Every form is built first, so a plan mistake (a bad kind, a rule naming an unknown field, a
  // placeholder over 100 characters) surfaces before any call; then the site's caps are checked.
  const bodies = new Map((plan.forms ?? []).filter((f) => !byName.has(f.name)).map((f) => [f.name, buildForm(f)]));
  checkPlanAgainstLimits(ctx, plan, byName, await readLimits(ctx));

  const out = [];
  for (const planForm of plan.forms ?? []) {
    const already = byName.get(planForm.name);
    if (already) {
      out.push({
        name: planForm.name,
        formId: already.id ?? already._id,
        created: false,
        fields: (already.formFields ?? [])
          .filter((f) => f.fieldType === "INPUT")
          .map((f) => ({ target: f.inputOptions?.target, label: planForm.name })),
      });
      continue;
    }

    const body = bodies.get(planForm.name);
    let form;
    try {
      ({ form } = await req(ctx, "/form-schema-service/v4/forms", { body: { form: body } }));
    } catch (e) {
      // Verified live: a `file` field on a site whose plan does not include file uploads fails the
      // whole create with this code — nothing is created. There is no API switch for it; the owner
      // upgrades the site's plan, or the form drops the `file` kind (see SEED.md, "Kinds").
      if (String(e.message).includes("FILE_UPLOAD_RESTRICTIONS_ERROR")) {
        const names = planForm.fields.filter((f) => f.kind === "file").map((f) => `"${f.label}"`).join(", ");
        throw new Error(
          `form "${planForm.name}": this site's plan does not allow file-upload fields (${names}) — ` +
          `FILE_UPLOAD_RESTRICTIONS_ERROR; Wix Forms gates the file field behind a paid site plan and nothing was created. ` +
          `Either the owner upgrades the site in the dashboard, or replace the field with a "url" kind and let the site ` +
          `upload the file itself through the shared mediaUpload capability (plan.capabilities.mediaUpload, Astro only), ` +
          `submitting the uploaded file's URL. Tell the owner which one you did.`,
        );
      }
      throw e;
    }
    const formId = form?.id ?? form?._id;
    if (!formId) throw new Error(`form "${planForm.name}": created but no id returned`);

    const expected = body.formFields
      .filter((f) => f.fieldType === "INPUT")
      .map((f) => ({
        target: f.inputOptions.target,
        componentType: f.inputOptions[INPUT_BLOCK[f.inputOptions.inputType]].componentType,
      }));
    const check = await verifyForm(ctx, formId, expected, body);

    out.push({
      name: planForm.name,
      formId,
      created: true,
      fields: body.formFields
        .filter((f) => f.fieldType === "INPUT")
        .map((f, i) => ({ target: f.inputOptions.target, label: planForm.fields[i].label })),
      ...check,
    });
  }
  return { forms: out };
}

// ---- CLI entry -------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-forms.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupForms(ctx, plan)
    .then((result) => console.log(JSON.stringify(result, null, 2)))
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: f35aab339e4536ab05343021c6b589d3158c7b20cd67ad83d5a06e4ccbe9acd0