Wix Replatform
WixOfficialPublished by Wix
- Skill
- Workflow
- Automation and integrations
- MIT
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
wix-replatform acts as a central supervisor for migrating data and content to Wix. It does not perform migration tasks directly but instead inspects migration artifacts to determine the next required stage in the pipeline. By maintaining a deterministic state, it ensures that migrations can be started, resumed, or recovered reliably without duplicating work or creating redundant destination sites. It manages the flow through various phases including discovery, mapping, setup, and import, while enforcing strict validation and approval gates.
What it does
- Routes migration tasks to appropriate sub-modules based on current project state.
- Ensures resumability by tracking migration progress through authoritative artifacts.
- Enforces mandatory approval gates before performing write operations.
- Dispatches specialized subagents for specific tasks to maintain context boundaries.
- Validates artifacts at each stage to prevent proceeding with corrupted or stale data.
- Handles reruns for incremental data updates without re-scaffolding the entire project.
When to use it
- When initiating a new migration from an external source to a Wix site.
- When resuming a migration that was interrupted or stopped.
- When recovering from a failed migration stage by addressing specific errors.
- When performing incremental imports to sync new or modified records after an initial migration.
Advantages
- Prevents data duplication by enforcing idempotent write operations.
- Maintains a clear audit trail through structured telemetry and completion reports.
- Reduces risk by requiring explicit approvals before critical setup or import steps.
- Supports both standard interactive flows and automated 1-click execution modes.
Usage examples
- “Start a new migration for my project in ./migrations/my-store.”
- The agent resolves the project directory, initializes orchestration artifacts, and routes to the project intake module.
- “Resume the migration in ./migrations/my-store.”
- The agent inspects the existing artifacts, determines the last completed stage, and dispatches the next required module.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/wix/wix-replatform-fya3/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip wix-replatform.zip -d .agents/skills
Documentation by Wix
wix-replatform
wix-replatform is the compact supervisor for a resumable source-to-Wix migration. It
selects one internal module for the active stage; it does not carry every stage procedure
in its own context.
Always-active contract
Resolve the active project under
migrations/<project>/(orREPLATFORM_MIGRATIONS_DIR) before inspecting or creating anything. Only that project's artifacts are authoritative.Preserve
EXPECTATIONS.mdat that project root. It is the user's free-form record of how they expected the run to behave; initialization creates it once and never overwrites it.Start/resume
resources/rp-telemetry/at the beginning of every run and finalize it at a terminal state. Never hand-write telemetry events.Use orchestration JSON plus validated stage artifacts as resume authority. Logs and chat history are diagnostic context only.
Preserve user data: write idempotently, dedupe by source identity, resume rather than restart, and never create a second destination site for a migration.
Never print secrets, secret-bearing config contents, tokens, or credentials. Report only key names and present/blank/missing status.
Do not declare a migration complete from prose or a single artifact. The deterministic router and aggregate completion artifact own terminal completion.
Preserve the owner's cutover handoff: unresolved data, applicable manual setup, missing apps and changed plugin behavior. Reconcile discovery, mapping decisions, setup receipts and final outcomes; imported records alone do not establish working functionality. The producing resources own this guidance and durable completion/handoff artifacts. Use a supported cutover publisher when available; report missing publication integration explicitly rather than claiming the application received local artifacts.
CLI-launched runs and human actions
When a migration is started through replatform, the terminal is deliberately
non-interactive: do not use an agent question UI, ask for stdin, or wait for a person in the
current agent turn. The runner owns process lifecycle and emits only observed events; it does
not replace this skill's router or completion receipts.
When a human choice, review, approval, or manual prerequisite is needed, create a durable
request with node skills/wix-replatform/scripts/user-action.js create --project <projectDir> --request-file <request.json>, then end the turn. Put only reviewed artifact paths in the
request. The person can later use replatform requests, replatform show, and
replatform answer; a resumed agent reads the answered request from
orchestration/user-actions/, never from reconstructed chat context.
Cancellation is not completion. If the runner has recorded cancel_requested, canceled, interrupted or an explicit stop (blocked,
transition --status blocked --resume-from '<json>'; routes run_blocked until explicit resume or --status running, never answer --continue), preserve partial/recovery
artifacts and let a later explicit replatform resume route recovery. Never create aggregate migration completion from an aborted execution report.
Pipeline
intake → destination → source inputs → preflight → (standard: discovery → mapping → codegen;
quick: adapter preflight → quick plan) → setup discovery → website handoff
→ safety review/approval → setup → import
→ storefront continuation (website only) → aggregate finalization → Migration reviewDelivery modes are management (backend only), website (frontend only), and
management_and_website (the integrated flow above). Accept user input both as
management_and_website and persist the canonical value.
For deliveryMode=management, aggregate finalization requires valid backend import
accounting. For deliveryMode=management_and_website, it additionally requires a current
website/completion.json with completed screenshot review, no unresolved critical/high
gaps, and release evidence. execution/completion-report.json is backend accounting,
never website-migration completion by itself.
Route before acting
On every new or resumed turn:
Resolve the project and initialize/load its orchestration artifacts.
Run
node skills/wix-replatform/scripts/orchestration-route.js <projectDir>.Dispatch the
nextResourceto a Developer subagent instead of loading and following it yourself. Give it the project directory and the resource to run; it follows that resource's contract exactly as written, unchanged. Read back only its result — never its transcript, tool output, or logs.Every dispatch (Developer or QA) is a fresh subagent, never a resumed one; pass only a handoff file and stay within a context budget — follow
resources/rp-subagent-dispatch/. Never readstate/attempts/write-attempts.ndjsonraw; usescripts/write-log-summary.js.A Developer may fan out to sub-subagents only for harmless read-only work with no shared state, and only if the host supports it; writes stay one at a time, as each resource says.
Dispatch a QA subagent to check the Developer's result against that resource's own stated required outputs (its own "artifacts to create or update" or "## Output" section, and any review/gap documents it defines). QA is read-only — it never writes or calls Wix. If something's missing, dispatch a new Developer for the same resource with QA's findings; otherwise the stage is done.
At the end of each pipeline phase (discovery, mapping, setup discovery, import, and so on — see the pipeline above; a phase spans several resource-level stages), dispatch a QA subagent to review that phase's documents as a whole (for example
mapping/review/mapping-summary.mdandmapping-gaps.json, orexecution/completion-report.json) before routing to the next phase. This catches anything a single per-task check could miss. For the import phase specifically, this review must be entity-by-entity against the accepted mapping plan, not a skim of the top-level status string — seerp-execute-import's "Entity-by-entity QA" section. Any run-fixing dispatch mid-phase (a crash fix, a slowness fix, a resumed pass) does not itself satisfy this; run the phase-level QA again once the fix lands, since fixing one entity's defect certifies nothing about its neighbors. Mapping QA also checks exact source coverage, decided operation/path semantics and canonical projections. Codegen QA reads the Developer's independently expected synthetic request evidence and its freshness; it does not execute examples. Semantic defects return to mapper, implementation defects to codegen. One-click tests defer unresolved scope and finish independent work with a typed incomplete result, without questions or repeated redispatch; customer runs retain their existing checkpoints.Route again after every stage boundary. Stale requests use
resources/rp-user-action-recovery/; unreadable request artifacts block until repaired. Never discard pending requests or decide the next stage from memory.
The router (lib/orchestration-router.js) is the only routing and resume authority. It
recomputes the route from decisions, approvals, preflight, run.json status and the phase
outputs on every call; run.json.currentState is diagnostic only, never a routing input.
A recorded rejection stops the route in every mode, one-click included:
execution_approval_rejectedisblockedbefore any setup or import write, andmapping_approval_rejectedreturns torp-mapperfor a new plan.A gated output that exists but is unusable (
source_schema_invalid,mapping_plan_invalid,setup_plan_invalid,setup_requirements_invalid,execution_manifest_invalid) isblockedwitherrorsand the producer asnextResource. Do not re-run the producer automatically; report the errors to the Import Operator and stop.The import routes (
missing_completion_report,import_interrupted= a stopped run's aborted report,quick_mode_import_required) carryimportProgress.interruptedlistsrecoveryArtifacts: resume it, never a second import.interruptedReport: "missing": first runscripts/interrupted-completion-report.js.
If an artifact is malformed, partial, stale, or fails its validator, treat its producing stage as incomplete and route to the producer. Do not make parallel replacement artifacts.
A user action whose reviewed artifacts changed after it was created is refused with
user_action_stale and stays pending. Create a new request from the current artifacts.
Catching up a finished migration is a rerun, not a new project. When the user asks to pull
in records that arrived or changed since the import — "the source kept selling", "these orders
are missing", "re-run it" — reuse the same project, destination, mapping, generated importer
and crosswalk, and route to rp-execute-import → "Rerunning an import for new and modified
records". Do not scaffold a second project or re-approve an unchanged mapping; a
mapping or scope change does go back through the existing gates. A rerun's revision conflicts
are user decisions even in 1-click mode.
Demand-loaded module map
| Router situation | Dispatch |
|---|---|
| project, delivery mode, source mode, or grouped intake is unresolved | resources/rp-project-intake/ |
| destination is absent, conflicting, or needs creation/adoption | resources/rp-destination/ |
| source URL/files/config/acquisition mode/credentials are required, including source credentials said to exist already on another site | resources/rp-source-inputs/ |
| deterministic preflight is missing, repaired, or blocked on CLI checks | resources/rp-preflight/ |
| quick mode needs platform resolution, endpoint preflight, plan, extraction, or import | selected resources/rp-quick-<platform>/ |
| source schema is missing | resources/rp-discovery/ and its selected source adapter |
| mapping is missing or needs review | resources/rp-mapper/ |
| Wix requirements are missing | resources/rp-setup-discovery/ |
| import code/review is missing or stale | resources/rp-import-codegen/ |
| setup verification is missing | resources/rp-execute-setup/ |
| backend completion report is missing | resources/rp-execute-import/ |
| frontend-only work, or integrated website handoff/build/gap loop/release receipt is incomplete | resources/rp-website-continuation/ |
| aggregate completion is missing | resources/rp-execution-policy/ |
| aggregate completion exists but the local review publication or current recap is missing | resources/rp-migration-review/ |
The resource named by the route is the complete stage contract, dispatched as described in "Route before acting" — the main session itself never loads it directly. Only the dispatched Developer loads source-file, secret, scaffolding, or storefront procedures, and only for the stage that actually needs them; nothing about a stage where they're irrelevant is loaded, by the main session or the Developer.
Interaction policy
Normal mode asks only for a missing required input, a genuinely ambiguous active project, or a mandatory write approval. Ask one question at a time unless a module says otherwise.
When the user says add to expectations: <note> (or an unambiguous natural-language
equivalent), append the note to the active project's EXPECTATIONS.md before continuing.
Use scripts/expectations-ledger.js so the entry retains the supplied statement plus safe
current context (stage/state, relevant project-relative artifacts, and known entity/field
labels). Do not treat this as approval or silently change a mapping. Never record secrets,
tokens, raw customer data, or raw source records; redact them if supplied and say so.
Before writes, preserve the mandatory execution-plan approval gate. Read-only discovery, mapping, setup verification, code generation, and review happen before it.
Quick management import mode
When managementImportMode=quick, select the platform adapter only after the deterministic
source probe identifies a compatible platform. Quick mode is a bounded public-data import:
the adapter's versioned quick-mode.json owns its source routes, entity graph, field matrix,
and exclusions. Do not run agentic discovery, mapping, or project-specific code generation for
the quick backend branch. Still run destination setup, application installation, Catalog-V3 and
notification safety checks, execution approval, shared write runtime, crosswalks, and completion
accounting. If no adapter qualifies, persist the unsupported-mode result and ask the user to
select standard mode; never silently fall back.
Halt only for missing/invalid required input, a manual action with no API, or a systemic failure/data-loss risk. A halt must persist the code, evidence, owner, and exact unblock action; never silently stop.
1-click mode
When explicitly requested by the user, record automationMode=one_click with
source=user and apply the following. A missing mode or a one_click value recorded as
deterministic_inference/prior_artifact is normal interactive mode and must not skip a
checkpoint:
collect all still-missing intake values in one grouped request; then do not pause for routine approvals;
record mapping and execution approvals as
approved,decidedBy=agent; skipping a pause without state is invalid;for CSV/file-only runs, validate and accept the required sample preview as the agent; without explicit user-authored one-click, pause for the normal mapping review, sample preview validation, and execution approval;
default to a new isolated site unless an existing destination is explicitly supplied or already pinned, but never default the new site's type (Headless, Studio or Harmony): ask it in the grouped intake; choose website scope deterministically from discovered route families;
retry PATH/tool-resolution failures under the operator's compatible interactive login shell; request required browser/network sandbox escalation and retry before calling it blocked;
retry the obvious recovery before stopping: aligned shell first for PATH/tool failures, then the required escalation retry for sandboxed browser or network/API failures;
continue through the complete selected deliverable. In
management_and_websitemode that includes frontend build, post-build gap analysis, bounded fix loop, and release receipt; when the user selected facelift at intake, it also includes the separate post-clone facelift review;before yielding, re-route. A 1-click run is only
completedwithcompletion/migration-completion.json, orblockedwith a typed durable blocker.
Use the operator's compatible local Node/Yarn shell when recovering from a PATH or tool resolution failure. Machine-specific shell paths belong only in local, git-excluded agent instructions.
Repo-local interactive shell contract: a PATH/tool-resolution failure on first attempt may
mean the command ran under a plain, non-interactive shell that never sourced the operator's
Node/Yarn profile. Resolve the operator's compatible login shell from the local environment or
git-excluded agent instructions, verify that it is executable, and retry the exact same command
through that shell in interactive login mode so the profile that resolves node/yarn/npx
loads. Do not hardcode a machine-specific shell path in this public skill. Only escalate to a
durable blocker if the compatible-shell retry itself fails.
Guardrails
Never bypass a validator, approval-state update, verified notification mute, or Catalog-V3 gate to make a stage appear finished.
websitemeans frontend only;management_and_websitemeans the integrated backend and frontend migration. Never hand the integrated migration destination to the frontend skill or create a second frontend site in that mode.A Studio or Harmony destination (
targetSiteType) is created and verified byrp-destination's coordinator and is never published, unpublished or handed towix-headless-replatform; its website and publication remain operator handoff items.Do not implement a storefront manually when
wix-headless-replatformis unavailable; persist a blocker with the exact required runtime capability.In management mode, still generate
website/handoff.*after setup discovery so a later storefront can start without repeating backend discovery.
Files
- .publish-manifest.json210 B
- ATTRIBUTION.mdAdded by AutoGineer815 B
- CLI.md9.8 KiB
- LICENSELicence file added by AutoGineer1.0 KiB
- README.md11.9 KiB
- SKILL.md16.4 KiB
- VERSION6 B
- console/account.css633 B
- console/app.css5.2 KiB
- console/app.jsScript22.9 KiB
- console/index.html837 B
- console/json.css639 B
- lib/agent-adapters/index.jsScript2.9 KiB
- lib/app-page-routing.jsScript4.3 KiB
- lib/app-requirements.jsScript1.5 KiB
- lib/artifact-freshness.jsScript12.7 KiB
- lib/blocked-data-requests.jsScript21.4 KiB
- lib/cached-json.jsScript1.3 KiB
- lib/catalog-baseline.jsScript23.3 KiB
- lib/census-reconciliation.jsScript22.8 KiB
- lib/cms-collection-compatibility.jsScript2.0 KiB
- lib/completion-report.jsScript124.6 KiB
- lib/config-env.jsScript6.1 KiB
- lib/confirmed-pictures.jsScript6.9 KiB
- lib/crosswalk-adoption.jsScript6.3 KiB
- lib/dependency-proofs.jsScript46.7 KiB
- lib/dependency-readiness.jsScript112.3 KiB
- lib/dependency-recovery.jsScript1.5 KiB
- lib/dependency-waves.jsScript2.1 KiB
- lib/destination-choice.jsScript9.7 KiB
- lib/destination-handoff.jsScript12.5 KiB
- lib/destination-http.jsScript2.7 KiB
- lib/destination-route.jsScript5.1 KiB
- lib/destination.jsScript57.1 KiB
- lib/discovered-census.jsScript11.0 KiB
- lib/discovered-set-status.jsScript15.2 KiB
- lib/document-url-guard.jsScript12.7 KiB
- lib/editor-type.jsScript748 B
- lib/egress-proxy.jsScript20.8 KiB
- lib/entity-qa-attestation.jsScript19.4 KiB
- lib/entity-verification.jsScript1.3 KiB
- lib/execution-capability.jsScript7.3 KiB
- lib/execution-log.jsScript6.4 KiB
- lib/execution-manifest.jsScript10.2 KiB
- lib/execution-state.jsScript5.9 KiB
- lib/expectations-ledger.jsScript3.4 KiB
- lib/extract-ledger.jsScript21.4 KiB
- lib/field-mapping-examples.jsScript7.3 KiB
- lib/generated-plugin-handoff.jsScript8.7 KiB
- lib/import-interruption.jsScript13.6 KiB
- lib/import-readiness-contract.jsScript4.3 KiB
- lib/import-recovery.jsScript54.0 KiB
- lib/import-task-runner.jsScript18.1 KiB
- lib/imported-redirect-targets.jsScript32.5 KiB
- lib/internal-link-reconciliation.jsScript10.7 KiB
- lib/link-audit.jsScript11.1 KiB
- lib/link-census.jsScript11.0 KiB
- lib/link-plan.jsScript9.4 KiB
- lib/link-verification.jsScript18.7 KiB
- lib/link-waivers.jsScript5.8 KiB
- lib/link-write-journal.jsScript1.8 KiB
- lib/local-state.js66.8 KiB
- lib/mapping-field-contract.js54.1 KiB
- lib/mapping-omission-approval.js7.6 KiB
- lib/mapping-recovery.js22.4 KiB
- lib/mapping-resolution.js6.1 KiB
- lib/mapping-target-rules.js7.4 KiB
- lib/member-completion.js31.6 KiB
- lib/member-crosswalk.js2.9 KiB
- lib/member-outcome-registry.js13.6 KiB
- lib/migration-completion.js18.3 KiB
- lib/migration-review-mapping.js21.4 KiB
- lib/migration-review.js73.7 KiB
- lib/multi-step-attempt.js4.4 KiB
- lib/multilingual-public-report.js14.5 KiB
- lib/multilingual-public.js26.3 KiB
- lib/notification-mute.js9.9 KiB
- lib/operation-assessments.js9.8 KiB
- lib/operator-identity.js1.2 KiB
- lib/orchestration-decisions.js8.7 KiB
- lib/orchestration-preflight.js25.1 KiB
- lib/orchestration-router.js50.8 KiB
- lib/orchestration-state.js30.9 KiB
- lib/order-reference-readiness.js2.8 KiB
- lib/output-applicability.js5.0 KiB
- lib/payments-outcome-registry.js9.8 KiB
- lib/plan-order-outcome-registry.js13.1 KiB
- lib/plugin-cms-fallback.js35.1 KiB
- lib/preservation-approval-stamp.js2.8 KiB
- lib/product-media-deferral.js3.8 KiB
- lib/product-videos.js7.1 KiB
- lib/progress-log.js6.6 KiB
- lib/progress-log.sh4.1 KiB
- lib/raw-request-guard.js18.5 KiB
- lib/read-back-approval.js16.1 KiB
- lib/read-back-comparison.js10.5 KiB
- lib/read-back-contract.js37.4 KiB
- lib/read-back-extract.js4.0 KiB
- lib/read-back-gate.js46.6 KiB
- lib/read-back-reads.js2.9 KiB
- lib/read-back-state.js3.4 KiB
- lib/read-back-summary.js6.5 KiB
- lib/rerun-conflicts.js14.5 KiB
- lib/run-controller.js26.3 KiB
- lib/seo-outcome-registry.js3.2 KiB
- lib/setup-producers.js1.3 KiB
- lib/setup-verification.js11.6 KiB
- lib/site-regional-gate.js3.3 KiB
- lib/site-regional-report.js10.3 KiB
- lib/site-regional.js37.0 KiB
- lib/source-access.js11.2 KiB
- lib/source-coverage.js54.0 KiB
- lib/source-location.js10.6 KiB
- lib/source-redirect-capture.js2.6 KiB
- lib/source-regional.js15.5 KiB
- lib/source-secrets.js8.1 KiB
- lib/structured-link-patch.js3.1 KiB
- lib/subagent-handoff.js8.9 KiB
- lib/target-import-support.js18.4 KiB
- lib/task-state.js6.2 KiB
- lib/toolkit-dependencies.js947 B
- lib/url-preservation-report.js20.4 KiB
- lib/url-preservation-state.js27.0 KiB
- lib/url-redirect-plan.js36.3 KiB
- lib/url-verification.js15.5 KiB
- lib/user-action-effects.js2.9 KiB
- lib/user-actions.js18.4 KiB
- lib/vendor-closure.js5.6 KiB
- lib/website-handoff.js60.6 KiB
- lib/wix-auth.js11.0 KiB
- lib/wix-client-callers.js6.7 KiB
- lib/wix-dashboard-url.js1.0 KiB
- lib/write-log-summary.js9.4 KiB
- lib/write-verification-state.js7.9 KiB
- lib/write-verification.js30.0 KiB
- scripts/artifact-freshness.js2.3 KiB
- scripts/census-reconciliation.js781 B
- scripts/cms-fallback.js3.2 KiB
- scripts/cms-item-quota.js3.9 KiB
- scripts/crosswalk-adopt.js3.0 KiB
- scripts/dependency-preflight.js2.2 KiB
- scripts/discovered-census.js867 B
- scripts/discovered-set-status.js1.1 KiB
- scripts/entity-qa-attestation.js4.8 KiB
- scripts/execution-health-check.js19.9 KiB
- scripts/execution-manifest-validate.js1.7 KiB
- scripts/execution-state-prepare.js3.9 KiB
- scripts/expectations-ledger.js1.5 KiB
- scripts/extract-ledger.js1.9 KiB
- scripts/import-contract-verify.js20.4 KiB
- scripts/import-supervise.js16.1 KiB
- scripts/internal-link-audit.js6.5 KiB
- scripts/interrupted-completion-report.js2.7 KiB
- scripts/item-seo-tags-verify.js8.2 KiB
- scripts/link-waiver.js3.8 KiB
- scripts/migration-completion.js3.8 KiB
- scripts/migration-review.js3.7 KiB
- scripts/orchestration-preflight.js3.5 KiB
- scripts/orchestration-route.js3.7 KiB
- scripts/orchestration-state.js18.9 KiB
- scripts/read-back-gate.js11.3 KiB
- scripts/replatform-console.js12.9 KiB
- scripts/replatform.js35.4 KiB
- scripts/site-regional.js2.4 KiB
- scripts/source-secrets.js2.9 KiB
- scripts/subagent-handoff.js2.5 KiB
- scripts/url-redirects.js13.0 KiB
- scripts/user-action.js2.9 KiB
- scripts/vendor-closure.js1.0 KiB
- scripts/website-handoff-generate.js2.3 KiB
- scripts/wix-dashboard-url.js1.6 KiB
- scripts/wix-destination.js6.4 KiB
- scripts/wix-env.js1.5 KiB
- scripts/wix-headless-scaffold.js34.8 KiB
- scripts/write-log-summary.js2.2 KiB
- vendor/README.md604 B
- vendor/commander/LICENSE1.1 KiB
- vendor/commander/index.js711 B
- vendor/commander/lib/argument.js3.1 KiB
- vendor/commander/lib/command.js85.6 KiB
- vendor/commander/lib/error.js1.1 KiB
- vendor/commander/lib/help.js20.3 KiB
- vendor/commander/lib/option.js10.0 KiB
- vendor/commander/lib/suggestSimilar.js2.7 KiB
- vendor/commander/package.json1.6 KiB
lib/destination.js
'use strict';
// The deterministic destination coordinator. It creates at most one new
// site per project, persists the returned identifier before anything else, verifies the
// actual editor and that a new Editor site is unpublished, and turns every uncertain or
// failed outcome into a durable receipt plus one recovery request. It never publishes,
// unpublishes or deletes a site, and never retries a create whose outcome is unknown.
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const { readEnvFile, upsertEnvFile } = require('./config-env.js');
const { CallTimeoutError, boundedRequest, isTransientStatus, retryAfterMs } = require('./destination-http.js');
const { editorTypeFromUrls, HARMONY_EDITOR_TYPE } = require('./editor-type.js');
const {
getDecisionValue,
isEditorSiteType,
isNewSiteStrategy,
readSiteTypeDecision,
} = require('./orchestration-decisions.js');
const { loadArtifacts, writeJsonAtomic } = require('./orchestration-state.js');
const { createUserAction, readUserAction, listUserActions } = require('./user-actions.js');
const { editorWebsiteRequestInput, siteTypeRequestInput, KNOWN_BUSINESS_TYPES } = require('./destination-choice.js');
const RECEIPT_SCHEMA_VERSION = 1;
const RECEIPT_STATUSES = Object.freeze(['in_progress', 'created', 'verified', 'failed_before_create', 'ambiguous', 'blocked']);
const BLANK_TEMPLATES = Object.freeze({
studio: 'fe86a14e-ef67-49b4-a409-d086f3abaa1a',
harmony: '91532250-8835-46ac-9666-e4db8770b275',
});
// "Developer Tools" gives a site its Secrets Manager and the `secrets` object. Headless sites
// come with it; Harmony and Studio sites do not.
const DEVELOPER_TOOLS_APP_DEF_ID = '4b10fcce-732d-4be3-9d46-801d271acda9';
const EXPECTED_EDITOR = Object.freeze({ studio: 'WIX_STUDIO', harmony: HARMONY_EDITOR_TYPE, headless: 'EDITORLESS' });
const URLS = Object.freeze({
createFromTemplate: 'https://www.wixapis.com/msm/v1/meta-site/create-from-template',
editorUrls: 'https://www.wixapis.com/editor-urls',
installApp: 'https://www.wixapis.com/apps-installer-service/v1/app-instance/install',
installedApps: 'https://www.wixapis.com/apps-installer-service/v1/app-instances',
publishedSiteUrls: 'https://www.wixapis.com/urls-server/v2/published-site-urls',
});
// Per-call timeouts, polling bounds and publication readback bounds.
const LIMITS = Object.freeze({
createTimeoutMs: 60_000,
editorTimeoutMs: 20_000,
installTimeoutMs: 30_000,
publicationTimeoutMs: 15_000,
publicationAttempts: 3,
publicationWaitsMs: [2_000, 8_000],
publicationRetryAfterCapMs: 30_000,
publicationBudgetMs: 90_000,
publicationMinLeftMs: 1_000,
});
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const IMPORT_OPERATOR_PROMPT = 'Requires Import Operator. In the original migration workspace, reconcile the destination creation attempt against the intended account, preserve the returned metasite ID, rerun editor verification and the published-URLs readback, and resume only after recording the resolved receipt. Do not create a replacement site, and do not publish or unpublish it without an explicit human publication decision.';
function receiptPath(projectDir) { return path.join(projectDir, 'orchestration', 'destination.json'); }
function lockPath(projectDir) { return path.join(projectDir, 'orchestration', 'destination.lock'); }
function wixEnvPath(projectDir) { return path.join(projectDir, 'config', 'wix.env'); }
async function readReceipt(projectDir) {
try {
return JSON.parse(await fs.readFile(receiptPath(projectDir), 'utf8'));
} catch (error) {
if (error && error.code === 'ENOENT') return null;
if (error instanceof SyntaxError) return { invalid: true, error: error.message };
throw error;
}
}
async function readWixEnv(projectDir) {
try {
return await readEnvFile(wixEnvPath(projectDir));
} catch (error) {
if (error && error.code === 'ENOENT') return {};
throw error;
}
}
function validateReceipt(receipt) {
const errors = [];
if (!receipt || typeof receipt !== 'object' || receipt.invalid) return ['orchestration/destination.json is not valid JSON'];
if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) errors.push(`destination receipt schemaVersion must be ${RECEIPT_SCHEMA_VERSION}`);
if (!RECEIPT_STATUSES.includes(receipt.status)) errors.push(`destination receipt status must be one of ${RECEIPT_STATUSES.join(', ')}`);
if (!['headless', 'studio', 'harmony'].includes(receipt.requestedSiteType)) errors.push('destination receipt requestedSiteType is invalid');
if (receipt.metaSiteId != null && !UUID_RE.test(String(receipt.metaSiteId))) errors.push('destination receipt metaSiteId is not a metasite id');
return errors;
}
// 4–20 chars of [a-z0-9_-], ending in a letter, digit or underscore.
function normalizeSiteName(value, { fallback = 'replatform-site', suffix = '' } = {}) {
const clean = (input) => String(input || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9_-]+/g, '-').replace(/-+/g, '-').replace(/^[-_]+/, '');
let base = clean(value);
const tail = suffix ? `-${clean(suffix).replace(/[-_]+$/, '')}` : '';
base = base.slice(0, 20 - tail.length).replace(/-+$/, '');
let name = `${base}${tail}`;
if (name.length < 4) name = `${clean(fallback)}${tail}`.slice(0, 20);
name = name.replace(/-+$/, '');
while (name.length < 4) name += '0';
return name;
}
function identityRef(identity) {
return identity ? `sha256:${crypto.createHash('sha256').update(String(identity)).digest('hex').slice(0, 16)}` : null;
}
function isoAt(clock) { return new Date(clock.now()).toISOString(); }
// --- lock -------------------------------------------------------------------------------
function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch (error) { return error && error.code === 'EPERM'; }
}
async function readRaw(filePath) {
try { return await fs.readFile(filePath, 'utf8'); } catch (error) { if (error && error.code === 'ENOENT') return null; throw error; }
}
function lockHolderAlive(raw) {
let holder = null;
try { holder = JSON.parse(raw); } catch { holder = null; }
return Boolean(holder && pidAlive(holder.pid));
}
// Creates `filePath` with its full content in one atomic step (hard link of a private temp
// file), so no contender ever observes an empty or half-written lock. Returns false on EEXIST.
async function createExclusive(filePath, content) {
const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temp, content, { flag: 'wx' });
try {
await fs.link(temp, filePath);
return true;
} catch (error) {
if (error && error.code === 'EEXIST') return false;
throw error;
} finally {
await fs.rm(temp, { force: true });
}
}
// Project-local exclusive lock. Every lock carries a unique owner token. A lock left by
// a dead process is reclaimed only under a second exclusive "reclaim" lock, and only if the
// lock file still holds the exact stale content this contender read, so two contenders can
// never both reclaim (or one delete the other's live lock). Release removes the lock only
// while it still holds this owner's token. A live holder, or a reclaim lock left by a dead
// process, makes this invocation stop without touching anything.
async function acquireLock(projectDir) {
const filePath = lockPath(projectDir);
const reclaimPath = `${filePath}.reclaim`;
await fs.mkdir(path.dirname(filePath), { recursive: true });
const token = crypto.randomUUID();
const content = JSON.stringify({ pid: process.pid, token, acquiredAt: new Date().toISOString() });
const release = async () => {
if ((await readRaw(filePath)) === content) await fs.rm(filePath, { force: true });
};
for (let attempt = 0; attempt < 2; attempt += 1) {
if (await createExclusive(filePath, content)) return release;
const seen = await readRaw(filePath);
if (seen === null) continue;
if (lockHolderAlive(seen)) return null;
if (attempt > 0) return null;
const reclaimContent = JSON.stringify({ pid: process.pid, token, at: new Date().toISOString() });
if (!(await createExclusive(reclaimPath, reclaimContent))) return null;
try {
if ((await readRaw(filePath)) === seen) await fs.rm(filePath, { force: true });
} finally {
if ((await readRaw(reclaimPath)) === reclaimContent) await fs.rm(reclaimPath, { force: true });
}
}
return null;
}
// --- receipt writes -----------------------------------------------------------------------
async function saveReceipt(projectDir, receipt, clock) {
receipt.updatedAt = isoAt(clock);
await writeJsonAtomic(receiptPath(projectDir), receipt);
return receipt;
}
// Keys rp-execute-import requires that a created site settles. Each value is what the runtime
// already assumes when the key is absent, so writing it changes no behavior; a present value is kept.
const BINDING_DEFAULTS = Object.freeze({ WIX_SITE_STRATEGY: 'new', DRY_RUN: 'false' });
// Persists WIX_SITE_ID only after the receipt holds it, and never over a different value.
// Every site type binds through here, Headless included (its wrapper writes only WIX_SITE_ID).
async function persistSiteBinding(projectDir, metaSiteId) {
const env = await readWixEnv(projectDir);
const current = env.WIX_SITE_ID && String(env.WIX_SITE_ID).trim();
if (current && current !== 'REPLACE_WITH_REAL_VALUE' && current !== metaSiteId) {
return { ok: false, conflict: current };
}
const updates = current === metaSiteId ? {} : { WIX_SITE_ID: metaSiteId };
for (const [key, value] of Object.entries(BINDING_DEFAULTS)) if (!env[key]) updates[key] = value;
if (Object.keys(updates).length) await upsertEnvFile(wixEnvPath(projectDir), updates);
return { ok: true };
}
// --- recovery requests --------------------------------------------------------------------
const RECOVERY = Object.freeze({
failed_before_create: {
title: 'Fix the site-creation prerequisite',
choices: [{ id: 'retry_create', label: 'Prerequisite fixed — create the site', description: 'The stated cause (access, site limit, template or name) has been corrected; make one new creation attempt.' }],
},
ambiguous: {
title: 'Reconcile an uncertain site creation',
mode: 'text',
},
editor_unverified: {
title: 'Editor verification did not pass',
choices: [{ id: 'recheck', label: 'Re-run verification', description: 'Run one new editor readback and published-URLs readback for the saved site; no site is created.' }],
},
publication_unknown: {
title: 'Publication state of the new site could not be read',
choices: [{ id: 'recheck', label: 'Read the publication state again', description: 'The stated cause (for example access) is fixed; run one new published-URLs readback. No site is created, published or unpublished.' }],
},
publication_published: {
title: 'The new site is already published',
choices: [{ id: 'recheck_after_unpublish', label: 'Site unpublished in Wix — read again', description: 'A person has unpublished the site in Wix; run one new published-URLs readback. RePlatform never unpublishes it.' }],
},
ownership_unverified: {
title: 'Confirm which account owns the new site',
mode: 'text',
},
});
function recoveryPrompt(kind, receipt) {
const site = receipt.metaSiteId ? ` Site: ${receipt.metaSiteId}.` : '';
const cause = receipt.error ? ` Cause: ${receipt.error.message}` : '';
switch (kind) {
case 'failed_before_create':
return `The ${receipt.requestedSiteType} site was not created: the request was rejected before creation.${cause} Next: ${receipt.error ? receipt.error.nextAction : 'fix the prerequisite'}. Nothing was created, so one new attempt is safe after the fix.`;
case 'ambiguous':
return `The outcome of the site-creation attempt ${receipt.attemptId} is unknown; a site may or may not exist.${site}${cause} ${IMPORT_OPERATOR_PROMPT} Reply with the metasite ID of the site this attempt created (it is then verified before use), or with "none-created: <evidence>" when an independently verified account site list shows that no site was created. Names alone are not identity proof.`;
case 'editor_unverified':
return `The new site's editor could not be verified as ${EXPECTED_EDITOR[receipt.requestedSiteType]}.${site}${cause} The site is kept and import stays blocked. ${IMPORT_OPERATOR_PROMPT}`;
case 'ownership_unverified':
return `The new site was created, but its owner could not be matched to the confirmed Wix account.${site}${cause} The site is kept and not bound for import. ${IMPORT_OPERATOR_PROMPT} Reply "owner-verified: <evidence>" once the intended account's site list shows this site; editor verification and the published-URLs readback then run. Any other reply asks again. Names alone are not identity proof.`;
case 'publication_unknown':
return `The publication state of this new site could not be read.${site}${cause} Import stays blocked until a fresh readback shows the site unpublished; answering only triggers that readback.`;
case 'publication_published':
return `This new site is already published. RePlatform imports only into an unpublished new site.${site} A person may unpublish it in Wix (an explicit human publication decision); RePlatform never does. Answering only triggers a new readback.`;
default:
return 'Destination recovery is required.';
}
}
async function raiseRecovery(projectDir, receipt, kind, clock, observation = null) {
const spec = RECOVERY[kind];
const request = await createUserAction(projectDir, {
kind: 'manual_action',
stage: 'destination',
title: spec.title,
prompt: recoveryPrompt(kind, receipt),
response: { mode: spec.mode || 'choice', required: true },
choices: spec.choices || [],
subject: {
type: 'destination_recovery',
recovery: kind,
attemptId: receipt.attemptId,
metaSiteId: receipt.metaSiteId || null,
...(observation ? { observation } : {}),
},
});
receipt.recovery = { kind, requestId: request.requestId, raisedAt: isoAt(clock) };
return request;
}
async function readRecoveryAnswer(projectDir, receipt) {
if (!receipt.recovery || !receipt.recovery.requestId) return null;
let request;
try { request = await readUserAction(projectDir, receipt.recovery.requestId); } catch { return null; }
return request && request.status === 'answered' ? request : null;
}
// --- HTTP steps -------------------------------------------------------------------------
function errorFromCreateResponse(res) {
const body = res.json || {};
const detail = body.details && body.details.applicationError ? body.details.applicationError : {};
const code = detail.code || body.code || null;
const message = String(detail.description || body.message || `HTTP ${res.status}`).slice(0, 500);
if (res.status === 401 || res.status === 403) return { code: 'access_denied', message, nextAction: 'Log the Wix CLI into the intended account with site-creation permission (access recovery).' };
if (code === 'TOO_MANY_SITES_FOR_FREE_USER') return { code: 'site_limit', message, nextAction: 'Free a site slot or upgrade the account, then retry.' };
if (code === 'NOT_A_TEMPLATE' || code === 'APP_NOT_ON_TEMPLATE') return { code: 'template_unavailable', message, nextAction: 'Template support is missing (capability recovery); do not switch editor or fall back to Headless.' };
if (/name/i.test(message) || /NAME/.test(String(code))) return { code: 'name_rejected', message, nextAction: 'Choose another site name, then retry.' };
return { code: code ? String(code).toLowerCase() : `http_${res.status}`, message, nextAction: 'Correct the rejected request, then retry.' };
}
async function createFromTemplate(deps, { accountToken, templateId, siteName }) {
let res;
try {
res = await boundedRequest(deps, URLS.createFromTemplate, {
method: 'POST',
headers: { Authorization: bearer(accountToken), 'Content-Type': 'application/json' },
body: JSON.stringify({ originTemplateId: templateId, siteName }),
}, LIMITS.createTimeoutMs);
} catch (error) {
return { outcome: 'ambiguous', error: { code: error instanceof CallTimeoutError ? 'create_timeout' : 'create_transport', message: error.message } };
}
if (res.status === 200) {
const metaSite = res.json && res.json.metaSite;
const metaSiteId = metaSite && metaSite.metaSiteId;
if (!metaSiteId || !UUID_RE.test(String(metaSiteId))) {
return { outcome: 'ambiguous', error: { code: 'create_response_invalid', message: 'HTTP 200 without a valid metaSite.metaSiteId' } };
}
return { outcome: 'created', metaSiteId: String(metaSiteId), ownerId: metaSite.ownerId || null };
}
if (res.status >= 500 || res.status === 408) {
return { outcome: 'ambiguous', error: { code: `create_http_${res.status}`, message: `HTTP ${res.status}; the remote outcome is unknown` } };
}
return { outcome: 'failed_before_create', error: errorFromCreateResponse(res) };
}
function bearer(token) {
const text = String(token).trim();
return /^Bearer\s/i.test(text) ? text : `Bearer ${text}`;
}
function siteHeaders(siteToken, metaSiteId) {
return { Authorization: bearer(siteToken), 'wix-site-id': metaSiteId };
}
async function readEditor(deps, { siteToken, metaSiteId }) {
try {
const res = await boundedRequest(deps, URLS.editorUrls, { method: 'GET', headers: siteHeaders(siteToken, metaSiteId) }, LIMITS.editorTimeoutMs);
const urls = res.json && res.json.urls;
const editorType = editorTypeFromUrls(res.json);
if (res.status !== 200 || editorType === null) {
return { ok: false, httpStatus: res.status, reason: res.status === 200 ? 'malformed_editor_readback' : `editor_readback_http_${res.status}` };
}
return { ok: true, httpStatus: 200, editorType, editorUrl: typeof urls.editorUrl === 'string' ? urls.editorUrl : null };
} catch (error) {
return { ok: false, httpStatus: null, reason: error instanceof CallTimeoutError ? 'editor_readback_timeout' : 'editor_readback_transport' };
}
}
// Returns the observation recorded in the receipt's `publication` field.
async function readPublication(deps, { siteToken, metaSiteId }) {
const { clock } = deps;
const startedAt = clock.now();
const budgetEnd = startedAt + LIMITS.publicationBudgetMs;
let attempts = 0;
let lastStatus = null;
const finish = (state, extra = {}) => ({
state,
checkedAt: new Date(clock.now()).toISOString(),
httpStatus: lastStatus,
urlCount: null,
attempts,
...extra,
});
while (attempts < LIMITS.publicationAttempts) {
const left = budgetEnd - clock.now();
if (left < LIMITS.publicationMinLeftMs) return finish('unknown', { reason: 'readback_budget_exhausted' });
attempts += 1;
let retryAfter = null;
try {
const res = await boundedRequest(deps, URLS.publishedSiteUrls, { method: 'GET', headers: siteHeaders(siteToken, metaSiteId) }, Math.min(LIMITS.publicationTimeoutMs, left));
lastStatus = res.status;
if (res.status === 200) {
const urls = res.json && res.json.urls;
if (!Array.isArray(urls)) return finish('unknown', { reason: 'malformed_readback' });
const primary = urls.find((entry) => entry && entry.primary) || urls[0];
return finish(urls.length ? 'published' : 'unpublished', { urlCount: urls.length, ...(primary && typeof primary.url === 'string' ? { primaryUrl: primary.url } : {}) });
}
if (res.status === 401 || res.status === 403) {
return finish('unknown', { reason: 'access_denied', accessNote: 'The site-scoped credential was refused; restore access for the bound site (access recovery), then recheck.' });
}
if (!isTransientStatus(res.status)) return finish('unknown', { reason: `readback_http_${res.status}` });
retryAfter = retryAfterMs(res.retryAfter, clock.now());
} catch {
// Network failure or per-call timeout: transient, retried within the budget.
lastStatus = null;
}
if (attempts >= LIMITS.publicationAttempts) break;
const scheduled = LIMITS.publicationWaitsMs[attempts - 1] || 0;
const wait = Math.max(scheduled, retryAfter === null ? 0 : Math.min(retryAfter, LIMITS.publicationRetryAfterCapMs));
const remaining = budgetEnd - clock.now();
await clock.sleep(Math.max(0, Math.min(wait, remaining)));
}
return finish('unknown', { reason: attempts >= LIMITS.publicationAttempts ? 'retries_exhausted' : 'readback_budget_exhausted' });
}
function clip(value, max) { return String(value).replace(/[\r\n]+/g, ' ').trim().slice(0, max); }
// Business context the run already has (type and the evidence behind it); it only pre-fills the
// site-type question and never reaches a Wix call.
async function readDestinationBrief(projectDir) {
try {
const value = JSON.parse(await fs.readFile(path.join(projectDir, 'orchestration', 'destination-brief.json'), 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch {
return {};
}
}
// --- coordinator --------------------------------------------------------------------------
function newReceipt({ projectId, siteType, pair, clock, randomUUID }) {
return {
schemaVersion: RECEIPT_SCHEMA_VERSION,
projectId,
attemptId: `dst_${randomUUID()}`,
createdAt: isoAt(clock),
updatedAt: isoAt(clock),
strategy: 'new',
requestedSiteType: siteType,
decisionRef: 'orchestration/decisions.json#targetSiteType',
businessType: pair.businessType || null,
businessTypeSource: pair.businessTypeSource || null,
status: 'in_progress',
// Only a receipt made by this version installs Developer Tools when it verifies: a receipt written
// earlier belongs to a site that may already be in use, and is left alone.
...(siteType === 'headless' ? {} : { developerToolsOnVerify: true }),
creationMethod: null,
templateId: null,
siteName: null,
accountIdentityRef: null,
intendedAccount: null,
metaSiteId: null,
ownerId: null,
ownerCheck: null,
observedEditorType: null,
editorUrl: null,
editorReadback: null,
publication: null,
error: null,
recovery: null,
attempts: [],
};
}
async function pendingRequestFor(projectDir, predicate) {
const pending = await listUserActions(projectDir, { status: 'pending' });
for (const row of pending) {
const request = await readUserAction(projectDir, row.requestId);
if (predicate(request)) return request;
}
return null;
}
function result(outcome, receipt, extra = {}) {
return { outcome, status: receipt ? receipt.status : null, metaSiteId: receipt ? receipt.metaSiteId : null, receipt, ...extra };
}
// Verification after a metasite ID is known: binding, site credential, editor, publication.
async function verifyCreatedSite(deps, projectDir, receipt) {
const { clock } = deps;
const binding = await persistSiteBinding(projectDir, receipt.metaSiteId);
if (!binding.ok) {
receipt.status = 'blocked';
receipt.error = { code: 'binding_conflict', message: `config/wix.env names a different WIX_SITE_ID (${binding.conflict}); refusing to overwrite it.`, nextAction: 'Resolve which destination is authoritative; never retarget on a guess.' };
await raiseRecovery(projectDir, receipt, 'editor_unverified', clock);
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
receipt.envPersisted = true;
let siteToken;
try {
siteToken = await deps.credentials.siteToken(receipt.metaSiteId);
} catch (error) {
siteToken = null;
}
if (!siteToken) {
receipt.status = 'blocked';
receipt.error = { code: 'site_credential_unavailable', message: 'A site-scoped credential could not be minted for the new site through the Wix CLI login.', nextAction: 'Confirm the Wix CLI is logged into the account that owns the site (access recovery), then recheck.' };
await raiseRecovery(projectDir, receipt, 'editor_unverified', clock);
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
const editor = await readEditor(deps, { siteToken, metaSiteId: receipt.metaSiteId });
receipt.editorReadback = { checkedAt: isoAt(clock), httpStatus: editor.httpStatus, ...(editor.ok ? {} : { reason: editor.reason }) };
receipt.observedEditorType = editor.ok ? editor.editorType : null;
if (editor.ok && editor.editorUrl) receipt.editorUrl = editor.editorUrl;
const expected = EXPECTED_EDITOR[receipt.requestedSiteType];
if (!editor.ok || editor.editorType !== expected) {
receipt.status = 'blocked';
receipt.error = editor.ok
? { code: 'editor_mismatch', message: `The site's editor is ${editor.editorType}, not the requested ${expected}.`, nextAction: 'Keep the site; reconcile the choice with the Import Operator. Never create another site to fix this.' }
: { code: 'editor_unverified', message: `Editor readback failed (${editor.reason}).`, nextAction: 'Fix the stated cause, then recheck.' };
await raiseRecovery(projectDir, receipt, 'editor_unverified', clock);
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
return checkPublication(deps, projectDir, receipt, siteToken);
}
async function checkPublication(deps, projectDir, receipt, siteToken, { recheckRequestId = null } = {}) {
const { clock } = deps;
const observation = await readPublication(deps, { siteToken, metaSiteId: receipt.metaSiteId });
receipt.publication = { ...observation, ...(recheckRequestId ? { recheckRequestId } : {}) };
if (observation.state === 'unpublished') {
// First time this editor site verifies: install the app once, then save `verified` with the outcome.
if (receipt.developerToolsOnVerify === true && !receipt.developerTools) await installDeveloperTools(deps, receipt, siteToken);
receipt.status = 'verified';
receipt.error = null;
receipt.recovery = null;
await saveReceipt(projectDir, receipt, clock);
return result('verified', receipt);
}
receipt.status = 'blocked';
receipt.error = observation.state === 'published'
? { code: 'publication_published', message: `The published-URLs readback returned ${observation.urlCount} URL(s).`, nextAction: 'A person may unpublish the site in Wix; then recheck. RePlatform never unpublishes it.' }
: { code: 'publication_unknown', message: `The published-URLs readback was not conclusive (${observation.reason}).${observation.accessNote ? ` ${observation.accessNote}` : ''}`, nextAction: 'Fix the stated cause, then recheck.' };
await raiseRecovery(projectDir, receipt, observation.state === 'published' ? 'publication_published' : 'publication_unknown', clock, {
state: observation.state, checkedAt: observation.checkedAt, httpStatus: observation.httpStatus, urlCount: observation.urlCount,
});
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
// One new readback for the bound site, minted fresh; no create call. Used by a recovery
// answer and by every resume of a `verified` receipt, so an earlier `unpublished` is never
// reused as proof before setup/import. Anything but a new `unpublished` blocks (fail-closed).
async function freshPublicationReadback(deps, projectDir, receipt, { recheckRequestId = null } = {}) {
const { clock } = deps;
let siteToken = null;
try { siteToken = await deps.credentials.siteToken(receipt.metaSiteId); } catch { siteToken = null; }
if (!siteToken) {
// Being unable to obtain the readback is itself an `unknown` observation. Record it so
// the receipt never keeps an earlier `unpublished` beside a publication_unknown block.
receipt.status = 'blocked';
receipt.publication = {
state: 'unknown', checkedAt: isoAt(clock), httpStatus: null, urlCount: null, attempts: 0, reason: 'site_credential_unavailable',
...(recheckRequestId ? { recheckRequestId } : {}),
};
receipt.error = { code: 'site_credential_unavailable', message: 'A site-scoped credential could not be minted for the readback.', nextAction: 'Restore access, then recheck.' };
await raiseRecovery(projectDir, receipt, 'publication_unknown', clock, {
state: 'unknown', checkedAt: receipt.publication.checkedAt, httpStatus: null, urlCount: null,
});
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
return checkPublication(deps, projectDir, receipt, siteToken, { recheckRequestId });
}
// --- intended account (confirm the intended account before mutation) ----------------
const ACCOUNT_CONFIRMATION = 'destination_account_confirmation';
const EDITOR_LABEL = Object.freeze({ studio: 'Wix Studio', harmony: 'Wix Harmony' });
// The account the Wix CLI is logged into right now: the `whoami` identity (shown to the
// operator, stored only as a hash) and the login's user id, which the creation response's
// `metaSite.ownerId` is checked against.
async function readCurrentAccount(deps) {
let identity = null;
try { identity = await deps.credentials.identity(); } catch { identity = null; }
if (!identity || !identity.authenticated || !identity.identity) {
return { ok: false, error: { code: 'account_unconfirmed', message: 'The Wix CLI account identity could not be confirmed before creation.', nextAction: 'Log the Wix CLI into the intended account (replatform account), then retry.' } };
}
let userId = null;
try { userId = deps.credentials.userId ? await deps.credentials.userId() : null; } catch { userId = null; }
if (!userId || !UUID_RE.test(String(userId))) {
return { ok: false, error: { code: 'account_unconfirmed', message: 'The Wix CLI login\'s user id could not be read, so the new site\'s ownership could not be checked.', nextAction: 'Log the Wix CLI into the intended account again (wix login), then retry.' } };
}
return { ok: true, identity: clip(identity.identity, 200), identityRef: identityRef(identity.identity), userId: String(userId).toLowerCase() };
}
function sameAccount(left, right) {
return Boolean(left && right && left.identityRef === right.identityRef && left.userId === right.userId);
}
async function latestAccountConfirmation(projectDir) {
const rows = await listUserActions(projectDir);
for (let index = rows.length - 1; index >= 0; index -= 1) {
const request = await readUserAction(projectDir, rows[index].requestId);
if (request.subject && request.subject.type === ACCOUNT_CONFIRMATION) return request;
}
return null;
}
// The operator confirms the account actually logged in, shown by name, before the first
// create. The newest confirmation request decides: pending waits; `confirm_account` binds the
// account it showed, but only while that same account is still logged in; anything else
// (switched login, `switched_account`) raises a new request that shows the current account.
async function confirmIntendedAccount(projectDir, receipt, account) {
const latest = await latestAccountConfirmation(projectDir);
if (latest && latest.status === 'pending') return { ok: false, awaiting: true, requestId: latest.requestId };
if (latest && latest.status === 'answered' && latest.answer && latest.answer.choice === 'confirm_account' && sameAccount(latest.subject, account)) {
return { ok: true, intended: { identityRef: account.identityRef, userId: account.userId, confirmationRequestId: latest.requestId, confirmedAt: latest.answer.answeredAt || null } };
}
const siteName = normalizeSiteName(receipt.siteName || receipt.projectId, { suffix: receipt.nameSuffix || '' });
const request = await createUserAction(projectDir, {
kind: 'question',
stage: 'destination',
title: 'Confirm the Wix account for the new site',
prompt: `RePlatform is about to create the new ${EDITOR_LABEL[receipt.requestedSiteType]} site "${siteName}" in the Wix account the Wix CLI is logged into: ${account.identity}. Confirm that this is the intended account; nothing is created until you do. If it is not, log the Wix CLI into the intended account first, then choose "I switched accounts" so the account is shown again.`,
response: { mode: 'choice', required: true },
choices: [
{ id: 'confirm_account', label: 'Create the site in this account', description: 'This is the intended Wix account. The creation response must name this login as the site owner.' },
{ id: 'switched_account', label: 'I switched accounts — show it again', description: 'The Wix CLI is now logged into the intended account; show that account for confirmation. Nothing is created.' },
],
subject: { type: ACCOUNT_CONFIRMATION, identityRef: account.identityRef, userId: account.userId, siteType: receipt.requestedSiteType },
});
return { ok: false, awaiting: true, requestId: request.requestId };
}
// Before any create: the logged-in account must be the confirmed intended account. With
// `confirm: false` it only re-checks a recorded binding.
async function requireAccount(deps, projectDir, receipt, { confirm = true } = {}) {
const account = await readCurrentAccount(deps);
if (!account.ok) return account;
receipt.accountIdentityRef = account.identityRef;
if (receipt.intendedAccount) {
if (!sameAccount(receipt.intendedAccount, account)) {
return { ok: false, error: { code: 'account_mismatch', message: 'The Wix CLI is logged into a different account from the one confirmed for this site.', nextAction: 'Log the Wix CLI back into the confirmed account, then retry. To use another account instead, retry while logged into it and confirm that account when asked.' } };
}
} else if (confirm) {
const confirmation = await confirmIntendedAccount(projectDir, receipt, account);
if (!confirmation.ok) return confirmation;
receipt.intendedAccount = confirmation.intended;
}
let accountToken = null;
try { accountToken = await deps.credentials.accountToken(); } catch { accountToken = null; }
if (!accountToken) {
return { ok: false, error: { code: 'account_credential_unavailable', message: 'No account credential is available from the Wix CLI login.', nextAction: 'Log the Wix CLI into the intended account, then retry.' } };
}
return { ok: true, accountToken };
}
function awaitingAccount(confirmation) {
return { outcome: 'awaiting_account_confirmation', status: null, metaSiteId: null, receipt: null, requestId: confirmation.requestId };
}
async function failBeforeCreate(deps, projectDir, receipt, error) {
receipt.status = 'failed_before_create';
receipt.error = error;
await raiseRecovery(projectDir, receipt, 'failed_before_create', deps.clock);
await saveReceipt(projectDir, receipt, deps.clock);
return result('failed_before_create', receipt);
}
async function markAmbiguous(deps, projectDir, receipt, error) {
receipt.status = 'ambiguous';
receipt.error = { ...error, nextAction: 'Reconcile with the Import Operator; never repeat the create.' };
await raiseRecovery(projectDir, receipt, 'ambiguous', deps.clock);
await saveReceipt(projectDir, receipt, deps.clock);
return result('ambiguous', receipt);
}
async function createEditorSite(deps, projectDir, receipt, decisions) {
const { clock } = deps;
const account = await requireAccount(deps, projectDir, receipt);
// Awaiting the account confirmation writes nothing: no receipt status changes, no attempt.
if (!account.ok && account.awaiting) return awaitingAccount(account);
if (!account.ok) return failBeforeCreate(deps, projectDir, receipt, account.error);
receipt.attempts.push({ attemptId: receipt.attemptId, startedAt: isoAt(clock) });
// Harmony and Studio sites are both created from the public blank template. Harmony's AI site
// generation lives behind a private service with no stability promise, so it is not used.
receipt.creationMethod = 'blank_template';
receipt.templateId = BLANK_TEMPLATES[receipt.requestedSiteType];
receipt.siteName = normalizeSiteName(receipt.siteName || receipt.projectId, { suffix: receipt.nameSuffix || '' });
await saveReceipt(projectDir, receipt, clock);
const created = await createFromTemplate(deps, { accountToken: account.accountToken, templateId: receipt.templateId, siteName: receipt.siteName });
if (created.outcome === 'failed_before_create') return failBeforeCreate(deps, projectDir, receipt, created.error);
if (created.outcome === 'ambiguous') return markAmbiguous(deps, projectDir, receipt, created.error);
receipt.status = 'created';
receipt.metaSiteId = created.metaSiteId;
receipt.ownerId = created.ownerId;
// The response's owner must be the confirmed account's login. A missing or different
// owner keeps the site (ID saved above) but never binds it for import.
const intendedUserId = receipt.intendedAccount && receipt.intendedAccount.userId;
const owner = created.ownerId ? String(created.ownerId).toLowerCase() : null;
receipt.ownerCheck = { state: !owner ? 'missing' : owner === intendedUserId ? 'matched' : 'mismatch', checkedAt: isoAt(clock) };
if (receipt.ownerCheck.state !== 'matched') {
receipt.status = 'blocked';
receipt.error = owner
? { code: 'owner_mismatch', message: `The creation response names owner ${owner}, not the confirmed Wix CLI login.`, nextAction: 'Keep the site; reconcile its account with the Import Operator. Never create another site to fix this.' }
: { code: 'owner_missing', message: 'The creation response did not name the new site\'s owner, so the intended account could not be checked.', nextAction: 'Keep the site; confirm its account with the Import Operator.' };
await raiseRecovery(projectDir, receipt, 'ownership_unverified', clock);
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
await saveReceipt(projectDir, receipt, clock);
return verifyCreatedSite(deps, projectDir, receipt);
}
// Installs the Developer Tools app the first time an editor site's receipt becomes `verified`: one
// attempt with the credential the verification just used, bounded by the client timeout, never
// retried. The result is written to `receipt.developerTools` (the caller saves the receipt). A
// failure never changes the destination status: the site is kept and the owner adds the app from the
// dashboard. The app has no editor part, so the editor installer does not apply. A 200 answer proves
// nothing about the site (setup's install step treats it the same way), so success is the installed-apps
// list read back afterwards naming this app as enabled.
async function installDeveloperTools(deps, receipt, siteToken) {
const record = { appDefId: DEVELOPER_TOOLS_APP_DEF_ID, status: 'failed', checkedAt: isoAt(deps.clock) };
try {
const res = await boundedRequest(deps, URLS.installApp, {
method: 'POST',
headers: { ...siteHeaders(siteToken, receipt.metaSiteId), 'Content-Type': 'application/json' },
body: JSON.stringify({
appInstance: { appDefId: DEVELOPER_TOOLS_APP_DEF_ID, enabled: true },
tenant: { tenantType: 'SITE', id: receipt.metaSiteId },
installType: 'INSTALL_TYPE_SITE',
appsInstallOptions: {},
}),
}, LIMITS.installTimeoutMs);
const answered = res.json && res.json.appInstance && res.json.appInstance.appDefId;
if (res.status !== 200) record.reason = `install_http_${res.status}`;
else if (answered !== DEVELOPER_TOOLS_APP_DEF_ID) record.reason = 'install_response_without_app';
else {
const listed = await boundedRequest(deps, URLS.installedApps, { method: 'GET', headers: siteHeaders(siteToken, receipt.metaSiteId) }, LIMITS.installTimeoutMs)
.catch((error) => ({ error }));
if (listed.error) record.reason = listed.error instanceof CallTimeoutError ? 'install_readback_timeout' : 'install_readback_transport';
else if (listed.status !== 200) record.reason = `install_readback_http_${listed.status}`;
else {
const apps = listed.json && Array.isArray(listed.json.appInstances) ? listed.json.appInstances : [];
// Same rule as setup's isWixAppInstalled: listed, and not disabled.
if (apps.some((app) => app && app.appDefId === DEVELOPER_TOOLS_APP_DEF_ID && app.enabled !== false)) record.status = 'installed';
else record.reason = 'install_not_listed';
}
}
} catch (error) {
record.reason = error instanceof CallTimeoutError ? 'install_timeout' : 'install_transport';
}
record.checkedAt = isoAt(deps.clock);
receipt.developerTools = record;
}
// Acts on an answered recovery request. Each answer triggers exactly one bounded step.
async function resumeFromRecovery(deps, projectDir, receipt, request, decisions) {
const { clock } = deps;
const kind = receipt.recovery.kind;
const choice = request.answer && request.answer.choice;
if (kind === 'failed_before_create' && choice === 'retry_create') {
const previous = receipt.attempts.length;
receipt.attemptId = `dst_${deps.randomUUID()}`;
if (receipt.error && receipt.error.code === 'name_rejected') receipt.nameSuffix = String(previous + 1);
// Retrying after a login change means the account must be confirmed again before a create.
if (receipt.error && receipt.error.code === 'account_mismatch') receipt.intendedAccount = null;
receipt.status = 'in_progress';
receipt.error = null;
receipt.recovery = null;
return createEditorSite(deps, projectDir, receipt, decisions);
}
if (kind === 'ambiguous') {
const text = String(request.answer && request.answer.text || '').trim();
const idMatch = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (/^none-created:\s*\S/i.test(text)) {
// Explicit recovery decision with evidence that the prior request created nothing.
receipt.attempts.push({ attemptId: receipt.attemptId, resolvedAt: isoAt(clock), resolution: 'none_created', evidence: clip(text.replace(/^none-created:\s*/i, ''), 500), requestId: request.requestId });
receipt.attemptId = `dst_${deps.randomUUID()}`;
receipt.status = 'in_progress';
receipt.error = null;
receipt.recovery = null;
return createEditorSite(deps, projectDir, receipt, decisions);
}
if (idMatch) {
receipt.attempts.push({ attemptId: receipt.attemptId, resolvedAt: isoAt(clock), resolution: 'adopted', requestId: request.requestId });
receipt.metaSiteId = idMatch[0].toLowerCase();
receipt.status = 'created';
receipt.error = null;
receipt.recovery = null;
await saveReceipt(projectDir, receipt, clock);
return verifyCreatedSite(deps, projectDir, receipt);
}
// Unusable answer: ask again; never guess.
receipt.error = { code: 'reconciliation_answer_invalid', message: 'The reconciliation answer named neither a metasite ID nor "none-created: <evidence>".', nextAction: 'Answer the new reconciliation request.' };
await raiseRecovery(projectDir, receipt, 'ambiguous', clock);
await saveReceipt(projectDir, receipt, clock);
return result('ambiguous', receipt);
}
if (kind === 'ownership_unverified') {
const text = String(request.answer && request.answer.text || '').trim();
if (/^owner-verified:\s*\S/i.test(text)) {
receipt.ownerCheck = { ...receipt.ownerCheck, state: 'operator_verified', evidence: clip(text.replace(/^owner-verified:\s*/i, ''), 500), requestId: request.requestId, checkedAt: isoAt(clock) };
receipt.status = 'created';
receipt.error = null;
receipt.recovery = null;
await saveReceipt(projectDir, receipt, clock);
return verifyCreatedSite(deps, projectDir, receipt);
}
receipt.error = { code: 'ownership_answer_invalid', message: 'The ownership answer was not "owner-verified: <evidence>".', nextAction: 'Answer the new ownership request.' };
await raiseRecovery(projectDir, receipt, 'ownership_unverified', clock);
await saveReceipt(projectDir, receipt, clock);
return result('blocked', receipt);
}
if (kind === 'editor_unverified' && choice === 'recheck') {
receipt.status = 'created';
receipt.error = null;
receipt.recovery = null;
return verifyCreatedSite(deps, projectDir, receipt);
}
if ((kind === 'publication_unknown' && choice === 'recheck') || (kind === 'publication_published' && choice === 'recheck_after_unpublish')) {
// The answer itself changes neither status nor `publication`; only the new readback can.
return freshPublicationReadback(deps, projectDir, receipt, { recheckRequestId: request.requestId });
}
return result('blocked', receipt, { reason: 'unsupported_recovery_answer' });
}
// Headless evidence after the wrapper's site is confirmed: editor type and publication state.
// Read-only and never a gate: it never changes the status, sets `error` or raises
// a recovery request. A site that is not `published` becomes a cutover item in
// destination-handoff.js.
async function readBackHeadlessSite(deps, receipt) {
const { clock } = deps;
let siteToken = null;
try { siteToken = await deps.credentials.siteToken(receipt.metaSiteId); } catch { siteToken = null; }
if (!siteToken) {
const checkedAt = isoAt(clock);
receipt.editorReadback = { checkedAt, httpStatus: null, reason: 'site_credential_unavailable' };
receipt.observedEditorType = null;
receipt.publication = { state: 'unknown', checkedAt, httpStatus: null, urlCount: null, attempts: 0, reason: 'site_credential_unavailable' };
return;
}
const editor = await readEditor(deps, { siteToken, metaSiteId: receipt.metaSiteId });
receipt.editorReadback = { checkedAt: isoAt(clock), httpStatus: editor.httpStatus, ...(editor.ok ? {} : { reason: editor.reason }) };
receipt.observedEditorType = editor.ok ? editor.editorType : null;
if (editor.ok && editor.editorUrl) receipt.editorUrl = editor.editorUrl;
receipt.publication = await readHeadlessPublication(deps, { siteToken, metaSiteId: receipt.metaSiteId });
}
// For a Headless site the published-URLs readback lists the free URL whether or not the site
// is published (observed live: the same URL before and after a release), so it cannot decide
// the state. The public URL decides: 2xx is published, 404 is unpublished, anything else is
// unknown. Redirects are not followed: a 3xx to some other page proves nothing, so it is
// unknown. An empty URL list stays unpublished.
async function readHeadlessPublication(deps, { siteToken, metaSiteId }) {
const listed = await readPublication(deps, { siteToken, metaSiteId });
if (listed.state !== 'published') return listed;
if (!listed.primaryUrl || !/^https:\/\//.test(listed.primaryUrl)) return { ...listed, state: 'unknown', reason: 'public_url_missing' };
let publicHttpStatus = null;
try {
const res = await boundedRequest(deps, listed.primaryUrl, { method: 'GET', redirect: 'manual' }, LIMITS.publicationTimeoutMs);
publicHttpStatus = res.status;
} catch (error) {
return { ...listed, state: 'unknown', publicHttpStatus, reason: error instanceof CallTimeoutError ? 'public_url_timeout' : 'public_url_transport', checkedAt: isoAt(deps.clock) };
}
const state = publicHttpStatus >= 200 && publicHttpStatus < 300 ? 'published' : publicHttpStatus === 404 ? 'unpublished' : 'unknown';
return { ...listed, state, publicHttpStatus, checkedAt: isoAt(deps.clock), ...(state === 'unknown' ? { reason: `public_url_http_${publicHttpStatus}` } : {}) };
}
async function runHeadless(deps, projectDir, receipt, clock) {
receipt.creationMethod = 'headless_cli';
receipt.attempts.push({ attemptId: receipt.attemptId, startedAt: isoAt(clock) });
await saveReceipt(projectDir, receipt, clock);
const outcome = await deps.headless({ projectDir, businessName: receipt.projectId });
const env = await readWixEnv(projectDir);
const siteId = env.WIX_SITE_ID ? String(env.WIX_SITE_ID).trim() : '';
// A truthy placeholder or malformed id is not a binding: only a UUID-shaped site id counts as created.
if (outcome && outcome.ok && UUID_RE.test(siteId)) {
receipt.status = 'created';
receipt.metaSiteId = siteId;
receipt.error = null;
await persistSiteBinding(projectDir, siteId);
await readBackHeadlessSite(deps, receipt);
} else {
// The wrapper's own WIX_SCAFFOLD_STATUS safeguards decide recovery; the receipt only records.
receipt.status = 'blocked';
receipt.error = { code: 'headless_scaffold_failed', message: clip(outcome && outcome.message ? outcome.message : 'headless scaffold did not confirm a destination', 500), nextAction: 'Follow the wrapper\'s WIX_SCAFFOLD_STATUS recovery; never retry blindly.' };
}
await saveReceipt(projectDir, receipt, clock);
return result(receipt.status === 'created' ? 'created' : 'blocked', receipt);
}
// A Headless receipt is resumed through the wrapper's own WIX_SCAFFOLD_STATUS contract and is
// never a reason to delegate again: a confirmed site is adopted, and any status marker the
// wrapper left (in_progress, incomplete, ambiguous, ...) is that wrapper's recovery to own.
// Only an untouched wix.env (no WIX_SITE_ID, no marker), which the wrapper itself treats as
// "nothing was attempted" because it writes its marker before any create, allows one new
// delegation (for example after an operator cleared the marker per the wrapper's guidance).
async function resumeHeadless(deps, projectDir, receipt, env) {
const { clock } = deps;
const siteId = env.WIX_SITE_ID && env.WIX_SITE_ID !== 'REPLACE_WITH_REAL_VALUE' ? String(env.WIX_SITE_ID).trim() : null;
const marker = env.WIX_SCAFFOLD_STATUS;
if (siteId && (marker === undefined || marker === 'complete')) {
// Same gate as runHeadless: a malformed id is never adopted as a created site.
if (!UUID_RE.test(siteId)) {
if (receipt.status !== 'blocked' || !receipt.error || receipt.error.code !== 'headless_site_id_invalid') {
receipt.status = 'blocked';
receipt.error = { code: 'headless_site_id_invalid', message: 'config/wix.env WIX_SITE_ID is not a metasite id, so the Headless scaffold result cannot be adopted.', nextAction: 'Correct WIX_SITE_ID to the real site id, or follow the wrapper\'s WIX_SCAFFOLD_STATUS recovery; never retry blindly.' };
await saveReceipt(projectDir, receipt, clock);
}
return result('blocked', receipt, { reason: 'headless_site_id_invalid' });
}
receipt.status = 'created';
receipt.metaSiteId = siteId;
receipt.error = null;
await persistSiteBinding(projectDir, siteId);
// Every rerun reads the site back again, so the cutover handoff reflects the latest state.
await readBackHeadlessSite(deps, receipt);
await saveReceipt(projectDir, receipt, clock);
return result('created', receipt);
}
if (marker !== undefined) {
return result('blocked', receipt, { reason: 'destination_scaffold_state_requires_recovery' });
}
return runHeadless(deps, projectDir, receipt, clock);
}
// Entry point. Returns `{ outcome, ... }` where outcome is one of: not_new_site,
// existing_destination, awaiting_site_type, awaiting_editor_website_decision,
// awaiting_account_confirmation, awaiting_recovery,
// verified, created, failed_before_create, ambiguous, blocked, locked, invalid.
async function ensureDestination(projectDir, deps) {
const release = await acquireLock(projectDir);
if (!release) {
const reclaimLeft = (await readRaw(`${lockPath(projectDir)}.reclaim`)) !== null;
return {
outcome: 'locked',
reason: reclaimLeft
? 'orchestration/destination.lock.reclaim exists: a lock reclamation did not finish. If no destination command is running, remove that file, then retry.'
: 'another destination invocation holds orchestration/destination.lock',
};
}
try {
return await ensureDestinationLocked(projectDir, deps);
} finally {
await release();
}
}
async function ensureDestinationLocked(projectDir, deps) {
const { clock } = deps;
const artifacts = await loadArtifacts(projectDir);
if (!artifacts.decisions) return { outcome: 'invalid', reason: 'orchestration decisions are not initialized' };
const decisions = artifacts.decisions;
const strategy = getDecisionValue(decisions, 'targetSiteStrategy');
// Recheck receipts and config while holding the lock, before every create.
let receipt = await readReceipt(projectDir);
const env = await readWixEnv(projectDir);
if (receipt) {
const errors = validateReceipt(receipt);
if (errors.length) return { outcome: 'invalid', reason: 'destination_receipt_invalid', errors };
}
if (!isNewSiteStrategy(strategy) && !receipt) return { outcome: 'not_new_site' };
const envSiteId = env.WIX_SITE_ID && env.WIX_SITE_ID !== 'REPLACE_WITH_REAL_VALUE' ? String(env.WIX_SITE_ID).trim() : null;
if (!receipt && envSiteId) {
// Authoritative existing or legacy destination: zero creates, no retroactive type gate.
return { outcome: 'existing_destination', metaSiteId: envSiteId };
}
if (!receipt && env.WIX_SCAFFOLD_STATUS !== undefined) {
// A prior Headless wrapper attempt left a marker: its own safeguards own recovery.
return { outcome: 'blocked', reason: 'destination_scaffold_state_requires_recovery' };
}
const choice = readSiteTypeDecision(decisions);
if (!receipt) {
if (!choice) {
const pending = await pendingRequestFor(projectDir, (request) => request.effect && request.effect.key === 'targetSiteType');
if (pending) return { outcome: 'awaiting_site_type', requestId: pending.requestId };
const brief = await readDestinationBrief(projectDir);
const contextBusinessType = KNOWN_BUSINESS_TYPES.includes(brief.businessType) && brief.businessTypeEvidenceRef ? brief.businessType : null;
// The account is confirmed by name in its own request just before the first create.
const request = await createUserAction(projectDir, siteTypeRequestInput({
contextBusinessType,
evidenceRef: contextBusinessType ? clip(brief.businessTypeEvidenceRef, 200) : null,
siteName: normalizeSiteName(artifacts.run && artifacts.run.projectId),
}));
return { outcome: 'awaiting_site_type', requestId: request.requestId };
}
if (choice.errors.length) return { outcome: 'invalid', reason: 'site_type_decision_invalid', errors: choice.errors };
} else if (choice && !choice.errors.length && choice.siteType !== receipt.requestedSiteType) {
// A decision change never retargets a bound attempt (body item 3).
return { outcome: 'invalid', reason: 'destination_type_conflict', errors: [`receipt requested ${receipt.requestedSiteType}; decisions now say ${choice.siteType}`] };
}
const siteType = receipt ? receipt.requestedSiteType : choice.siteType;
if (receipt && receipt.metaSiteId && envSiteId && envSiteId !== receipt.metaSiteId) {
return { outcome: 'invalid', reason: 'destination_binding_conflict', errors: [`config/wix.env WIX_SITE_ID differs from the receipt's metaSiteId`] };
}
if (siteType === 'headless') {
if (!receipt) {
receipt = newReceipt({ projectId: (artifacts.run && artifacts.run.projectId) || path.basename(projectDir), siteType, pair: choice, clock, randomUUID: deps.randomUUID });
return runHeadless(deps, projectDir, receipt, clock);
}
return resumeHeadless(deps, projectDir, receipt, env);
}
// Editor branch. Scope guard first, before any site exists.
if (!receipt && getDecisionValue(decisions, 'deliveryMode') === 'management_and_website' && !getDecisionValue(decisions, 'editorWebsiteDecision')) {
const pending = await pendingRequestFor(projectDir, (request) => request.effect && request.effect.key === 'editorWebsiteDecision');
if (pending) return { outcome: 'awaiting_editor_website_decision', requestId: pending.requestId };
const request = await createUserAction(projectDir, editorWebsiteRequestInput({ siteType }));
return { outcome: 'awaiting_editor_website_decision', requestId: request.requestId };
}
if (!receipt) {
receipt = newReceipt({ projectId: (artifacts.run && artifacts.run.projectId) || path.basename(projectDir), siteType, pair: choice, clock, randomUUID: deps.randomUUID });
return createEditorSite(deps, projectDir, receipt, decisions);
}
switch (receipt.status) {
case 'verified': {
const binding = await persistSiteBinding(projectDir, receipt.metaSiteId);
if (!binding.ok) return { outcome: 'invalid', reason: 'destination_binding_conflict', errors: [`config/wix.env WIX_SITE_ID differs from the receipt's metaSiteId`] };
// A resume repeats the readback before setup/import; never recreates the site.
return freshPublicationReadback(deps, projectDir, receipt);
}
case 'in_progress':
// The attempt marker exists but no outcome was recorded: the request may have been sent.
return markAmbiguous(deps, projectDir, receipt, { code: 'attempt_interrupted', message: 'A creation attempt was interrupted before its outcome was recorded.' });
case 'created':
// A receipt written by an older version that used Harmony site generation cannot be resumed:
// the generation service is no longer called. Reconcile the site manually instead.
if (receipt.creationMethod === 'harmony_generation' && !receipt.metaSiteId) {
return markAmbiguous(deps, projectDir, receipt, { code: 'harmony_generation_unsupported', message: 'This destination was started with Harmony site generation, which RePlatform no longer uses, so the generation job cannot be resumed.' });
}
return verifyCreatedSite(deps, projectDir, receipt);
case 'failed_before_create':
case 'ambiguous':
case 'blocked': {
const answered = await readRecoveryAnswer(projectDir, receipt);
if (!answered) return result('awaiting_recovery', receipt, { requestId: receipt.recovery ? receipt.recovery.requestId : null });
return resumeFromRecovery(deps, projectDir, receipt, answered, decisions);
}
default:
return { outcome: 'invalid', reason: 'destination_receipt_invalid' };
}
}
module.exports = {
BLANK_TEMPLATES,
acquireLock,
EXPECTED_EDITOR,
LIMITS,
RECEIPT_STATUSES,
URLS,
ensureDestination,
normalizeSiteName,
readPublication,
readReceipt,
receiptPath,
validateReceipt,
};
Requirements and compatibility
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: Needs Node.js
Security
What our scanner noticed· Low risk
- 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 · 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 wix-replatform.zip