Setting the file. One moment.
Utils · Wix Base44 Connector · wix/skills · Skills Docs
ContentsBack to the top of the page — line 94
This file
Number 3.2
Position 2 of 2
Type JavaScript
Size 27 KB
Lines 463 scripts/ utils.cjs
JavaScript · 463 lines · 27 KB
// round needed), pipelines for the rest (GNU grep/sed; awk is mawk; no rg).
14 //
15 // API transports return data directly; oversized context reports are saved for reading.
16
17 const fs = require ( "fs" );
18 const path = require ( "path" );
19
20 const BUDGET = 4000 ;
21 const SCRATCH = ".agents/skills/wix-base44-connector/tmp" ;
22
23 const clip = ( out ) => {
24 if ( typeof out === "string" )
25 return out. length <= BUDGET ? out : { truncated: true , total: out. length , head: out. slice ( 0 , BUDGET ) };
26 // absence must be visible: JSON.stringify silently ERASES undefined keys, so a probe like
27 // { lineItems: resp.cart?.lineItems } loses the very field that proves the call failed —
28 // render undefined as null instead
29 const s = JSON . stringify (out, ( k , v ) => v === undefined ? null : v);
30 return s. length <= BUDGET ? JSON . parse (s) : { truncated: true , total: s. length , head: s. slice ( 0 , BUDGET ) };
31 };
32
33 // One transport for every JSON call — Content-Type, optional Bearer, ok-guard. One per verb, so a
34 // PATCH/GET/PUT/DELETE endpoint is a helper call, never a hand-rolled fetch. Admin calls are these
35 // with the connector token: patch(publicUrl, body, accessToken).
36 async function req ( method , url , body , token ) {
37 const r = await fetch (url, { method,
38 ... (body !== undefined && { body: JSON . stringify (body) }),
39 headers: { "Content-Type" : "application/json" , ... (token && { Authorization: `Bearer ${ token }` }) } });
40 if ( ! r.ok) {
41 const head = ( await r. text ()). slice ( 0 , 300 );
42 const guidance = r.status === 401 || r.status === 403
43 ? " — check the endpoint's required caller identity, token validity, and permissions"
44 : r.status >= 400 && r.status < 500
45 ? " — read the API error and endpoint contract before changing the call"
46 : "" ;
47 throw new Error (r.status + " " + head + guidance);
48 }
49 return r. json ();
50 }
51 const post = ( url , body , token ) => req ( "POST" , url, body, token);
52 const get = ( url , token ) => req ( "GET" , url, undefined , token);
53 const patch = ( url , body , token ) => req ( "PATCH" , url, body, token);
54 const put = ( url , body , token ) => req ( "PUT" , url, body, token);
55 const del = ( url , token ) => req ( "DELETE" , url, undefined , token);
56
57 function save ( name , text ) {
58 const dir = SCRATCH ;
59 fs. mkdirSync (dir, { recursive: true });
60 fs. writeFileSync (path. join (dir, name), text);
61 return { path: SCRATCH + "/" + name, bytes: Buffer. byteLength (text), lines: text. split ( " \n " ). length };
62 }
63
64 const outlineOf = ( lines , cap = 30 ) => {
65 const heads = [];
66 lines. forEach (( t , i ) => { if ( / ^ # {1,3} / . test (t)) heads. push ({ line: i + 1 , text: t. trim (). slice ( 0 , 80 ) }); });
67 return { outline: heads. slice ( 0 , cap), outlineOmitted: Math. max ( 0 , heads. length - cap) };
68 };
69
70 // A ref is a saved path or the URL it came from — resolve to lines, fetching+saving
71 // URLs on first touch (the .md suffix is appended for extensionless docs URLs).
72 // two path segments, not one: doc leaves repeat across products (every API has an
73 // introduction.md), and a one-segment name makes resolveRef's cache return the WRONG document
74 const slugOf = ( url ) => url. replace ( / \? . *$ / , "" ). replace ( / \. md $ / , "" )
75 . split ( "/" ). filter (Boolean). slice ( - 2 ). join ( "-" ) + ".md" ;
76 async function resolveRef ( ref ) {
77 const isUrl = / ^ https ? :/ . test (ref);
78 const name = isUrl ? slugOf (ref) : ref. split ( "/" ). pop ();
79 const file = path. join ( SCRATCH , name);
80 if (fs. existsSync (file)) return { path: SCRATCH + "/" + name, lines: fs. readFileSync (file, "utf8" ). split ( " \n " ) };
81 if ( ! isUrl) throw new Error ( "no such saved file: " + ref + " — pass a returned path or the source URL" );
82 const mdUrl = / \. [a-z] {2,5}$ / . test (ref. replace ( / \? . *$ / , "" )) ? ref : ref. replace ( / \? . *$ / , "" ) + ".md" ;
83 const res = await fetch (mdUrl);
84 if ( ! res.ok) throw new Error (res.status + " on " + mdUrl + " — not a docs page; take URLs from output, don't compose" );
85 const text = await res. text ();
86 const saved = save (name, text);
87 return { path: saved.path, lines: text. split ( " \n " ) };
88 }
89
90 // ── gather context ────────────────────────────────────────────────────────────
91
92 // Return the full dynamic context report inline when it fits. Larger reports use the
93 // same saved-file and heading-outline format as documentation pages.
94 async function context ( token ) {
95 const { markdown } = await post (
96 "https://www.wixapis.com/_api/dynamic-context/v1/dynamic-context/markdown" , {}, token);
97 if (markdown. length <= BUDGET ) return markdown;
98 const saved = save ( "site-context-" + require ( "crypto" ). randomUUID () + ".md" , markdown);
99 return { ... saved, ... outlineOf (markdown. split ( " \n " )) };
100 }
101
102 // The wix-manage skill, when it is installed in the sandbox, is these same recipes on disk —
103 // a read_file instead of a fetch. Indexed by frontmatter name AND filename slug, because the
104 // docs title drifts from the local one ("…Connect a domain" vs "…Connect").
105 const RECIPE_ROOT = ".agents/skills/wix-manage/references" ;
106 const rkey = ( s ) => String (s || "" ). toLowerCase (). replace ( / [ ^ a-z0-9] / g , "" );
107 let LOCAL_RECIPES ;
108 function localRecipes () {
109 if ( LOCAL_RECIPES ) return LOCAL_RECIPES ;
110 LOCAL_RECIPES = new Map ();
111 if ( ! fs. existsSync ( RECIPE_ROOT )) return LOCAL_RECIPES ;
112 const walk = ( rel ) => {
113 for ( const e of fs. readdirSync ( RECIPE_ROOT + rel, { withFileTypes: true })) {
114 if (e. isDirectory ()) { walk (rel + "/" + e.name); continue ; }
115 if ( ! e.name. endsWith ( ".md" )) continue ;
116 const file = RECIPE_ROOT + rel + "/" + e.name;
117 const name = (fs. readFileSync (file, "utf8" ). slice ( 0 , 600 ). match ( / ^ name: \s * " ? ( . +? )" ? \s *$ / m ) || [])[ 1 ];
118 if (name) LOCAL_RECIPES . set ( rkey (name), file);
119 LOCAL_RECIPES . set ( rkey (e.name. replace ( / \. md $ / , "" )), file);
120 }
121 };
122 walk ( "" );
123 return LOCAL_RECIPES ;
124 }
125 // title, else the docsUrl slug, else either one as a prefix of the other
126 function recipeFile ( title , docsUrl ) {
127 const idx = localRecipes ();
128 if ( ! idx.size) return undefined ;
129 const slug = (docsUrl || "" ). replace ( / \/ +$ / , "" ). split ( "/" ). pop ();
130 for ( const k of [ rkey (title), rkey (slug)]) if (k && idx. has (k)) return idx. get (k);
131 const t = rkey (title);
132 if (t. length >= 12 ) for ( const [ k , v ] of idx) if (k. startsWith (t) || t. startsWith (k)) return v;
133 return undefined ;
134 }
135
136 // ── find what to read ─────────────────────────────────────────────────────────
137
138 // Browse the docs tree — deterministic. menuUrl alone orients (children + counts);
139 // filter before listing methods. An oversized listing is saved with its outline.
140 async function browse ( menuUrl , { include , filter , depth } = {}) {
141 const { content } = await post ( "https://www.wixapis.com/mcp-docs-search/v1/docs/menu/browse" , {
142 menu_url: menuUrl, ... (include && { include }),
143 ... (filter && { name_filter: filter }), ... (depth && { depth }),
144 }); // 404 "No menu node found" ⇒ re-orient a level up
145 if (content. length <= BUDGET ) return content;
146 const s = save ( "browse-" + (menuUrl. replace ( / \/ +$ / , "" ). split ( "/" ). pop () || "root" ) + ".md" , content);
147 return { ... s, next: `wx.bash("grep -in 'term' ${ s . path } | head -40") // one line per node — or re-browse with a filter` };
148 }
149
150 // Semantic search — ranks, never says "no match". The reduced hits come back inline AND the full
151 // raw content is saved for grep/window follow-ups. { type } picks the corpus, one per request:
152 // REST (default) · SKILLS · WIX_HEADLESS · SDK · VELO · CLI · WDS · BUILD_APPS · OVERVIEW ·
153 // BUSINESS_SOLUTIONS. A REST search also runs SKILLS and WIX_HEADLESS — the management recipes appear as recipe hits
154 // alongside methods and articles, and land in the saved file whole. Each method hit lists the worked
155 // requests the docs publish for it; every line number reads with read_file(path, offset: <line>).
156 async function search ( term , { type = "REST" , max = 15 , lines = 0 , recipes = type === "REST" , headless = type === "REST" } = {}) {
157 const document_types = [ ...new Set ([type, ... (recipes ? [ "SKILLS" ] : []), ... (headless ? [ "WIX_HEADLESS" ] : [])])];
158 // Keep full documentation in the saved file; compact only the inline index.
159 const { content } = await post ( "https://www.wixapis.com/mcp-docs-search/v1/docs/search/markdown" ,
160 { search_term: term, document_types, maximum_results: max, lines_in_each_result: lines });
161 const nl = []; // newline offsets — a match's char offset becomes its line in the saved file
162 for ( let i = content. indexOf ( " \n " ); i >= 0 ; i = content. indexOf ( " \n " , i + 1 )) nl. push (i);
163 const lineAt = ( off ) => { let lo = 0 , hi = nl. length ; while (lo < hi) { const m = (lo + hi) >> 1 ; nl[m] < off ? lo = m + 1 : hi = m; } return lo + 1 ; };
164 // recipes are articles — no "# Method:" header, no code-example delimiters, and no fixed body
165 // shape. Two things every one of them has: headings, and the endpoints it calls. Those are the
166 // outline — enough to tell whether this is the recipe for the task without reading 400 lines.
167 const parseRecipe = ( b , at ) => {
168 const rows = b. split ( " \n " );
169 const steps = [], calls = [];
170 let verb = null ;
171 for ( const t of rows) {
172 if ( / ^ # {1,4} / . test (t) && ! / ^ #### \[ |^ ## (Resource | Article | Article Link | Article Content):/ . test (t))
173 steps. push (t. replace ( / ^ # + / , "" ). trim (). slice ( 0 , 52 ));
174 const c = t. match ( /curl \s + -X \s + (GET | POST | PATCH | PUT | DELETE)/ i );
175 const u = t. match ( /https: \/\/ www \. wixapis \. com \/ [ ^ \s"'`) \\ ] + / );
176 if (c && ! u) { verb = c[ 1 ]. toUpperCase (); continue ; } // curl -X VERB, url on the next line
177 if ( ! u) continue ;
178 const v = (c && c[ 1 ]. toUpperCase ()) || (t. match ( / \b (GET | POST | PATCH | PUT | DELETE) \b / ) || [])[ 1 ] || verb || "" ;
179 verb = null ;
180 const call = (v + " " + u[ 0 ]. replace ( / \{ [ ^ }] * \} | < [ ^ >] * >/ g , "{id}" )). trim ();
181 if ( ! calls. includes (call)) calls. push (call);
182 }
183 const title = (b. match ( / ^ ## Resource: ( . + ) $ / m ) || [])[ 1 ];
184 const docsUrl = (b. match ( /#### \[ [ ^ \] ] + \]\( (https: [ ^ )] + ) \) / ) || [])[ 1 ];
185 return { recipe: title, docsUrl,
186 ... ( recipeFile (title, docsUrl) && { file: recipeFile (title, docsUrl) }),
187 line: at < 0 ? 1 : lineAt (at), lines: rows. length ,
188 ... (steps. length && { steps: steps. slice ( 0 , 6 ) }),
189 ... (calls. length && { calls: calls. slice ( 0 , 4 ) }) };
190 };
191 const articleOutline = ( block , start ) => {
192 const outline = [];
193 let fenced = false , offset = 0 ;
194 for ( const row of block. split ( " \n " )) {
195 if ( / ^ \s * (``` | ~~~)/ . test (row)) fenced = ! fenced;
196 const heading = ! fenced && row. match ( / ^ # {2,3} ( . + ) $ / );
197 if (heading && ! / ^ (Resource | Article | Article Link | Article Content):/ . test (heading[ 1 ])) {
198 outline. push ({ title: heading[ 1 ]. slice ( 0 , 64 ), line: lineAt (start + offset) });
199 if (outline. length === 3 ) break ;
200 }
201 offset += row. length + 1 ;
202 }
203 return outline;
204 };
205 let cursor = 0 ;
206 const hits = content. split ( / \n --- \n + (?=#### )/ ). map ( b => {
207 const start = content. indexOf (b, cursor); cursor = start + b. length ;
208 const examples = [ ... b. matchAll ( /--- Code Example: ( . +? ) ---/ g )]
209 . map ( m => ({ title: m[ 1 ]. trim (), line: lineAt (start + m.index) }));
210 const docsUrl = (b. match ( /#### \[ [ ^ \] ] + \]\( (https: [ ^ )] + ) \) / ) || [])[ 1 ];
211 const method = (b. match ( / ^ # Method: ( . + ) $ / m ) || [])[ 1 ];
212 if ( ! method && docsUrl && new URL (docsUrl).pathname. includes ( "/skills/" )) return parseRecipe (b, start);
213 // the REST corpus mixes guides in with the methods — an article has no method header, so
214 // name it from its own title rather than returning a row of nulls
215 if ( ! method) return { article: (b. match ( / ^ ## (?:Resource | Article): ( . + ) $ / m ) || [])[ 1 ], docsUrl,
216 line: start < 0 ? 1 : lineAt (start),
217 outline: articleOutline (b, start) };
218 return {
219 method,
220 endpoint: (b. match ( / ^ # Method API Endpoint: ( . + ) $ / m ) || [])[ 1 ], // "VERB url" — read the verb + url; call wx.<verb>(url, body, token)
221 docsUrl,
222 gist: (() => {
223 const description = ((b. match ( /## Method Description: \s * \n ( [\s\S] *? )(?= \n ## |$ )/ ) || [])[ 1 ] || "" )
224 . trim (). replace ( / \s + / g , " " );
225 return description. length > 160 ? description. slice ( 0 , 159 ). trimEnd () + "…" : description;
226 })(),
227 ... (examples. length && { examples }),
228 }; }). filter ( h => h.docsUrl);
229 const saved = save ( "search-" + term. toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ). slice ( 0 , 40 ) + ".md" , content);
230 if ( ! hits. length ) return clip ({ ... saved, head: content. slice ( 0 , 1200 ),
231 note: `no result blocks parsed — raw head above; wx.bash("grep -in 'term' ${ saved . path }") for the rest` });
232 // Keep the service's combined ranking/interleave; retain the first occurrence of each URL.
233 const seen = new Set ();
234 const ordered = hits. filter ( h => ! seen. has (h.docsUrl) && seen. add (h.docsUrl));
235 const recipeRows = ordered. filter ( h => h.recipe);
236 const uniq = ordered. filter ( h => ! h.recipe);
237 const out = { ... saved, hits: ordered };
238 // over budget, shed enrichment rather than structure — clip would drop the whole shape, and
239 // every title, URL and line number stays useful with the outlines gone
240 for ( const shed of [() => recipeRows. forEach ( r => delete r.calls),
241 () => recipeRows. forEach ( r => delete r.steps),
242 () => uniq. forEach ( h => { if (h.examples) h.examples = h.examples. slice ( 0 , 3 ); }),
243 () => uniq. forEach ( h => delete h.examples),
244 () => uniq. forEach ( h => delete h.gist),
245 () => uniq. forEach ( h => delete h.outline)]) {
246 if ( JSON . stringify (out). length <= BUDGET ) break ;
247 shed ();
248 }
249 // Preserve at least one result of each kind when the inline index needs trimming.
250 while ( JSON . stringify (out). length > BUDGET ) {
251 const kind = h => h.recipe ? "recipe" : h.method ? "method" : "article" ;
252 const counts = out.hits. reduce (( n , h ) => (n[ kind (h)] = (n[ kind (h)] || 0 ) + 1 , n), {});
253 const index = out.hits. findLastIndex ( h => counts[ kind (h)] > 1 );
254 if (index < 0 ) break ;
255 out.hits. splice (index, 1 );
256 out.note = "Additional results are in the saved file." ;
257 }
258 return clip (out);
259 }
260
261 // ── read a page (docs pages and recipes alike) ────────────────────────────────
262
263 // Fetch + save + map in one round: whole text inline when small, else
264 // { path, bytes, lines, outline } — the outline's line numbers feed grep and read_file windows.
265 // Examples come back as their own title+line list, so the outline's cap can never drop them.
266 async function page ( url ) {
267 const { path : p , lines } = await resolveRef (url);
268 const text = lines. join ( " \n " );
269 if (text. length <= BUDGET ) return text;
270 const o = outlineOf (lines);
271 const bytes = Buffer. byteLength (text);
272 const ex = pageExamples (lines);
273 return { path: p, bytes, lines: lines. length , ... (ex. length && { examples: ex }), ... o,
274 next: [
275 `wx.bash("grep -in 'term' ${ p } | head -40")` ,
276 bytes <= 45000 ? `read_file ${ p } // whole (fits the 45K cap), or a window via offset/limit`
277 : `read_file ${ p } with offset/limit // window a section by the outline's lines` ,
278 ] };
279 }
280
281 // The page's Examples section, as titles + line numbers — listed on their own so the
282 // outline's cap can never drop them.
283 function pageExamples ( lines ) {
284 const heads = [];
285 lines. forEach (( t , i ) => { const m = / ^ (# {1,6} ) ( . + ) $ / . exec (t); if (m) heads. push ({ line: i + 1 , level: m[ 1 ]. length , text: m[ 2 ]. trim () }); });
286 const at = heads. findIndex ( h => / ^ (examples ?| method code examples) $ / i . test (h.text));
287 if (at < 0 ) return [];
288 const sec = heads[at], rest = heads. slice (at + 1 );
289 const end = (rest. find ( h => h.level < sec.level) || { line: lines. length + 1 }).line;
290 return rest. filter ( h => h.line < end). map ( h => ({ title: h.text, line: h.line }));
291 }
292
293 // ── shell ─────────────────────────────────────────────────────────────────────
294
295 // Compose native pipelines over .agents/skills/wix-base44-connector/tmp — grep -n, sed -n, mawk, sort, uniq, wc
296 // (GNU grep/sed; awk is mawk; no rg). Cap your own output (| head -40); the return
297 // clips regardless. grep's exit 1 means no match, not failure — the return says so.
298 function bash ( cmd ) {
299 const { execSync } = require ( "child_process" );
300 try {
301 const out = execSync (cmd, { timeout: 15000 , encoding: "utf8" , maxBuffer: 8 * 1024 * 1024 });
302 return out. trim () ? clip (out)
303 : "(no output — a filter may have swallowed the signal; rerun without the reducer)" ;
304 } catch (e) {
305 const exit = e.status ?? null , err = (e.stderr || "" ). toString (). trim ();
306 return { exit, ... (err && { err: err. slice ( 0 , 300 ) }),
307 out: clip ((e.stdout || "" ). toString ()),
308 ... (exit === 1 && ! err && { note: "exit 1 with no stderr — a no-match, not a failure" }) };
309 }
310 }
311
312 // ── request examples ──────────────────────────────────────────────────────────
313
314 // The docs' own working requests: the code-mode index carries them at
315 // methods[].legacyExamples[].content, a doc page under its Examples heading. Both are saved
316 // whole and come back as titles + line numbers — read the one you need with read_file.
317 const examplesOf = ( result ) => {
318 const methods = result?.methods?. length ? result.methods : (result?.legacyExamples ? [result] : []);
319 const out = [];
320 for ( const m of methods) for ( const e of m.legacyExamples || []) {
321 const c = e.content || e;
322 if (c.request) out. push ({ title: c.title || "" , request: typeof c.request === "string" ? c.request : JSON . stringify (c.request, null , 1 ) });
323 }
324 return out;
325 };
326
327 // One file, one heading per example — the returned line is where its body starts.
328 const saveExamples = ( exs , name ) => {
329 const parts = [];
330 let line = 1 , index = [];
331 for ( const e of exs) {
332 const block = "## " + e.title + " \n\n " + e.request + " \n " ;
333 index. push ({ title: e.title, line: line + 2 });
334 parts. push (block);
335 line += block. split ( " \n " ). length ;
336 }
337 return { ... save ( "examples-" + name + ".md" , parts. join ( " \n " )), index };
338 };
339
340 // ── the spec index ────────────────────────────────────────────────────────────
341
342 // Inspect a method's schema — request body, responses, enums, filterable-fields map — plus the
343 // titles of the docs' own request examples, saved together at examplesPath: read the one that
344 // matches your task with read_file(examplesPath, offset: <its line>). Pass a docsUrl
345 // (from search/browse — a direct load, no scan) and spec returns that method's schema. Or pass raw
346 // `async function(){…}` to query the index yourself: lightIndex (RESOURCES with .methods —
347 // operationId, summary, httpMethod, publicUrl [callable], docsUrl) and getResourceSchemaByUrl(docsUrl)
348 // → s.methods (each with requestBody, responses, legacyExamples,
349 // queryMethodData.queryFieldsCapabilitiesMap; $circular via s.components.schemas). A big result is
350 // saved as JSON; grep it for the keys you saw in its head.
351 async function spec ( arg ) {
352 const s = String (arg). trim ();
353 const code = / ^ async function/ . test (s) ? s
354 : / ^ https: \/\/ dev \. wix \. com \/ docs \/ / . test (s) ? "async function(){ return await getResourceSchemaByUrl(" + JSON . stringify (s) + "); }"
355 : "async function(){ " + s + " }" ;
356 const { result , error } = await post ( "https://mcp.wix.com/api/code-mode/search" , { code });
357 // The envelope is 200 even when the query throws — the cause is in `error`, not the status.
358 // Surfacing it matters: swallowing it turns "your code threw" into "nothing matched".
359 if (error)
360 return { error, note: "the query threw — read the message and fix the code; don't re-send it unchanged" };
361 if (result == null || (Array. isArray (result) && ! result. length ))
362 return { result, note: "the query ran but matched nothing — widen it, or check the url resolves" };
363 const text = JSON . stringify (result, null , 1 );
364 if (text. length <= BUDGET ) return result;
365 let h = 5381 ;
366 for ( const ch of code) h = ((h * 33 ) ^ ch. charCodeAt ( 0 )) >>> 0 ; // same query → same file
367 const exs = examplesOf (result);
368 const ex = exs. length ? saveExamples (exs, h. toString ( 36 )) : null ;
369 return { ... save ( "spec-" + h. toString ( 36 ) + ".json" , text),
370 shape: Array. isArray (result) ? `Array(${ result . length })` : Object. keys (result || {}). slice ( 0 , 15 ),
371 ... (ex && { examplesPath: ex.path, examples: ex.index }),
372 head: text. slice ( 0 , 600 ) };
373 }
374
375 // ── management recipes ────────────────────────────────────────────────────────
376
377 // ~100 curated multi-step MANAGEMENT (admin) flows across 23 categories — ecommerce,
378 // bookings, stores, cms, contacts, sites, get-paid, marketing, pricing-plans, events,
379 // blog, forms, restaurants, domains, media, … No arg → categories with counts; a
380 // category name → its recipes; any other term → search every recipe's name + gist.
381 // Read the chosen url with page(url), then grep/window/fields.
382 async function mgmtRecipes ( q ) {
383 // the recipes ARE the wix-manage skill's references — when that skill is installed, both the
384 // listing and the files come straight off disk; the manifest fetch is only the not-installed path
385 const ROOT = ".agents/skills/wix-manage/references" ;
386 let files;
387 if (fs. existsSync ( ROOT )) {
388 files = [];
389 const walk = rel => {
390 for ( const e of fs. readdirSync ( ROOT + rel, { withFileTypes: true })) {
391 if (e. isDirectory ()) { walk (rel + "/" + e.name); continue ; }
392 const file = ROOT + rel + "/" + e.name, head = fs. readFileSync (file, "utf8" ). slice ( 0 , 1000 );
393 files. push ({ path: "references" + rel + "/" + e.name, file, size: fs. statSync (file).size,
394 name: (head. match ( / ^ name: \s * " ? ( . +? )" ? \s *$ / m ) || [])[ 1 ] || e.name,
395 description: (head. match ( / ^ description: \s * " ? ( . +? )" ? \s *$ / m ) || [])[ 1 ] || "" });
396 }
397 };
398 walk ( "" );
399 } else {
400 const { base , files : manifest } = await ( await fetch ( "https://dev.wix.com/docs/skills/manage.manifest.json" )). json ();
401 files = manifest. map ( f => ({ ... f, url: base + f.path }));
402 }
403 const cat = f => (f.path. match ( / ^ references \/ ( [ ^ /] + ) \/ / ) || [])[ 1 ];
404 const row = f => ({ name: f.name, cat: cat (f), gist: (f.description || "" ). slice ( 0 , 120 ),
405 ... (f.file ? { file: f.file } : { url: f.url }), kb: Math. round (f.size / 1024 ) });
406 if ( ! q) {
407 const cats = {};
408 for ( const f of files) { const c = cat (f); if (c) cats[c] = (cats[c] || 0 ) + 1 ; }
409 return cats;
410 }
411 const inCat = files. filter ( f => cat (f) === q);
412 if (inCat. length ) return clip (inCat. map (row));
413 const re = new RegExp (q, "i" );
414 const m = files. filter ( f => re. test (f.name + " " + (f.description || "" )));
415 if ( ! m. length ) {
416 const cats = {};
417 for ( const f of files) { const c = cat (f); if (c) cats[c] = (cats[c] || 0 ) + 1 ; }
418 return { note: `nothing matches "${ q }" — the categories:` , categories: cats };
419 }
420 return clip (m. map (row));
421 }
422
423 // Install a Wix app on the site (Apps Installer). Use when discovery finds an API but its app is
424 // not installed yet — that is a one-call prerequisite, not a dead end. appDefId from search or the
425 // Apps-Created-by-Wix table; siteId from context (the site report).
426 async function installApp ( appDefId , siteId , token ) {
427 return post ( "https://www.wixapis.com/apps-installer-service/v1/app-instance/install" ,
428 { tenant: { tenantType: "SITE" , id: siteId }, appInstance: { appDefId } }, token);
429 }
430
431 // The headless OAuth app: the visitor client id, and the redirect config every Wix-hosted
432 // return needs. Idempotent by name — a site reached by the "connect an existing site" flow
433 // has none, and creating a second one for the same app strands the first app's returns.
434 // Redirect lists are merged, never replaced: an app that already serves a custom domain
435 // keeps it when a preview URL is added.
436 async function ensureOAuthApp ( token , { name , redirectUris = [], redirectDomains = [] }) {
437 const { oAuthApps = [] } = await post (
438 "https://www.wixapis.com/oauth-app/v1/oauth-apps/query" , { query: {} }, token);
439 const existing = oAuthApps. find (( a ) => a.name === name);
440 const merge = ( a = [], b = []) => [ ...new Set ([ ... a, ... b])];
441 if ( ! existing) {
442 const { oAuthApp } = await post ( "https://www.wixapis.com/oauth-app/v1/oauth-apps" , {
443 oAuthApp: { name, allowedRedirectUris: redirectUris, allowedRedirectDomains: redirectDomains },
444 }, token);
445 return { clientId: oAuthApp.id, created: true , oAuthApp };
446 }
447 const allowedRedirectUris = merge (existing.allowedRedirectUris, redirectUris);
448 const allowedRedirectDomains = merge (existing.allowedRedirectDomains, redirectDomains);
449 const unchanged =
450 allowedRedirectUris. length === (existing.allowedRedirectUris || []). length &&
451 allowedRedirectDomains. length === (existing.allowedRedirectDomains || []). length ;
452 if (unchanged) return { clientId: existing.id, created: false , oAuthApp: existing };
453 // The update docs disagree with themselves on the mask field (prose says `paths`, the
454 // curl example says `path`); send both so the call does not silently no-op.
455 const paths = [ "allowedRedirectUris" , "allowedRedirectDomains" ];
456 const { oAuthApp } = await patch (
457 `https://www.wixapis.com/oauth-app/v1/oauth-apps/${ existing . id }` ,
458 { oAuthApp: { id: existing.id, allowedRedirectUris, allowedRedirectDomains },
459 mask: { paths, path: paths. join ( "," ) } }, token);
460 return { clientId: (oAuthApp || existing).id, created: false , oAuthApp: oAuthApp || existing };
461 }
462
463 module . exports = { req, post, get, patch, put, del, clip, context, browse, search, page, bash, spec, mgmtRecipes, installApp, ensureOAuthApp };