AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The rp-execute-setup skill is a critical workflow component in the Wix RePlatform migration pipeline. It consumes machine-readable setup artifacts to verify that a target Wix site is correctly configured and provisioned before any data import occurs. The skill handles app installation, collection setup, and site-level configuration, ensuring that the target environment meets all requirements defined during the codegen phase. It operates under a strict shared runtime contract, prioritizing API-driven provisioning and idempotent verification to prevent duplicate entities or configuration drift.
What it does
- Verifies existing Wix site configurations, apps, and collections against migration requirements.
- Provisions missing infrastructure, such as installing required Wix apps and creating necessary CMS collections.
- Mutes site notifications to prevent customer-facing alerts during the migration process.
- Captures and applies source store configurations, including business contact information.
- Manages the lifecycle of sample data generated by Wix app installations on new sites.
- Records verification results and execution status in machine-readable artifacts for auditability.
When to use it
- After codegen is complete and before starting the data import process.
- When you need to ensure a target Wix site is fully prepared to receive migrated data.
- To validate that a pre-existing Wix site meets the specific schema and app requirements of a migration plan.
- When performing a dry-run to assess the impact of setup changes without mutating the target site.
Advantages
- Ensures environment consistency by using a shared, contract-based setup runtime.
- Prevents accidental data duplication by verifying existing state before provisioning.
- Automates complex setup tasks like app dependency management and sample data cleanup.
- Provides clear, traceable verification logs for every setup step performed.
Usage examples
- “Execute the setup plan for the current migration project.”
- The agent reads the setup-plan.json and execution-manifest.json, verifies the target site's state, installs required apps, and records the results in setup-verification.json.
- “Run a dry-run of the setup process for my Wix migration.”
- The agent simulates the setup steps, generating a report of intended actions without making any live API calls or mutations to the Wix site.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/wix/rp-execute-setup/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip rp-execute-setup.zip -d .agents/skills
Documentation by Wix
rp-execute-setup
Verify that required Wix-side setup exists and is ready for import.
Purpose
This skill validates the prerequisites discovered by rp-setup-discovery. It can also drive the setup work when the environment and permissions allow it.
Required inputs
migrations/<project>/setup/setup-plan.jsonmigrations/<project>/setup/setup-requirements.jsonmigrations/<project>/execution/execution-manifest.jsonmigrations/<project>/config/wix.envor equivalent environment valuesaccess to the target Wix environment or to exported evidence from that environment
Prefer the machine-readable setup artifacts above. Markdown setup summaries are secondary renderings for humans, not the primary execution contract.
Config
Prefer project-local config over ad hoc shell state. config/wix.env should exist before
setup verification/provisioning and contain:
WIX_SITE_STRATEGY=
WIX_SITE_ID=
WIX_AUTH_TOKEN=WIX_SITE_STRATEGY is always required. WIX_SITE_ID is required before setup
verification/provisioning begins. If the strategy is new and no site has been created
yet, halt to needs-user and route back to the site-creation step rather than asking for an
existing site ID.
Studio/Harmony destinations. A new Editor site is bound only after
orchestration/destination.json is verified (actual editor and an unpublished readback).
Setup treats it like any new site: mint WIX_AUTH_TOKEN for that WIX_SITE_ID through
mint-token.sh, confirm it with a harmless target read before the first write, mute site
notifications first (WIX_SITE_STRATEGY=new regardless of site type), and install/verify
every required app — never infer installed apps, Catalog version or backend readiness from the
editor type or template. Never publish the Editor site; its website and publication remain
operator handoff items.
Any Wix app can be installed by a setup step that carries its app id — { "id": "<unique id>", "kind": "installApp", "appDefId": "<app id>" } — with no code change; the named
install-wix-* steps are the same call under a name the planner already uses.
On a Harmony site every app is installed through the editor's installation service, not the
generic app installer: that installer lists an app but leaves the Harmony editor without the pages the
app adds. Each install step reads the site's editor type (then the destination record) and, on Harmony,
sends the app's id to the service with the editor switch it needs, then reads the installed-apps list
back. An app the service says has no editor part is installed
with the generic installer instead. The service installs an app's own dependencies. A small
companions table in the writers lists apps an app's widgets need that it does not add (Wix Stores: the
rich-text viewer app, installed first). Every other editor keeps the generic install. If the installer refuses an app because another app already owns its page paths (Wix installs two
apps that add the same eCommerce pages, such as Stores and Bookings, only one at a time), the earlier
step wins: the later app is left uninstalled, its step ends with installed: false and deferred, setup
goes on, and the destination hand-off asks the site owner to add it once Wix fixes the conflict. Order
the install steps by importance (Stores before Bookings). Setup cannot read a Harmony page list, so it records appPages: "not-read" and the
destination hand-off asks the site owner to confirm each app's pages. The route and the switch are a
temporary workaround for an eCommerce feature closed on Harmony: re-check both once it reopens.
When WIX_SITE_ID names a site this run did not create, setup changes posture: verify
and fill gaps, never provision from scratch. The site is already in use, so treat
everything in it as pre-existing state to work around rather than a blank slate:
Expect apps, collections and fields to exist already. Install only the delta the mapping actually needs. An app that is already installed is a PASS, not a step to redo, and re-provisioning is how a working site acquires duplicate collections.
Never delete or recreate an entity to "fix" a mismatch. A collection whose schema differs from what the mapping expects is a
blockedrequirement with a remediation note — the owner decides, not this skill.A
V1_CATALOGverdict is terminal here. The replacement-site recovery inresources/rp-destination/applies only to a site this run scaffolded. Record the blocker, write nothing to Stores, halt to needs-user.When the approved plan requests a CMS mirror, provision the on-site
ImportCrosswalkcollection (see "Import crosswalk CMS mirror" below). Whether a mirror is requested at all is the owner's call and the mapper's setting — it is optional by design. Once it has been requested, two things follow and neither one counts as the other: provisioning is a required setup step even on a destination the owner already had (an existing site is not a reason to treat it as already satisfied), and provisioning is not the deliverable — the run still owes the post-execution snapshot pass that keeps the collection current.Record which requirements were already satisfied versus newly provisioned in
setup/setup-verification.json. On a site the user owns, "we changed nothing here" is itself a result worth reporting.
WIX_AUTH_TOKEN is the canonical Wix credential key for this project
flow; for CLI-scaffolded headless sites it holds the site write credential (a CLI token
sent as a Bearer token). Never print secret values. Mint the token without printing it
to the transcript by running scripts/mint-token.sh from the migration project root:
bash migrations/<project>/scripts/mint-token.shThe canonical copy lives at
skills/wix-replatform/resources/rp-execute-setup/scripts/mint-token.sh.
rp-import-codegen copies it into migrations/<project>/scripts/ at scaffolding time —
do not write it from scratch. The script reads WIX_SITE_ID from config/wix.env, calls
npx @wix/cli@latest token --site "$WIX_SITE_ID", captures stdout (token shape:
OauthNG.JWS.<base64>.<base64>.<sig>), writes it directly into config/wix.env as
WIX_AUTH_TOKEN, and prints only a char-count confirmation. Do NOT run the token command
raw in a Bash tool call — it prints the credential to transcript.
Setup and import clients renew the token through this script while they run, for the same
WIX_SITE_ID only, so one mint before a long run is enough. When renewal fails, setup records
the blocker wix_auth_expired and exits 75 with the same Import Operator action as the import
(see rp-execute-import).
If WIX_SITE_ID is missing for a RePlatform new site + headless flow, the recovery
path is resources/rp-destination/, which scaffolds via
npm create @wix/new@latest headless. The account-level Projects API is
deprecated for this workflow. Note: the scaffold does not install Wix Stores — this
skill (setup) is where Wix Stores and other required apps get installed (via the
wix-manage skill / app-install path), then verified, before import.
Treat migrations/<project>/config/*.env as secret-bearing once they may contain real
values. Do not verify them with whole-file reads that echo contents into tool output.
Only check existence plus present / blank / missing status for required keys.
Workflow
Resolve the active project.
Read the machine-readable setup artifacts.
Verify each required app, collection, schema, field, and permission through the shared setup runtime contract. If setup requirements came from domain knowledge, preserve the originating
targetRefin verification output so later execution warnings can be traced back to the selected entity guidance.Record pass, fail, or blocked status for each item.
If execution is allowed, perform missing setup steps through the shared setup runtime and re-verify.
Save the verification results and setup execution artifacts.
Carry the source store's own configuration. See the section below — this is a required step, not an optional extra, and it is the one every run before it skipped.
Carry the source store's own configuration
A migrated site that publishes with a blank business address is not a cosmetic miss: it is a whole class of source data that no run ever read, because two independent obstacles each made the route look like a dead end (the REST index advertises only the parameterized settings route, and the WooCommerce consumer key 401s on it while the WordPress application password succeeds). Nothing reported it missing, because no domain owned it.
Three steps, in order:
Capture. Run
rp-source-wordpress/scripts/capture-store-settings.js <projectDir>. Deterministic; writesdata/store-settings-general.json. Do not hand-roll the fetch — the script exists specifically because the credential and the route shape are both traps.Write the business address. Build the payload with
rp-target-wix/lib/store-config-build.js'sbuildBusinessContactInput, write it withwix-writers.js'supdateBusinessContact, then read it back withgetSitePropertiesand compare. The read-back is not optional: the update returns an empty body, so a 200 proves only that the request was accepted. Seedomains/site/entities/business-contact.json. NEVER assemble thefields.pathsmask by hand — a path listed but not sent is CLEARED, and Wix's own documented example demonstrates that hazard rather than the safe pattern.Report the rest at the gate.
collectReportOnlySettingsreturns currency, selling countries, ship-to countries, tax on/off, coupons on/off and price format, each with its source value and why it matters. These are NOT written by this pipeline — surfacing them beside the destination's current values is what turns "we didn't migrate it" from an invisible omission into a decision the merchant can make in one look.
The captured address also feeds the shipping step: with it, a collection point whose source
carries no address is proposed ("your store address is X — is that where customers collect?")
instead of prompting blank. See shipping-build.js's resolvePickupAddressSource.
Execute the setup artifacts — do not re-derive setup from prose
Setup execution should follow the approved machine artifacts and the shared setup runtime.
It should not rebuild setup decisions from markdown or ad hoc reasoning when
setup/setup-plan.json, setup/setup-requirements.json, and execution/execution-manifest.json
already exist.
This skill owns execution of those artifacts. It does not own redefining the setup plan or creating a separate setup approval checkpoint.
The shared executor uses startSetupRun and bindSetupStep to issue a capability bound to
the current plan, destination and named step. It admits registered setup operations and
writes for that step's entityRefs; it cannot import records or send arbitrary requests.
Setup, import, quick and disposable-probe process marks are exclusive. Do not pass a
caller-authored phase or substitute a general import/probe client for a setup client.
For a step declaring referenceKeys, capture referenceBindings on its passed requirement
in setup/setup-verification.json from the capability-bound executor's actual target
read-back. Reuse a successful operation's read result when available; otherwise perform the
bounded read for each distinct declared target. Bind the receipt to siteId, planDigest
and the source keys/paths. Guessed IDs, copied source IDs, raw/unrelated reads and write-only
responses are not verified bindings. Imports resolve these values through the gate only
after every bound setup step passes; successful partial setup never unlocks a dependent.
Every passed requirement in the receipt carries verification: { method, checkedAt, observed }
for the check execute-setup.js actually ran — for example status-read (mute state read
back), installed-apps-read or install-response (an install with no read-back),
collection-read, catalog-version-read. The shared validator rejects a passed row without
a known method, a valid timestamp and an observed-state object. A receipt written before
these fields existed is invalid: re-run setup; never add the fields by hand.
Each live attempt records the receipt it replaces as replaces: { sha256, generatedAt }
(or null) in the new receipt, and carries it through failed attempts in
setup/setup-blockers.json. Import-plan freshness uses it: re-running setup after the
approved setup already produced a receipt makes the approved plan stale, so present the
regenerated plan or delta before importing.
Dry-run setup uses the same setup plan and setup executor as live setup. When DRY_RUN
is enabled by config or --dry-run, every setup step must first be reduced to a
structured intent describing the REST request, SDK operation, MCP tool call, or CLI
command that live setup would use. The shared setup runtime then captures that intent,
marks the step planned_dry_run, and must not invoke Wix MCP tools, Wix CLI commands,
SDK calls, or fetch calls that access or mutate Wix account/site state.
Do not override DRY_RUN=true with --no-dry-run for setup verification or provisioning
unless the user has explicitly approved leaving dry-run for setup. Prefer to avoid that
override entirely when a dry-run or plan/report can answer the question. The one allowed
live action while a project otherwise remains in dry-run mode is the separate new-site
creation step handled upstream by wix-replatform; this skill must not treat that exception
as permission to run live setup writes or live setup verification probes.
Dry-run setup must not update setup/setup-verification.json in a way that claims a Wix
capability, app installation, collection, or site exists. Write a separate dry-run setup
report or clearly dry-run-scoped observations instead.
Provisioning — exhaust programmatic options before declaring anything "manual"
Default to provisioning via API. Do not label a requirement "manual" or "owner action" until you have confirmed no API can do it.
The preferred contract is a shared setup runtime owned by rp-target-wix, executed
against the machine setup artifacts produced upstream. This skill may use available Wix
API/MCP surfaces as the transport beneath that runtime, but the contract at the
RePlatform level is:
setup execution consumes machine setup artifacts
setup execution follows shared runtime behavior
setup provisioning is not re-planned live by the agent
The approval gate is unchanged: no setup write before the user accepts the execution plan.
If a helpful Wix tool surface is available in the runtime, it may be used beneath the
shared setup runtime for verification or transport. If no such surface is available, do
not treat that alone as a blocker; continue with the shared setup/runtime contract and
verified rp-target-wix behavior, marking unverified items where needed.
Only proceed in a docs-only/read-only posture when the MCP is genuinely unavailable and the step can still produce useful non-destructive output.
Concrete mechanisms:
Mute site notifications FIRST. When the setup artifacts carry the
mute-site-notificationsrequirement (always forWIX_SITE_STRATEGY=new; for existing sites only when the operator answered mute at intake), execute it before every other setup write. Then runimport-site-locale(which writes site properties only and sends nothing) — immediately after the target site is available andWIX_AUTH_TOKENis minted, and before app installs, collection creation, or any other provisioning — so setup writes themselves cannot fire notifications. Use themuteSiteNotificationsprimitive fromrp-target-wix/lib/wix-writers.js(VERIFIED 2026-08-04) with a project-identifying reason (RePlatform migration — <project>); verify viagetSiteMuteState→muted: true(verification methodstatus-read; an idempotent re-mute is only a documented fallback, methodidempotent-recall). Record the result insetup/setup-verification.jsonon the requirement at call time — status (pass/fail/blocked), timestamp, and verification method — like any other verified item; downstream reports read this recorded state, never infer it. AUTH TRAP: the endpoints accept user tokens only — the CLI-mintedOauthNGsite token works; an account API key gets an empty-body 403. A failed mute is a blocker, not a warning: the run halts to needs-user and never proceeds to import writes — no degraded mode, no continue-anyway. Never callunmuteSiteNotificationsfrom this skill — unmute is an explicit owner request handled at the orchestrator level. Record the provision outcome through the standardrp-telemetryrecorder like any other notable boundary (anerrorevent witherror_codeon failure; the existing-site answer is thenotificationMutedecision recorded at intake) — no new telemetry surface.Installing / enabling Wix apps (Blog, Members, etc.) IS automatable. Use the App Installation API:
Pre-check with
POST /apps-installer-service/v1/app-instance/is-permitted-to-install(read-only) to see whether the identity may install the app.If permitted, install with
POST /apps-installer-service/v1/app-instance/install. Body (all fields required — confirmed by live 400s):{ appInstance: { appDefId, enabled: true }, tenant: { tenantType: "SITE", id: <siteId> }, installType: "INSTALL_TYPE_SITE", appsInstallOptions: {} }. (Theis-permitted-to-installpre-check uses a different, oneof-based body and is informational only — if its validation fights you, skip it and rely on/install.)List current state with
GET /apps-installer-service/v1/app-instances. An install call that returns 200 is not a pass. The executor records an app step as passed only when this list, read back after the install, names theappDefIdand it is enabled (APP_INSTALL_NOT_VERIFIEDotherwise). Wix Stores is215238eb-…; the quick-modewix-storesstep and the standardinstall-wix-storesstep install the same app. Wix eCommerce (1380b703-…) is only Stores' dependency and does not make Stores installed.Installing Wix Stores adds sample products and categories (12 products on the Tuti runs; no Wix marker identifies them). The Stores step (
install-wix-storesorwix-stores) handles them itself (owner decision 2026-10-26):It reads products and categories (each on its own) just before the install, then after it until two reads agree. Ids that appear in between are
install_seeded. If a pre-install read fails on a site this run did not create, nothing in that collection is treated as seeded. If the catalog is still changing after every read, or new items appear while it deletes, the step is blocked withCATALOG_NOT_SETTLEDbefore (or instead of further) deletes; re-run setup to finish.The record is written before any delete call, so a crash or failed check keeps it.
On a site this run created (destination receipt
verified,strategy: new), it deletes exactly theinstall_seededitems, keeping the built-inAll Productscategory, then reads the catalog again and records each id asdeletedordelete_failed. It asks nothing. On any other site it deletes nothing: a reinstall there may bring back the owner's earlier catalog, which looks like samples. Those items stay protected and become the same cutover item. A failed delete does not fail setup; it becomes the cutover itemSTORES_INSTALL_SEEDED_NOT_DELETED, and a re-run of setup tries again.If Stores was already installed, it deletes nothing and records every item as
pre_existing. A re-run keeps the baseline the first run recorded.It writes
setup/destination-catalog-baseline.json(ids, slugs, kind, status; no prices or customer data). The step'sinstalled-apps-readcheck carriesobserved.catalogBaselinewith the file's digest and counts. If a catalog read fails the step is blocked withCATALOG_BASELINE_UNREADABLE.Never delete products or categories by hand or with a project-local script. A dry run makes no delete call.
Ground
appDefIdfrom the official "Apps Created by Wix" table (/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix), NOT from a docs example — e.g. the install-app example uses1380b703-…, which is Wix eCommerce, not Blog. Installing the wrong app on a live site is a real hazard; verify the ID maps to the app you intend.Installing Wix Stores on a site this run created provisions Catalog V3 unconditionally — Catalog V3 is now guaranteed at provisioning regardless of scaffold template, so there is no verification step here for that case. This does not apply to an existing site this run did not create — see "A
V1_CATALOGverdict is terminal here" above for that case, which is unchanged.
Wix Data / CMS collections (the
WDE0110: Wix Code not enabledcase). Enable Wix Data by installing the Wix Data appappDefId e593b0bd-b783-45b8-97c2-873d42aacaf4via the App Installation API (same/installbody shape as any other app; it also auto-installs a dependency app1a711f05-2040-47df-a9f0-4f9cddb4c3c6). Once installed, plain RESTPOST /wix-data/v2/collectionscreates NATIVE collections with noWDE0110— no code editor toggle, no custom app needed. Verified live 2026-06-10 on a fresh free site (install → 200; collection create → 200collectionType: NATIVE).This is the preferred path. The older data-collections-extension app (authoring a custom app that declares collections) is now a fallback — only needed if you must declare collection schemas at install time, and it still can't express
REFERENCEfields (add those after install viacreate-field).Note: the standalone "Wix CMS" app (
appDefId 675bbcef-…) is not installable (is-permitted-to-install→false) — do not use it; usee593b0bd-….
Import crosswalk CMS mirror — OPTIONAL, provisioned only when a mirror mode is requested. Native Wix entities use local crosswalk state under
migrations/<project>/state/crosswalk/as the sole runtime authority for idempotency; a CMS mirror is never a substitute for it and never required for correctness. Do not provisionImportCrosswalkby default for every project — Wix CMS quotas are plan-based and shared across every custom collection on the site, so an unconditional mirror can spend a meaningful fraction of a lower-tier plan's total item quota on operational migration state the site owner never asked to store. Provision it only when the mapper'scmsMirrorsetting for the project is something other thannone(typically an existing-destination flow where the user opted into site-local reference data, so a later re-import from another project or a different surface can see "what has already been imported here" instead of duplicating it). When a mirror IS requested, provisioning alone is not the deliverable: the mirror is a compact snapshot uploaded AFTER execution, not written to inline during it.rp-import-codegengenerates that snapshot as a distinct post-execution step, gated on the samecmsMirrormode, which reads the finished local crosswalk and uploads it throughrp-target-wix/lib/wix-writers.js'ssyncImportCrosswalk(seerp-execute-import's "Import & idempotency"). LIVE-VERIFIED a project whose collection was provisioned early and never written to again left every already-imported record with no on-site trace at all until a one-time backfill caught it up, so provisioning must not be treated as satisfied by itself — a provisioned collection is only trustworthy once that post-execution pass has run over the local crosswalk. A mirror requested but never snapshotted is a reportable shortfall, not a completed setup. After enabling Wix Data, create it withPOST /wix-data/v2/collectionsand fields such asentityType(TEXT),sourceId(TEXT),sourceStableKey(TEXT),targetId(TEXT),targetType(TEXT), andupdatedAt(DATETIME/TEXT). Do not provision this collection as the default native-entity idempotency mechanism. If upstream artifacts still call the optional mirrorMigrationRefs, normalize them here rather than creating both collections.Genuinely manual (no API exists): upgrading the storage plan, generating external-system credentials (e.g. a WordPress Application Password), and account-level billing. These are the only categories that may be reported as manual — and only after confirming no API covers them.
Inventory prerequisites for product imports
For planned variant inventory, resolve the intended Stores location with
rp-target-wix/lib/stores-inventory.js → resolveInventoryLocation(wix, locationId?).
Do not create a location if the lookup is empty or ambiguous. Verify inventory-read access.
Inventory create and update require their own write permissions in addition to product
create; a token field or configured scope name is not proof that those permissions work.
On an explicitly selected, already-muted disposable target, run:
node resources/rp-target-wix/scripts/verify-stores.js stores inventory-import --env <test-target-config> --artifact <verification-json>This creates hidden synthetic products, verifies bulk stock create, tracked/untracked updates, inline stock, and a no-write rerun, then deletes only those probe products. It does not mute or unmute a site. Record actual target, token context, timestamp, assertions, and cleanup in the setup receipt; a failed probe cannot satisfy the inventory requirement. A disposable-site receipt verifies that token/site only: run the authorized canary under the real destination's own setup approval before its full import. Reuse current evidence rather than probing per product.
Artifact to create or update
migrations/<project>/setup/setup-verification.jsonmigrations/<project>/setup/review/setup-verification.mdaudit/report artifacts emitted by the shared setup runtime
dry-run request captures in
migrations/<project>/state/attempts/wix-request-captures.ndjsonwhen setup is run with dry-run enabled
Stores live setup checks that require a probe record must use the shared rp-target-wix verification CLI, not ad hoc snippets. For subscription support, run:
node skills/wix-replatform/resources/rp-target-wix/scripts/verify-stores.js stores subscription-create \
--artifact migrations/<project>/setup/stores-subscription-verification.json \
--proposal-artifact migrations/<project>/setup/contract-ledger-proposal.jsonKeep the JSON verification artifact and contract-ledger proposal alongside setup
verification. The proposal is not itself shared product knowledge; the orchestrator must
promote accepted proposal data into rp-target-wix/domains/**/entities/*.json in the same
session or record a deferral reason. If cleanup fails, do not mark the probe as clean;
preserve the warning and run the emitted stores delete-probe recovery command after
permissions or target state are fixed.
Verification output format
For each requirement capture:
requirement name
expected state
observed state
status: passed, failed, blocked
remediation needed
The machine-readable verification artifact should also preserve:
stable requirement ID
checkpoint/provisioning step ID when applicable
verification evidence reference
automation mode:
automatable | manual | blocked | unverified
Optional media reachability verification
When the migration includes media import by source URL, check whether discovered media
URLs are publicly reachable by Wix. If the source URL is localhost, 127.0.0.1, or a
private-only host, mark media import as blocked or deferred, but do not block
unrelated non-media entities. This is optional setup and, as far as we know today, affects
only Wix Media import.
Record the user's chosen path in setup/setup-verification.json and render it into the
review markdown:
Tunnel media URLs: ask the user to expose the source through a public HTTPS tunnel, then use that URL for
WP_BASE_URL/SOURCE_URLor rewrite media URLs to that base.Skip/defer media: proceed only if the execution plan clearly says media and any media-dependent references (hero images, galleries, downloads) will be skipped or deferred.
Ngrok quick setup for macOS:
brew install ngrok
ngrok config add-authtoken "<YOUR_AUTHTOKEN>"
ngrok http 8090
export WP_BASE_URL=https://<id>.ngrok-free.appRuntime policy
Split this skill's work by side effect:
Verification is read-only — checking what's installed, what's missing, and what's genuinely manual. It runs before the execution-plan acceptance gate and feeds the plan.
Provisioning writes — installing apps, enabling Wix Data (via the data-collections enabler), creating collections, adding fields — happen only after the user accepts the execution plan. No site write before acceptance. Once accepted, the "Migrate" consent covers the individual writes, so don't re-prompt per app/collection. Halt to needs-user only for genuinely manual items (storage-plan upgrade) or a missing/invalid credential.
The execution behavior itself should come from the shared setup runtime and approved setup artifacts, not from improvised per-run logic in this skill.
Guardrails
Never report setup as complete without evidence.
Prefer machine-readable setup verification artifacts over prose-only reporting.
Before marking an item blocked or manual, confirm no API can perform it (see Provisioning above). Reserve "manual" for storage/billing/external-credential steps.
If credentials or permissions are genuinely missing, mark the item blocked and state the exact API that was refused and why.
Do not start import execution from this skill.
Do not reinterpret setup requirements from markdown when machine artifacts exist.
Wix site locale: import-site-locale
The first imported entity after notification safety, for any source. It reads the site's settings, compares currency, time zone, language
and locale with discovery/source-locale.json (each source adapter maps its own settings into that file),
writes only what differs, and reads every value back. The Wix call stores whatever it is sent (a
made-up currency and time zone were both stored with a 200), so values are validated before sending and a 200
is never evidence; a repeated write bumps the site version, so a matching site gets zero write calls.
A site this run created (destination receipt
verified,strategy: new, same site id) is set directly.An existing site is probed for orders and products only. No orders and no products means set directly. Any order, any product, an unreadable installed-apps result, or a failed probe raises one durable question for all differences; never answer it for the owner, one-click included. No sample-product heuristic ships.
changeapplies every difference.keepis a passed receipt: neutral entities continue, while money-bearing tasks and wall-clock tasks whose relevant setting differs are held underSITE_LOCALE_KEPT. Unclassified refs fail closed. The answer survives partial apply for the immutable rows originally shown and is replaceable.The pre-change values are kept in
setup/site-locale-before.jsonso a re-run after a crash still reports the true before and after.SOURCE_LOCALE_REQUIREDmeans one or more source values are stillunknown. Full adapters update their adapter discovery file and promote it. Quick adapters and SFCC have no promote step: ask the merchant, record each missing canonical field indiscovery/source-locale.jsonasstate: suppliedwith a validatedvalue,evidence, andsuppliedBy, re-run the quick plan when applicable, then rerun setup. Public re-capture preserves a valid supplied value while that field remains unknown.Any rejected write or read-back mismatch is a blocker. When payment currency changes, create one durable payment-method confirmation action and preserve it across matching reruns until acknowledged.
The receipt is the
regionalobject on the step's row insetup/setup-verification.json; import tasks read it (files only) to decide whether they may start. Dry run reduces the step to an ordered intent and calls nothing.
Provisioning user-defined extended fields (Data Extension Schema)
Never build a Data Extension Schema update by hand. Use
rp-target-wix/lib/data-extension-schema.js and send the request it returns.
The reason is a single API property: Update Data Extension Schema is a PUT that overrides the
stored schema, and jsonSchema is required. Sending only the fields this migration needs
therefore DELETES every field the merchant created, silently and permanently, on their own data.
planDataExtensionSchemaProvisioning() reads the existing schema, merges into it, and refuses the
update when it would drop, retype, narrow or re-classify anything already there.
The order it enforces:
List the existing schema with
fields=ARCHIVED— archived fields are hidden by default, and an archived key cannot be reused, so the collision check is blind without them.Select the schema by
namespace(_user_fields). List returns global and app schemas alongside the user-defined one; picking by position merges into somebody else's schema.Plan. The result is
create,update,nooporblocked—noopis what a repeated run returns, andblockedcarries the reason.Send the returned request. An update carries the schema
idand the currentrevision; the API uses the revision to reject conflicting changes.Re-read and verify each field with
verifyDataExtensionSchemaFields(), and persist the evidence. A create or update can report success without producing the field.Generated import code writes an extended-field value only after verification passed for that exact field —
validateExtendedFieldWriterReferences()enforces it.
Use writers.provisionExtendedFieldRequirement(); it routes each requirement by schemaKind and
the provision-extended-fields step already calls it. Scope: orders, products
and Contacts V5 through the Data Extension Schema; Contacts V4 through CRM Extended Fields only when
the requirement says contactWriteSurface: "contacts-v4". Everything else is refused before any
network call. Types: string (DES) and TEXT (V4); the helpers refuse an unverified type.
Named blockers and what they mean:
CONTACTS_V5_DES_UNVERIFIED— the gating List forwix.contacts.*.contactwas refused. Setup does not fall back to V4. Wait for the path to be verified, or re-route the mapping tocontacts-v4explicitly; that is a mapping decision, not a recovery step.EXTENDED_FIELD_SCHEMA_CONFLICT_EXHAUSTED— three Update attempts in a row hit a revision conflict. Each retry re-listed and re-merged, so the concurrent change was kept, not overwritten. Re-run once the concurrent edits stop.schema-budget-exceeded(underEXTENDED_FIELD_SCHEMA_BLOCKED) — the namespace byte budget cannot hold the fields. Checked before create too, against the observed 10,000-byte limit. Route the excess to CMS; never pick fields to drop.CONTACT_EXTENDED_FIELD_MISMATCH— V4 Find Or Create matched a field by name with another type, returned no or another namespace thancustom, or returned a key different from the one an earlier run verified. Keys cannot change and approving a migration action does not fix it; decide with the owner rather than creating a second field.PRIOR_EXTENDED_FIELD_RECEIPT_UNREADABLE— a contacts-v4 run foundsetup/extended-field-verification.jsonbut could not read it, so key continuity cannot be proved. The file is kept; restore it, or remove it only if no earlier import wrote V4 values.
Contacts V4 receipts record each displayName, the returned key and the write path
info.extendedFields.<key>; V5 contact values go only under extendedFields.namespaces._user_fields.
Live proof for products and both contact paths is still pending on a disposable site.
The inventory verification token also needs Catalog read access to hidden products and variants. The canary must verify default-location availability in both Inventory and Catalog before the inventory path is marked verified.
CMS collection compatibility
The existing create-cms-collections executor reads every planned collection definition after creation/reuse (including a concurrent-create 409). Its REST collection fields, reference metadata, required/writable flags and all access roles must match the planned write; extra required business fields need explicit mappings. Missing or incompatible definitions remain CMS_COLLECTION_INCOMPATIBLE setup blockers. Never delete/recreate a collection to obtain a compatible definition.
CMS definition read-back uses documented GET /wix-data/v2/collections/<id>?consistentRead=true
to avoid immediately reading a stale replica after create. Compatibility enforces planned
metadata keys, types, required fields and permissions; unplanned server defaults are not a
conflict. This comparison has local/schema evidence; it is not a live verification claim.
Public multilingual setup
Run scripts/multilingual-setup.js after the verified site/locale step and before translation
inventory or writes. It snapshots every locale visibility choice. A destination proven to have
been created by this migration may install Multilingual, enable multilingual mode, and add missing
additional locales hidden. Every other destination first emits an exact setup proposal and makes
zero setup requests until a current confirm-setup decision exists; decline-setup leaves the
sidecar blocked while default import continues. The script never changes an existing locale's
visibility or the default locale, and every mutation is refused until its versioned disposable-site
contract evidence exists. Site origin is recomputed from the verified
orchestration/destination.json receipt and its exact WIX_SITE_ID binding; callers cannot assert
created through a flag. Setup gate/read-back failures are written to setup.json for completion
report projection before the script exits nonzero.
Files
- ATTRIBUTION.mdAdded by AutoGineer844 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md38.4 KiB
- scripts/execute-setup.jsScript49.4 KiB
- scripts/mint-token.shScript2.2 KiB
- scripts/multilingual-setup.jsScript3.3 KiB
scripts/execute-setup.js
#!/usr/bin/env node
'use strict';
const capabilities = require('../../../lib/execution-capability');
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const {assertCollectionCompatibility}=require('../../../lib/cms-collection-compatibility.js');
const REAL_WRITERS = require('../../rp-target-wix/lib/wix-writers.js');
const { SCHEMA_KIND: CONTACT_V4_SCHEMA_KIND } = require('../../rp-target-wix/lib/contact-extended-fields.js');
const { hashArtifact } = require('../../../lib/artifact-freshness.js');
const { validatePlanShape, validateSetupVerification } = require('../../../lib/setup-verification.js');
const siteRegional = require('../../../lib/site-regional.js');
const catalogBaseline = require('../../../lib/catalog-baseline.js');
const wixAuth = require('../../../lib/wix-auth.js');
const { HARMONY_EDITOR_TYPE } = require('../../../lib/editor-type.js');
// 'wix-stores' is the quick-mode step id; it is Wix Stores (215238eb-...), the same app as
// `install-wix-stores` below. 1380b703-... is Wix eCommerce, which Stores installs as its own
// dependency: installing it alone leaves the site without Stores.
const APPS = { 'wix-stores': '215238eb-22a5-4c36-9e7b-e7c08025e04e', 'wix-blog': '14bcded7-0066-7c35-14d7-466cb3f09103' };
// Additional app-install step ids, added for projects whose setup plan needs apps this
// executor previously had no executor for at all (a plan naming any of these used to fail
// closed with NO_EXECUTOR_FOR_REQUIREMENT). Ids verified live against dev.wix.com's "Apps
// Created by Wix" reference table, 2026-08-29 -- not from a docs example, and not reusing
// the known-wrong 'wix-stores' id above. Idempotent: an app already installed is a pass,
// not a redundant install call (see the isWixAppInstalled check in runStep below).
const INSTALL_STEPS = {
'install-wix-stores': '215238eb-22a5-4c36-9e7b-e7c08025e04e',
'install-wix-gift-cards': 'd80111c5-a0f4-47a8-b63a-65b54d774a27',
'install-wix-blog': '14bcded7-0066-7c35-14d7-466cb3f09103',
'install-wix-events': '140603ad-af8d-84a5-2c80-a0f60cb47351',
'install-wix-data': 'e593b0bd-b783-45b8-97c2-873d42aacaf4',
// Verified live 2026-10-27: the APIs name these ids when the app is missing.
'install-wix-reviews': 'a95a5fce-e370-4402-9ce4-96956acc747d',
'install-wix-back-in-stock': '16be6c71-d061-4f56-8cda-c6aa911d1832',
};
// Any other app needs no line here: a step that carries its own app id installs it,
// `{ "id": "<any unique id>", "kind": "installApp", "appDefId": "<app id>" }`. The lines above stay as
// names the planner and the dependency gate already use.
const APP_DEF_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function appIdForStep(step) {
if (step.kind === 'installApp') return APP_DEF_ID.test(step.appDefId) ? step.appDefId.toLowerCase() : null;
return APPS[step.id] || INSTALL_STEPS[step.id] || null;
}
async function readJson(file) {
return JSON.parse(await fs.readFile(file, 'utf8'));
}
async function readJsonIfExists(file) {
try {
return await readJson(file);
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async function readEnv(dir) {
const raw = await fs.readFile(path.join(dir, 'config', 'wix.env'), 'utf8');
return Object.fromEntries(
raw
.split(/\r?\n/)
.map((line) => line.match(/^([A-Z0-9_]+)=(.*)$/))
.filter(Boolean)
.map(([, key, value]) => [key, value.trim()]),
);
}
async function fallbackAuthor(dir, wix, writers) {
const id = path.basename(dir);
const email = `replatform-${id}@example.com`;
const listed = await writers.listMembers(wix, { limit: 100 });
let member = (listed.members || []).find((item) => String(item.loginEmail || '').toLowerCase() === email);
if (!member) member = await (writers.ensureBlogFallbackAuthor || writers.createMember)(wix, { email, name: 'Imported content', slug: `imported-content-${id}` });
if (!member?.id) throw new Error('fallback Blog author was not created');
const created = !(listed.members || []).includes(member);
const file = path.join(dir, 'config', 'wix.env');
const raw = await fs.readFile(file, 'utf8');
await fs.writeFile(
file,
`${raw.replace(/\n?WIX_BLOG_FALLBACK_MEMBER_ID=.*(?:\n|$)/g, '\n').replace(/\s*$/, '\n')}WIX_BLOG_FALLBACK_MEMBER_ID=${member.id}\n`,
);
return { method: created ? 'member-create-response' : 'member-read', observed: { memberId: member.id } };
}
// Creates one Wix Data collection via the real, verified request shape (top-level key is
// `collection`, NOT `dataCollection` — confirmed live 2026-08-29 against a 400 "collection
// must not be empty" response from the wrong key, and against CreateDataCollectionRequest's
// real schema). Idempotent: a collection id that already exists (409 or an existing-id list
// hit) requires a full compatible definition read, never a delete-and-recreate — matches this skill's own "never delete or
// recreate an entity to fix a mismatch" rule.
async function createCmsCollectionsStep({ dir, wix, writers }) {
const requirements = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const list = Array.isArray(requirements) ? requirements : requirements?.requirements;
const requirement = (list || []).find((item) => item && item.id === 'create-cms-collections');
if (!requirement) throw new Error('setup-requirements.json has no create-cms-collections requirement');
const existing = await wix.send(writers.buildListCmsCollectionsRequest());
const existingIds = new Set((existing.collections || []).map((c) => c.id));
const fields = (requirement.fieldSchemaPerCollection || []).map((f) => ({
key: f.key,
type: f.type,
displayName: f.displayName,
...(f.required ? { required: true } : {}),
...(f.typeMetadata ? {typeMetadata:f.typeMetadata} : {}),
}));
for (const collection of requirement.collections || []) {
const expected={id:collection.id,displayName:collection.displayName,fields:collection.fields || fields,
permissions:collection.permissions || requirement.permissions};
if (!existingIds.has(collection.id)) {
try {
await writers.ensureCmsCollection(wix, expected);
} catch(error) {
if (error.status!==409 && error.statusCode!==409) throw error;
// A concurrent create is not evidence of compatible fields or permissions.
}
}
const current=await wix.send(writers.buildGetCmsCollectionRequest(collection.id));
assertCollectionCompatibility(current.collection,expected);
}
return { method: 'collection-read', observed: { collections: (requirement.collections || []).map(c => c.id) } };
}
// Runs one setup-plan step. Throws (with `blockerCode`/`recommendedAction` attached where
// the failure is a recognized, expected condition rather than a genuinely unexpected one)
// on anything short of full success — the caller records the failure and stops the run
// rather than letting a step's exception propagate past a recorded outcome.
// Provisions every `extendedFieldSchema` requirement in the plan before any
// writer can put a value in one. Blocked reasons become setup blockers rather than being retried
// or worked around: a breaking schema change and an archived-key collision are both decisions for
// a human, not for this executor.
// A step is a provisioning step if it SAYS so, not only if it happens to be spelled
// `provision-extended-fields`. Recognising one literal id meant discovery emitting a valid
// requirement under any other natural id -- `setup-order-extended-fields`, say -- failed closed
// with NO_EXECUTOR_FOR_REQUIREMENT and produced no receipt at all.
const EXTENDED_FIELD_STEP_ID = 'provision-extended-fields';
// A step provisions extended fields if it SAYS so -- by the conventional id, by declaring the
// kind, or by a requirement naming it. Matching one literal id meant a requirement scheduled
// under any other natural id (`setup-order-extended-fields`, say) was both reported as
// unscheduled AND unexecutable. The scheduling guard and the dispatcher share this predicate so
// they can never disagree about which steps count.
function stepProvisionsExtendedFields(step, declaredList) {
if (!step) return false;
if (step.id === EXTENDED_FIELD_STEP_ID) return true;
if (step.kind === 'extendedFieldSchema') return true;
return (declaredList || []).some((item) => item
&& item.kind === 'extendedFieldSchema'
&& (item.setupStepId === step.id || item.id === step.id));
}
async function isExtendedFieldStep(step, dir) {
const requirements = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const list = Array.isArray(requirements) ? requirements : requirements?.requirements;
return stepProvisionsExtendedFields(step, list);
}
// Blocker codes that name their own remedy. Everything else stays EXTENDED_FIELD_SCHEMA_BLOCKED.
const EXTENDED_FIELD_BLOCKERS = {
CONTACTS_V5_DES_UNVERIFIED: 'The Contacts V5 Data Extension Schema path is not verified on this site. Wait until it is verified, or explicitly route the mapping to contactWriteSurface "contacts-v4" (legacy), then re-run setup. Setup does not fall back automatically.',
EXTENDED_FIELD_SCHEMA_CONFLICT_EXHAUSTED: 'Another change kept updating this schema while setup ran. Re-run setup once the concurrent edits stop; nothing was overwritten.',
'schema-budget-exceeded': 'The namespace byte budget cannot hold these fields. Route the excess fields to a CMS collection instead of extended fields, then re-run setup.',
'contact-extended-field-mismatch': 'A legacy contact extended field on the site does not match what the migration needs (another type or namespace, or a key different from the one an earlier run verified). Wix keys cannot change, and approving a migration action cannot repair this. Decide with the site owner how to handle the existing field; do not create a second field for the same data. Then re-run setup.',
};
// Lower-case writer codes that get their own blocker code rather than the generic one.
const EXTENDED_FIELD_BLOCKER_CODES = {
'contact-extended-field-mismatch': 'CONTACT_EXTENDED_FIELD_MISMATCH',
};
function extendedFieldBlockerCode(code) {
if (EXTENDED_FIELD_BLOCKER_CODES[code]) return EXTENDED_FIELD_BLOCKER_CODES[code];
return EXTENDED_FIELD_BLOCKERS[code] && /^[A-Z0-9_]+$/.test(code) ? code : 'EXTENDED_FIELD_SCHEMA_BLOCKED';
}
async function provisionExtendedFieldsStep({ dir, wix, writers, dryRun, priorVerification = null }) {
const requirements = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const list = Array.isArray(requirements) ? requirements : requirements?.requirements;
const schemaRequirements = (list || []).filter((item) => item && item.kind === 'extendedFieldSchema');
if (schemaRequirements.length === 0) {
throw new Error('setup-requirements.json has no extendedFieldSchema requirement');
}
const verifications = [];
for (const requirement of schemaRequirements) {
// Routed by the requirement's own schemaKind (DES for orders/products/contacts-v5, CRM
// Extended Fields for contacts-v4). Older writer fixtures expose only the DES entry point.
const provision = writers.provisionExtendedFieldRequirement || writers.provisionExtendedFieldSchema;
// eslint-disable-next-line no-await-in-loop
const result = await provision(wix, { requirement, dryRun, priorVerification });
if (result.blocking.length > 0) {
const first = result.blocking[0];
const named = EXTENDED_FIELD_BLOCKERS[first.code];
const where = requirement.fqdn ? `${requirement.fqdn} ${requirement.namespace}` : `${requirement.schemaKind} ${requirement.contactWriteSurface || ''}`.trim();
throw Object.assign(new Error(`${where}: ${first.detail}`), {
blockerCode: extendedFieldBlockerCode(first.code),
recommendedAction: named || 'Resolve the schema conflict on the destination site, or approve the migration action explicitly, then re-run setup.',
});
}
if (result.verification) verifications.push(result.verification);
}
if (!dryRun) {
// The writer guard reads this: a value write is refused unless setup verified that field.
await publishAtomically(
path.join(dir, 'setup', 'extended-field-verification.json'),
`${JSON.stringify({ schemaVersion: 1, verifications }, null, 2)}\n`,
);
}
return verifications;
}
// The regional step reads the site, decides each setting, applies the ones the plan approved, reads
// them back and returns the receipt object recorded on its requirement row.
async function isRegionalStep(step, dir) {
const requirements = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const list = Array.isArray(requirements) ? requirements : requirements?.requirements;
return siteRegional.stepConfiguresSiteRegional(step, list);
}
// --- Harmony: any app through the editor's installation service ------------------------------
// The generic app installer leaves a Harmony site without the pages an app adds. On a Harmony site
// setup installs every app (and the companion apps its widgets need) through the editor's own
// installation service. See EDITOR_INSTALL_COMPANIONS in wix-writers.js for the companion table.
const EDITOR_INSTALL_RETRY_STATUS = new Set([409, 428, 503]);
const EDITOR_INSTALL_BUSY = /progress|busy|outdated|not ready/i;
const EDITOR_INSTALL_RETRIES = 3;
const EDITOR_INSTALL_DEADLINE_MS = 200_000; // the service's own time budget is 180 s
const EDITOR_READ_ATTEMPTS = 3;
const READBACK_POLLS = 8;
const READBACK_WAIT_MS = 10_000;
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function blockerError(blockerCode, message, recommendedAction) {
return Object.assign(new Error(message), { blockerCode, recommendedAction });
}
// The wix client has no deadline for writes; a call the service never answers must end the step
// with an uncertain outcome, not hang setup. The listing is read first on a re-run.
function withDeadline(promise, ms, onExpire) {
let timer;
const expiry = new Promise((_, reject) => { timer = setTimeout(() => reject(onExpire()), ms); });
return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
}
// The editor the destination step recorded for this project's site, or null. A record that names
// another site than the one being set up is a leftover and is not trusted.
async function recordedEditorType(dir, siteId) {
const record = await readJsonIfExists(path.join(dir, 'orchestration', 'destination.json')).catch(() => null);
if (!record || typeof record.observedEditorType !== 'string' || !record.observedEditorType) return null;
if (record.metaSiteId && siteId && record.metaSiteId !== siteId) return null;
return record.observedEditorType;
}
// Which editor the destination opens in: the site itself (a few reads, with short waits between
// them), then the destination step's record. Unknown from both blocks: guessing "not Harmony" would
// install the app the way that leaves a Harmony site without pages.
async function resolveEditorType(dir, wix, writers, stepId, sleep, siteId) {
if (typeof writers.getEditorType !== 'function') throw new Error(`${stepId}: the writer set has no getEditorType; the step cannot choose its install route`);
let cause = 'the answer named no editor';
for (let attempt = 1; attempt <= EDITOR_READ_ATTEMPTS; attempt += 1) {
try {
const editorType = await writers.getEditorType(wix);
if (editorType) return editorType;
} catch (error) {
if (error.blockerCode || wixAuth.isRunInterruption(error)) throw error;
cause = error.status ? `HTTP ${error.status}` : String(error.message || error).slice(0, 120);
// A refused or missing answer does not change on a second try.
if ([401, 403, 404].includes(error.status)) break;
}
if (attempt < EDITOR_READ_ATTEMPTS) await sleep(2000 * attempt);
}
const recorded = await recordedEditorType(dir, siteId);
if (recorded) return recorded;
throw blockerError('EDITOR_TYPE_UNKNOWN', `${stepId}: the site's editor type could not be read (${cause}) and the destination step recorded none`,
'Re-run setup. Apps are installed differently on a Harmony site, so setup does not guess the editor. If the read keeps failing, run the destination step so the site\'s editor is recorded, or check that the Wix CLI login can read the site.');
}
// One read of the installed-apps list (the client paces every read), tested locally.
async function listedApps(wix, writers, ids) {
const apps = await writers.getInstalledWixApps(wix);
const listed = new Set((apps || []).filter((app) => app && app.enabled !== false).map((app) => app.appDefId));
return new Set(ids.filter((id) => listed.has(id)));
}
// Setup cannot read a Harmony site's pages. This durable record (setup/harmony-app-pages.json) names
// the apps installed on the Harmony route whose pages were not read (`apps`), the apps the service said
// have no editor part (`noEditorPart`, so a re-run does not turn them into pages to confirm), and the
// apps it refused because another app already owns their page paths (`deferred`, left uninstalled for
// the owner to add later). It survives the per-run rewrite of the setup receipt, so the owner's items
// cannot vanish because a later setup run blocked. Nothing in the pipeline removes it.
const APP_PAGES_RECORD = path.join('setup', 'harmony-app-pages.json');
async function readAppPages(dir, siteId) {
// Only a missing file means "no record"; an unreadable one fails loudly instead of being rewritten.
const prior = await readJsonIfExists(path.join(dir, APP_PAGES_RECORD));
const same = Boolean(prior) && prior.siteId === siteId;
const list = (key) => (same && Array.isArray(prior[key]) ? prior[key] : []);
return { apps: list('apps'), noEditorPart: list('noEditorPart'), deferred: list('deferred') };
}
// `outcome` is where the app belongs: 'apps', 'noEditorPart' or 'deferred'. An app lives in one list only.
async function recordAppPages(dir, siteId, prior, { appDefId, stepId, outcome }) {
const has = (list) => list.some((row) => (row.appDefId || row) === appDefId);
const next = {
apps: prior.apps.filter((row) => row.appDefId !== appDefId),
noEditorPart: prior.noEditorPart.filter((id) => id !== appDefId),
deferred: prior.deferred.filter((row) => row.appDefId !== appDefId),
};
if (outcome === 'noEditorPart') next.noEditorPart.push(appDefId); else next[outcome].push({ appDefId, stepId });
if (has(prior[outcome]) && JSON.stringify(next) === JSON.stringify({ apps: prior.apps, noEditorPart: prior.noEditorPart, deferred: prior.deferred })) return;
await publishAtomically(path.join(dir, APP_PAGES_RECORD), `${JSON.stringify({ schemaVersion: 1, siteId, appPages: 'not-read', ...next }, null, 2)}\n`);
}
// Installs every missing app of `wanted` in one batch, then reads the listing back until all of
// `wanted` are listed and enabled. The listing lags the install, so an app the service already
// reported as installed is never sent again, whatever the listing says. Returns `sent` (the apps this
// run sent through the editor route and the service reported as installed), `noEditorPart` (apps
// the service says have no editor part; the caller installs those the ordinary way) and `deferred` (apps
// it refused because their page paths are taken by another app; they are left uninstalled).
async function installOnHarmony({ stepId, wanted, siteId, wix, writers, timing }) {
const sleep = timing.sleep || realSleep;
const deadlineMs = timing.installDeadlineMs ?? EDITOR_INSTALL_DEADLINE_MS;
const sent = new Set();
const noEditorPart = new Set();
const deferred = new Set();
const refuse = (detail) => blockerError('APP_INSTALL_NOT_VERIFIED', `${stepId}: the editor installation service did not install ${detail}`,
"Re-run setup, or add the app from the Harmony editor's app panel by hand and re-run setup.");
const uncertain = (why) => blockerError('APP_INSTALL_OUTCOME_UNCERTAIN', `${stepId}: ${why}, so the outcome is unknown`,
'Wait a minute, then re-run setup: it reads the installed-apps list first and sends only what is missing.');
for (let attempt = 0; ; attempt += 1) {
const listed = await listedApps(wix, writers, wanted);
const missing = wanted.filter((id) => !listed.has(id) && !sent.has(id) && !noEditorPart.has(id) && !deferred.has(id));
if (!missing.length) break;
// true = wait and go round again; false = out of retries.
const again = async () => {
if (attempt >= EDITOR_INSTALL_RETRIES) return false;
await sleep(5000 * (attempt + 1));
return true;
};
let reply;
try {
reply = await withDeadline(writers.installEditorApps(wix, { appDefIds: missing, siteId }), deadlineMs,
() => uncertain(`the editor installation service did not answer within ${Math.round(deadlineMs / 1000)} s`));
} catch (error) {
if (error.blockerCode || wixAuth.isRunInterruption(error)) throw error;
if (EDITOR_INSTALL_RETRY_STATUS.has(error.status) && await again()) continue;
// A gateway timeout, any other 5xx, or a reply nobody can classify may still have installed.
if (error.outcomeKnown === false || error.status >= 500) throw uncertain(`the install call failed with ${error.status || 'a transport error'}`);
throw refuse(`${missing.join(', ')}: ${String(error.message || error).slice(0, 300)}`);
}
if (!reply || !Array.isArray(reply.results)) throw refuse(`${missing.join(', ')}: the reply had no results list`);
const allFailures = reply.results.filter((row) => !row || row.success !== true)
.map((row) => ({ appDefId: row?.appDefId ?? null, error: String(row?.error ?? 'install reported no success') }));
// An app the service says has no editor part is not a failure: it adds no pages, and the caller
// installs it with the generic installer.
// An app the service refuses because another app owns its page paths is not a failure either: it is
// left uninstalled and reported, so setup goes on with the apps that did install.
const isNoEditorPart = (row) => Boolean(row.appDefId) && REAL_WRITERS.NO_EDITOR_PART_ERROR.test(row.error);
const isPathConflict = (row) => Boolean(row.appDefId) && REAL_WRITERS.PAGE_PATH_CONFLICT_ERROR.test(row.error);
for (const row of allFailures) {
if (isNoEditorPart(row)) noEditorPart.add(row.appDefId);
else if (isPathConflict(row)) deferred.add(row.appDefId);
}
const failures = allFailures.filter((row) => !isNoEditorPart(row) && !isPathConflict(row));
// An app counts as sent only when the reply has a success row naming it; a row without an app id,
// or an app with no row at all, is never read as success.
const okIds = new Set(reply.results.filter((row) => row && row.success === true && row.appDefId).map((row) => row.appDefId));
const failedIds = new Set(allFailures.map((row) => row.appDefId).filter(Boolean));
for (const id of missing) if (okIds.has(id)) sent.add(id);
// A failure row that names no app speaks for every app that has no row of its own.
const unaccounted = failures.some((row) => !row.appDefId) ? [] : missing.filter((id) => !okIds.has(id) && !failedIds.has(id));
if (failures.length || unaccounted.length) {
const busyOnly = failures.every((row) => EDITOR_INSTALL_BUSY.test(row.error)) && !unaccounted.length;
if (busyOnly && await again()) continue;
throw refuse([...failures.map((row) => `${row.appDefId || 'unnamed'}: ${row.error.slice(0, 200)}`), ...unaccounted.map((id) => `${id}: the reply had no row for it`)].join('; '));
}
break;
}
// The listing lags the install: poll before calling an app missing.
const toVerify = wanted.filter((id) => !noEditorPart.has(id) && !deferred.has(id));
let absent = toVerify;
for (let poll = 1; poll <= READBACK_POLLS; poll += 1) {
const listed = await listedApps(wix, writers, toVerify);
absent = toVerify.filter((id) => !listed.has(id));
if (!absent.length) break;
if (poll < READBACK_POLLS) await sleep(timing.readbackWaitMs ?? READBACK_WAIT_MS);
}
if (absent.length) {
throw blockerError('APP_INSTALL_NOT_VERIFIED', `${stepId}: the install call returned but ${absent.join(', ')} is not listed as installed and enabled on the site`,
"Re-run setup; if the apps are still missing from GET /apps-installer-service/v1/app-instances, add them from the Harmony editor's app panel and re-run setup.");
}
return { sent: [...sent], noEditorPart: [...noEditorPart], deferred: [...deferred] };
}
// Returns the check that actually ran: `{ method, observed }` (see SETUP_CHECK_METHODS in
// lib/setup-verification.js). The caller stamps `checkedAt` once it returns. An install with no
// read-back records `install-response` -- what was observed, never a claimed read.
// An install call that returns 200 proves nothing about the site: the step passes only when the
// installed-apps list read back afterwards names the app and it is enabled. An app that is
// already listed is a pass without a second install call.
async function installAndVerifyApp(stepId, appDefId, { dir, env, wix, writers, catalogSettle = {} }) {
const listed = async (id = appDefId) => (writers.isWixAppInstalled ? writers.isWixAppInstalled(wix, id) : false);
// Any app takes the editor route; `companions` are the extra apps a particular app's widgets need.
const companions = REAL_WRITERS.editorInstallCompanions(appDefId);
const installed = await listed();
let editorType;
try {
editorType = await resolveEditorType(dir, wix, writers, stepId, catalogSettle.sleep || realSleep, env.WIX_SITE_ID);
} catch (error) {
// An app that is already listed needs no install, so an unreadable editor does not block it; only
// the Harmony companion repair is skipped, and the record says the route was not determined.
if (error.blockerCode !== 'EDITOR_TYPE_UNKNOWN' || !installed) throw error;
editorType = null;
}
const harmony = editorType === HARMONY_EDITOR_TYPE;
// The ordinary install: one call, then the listing must name the app.
const genericInstall = async (id) => {
await writers.installWixApp(wix, { appDefId: id, siteId: env.WIX_SITE_ID });
if (!(await listed(id))) {
throw blockerError('APP_INSTALL_NOT_VERIFIED', `${stepId}: install call returned but app ${id} is not listed as installed and enabled on the site`,
'Re-run setup; if the app is still missing from GET /apps-installer-service/v1/app-instances, install it from the Wix dashboard (Add Apps) and re-run setup.');
}
};
// Harmony: the app and its companions in one batch (the service installs the app's own dependencies).
// An app the service says has no editor part is installed the ordinary way.
let sentViaEditor = [];
let noEditorPart = [];
let deferred = [];
const harmonyInstall = async () => {
const wanted = [...companions, appDefId];
const result = await installOnHarmony({ stepId, wanted, siteId: env.WIX_SITE_ID, wix, writers, timing: catalogSettle });
sentViaEditor = result.sent;
noEditorPart = result.noEditorPart;
deferred = result.deferred;
for (const id of noEditorPart) await genericInstall(id);
};
const install = harmony ? harmonyInstall : () => genericInstall(appDefId);
// On a Harmony site that already lists the app, only the missing companions are installed, and
// before the catalog baseline is read so the baseline sees the final catalog.
if (harmony && installed && companions.length) await harmonyInstall();
// Wix Stores adds sample products when it installs. The catalog is read around the install, the
// items it added are deleted, and the rest is recorded so no importer takes them over.
let catalog = {};
if (appDefId === catalogBaseline.STORES_APP_DEF_ID) {
let baseline, unchanged;
try {
({ baseline, unchanged } = await catalogBaseline.captureAroundInstall({ projectDir: dir, wix, writers, installed, install, siteOrigin: siteRegional.siteOriginFor(dir, env.WIX_SITE_ID), ...catalogSettle }));
} catch (error) {
if (error.blockerCode || wixAuth.isRunInterruption(error)) throw error;
throw Object.assign(new Error(`${stepId}: the Stores catalog could not be read around the install: ${error.message}`), {
blockerCode: 'CATALOG_BASELINE_UNREADABLE',
recommendedAction: 'Re-run setup. Import cannot start until setup has recorded which products were on the store before it.',
});
}
catalog = { catalogBaseline: { artifact: catalogBaseline.BASELINE_PATH, sha256: catalogBaseline.digest(dir), unchanged, ...catalogBaseline.summarize(baseline) } };
} else if (!installed) await install();
// An app the service says has no editor part (this run, or an earlier one on record) adds no pages,
// so none are unconfirmed. An app it refused over a page-path conflict is not installed: the step ends
// with `installed: false` (no gate reads that as an installed app) and the owner gets an item.
let viaEditor = false;
if (harmony) {
const prior = await readAppPages(dir, env.WIX_SITE_ID);
const isDeferred = deferred.includes(appDefId);
viaEditor = !isDeferred && !noEditorPart.includes(appDefId) && !prior.noEditorPart.includes(appDefId);
await recordAppPages(dir, env.WIX_SITE_ID, prior, { appDefId, stepId, outcome: isDeferred ? 'deferred' : viaEditor ? 'apps' : 'noEditorPart' });
if (isDeferred) return { method: 'installed-apps-read', observed: { appDefId, installed: false, route: 'editor-installation', deferred: 'page_path_conflict' } };
}
// `installCall` only when this run installed the app itself.
const ranInstall = harmony ? [...sentViaEditor, ...noEditorPart].includes(appDefId) : !installed;
const route = viaEditor ? { route: 'editor-installation', appPages: 'not-read' }
: harmony ? { route: 'app-installer', reason: 'no_editor_part' }
: editorType ? { route: 'app-installer', reason: `editor_${editorType}` } : { route: 'not-determined' };
return { method: 'installed-apps-read', observed: { appDefId, installed: true, ...(ranInstall ? { installCall: 'succeeded' } : {}), ...catalog, ...route } };
}
async function runStep(step, { dir, env, wix, writers, priorExtendedFieldVerification = null, catalogSettle }) {
if (await isRegionalStep(step, dir)) {
const requirements = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const list = Array.isArray(requirements) ? requirements : requirements?.requirements;
const requirement = (list || []).find((item) => item && item.kind === 'siteLocale') || {};
const regional = await siteRegional.executeRegionalStep({ projectDir: dir, wix, writers, siteId: env.WIX_SITE_ID, requirement });
// The receipt's fields are the read-back this step performed; the check records what was observed.
return { method: 'site-properties-read', observed: { fields: Object.fromEntries(Object.entries(regional.fields || {}).map(([field, row]) => [field, row.action])) }, regional };
}
if (step.id === 'mute-site-notifications') {
const state = await writers.muteSiteNotifications(wix, { reason: `RePlatform migration — ${path.basename(dir)}` });
if (!state?.muted) throw new Error('notification mute verification failed');
const read = await writers.getSiteMuteState(wix);
if (read?.muted !== true) throw new Error('notification mute verification failed: status read is not muted');
return { method: 'status-read', observed: { muted: true } };
}
if (step.id === 'start-back-in-stock-collection') {
await wix.send(writers.buildStartBackInStockCollectionRequest());
const read = await wix.send(writers.buildGetBackInStockSettingsRequest());
const stores = (read?.settings?.collectionStates || []).find((row) => row.appId === catalogBaseline.STORES_APP_DEF_ID);
if (stores?.collectingRequests !== true) throw new Error('back-in-stock collection verification failed: settings read does not show collecting for Wix Stores');
return { method: 'settings-read', observed: { collectingRequests: true } };
}
if (step.kind === 'installApp' && !appIdForStep(step)) {
throw blockerError('INSTALL_APP_ID_INVALID', `${step.id}: an installApp step needs "appDefId" set to the app's id (a GUID)`, 'Fix the step in the setup plan and re-run setup.');
}
const appDefId = appIdForStep(step);
if (appDefId) return installAndVerifyApp(step.id, appDefId, { dir, env, wix, writers, catalogSettle });
if (await isExtendedFieldStep(step, dir)) {
const verifications = await provisionExtendedFieldsStep({ dir, wix, writers, dryRun: false, priorVerification: priorExtendedFieldVerification });
return { method: 'extended-field-verification', observed: { verifications: verifications.length } };
}
if (step.id === 'create-cms-collections') {
return createCmsCollectionsStep({ dir, wix, writers });
}
if (step.id === 'stores-catalog-v3') {
const result = await wix.send(writers.buildGetStoresCatalogVersionRequest());
if (result.catalogVersion !== 'V3_CATALOG') {
throw Object.assign(new Error(`Catalog V3 required (got ${result.catalogVersion || 'unknown'})`), {
blockerCode: 'CATALOG_V1',
recommendedAction: 'Use a destination site provisioned with Catalog V3, or omit Stores from this migration.',
});
}
return { method: 'catalog-version-read', observed: { catalogVersion: result.catalogVersion } };
}
if (step.id === 'blog-fallback-author') {
return fallbackAuthor(dir, wix, writers);
}
if (step.intent?.writerId) {
const executor = writers.createWixSetupExecutor({ wixClient: wix });
await executor.executeSetupStep(step);
// A declared reference is verified through its registered read builder, once per setup target.
if (step.referenceKeys?.length) {
const read = writers[step.intent.readWriterId];
if (typeof read !== 'function' || !/^build(Get|Query)/.test(step.intent.readWriterId || '')) throw new Error('setup_reference_readback_missing');
await wix.send(read(...(step.intent.readArgs || [])));
return { method: 'reference-read-back', observed: { writerId: step.intent.writerId, readWriterId: step.intent.readWriterId } };
}
return { method: 'writer-response', observed: { writerId: step.intent.writerId } };
}
throw Object.assign(new Error(`No executor implements setup step "${step.id}"`), {
blockerCode: 'NO_EXECUTOR_FOR_REQUIREMENT',
recommendedAction: 'Add an executor for this step id in execute-setup.js, or remove it from the setup plan.',
});
}
async function regionalDryRunIntent(dir) {
const source = await require('../../../lib/source-regional.js').readSourceRegional(dir).catch(() => null);
return source ? siteRegional.regionalDryRunIntent(source) : [];
}
// A dry run cannot install. The route it expects comes from the destination step's record, never from a
// call to the editor: the live run reads the site again, so the intent says what its basis is. For
// Stores it also states the sample-product rule it would apply (and makes no delete call).
async function appDryRunIntent(dir, siteId, appDefId) {
const recorded = await recordedEditorType(dir, siteId);
const route = !recorded ? { route: 'decided-at-run-time', routeBasis: 'no destination record' }
: recorded === HARMONY_EDITOR_TYPE ? { route: 'editor-installation', routeBasis: 'destination record; the live run reads the site again' }
: { route: 'app-installer', routeBasis: 'destination record; the live run reads the site again' };
if (appDefId !== catalogBaseline.STORES_APP_DEF_ID) return route;
const prior = catalogBaseline.loadBaseline(dir);
return prior
? { catalogBaseline: 'recorded', wouldDelete: prior.products.concat(prior.categories).filter((row) => row.kind === 'install_seeded' && row.status !== 'deleted').map((row) => row.id), ...route }
: { catalogBaseline: 'read before and after the install; delete only items the install adds', wouldDelete: 'unknown until install', ...route };
}
async function writeBlockers(setupDir, generatedAt, blockers, replaces = null) {
await fs.writeFile(
path.join(setupDir, 'setup-blockers.json'),
`${JSON.stringify({ schemaVersion: 1, generatedAt, replaces, blockers }, null, 2)}\n`,
);
}
async function replacedReceipt(setupDir) {
let bytes;
try { bytes = await fs.readFile(path.join(setupDir, 'setup-verification.json')); } catch (error) {
if (error.code !== 'ENOENT') throw error;
try { return JSON.parse(await fs.readFile(path.join(setupDir, 'setup-blockers.json'), 'utf8')).replaces ?? null; } catch (error) { return error.code === 'ENOENT' ? null : { sha256: null, generatedAt: null }; }
}
let generatedAt = null;
try { generatedAt = JSON.parse(bytes).generatedAt ?? null; } catch { /* unreadable: no trusted time */ }
return { sha256: crypto.createHash('sha256').update(bytes).digest('hex'), generatedAt };
}
// Writes the final file via a temp-then-rename so a crash mid-write can never leave a
// corrupt or partial receipt where a trusted one is expected — the rename is atomic on the
// same filesystem/directory.
async function publishAtomically(filePath, contents) {
const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.writeFile(tmpPath, contents);
await fs.rename(tmpPath, filePath);
}
// Core logic, independent of the CLI entry point below so tests can inject a fake
// `writers` module instead of making real Wix API calls. Performs every file write itself
// (the receipt, the blockers file) and returns a summary rather than throwing on a
// recognized blocker — `main()` decides how to surface that to the process exit code.
// `catalogSettle` ({ sleep, delayMs }) only shortens the post-install catalog re-reads in tests.
async function executeSetup({ dir, dryRun = false, writers = REAL_WRITERS, catalogSettle = {} }) {
const env = await readEnv(dir);
if (!env.WIX_SITE_ID || !env.WIX_AUTH_TOKEN) throw new Error('WIX_SITE_ID and WIX_AUTH_TOKEN are required');
const setupDir = path.join(dir, 'setup');
const verificationPath = path.join(setupDir, 'setup-verification.json');
const plan = await readJsonIfExists(path.join(setupDir, 'setup-plan.json'));
const generatedAt = new Date().toISOString();
// The receipt this attempt replaces, carried through failed attempts in setup-blockers.json, so
// import-plan freshness can tell the approved setup's first receipt from a later re-run.
const replaces = await replacedReceipt(setupDir);
// Read BEFORE the receipt is invalidated below: the legacy contacts-v4 path must prove a field's
// key did not change since an earlier verified run (Wix keys cannot change). Only used for that
// comparison, never as evidence that anything is provisioned now.
// Only a missing file means "no earlier run". A receipt that exists but cannot be read or
// parsed may hold keys a legacy contacts-v4 write already used; treating it as absent would let
// Find Or Create accept a different key and split old and new values across two fields.
let priorExtendedFieldVerification = null;
let priorReceiptError = null;
try {
priorExtendedFieldVerification = await readJsonIfExists(path.join(setupDir, 'extended-field-verification.json'));
} catch (error) {
priorReceiptError = error;
}
if (priorReceiptError) {
const declaredForPrior = await readJsonIfExists(path.join(setupDir, 'setup-requirements.json'));
const declaredPriorList = Array.isArray(declaredForPrior) ? declaredForPrior : declaredForPrior?.requirements;
const needsPriorKeys = (declaredPriorList || []).some((item) => item && item.kind === 'extendedFieldSchema' && item.schemaKind === CONTACT_V4_SCHEMA_KIND);
if (needsPriorKeys) {
const blocker = {
code: 'PRIOR_EXTENDED_FIELD_RECEIPT_UNREADABLE',
severity: 'blocker',
requirementId: null,
description: `setup/extended-field-verification.json exists but could not be read: ${String(priorReceiptError.message).slice(0, 200)}`,
whyBlocked: 'the legacy contact extended-field keys an earlier run verified cannot be checked, so a changed key could split contact values across two fields',
recommendedAction: 'Restore setup/extended-field-verification.json from the earlier run (it was left in place). Remove it only if no earlier import wrote legacy contact extended-field values. Then re-run setup.',
};
// Leave the unreadable receipt where it is for recovery; only the setup receipt is invalidated.
if (!dryRun) {
await fs.rm(verificationPath, { force: true });
await writeBlockers(setupDir, generatedAt, [blocker], replaces);
}
return { ok: false, dryRun, requirements: [], blocker };
}
// DES-only runs never read prior keys, so an unreadable earlier receipt is simply replaced.
}
if (!dryRun) {
// Invalidate any prior receipt the instant a new live attempt begins — before we even
// know whether this attempt will succeed. A crash partway through this run (or a
// failure to reach the blocker-write below) must never leave an earlier, possibly
// no-longer-true success receipt sitting there looking trusted.
await fs.rm(verificationPath, { force: true });
// Same reasoning for the extended-field receipt: the writers consult it before putting a
// value in one of those fields, so a receipt from an earlier run must not outlive it.
await fs.rm(path.join(setupDir, 'extended-field-verification.json'), { force: true });
}
const planShape = validatePlanShape(plan, dir);
if (!planShape.ok) {
const blocker = {
code: 'INVALID_SETUP_PLAN',
severity: 'blocker',
requirementId: null,
description: `setup-plan.json is invalid: ${planShape.reasons.join(', ')}`,
whyBlocked: planShape.reasons.join(', '),
recommendedAction: 'Regenerate setup/setup-plan.json with a valid schemaVersion, a steps array, unique, non-empty step ids, and the entityRefs each named step writes (create-cms-collections lists cms/collection).',
};
if (dryRun) throw new Error(blocker.description);
await writeBlockers(setupDir, generatedAt, [blocker], replaces);
return { ok: false, dryRun: false, requirements: [], blocker };
}
const planDigest = hashArtifact(dir, path.join('setup', 'setup-plan.json'));
const setupRun = capabilities.startSetupRun({ projectDir: dir, siteId: env.WIX_SITE_ID, dryRun });
// A requirement nobody scheduled provisions nothing. The writers that need those fields then
// refuse their writes, which is safe but fails late and for a reason that points at the wrong
// place -- so the mismatch is caught here, where the plan and the requirements are both in hand.
const declared = await readJsonIfExists(path.join(dir, 'setup', 'setup-requirements.json'));
const declaredList = Array.isArray(declared) ? declared : declared?.requirements;
const needsExtendedFields = (declaredList || []).some((item) => item && item.kind === 'extendedFieldSchema');
const schedulesExtendedFields = ((plan && plan.steps) || []).some((step) => stepProvisionsExtendedFields(step, declaredList));
if (needsExtendedFields && !schedulesExtendedFields) {
const blocker = {
code: 'EXTENDED_FIELD_REQUIREMENT_UNSCHEDULED',
severity: 'blocker',
requirementId: null,
description: 'setup-requirements.json declares an extendedFieldSchema requirement but setup-plan.json has no provision-extended-fields step',
whyBlocked: 'the fields would never be provisioned, and every writer that needs them would refuse its write later in the run',
recommendedAction: 'Add a "provision-extended-fields" step to the setup plan, or remove the extendedFieldSchema requirement.',
};
// A dry run reports; it does not leave artifacts behind. Writing the blockers file here made
// `dryRun: true` mutate the project and report itself as a live run.
if (!dryRun) await writeBlockers(path.join(dir, 'setup'), new Date().toISOString(), [blocker], replaces);
return { ok: false, dryRun, requirements: [], blocker };
}
// Same pairing rule for the site regional settings: a requirement with no step changes nothing,
// and a step with no requirement has nothing to compare against.
const needsRegional = (declaredList || []).some((item) => item && item.kind === 'siteLocale');
const schedulesRegional = ((plan && plan.steps) || []).some((step) => siteRegional.stepConfiguresSiteRegional(step, declaredList));
if (needsRegional !== schedulesRegional) {
const blocker = {
code: 'SITE_REGIONAL_REQUIREMENT_UNSCHEDULED',
severity: 'blocker',
requirementId: null,
description: 'setup-requirements.json and setup-plan.json disagree about import-site-locale: it must be in both or in neither',
whyBlocked: 'a requirement with no step never changes the site, and a step with no requirement has no source values or entity lists to act on',
recommendedAction: 'Regenerate the setup plan and requirements together (rp-setup-discovery), or remove the stray one.',
};
if (!dryRun) await writeBlockers(path.join(dir, 'setup'), new Date().toISOString(), [blocker]);
return { ok: false, dryRun, requirements: [], blocker };
}
const requirements = [];
let blocker = null;
// One renewing token for every step: setup also runs long enough to outlive a token.
const tokenSource = dryRun ? null : wixAuth.createWixTokenSource({ projectDir: dir, siteId: env.WIX_SITE_ID });
for (const step of plan.steps) {
if (dryRun) {
const intent = siteRegional.stepConfiguresSiteRegional(step, declaredList) ? await regionalDryRunIntent(dir)
: appIdForStep(step) ? await appDryRunIntent(dir, env.WIX_SITE_ID, appIdForStep(step)) : null;
requirements.push({ id: step.id, status: 'planned_dry_run', ...(intent ? { intent } : {}) });
continue;
}
try {
const stepCapability = capabilities.bindSetupStep(setupRun, step.id);
const wix = writers.createWixClient({ capability: stepCapability, tokenSource });
const { method, observed, regional } = await runStep(step, { dir, env, wix, writers, priorExtendedFieldVerification, catalogSettle });
const verification = { method, checkedAt: new Date().toISOString(), observed };
const referenceBindings = step.referenceKeys?.length ? writers.captureSetupReferenceBindings(wix, step) : undefined;
requirements.push({ id: step.id, status: 'passed', verification, ...(referenceBindings ? { referenceBindings } : {}), ...(regional ? { regional } : {}) });
} catch (caught) {
// A step helper may wrap the interruption in its own error; the project's latch is the
// authority that this step stopped on an expired token.
const error = wixAuth.interruptionFor(dir) || caught;
requirements.push({ id: step.id, status: 'blocked', detail: error.message });
blocker = {
code: error.blockerCode || (wixAuth.isRunInterruption(error) ? wixAuth.WIX_AUTH_EXPIRED : 'UNEXPECTED_SETUP_ERROR'),
severity: 'blocker',
requirementId: step.id,
description: error.message,
whyBlocked: error.message,
recommendedAction: error.recommendedAction || (wixAuth.isRunInterruption(error) ? error.message : 'Investigate the underlying error and re-run setup once resolved.'),
...(error.awaitingOwner ? { awaitingOwner: true } : {}),
...(wixAuth.isRunInterruption(error) ? { reason: error.reason } : {}),
};
break; // Stop at the first failure — later steps were never attempted, not skipped.
}
}
if (dryRun) {
await fs.writeFile(
path.join(setupDir, 'setup-dry-run.json'),
`${JSON.stringify({ schemaVersion: 1, generatedAt, dryRun: true, requirements }, null, 2)}\n`,
);
return { ok: true, dryRun: true, requirements, blocker: null };
}
if (blocker) {
// The old receipt (if any) was already removed above, before this attempt began.
await writeBlockers(setupDir, generatedAt, [blocker], replaces);
return { ok: false, dryRun: false, requirements, blocker };
}
// Full success: every planned requirement passed. Self-check the candidate against the
// exact same validator the router uses before publishing it — there must be exactly one
// definition of "valid", shared, never re-implemented separately here.
const candidate = { schemaVersion: 1, generatedAt, siteId: env.WIX_SITE_ID, dryRun: false, planDigest, replaces, requirements };
const selfCheck = validateSetupVerification({ verification: candidate, plan, planDigest, siteId: env.WIX_SITE_ID, projectDir:dir });
if (!selfCheck.ok) {
const internalBlocker = {
code: 'INTERNAL_VALIDATION_FAILURE',
severity: 'blocker',
requirementId: null,
description: `execute-setup.js produced a receipt that failed its own validator: ${selfCheck.reasons.join(', ')}`,
whyBlocked: selfCheck.reasons.join(', '),
recommendedAction: 'This indicates a bug in execute-setup.js itself — investigate rather than retrying.',
};
await writeBlockers(setupDir, generatedAt, [internalBlocker], replaces);
return { ok: false, dryRun: false, requirements, blocker: internalBlocker };
}
await writeBlockers(setupDir, generatedAt, [], replaces);
await publishAtomically(verificationPath, `${JSON.stringify(candidate, null, 2)}\n`);
return { ok: true, dryRun: false, requirements, blocker: null };
}
async function main() {
const dir = process.argv[2] && path.resolve(process.argv[2]);
if (!dir) throw new Error('Usage: execute-setup.js <projectDir> [--dry-run]');
const dryRun = process.argv.includes('--dry-run');
const result = await executeSetup({ dir, dryRun });
if (result.blocker?.code === wixAuth.WIX_AUTH_EXPIRED) {
console.error(result.blocker.description);
process.exit(await wixAuth.stopExitCode(Object.assign(new wixAuth.WixAuthInterruption(result.blocker.reason), { siteId: (await readEnv(dir)).WIX_SITE_ID }), dir));
}
if (result.blocker) throw new Error(result.blocker.description);
console.log(JSON.stringify({ ok: true, dryRun }));
}
if (require.main === module) {
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
}
module.exports = { executeSetup, runStep };
Requirements and compatibility
Written for: Claude Code
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Names Claude Code tools, Needs an API key, Needs a command-line tool, Needs Node.js
Security
What our scanner noticed· Medium risk
- Possible data-exfiltration endpoints
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Wix
- Publisher
- Wix (Official)
- Source
- wix/skills · skills/wix-replatform/resources/rp-execute-setup · commit d168bc2
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Wix or Wix.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download rp-execute-setup.zip