Setting the file. One moment.
Context · Wix Headless Kit · wix/skills · Skills Docs
ContentsBack to the top of the page 208
export function folderShape
— line 208
This file
Number 9.12
Position 12 of 20
Type JavaScript
Size 19 KB
Lines 280 install/ context.mjs
JavaScript · 280 lines · 19 KB
13
// is the parent's, and only the release goes to the child. Completing the migration follows
14 // guides/migration.md; this module only reads the state.
15 //
16 // `env pull` runs here when `.env.local` is missing (or --refresh): it is the source of the content
17 // identity, and the Astro build refuses to run without it. Non-interactive (CI=1); a failure is
18 // reported in `pullError` and the config's ids stand in, so a caller can still work offline.
19 import { spawnSync } from "node:child_process" ;
20 import { existsSync, readFileSync } from "node:fs" ;
21 import { homedir } from "node:os" ;
22 import { join } from "node:path" ;
23 import { failure, npmSync } from "./proc.mjs" ;
24 const PINS = JSON . parse ( readFileSync ( new URL ( "./pins.json" , import . meta .url), "utf8" ));
25 const WIX_CLI = `@wix/cli@${ PINS [ "@wix/cli" ] }` ;
26
27 // The variables the editor-to-headless migration writes into the child project's `prod`
28 // environment (headless-bo, editorMigrations/environmentMetadata.ts), which `wix env pull` copies
29 // into `.env.local` unchanged. The names are the platform's and are spelled once, here.
30 export const ENV = {
31 clientId: "WIX_CLIENT_ID" ,
32 parentSiteId: "EDITOR_MIGRATION_PARENT_SITE_ID" ,
33 /** ACTIVE while the migration is under way; COMPLETED once the parent serves the child's frontend. */
34 status: "EDITOR_MIGRATION_STATUS" ,
35 childSiteId: "EDITOR_MIGRATION_CHILD_SITE_ID" ,
36 childAppId: "EDITOR_MIGRATION_CHILD_APP_ID" ,
37 };
38
39 /** KEY=value lines of a dotenv file, quotes stripped; null when the file is absent. */
40 export function readEnvFile ( file ) {
41 if ( ! existsSync (file)) return null ;
42 const out = {};
43 for ( const raw of readFileSync (file, "utf8" ). split ( / \r ? \n / )) {
44 const line = raw. trim ();
45 if ( ! line || line. startsWith ( "#" )) continue ;
46 const eq = line. indexOf ( "=" );
47 if (eq === - 1 ) continue ;
48 const key = line. slice ( 0 , eq). trim (). replace ( / ^ export \s + / , "" );
49 let value = line. slice (eq + 1 ). trim ();
50 if ((value. startsWith ( '"' ) && value. endsWith ( '"' )) || (value. startsWith ( "'" ) && value. endsWith ( "'" ))) {
51 value = value. slice ( 1 , - 1 ). replace ( / \\ "/ g , '"' );
52 }
53 out[key] = value;
54 }
55 return out;
56 }
57
58 export function readWixConfig ( cwd ) {
59 const file = join (cwd, "wix.config.json" );
60 if ( ! existsSync (file)) return null ;
61 try { return JSON . parse ( readFileSync (file, "utf8" )); } catch { return null ; }
62 }
63
64 /**
65 * Where a release goes from this machine. `cli`: the Wix CLI's session file is here
66 * (`~/.wix/auth/account.json`, what `wix login` writes), so `wix release` and `wix env pull` work.
67 * `plugin`: no session; the Wix MCP's tools carry the user's login, the site is provisioned through
68 * them, and the Astro build is released by its upload tool.
69 */
70 export function publisher () {
71 return existsSync ( join ( homedir (), ".wix" , "auth" , "account.json" )) ? "cli" : "plugin" ;
72 }
73
74 /**
75 * Whether the kit runs here: `npm ping` against the configured registry, thirty seconds at most. A chat
76 * host's sandbox answers through a proxy that can take six seconds to the first byte (measured on one
77 * 2026-10-24), so a short limit reads an open sandbox as closed.
78 */
79 export function npmReachable () {
80 const r = npmSync ( "npm" , [ "ping" , "--no-fund" , "--no-audit" ], { encoding: "utf8" , timeout: 30_000 , env: { ... process.env, CI: "1" } });
81 return { ok: r.status === 0 , detail: r.status === 0 ? null : failure (r, "npm ping failed" ). trim (). slice ( - 200 ) };
82 }
83
84 /**
85 * Whether a build can travel from this shell: the upload tool's presigned URLs are on Amazon S3, and a
86 * sandbox limited to a few hosts gets a proxy's answer there instead of S3's (`Server: AmazonS3`). Measured
87 * with one HEAD request, twenty-five seconds at most (a sandbox proxy can take ten seconds to answer, and a
88 * short limit reads an open sandbox as closed); the client's attachment carrier is not measurable here.
89 */
90 export async function s3Reachable () {
91 // curl first: it is what the shell's upload would use and it honours the shell's proxy settings.
92 // S3 answered when its own headers are there: `x-amz-request-id` always, `Server: AmazonS3` unless a
93 // proxy in front rewrote the Server header (one chat host's proxy answers `server: envoy` and keeps the
94 // x-amz headers). A proxy that refuses the host answers with neither.
95 const fromS3 = ( headers ) => / ^ x-amz-request-id:/ im . test (headers) || / ^ server: \s * amazons3/ im . test (headers);
96 const c = spawnSync ( "curl" , [ "-sI" , "-m" , "25" , "https://s3.amazonaws.com" ], { encoding: "utf8" });
97 if (c.status === 0 && c.stdout) {
98 const server = (c.stdout. match ( / ^ server: \s * ( . + ) $ / im ) ?? [])[ 1 ]?. trim () ?? "" ;
99 const amz = / ^ x-amz-request-id:/ im . test (c.stdout);
100 return { ok: fromS3 (c.stdout), detail: `${ c . stdout . split ( " \n " )[ 0 ]. trim () }${ server ? `, server: ${ server }` : ""}${ amz ? ", x-amz-request-id present" : ", no S3 headers"}` };
101 }
102 if (c.status !== null && c.error?.code !== "ENOENT" ) return { ok: false , detail: `curl exited ${ c . status }` };
103 try {
104 const r = await fetch ( "https://s3.amazonaws.com" , { method: "HEAD" , redirect: "manual" , signal: AbortSignal. timeout ( 25_000 ) });
105 const server = r.headers. get ( "server" ) ?? "" ;
106 const amz = r.headers. has ( "x-amz-request-id" );
107 return { ok: amz || /amazons3/ i . test (server), detail: `status ${ r . status }${ server ? `, server: ${ server }` : ""}${ amz ? ", x-amz-request-id present" : ", no S3 headers"}` };
108 } catch (e) {
109 return { ok: false , detail: String (e?.cause?.code ?? e?.message ?? e). slice ( 0 , 120 ) };
110 }
111 }
112
113 const ENV_PULL = [ "-y" , WIX_CLI , "env" , "pull" ];
114 const runPull = ( dir ) => npmSync ( "npx" , ENV_PULL , { cwd: dir, env: { ... process.env, CI: "1" }, encoding: "utf8" , timeout: 180_000 });
115
116 /**
117 * `wix env pull` into `cwd/.env.local`, in place. Every project shape gets the command since Wix CLI
118 * 1.1.253 (2026-09-29): before it, a config with `site.outputDirectory` (the static stack, a site
119 * published through the drop flow) landed in a limited command set without `env`, and this pulled
120 * through a temp copy of the config. Non-interactive; a failure comes back as `error`.
121 */
122 export function pullEnv ( cwd ) {
123 const envFile = join (cwd, ".env.local" );
124 const r = runPull (cwd);
125 if (r.status === 0 && existsSync (envFile)) return { ok: true , via: "in place" };
126 return { ok: false , error: failure (r, `env pull produced no .env.local — is the Wix CLI logged in? (npx ${ WIX_CLI } whoami)` ). trim (). slice ( - 400 ) };
127 }
128
129 /**
130 * Whether a frontend exists in this folder: a `package.json` (a bundled project), an `index.html` at
131 * the root (plain pages), or an `index.html` inside the folder the config's `site.outputDirectory`
132 * names (a static site laid out for release, pages under site/). `publicIndex` and `astroPages` tell
133 * a dropped site on its Wix Dev Machine apart from an Astro project: the machine copies the drop's
134 * pages into public/ of an Astro starter that has no src/pages/. Nothing else counts — not this
135 * skill's code, not the skills folder, not AGENTS.md: those say who ran here, not what is here.
136 */
137 export function frontendPresent ( cwd = process. cwd ()) {
138 const has = ( p ) => existsSync ( join (cwd, p));
139 const outDir = ( readWixConfig (cwd)?.site?.outputDirectory ?? "" ). replace ( / ^ \.\/ / , "" ). replace ( / \/ $ / , "" );
140 return {
141 packageJson: has ( "package.json" ),
142 rootIndex: has ( "index.html" ),
143 outputIndex: !! outDir && outDir !== "." && has ( join (outDir, "index.html" )),
144 outputDirectory: outDir || null ,
145 publicIndex: has ( join ( "public" , "index.html" )),
146 astroPages: has ( join ( "src" , "pages" )),
147 };
148 }
149
150 /**
151 * The Wix CLI's blank Astro starter (`wix-astro-blank`, templates/blank/project), still as the CLI
152 * left it: every package it declares is one of the blank template's. That is the one frontend the
153 * kit knows carries no work of its own — a Dev Machine's never-released site, a bare `wix create`
154 * — so a vertical's composed template can take its place (deploy.mjs). A starter with any package
155 * of its own is someone's project. The blank template's package.json is read when the templates
156 * are on disk; otherwise the starter's known package names stand in.
157 */
158 const BLANK_STARTER = {
159 name: "wix-astro-blank" ,
160 packages: [ "@astrojs/react" , "@types/react" , "@types/react-dom" , "@wix/astro" , "@wix/astro-pages" , "@wix/astro-wix-hosting-adapter" , "@wix/cli" , "@wix/dashboard" , "@wix/essentials" , "@wix/sdk" , "astro" , "react" , "react-dom" , "typescript" ],
161 };
162 export function blankStarter ( cwd = process. cwd (), blankPkgPath = null ) {
163 const pkgPath = join (cwd, "package.json" );
164 if ( ! existsSync (pkgPath)) return false ;
165 let pkg;
166 try { pkg = JSON . parse ( readFileSync (pkgPath, "utf8" )); } catch { return false ; }
167 if (pkg.name !== BLANK_STARTER .name) return false ;
168 let known = BLANK_STARTER .packages;
169 if (blankPkgPath && existsSync (blankPkgPath)) {
170 try {
171 const blank = JSON . parse ( readFileSync (blankPkgPath, "utf8" ));
172 known = Object. keys ({ ... blank.dependencies, ... blank.devDependencies });
173 } catch { /* the known names stand in */ }
174 }
175 return Object. keys ({ ... pkg.dependencies, ... pkg.devDependencies }). every (( name ) => known. includes (name));
176 }
177
178 /**
179 * What the folder IS, from a few file facts — the one classification every script and SKILL.md
180 * step 3 share: wix.config.json, its site.outputDirectory, the migration variables in .env.local,
181 * package.json, index.html (at the root or in public/), src/pages/. `migrationActive` comes from
182 * siteContext (it needs `.env.local`).
183 *
184 * Two axes decide it: whether a site is bound to the folder (the config), and what frontend is
185 * here (`facts.frontend`: none, the CLI's untouched blank starter, the starter with plain pages in
186 * public/, plain pages with no bundler, or someone's project). The migration flag is read first.
187 * Anything the rows below do not name is `wix-project`, the fill-only path: never scaffold, init,
188 * reseed or delete.
189 *
190 * empty nothing that reads as a project → setup CREATE: the run makes the site, seeds the plan
191 * project a frontend, no wix.config.json → setup ADOPT: `init` gives it a new, empty site, seeds the plan
192 * config-only a config, no frontend → attach.mjs on the config's site; nothing seeded
193 * migration a config whose .env.local declares an ACTIVE editor migration, with or without the blank
194 * Astro starter the download carries → setup MIGRATE; nothing seeded. Decided before the
195 * frontend test: the starter's package.json must not read as a project to iterate on
196 * static-in-astro a config, a package.json, public/index.html and no src/pages/ (a site published
197 * through the drop flow, on its Wix Dev Machine) → the pages stay plain files in
198 * public/; the static REST layer deploys beside them; build and release
199 * blank-starter a config and the CLI's blank starter with no work of its own (a Dev Machine's
200 * never-released site, a bare `wix create`) → attach.mjs on the config's site: the
201 * vertical's composed template takes the starter's place, install and seed run detached
202 * wix-project a config AND a frontend (package.json, or index.html in the output folder) → iterate:
203 * never scaffold, init or reseed; deploy.mjs adds a solution, edits, release
204 * published-static a config, index.html at the ROOT, no package.json, no laid-out output folder (a site
205 * published through the drop flow and downloaded) → setup makes site/ the upload and
206 * deploys the REST layer; the config's site, no init, nothing seeded
207 */
208 export function folderShape ( cwd = process. cwd (), { migrationActive = false } = {}) {
209 const config = existsSync ( join (cwd, "wix.config.json" ));
210 const f = frontendPresent (cwd);
211 const starter = f.packageJson && blankStarter (cwd);
212 const next = {
213 empty: "setup.mjs --vertical <v> --business-name <brand> [--plan]: creates the site here and seeds the plan (CREATE)" ,
214 project: "setup.mjs --vertical <v> --stack <stack> [--plan]: init links the folder to a new, empty site, seeds the plan and deploys (ADOPT)" ,
215 "config-only" : "attach.mjs: the site exists and has no frontend yet; read what it holds (the vertical's read-site.mjs) — nothing is seeded; the vertical's seed module with a plan only when the brief supplies or describes content" ,
216 migration: "setup.mjs (MIGRATE): the shipped code into the starter the download carries (or the composed template around a bare config); the site being migrated owns its content — guides/migration.md" ,
217 "static-in-astro" : "a dropped site on its Dev Machine: the pages are plain files in public/, served as they are. deploy.mjs <vertical…> --stack static --out public puts the REST layer in public/js/wix/; wire the pages in place with <script type= \" module \" >: do the least that makes them work: connect each control the pages show and add only what a control can't work without (more only when the brief asks), and no Astro pages. Read the site (read-site.mjs); run the vertical's seed module with a plan only for content the brief supplies or describes. Release with npx @wix/cli@latest build && npx @wix/cli@latest release; the URL stays" ,
218 "blank-starter" : "attach.mjs --business-name <brand> --vertical <v…> [--plan plan.json] (the site comes from wix.config.json): the CLI's blank starter with no work of its own, as a Dev Machine or a bare wix create leaves it. attach deploys the vertical onto it with its composed lock and starts the install and the seed detached: build the brand layer while they run, then sync on .install-exit and .seed-exit before release (SKILL.md step 3). Read the site first (read-site.mjs) when it may already hold content" ,
219 "wix-project" : "iterate: never scaffold, init or reseed. deploy.mjs <vertical…> --stack <stack> adds a solution, then one install: node <SKILL_ROOT>/install/install.mjs (it skips when node_modules already satisfies the lock); file edits for a change; release. Read the site (read-site.mjs) before any seed module runs. On the plugin route, a project downloaded from the site's Code Store repository has no .env.local: setup.mjs --vertical <v> --site <siteId> --app <appId> writes it (and installs when node_modules is missing); the release is UploadHeadlessWebsiteFiles with the downloaded revision as expectedHeadRevisionId (SKILL.md step 5)" ,
220 "published-static" : "setup.mjs --vertical <v>: the config's site, no init; site/ becomes the upload and the REST layer lands in site/js/wix/; move the pages, styles and assets into site/. Nothing is seeded: read the site, then run the vertical's seed module with a plan when the brief gives content; release keeps the URL" ,
221 };
222 let shape;
223 if ( ! config) shape = f.packageJson || f.rootIndex ? "project" : "empty" ;
224 else if (migrationActive) shape = "migration" ;
225 else if (f.packageJson && f.publicIndex && ! f.astroPages) shape = "static-in-astro" ;
226 else if (starter) shape = "blank-starter" ;
227 else if (f.packageJson || f.outputIndex) shape = "wix-project" ;
228 else if (f.rootIndex) shape = "published-static" ;
229 else shape = "config-only" ;
230 const frontend = ! (f.packageJson || f.rootIndex || f.outputIndex) ? "none"
231 : starter ? (f.publicIndex && ! f.astroPages ? "starter+static" : "starter" )
232 : f.packageJson ? "project"
233 : "static" ;
234 return { shape, next: next[shape], facts: { config, frontend, starter, ... f } };
235 }
236
237 /**
238 * `{ folder: { shape, next, tells }, deploy: { siteId, appId }, content: { siteId, clientId },
239 * migration: { active, parentSiteId }, env: { file, present, pulled }, pullError?, warnings: [] }`.
240 * `pull`: "auto" (default) pulls when `.env.local` is missing; true always; false never.
241 */
242 export function siteContext ({ cwd = process. cwd (), pull = "auto" } = {}) {
243 const config = readWixConfig (cwd) ?? {};
244 const deploy = { siteId: config.siteId ?? config.projectId ?? null , appId: config.appId ?? null };
245 const envFile = join (cwd, ".env.local" );
246 let pulled = false , pullError;
247 // Only a machine with a CLI session can pull; on the plugin route setup.mjs wrote the env file.
248 if (deploy.siteId && publisher () === "cli" && (pull === true || (pull === "auto" && ! existsSync (envFile)))) {
249 const r = pullEnv (cwd);
250 if (r.ok) pulled = r.via;
251 else pullError = r.error;
252 }
253 const env = readEnvFile (envFile) ?? {};
254 const parentSiteId = env[ ENV .parentSiteId] || null ;
255 const status = env[ ENV .status];
256 // ACTIVE (or a parent id with no status yet) is a migration under way; COMPLETED means the parent
257 // already serves this frontend and the project is an ordinary one again.
258 const active = !! parentSiteId && (status === undefined || status. trim (). toUpperCase () === "ACTIVE" );
259 const migration = { active, parentSiteId: active ? parentSiteId : null , ... (status ? { status: status. trim (). toUpperCase () } : {}) };
260 const clientId = env[ ENV .clientId] || deploy.appId;
261 const content = { siteId: active ? parentSiteId : deploy.siteId, clientId };
262 const warnings = [];
263 if (env[ ENV .clientId] && deploy.appId && env[ ENV .clientId] !== deploy.appId && ! active && migration.status !== "COMPLETED" ) {
264 warnings. push ( `.env.local ${ ENV . clientId } differs from wix.config.json appId and no migration is declared — the SDK client runs as the env's app, the release goes to the config's site` );
265 }
266 if (active && migration.parentSiteId === deploy.siteId) {
267 warnings. push ( "the migration's parent site is the deploy site itself — nothing is being migrated" );
268 }
269 return { folder: folderShape (cwd, { migrationActive: active }), publisher: publisher (), npm: npmReachable (), deploy, content, migration, env: { file: envFile, present: Object. keys (env). length > 0 , pulled }, ... (pullError ? { pullError } : {}), warnings };
270 }
271
272 // ---- CLI ----------------------------------------------------------------------------------------
273 if (process.argv[ 1 ] && /context \. mjs $ / . test (process.argv[ 1 ])) {
274 const argv = process.argv. slice ( 2 );
275 const pull = argv. includes ( "--no-pull" ) ? false : argv. includes ( "--refresh" ) ? true : "auto" ;
276 // The carrier is measured only when printing: the scripts that read siteContext() do not decide the route.
277 const ctx = siteContext ({ pull });
278 ctx.carrier = { s3: await s3Reachable () };
279 console. log ( JSON . stringify (ctx, null , 2 ));
280 }