@wix/mcp 1.0.94 → 1.0.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/build/bin-standalone.js +1457 -332
  3. package/build/cjs/index.cjs +3379 -1333
  4. package/build/cjs/toolkit.cjs +2158 -373
  5. package/build/dts/api-call/headless-release.d.ts +56 -0
  6. package/build/dts/api-call/headless-release.js +135 -0
  7. package/build/dts/api-call/index.d.ts +9 -1
  8. package/build/dts/api-call/index.js +36 -8
  9. package/build/dts/api-call/site-files.d.ts +14 -0
  10. package/build/dts/api-call/site-files.js +106 -10
  11. package/build/dts/api-call/upload-headless-website-files.d.ts +117 -5
  12. package/build/dts/api-call/upload-headless-website-files.js +565 -20
  13. package/build/dts/api-call/upload-image-tool.d.ts +2 -0
  14. package/build/dts/api-call/upload-image-tool.js +8 -4
  15. package/build/dts/bin.d.ts +1 -1
  16. package/build/dts/bin.js +3 -1
  17. package/build/dts/code-mode/attachments.d.ts +9 -6
  18. package/build/dts/code-mode/attachments.js +23 -16
  19. package/build/dts/code-mode/index.d.ts +6 -1
  20. package/build/dts/code-mode/index.js +32 -12
  21. package/build/dts/code-mode/truncate-response.js +5 -1
  22. package/build/dts/config/default-config.d.ts +35 -5
  23. package/build/dts/config/default-config.js +339 -182
  24. package/build/dts/config/file-params.d.ts +22 -0
  25. package/build/dts/config/file-params.js +24 -0
  26. package/build/dts/config/param-descriptions.d.ts +14 -2
  27. package/build/dts/config/param-descriptions.js +33 -14
  28. package/build/dts/constants.d.ts +1 -0
  29. package/build/dts/constants.js +1 -0
  30. package/build/dts/docs/docs.d.ts +12 -1
  31. package/build/dts/docs/docs.js +274 -43
  32. package/build/dts/docs/fetch-article.d.ts +21 -0
  33. package/build/dts/docs/fetch-article.js +66 -4
  34. package/build/dts/docs/get-to-know.d.ts +7 -2
  35. package/build/dts/docs/get-to-know.js +36 -14
  36. package/build/dts/docs/guessed-url.d.ts +41 -0
  37. package/build/dts/docs/guessed-url.js +185 -0
  38. package/build/dts/docs/mintlify-docs.d.ts +121 -0
  39. package/build/dts/docs/mintlify-docs.js +391 -0
  40. package/build/dts/docs/semanticSearch.d.ts +2 -1
  41. package/build/dts/docs/semanticSearch.js +14 -7
  42. package/build/dts/docs/start-deploy-website.d.ts +7 -6
  43. package/build/dts/docs/start-deploy-website.js +13 -8
  44. package/build/dts/docs/start-headless-build.d.ts +8 -10
  45. package/build/dts/docs/start-headless-build.js +14 -28
  46. package/build/dts/index.d.ts +2 -2
  47. package/build/dts/index.js +2 -2
  48. package/build/dts/infra/redact-personal-data.d.ts +4 -0
  49. package/build/dts/infra/redact-personal-data.js +148 -0
  50. package/build/dts/site-widget-tools/constants.d.ts +50 -2
  51. package/build/dts/site-widget-tools/constants.js +65 -8
  52. package/build/dts/site-widget-tools/index.d.ts +1 -0
  53. package/build/dts/site-widget-tools/index.js +1 -0
  54. package/build/dts/site-widget-tools/pick-images-tool/index.d.ts +14 -0
  55. package/build/dts/site-widget-tools/pick-images-tool/index.js +138 -0
  56. package/build/dts/site-widget-tools/pull-site-creation-job-tool/index.js +1 -1
  57. package/build/dts/site-widget-tools/search-site-templates-tool/index.js +25 -2
  58. package/build/dts/site-widget-tools/site-builder-tool/index.js +35 -3
  59. package/build/dts/site-widget-tools/site-creation-tool/index.d.ts +7 -1
  60. package/build/dts/site-widget-tools/site-creation-tool/index.js +22 -51
  61. package/build/dts/site-widget-tools/suggested-domain-tool/index.js +26 -12
  62. package/build/dts/support/index.d.ts +4 -0
  63. package/build/dts/support/index.js +13 -7
  64. package/build/dts/tool-utils.d.ts +8 -1
  65. package/build/dts/tool-utils.js +26 -2
  66. package/build/dts/toolkit-entry.d.cts +15 -10
  67. package/build/dts/toolkit-entry.d.ts +15 -10
  68. package/build/dts/toolkit.d.ts +2 -2
  69. package/build/dts/toolkit.js +21 -8
  70. package/build/dts/wix-mcp-server.d.ts +10 -1
  71. package/build/dts/wix-mcp-server.js +25 -7
  72. package/build/dts/wixel/constants.d.ts +8 -1
  73. package/build/dts/wixel/constants.js +8 -1
  74. package/build/dts/wixel/index.d.ts +1 -1
  75. package/build/dts/wixel/index.js +2 -2
  76. package/build/dts/wixel/wixel-a2a-client.d.ts +83 -0
  77. package/build/dts/wixel/wixel-a2a-client.js +166 -0
  78. package/build/dts/wixel/wixel-bi.d.ts +2 -1
  79. package/build/dts/wixel/wixel-bi.js +2 -1
  80. package/build/dts/wixel/{external-agent-api.d.ts → wixel-session-entries.d.ts} +11 -33
  81. package/build/dts/wixel/{external-agent-api.js → wixel-session-entries.js} +33 -54
  82. package/build/dts/wixel/wixel-session-tools.d.ts +21 -26
  83. package/build/dts/wixel/wixel-session-tools.js +494 -599
  84. package/build/esm/index.js +3422 -1388
  85. package/build/esm/toolkit.js +2158 -373
  86. package/package.json +4 -4
@@ -5,22 +5,55 @@
5
5
  * `/headless-business/{msid}/drop`. Provisioning the site is NOT done here —
6
6
  * the recipe's step 1 (StartDeployWebsite → provision) stays with the agent.
7
7
  *
8
+ * A Wix Astro build among the files (`.wix/build-metadata.json` beside `dist/`,
9
+ * zipped from the project root) takes headless-business-setup's bundle routes
10
+ * instead, server-side in this handler: `bundle-upload-url` with the zip's size, a
11
+ * PUT of the zip, a PUT of the project's source zip when the upload holds the
12
+ * source and the site's repository takes it, then `release-bundle`. The service
13
+ * provisions the site's managed project and deploys. Same inputs, same siteId.
14
+ *
8
15
  * Takes the same file inputs as ExecuteWixAPI — the `files` text bundle and
9
16
  * ChatGPT `attachments` (openai/fileParams) — and does the work directly: parse,
10
- * unzip, check the site shape, POST, return the live URL. No sandbox, no
17
+ * unzip, check the site shape, upload, return the live URL. No sandbox, no
11
18
  * agent-written code, no runtime dependency.
19
+ *
20
+ * A Wix Astro build from a client that cannot hand a file to the tool (no attachment
21
+ * swap, bytes only as text) has a third form, in two calls over the same routes:
22
+ * `prepare: true` with the build zip's `size` returns presigned PUT URLs for the
23
+ * build zip and the project's source zip (the agent uploads them with curl from its
24
+ * sandbox), and a second call with the `uploadKey` releases the uploaded build
25
+ * server-side. A static site keeps the drop: `files`, `attachments`, or the
26
+ * StartDeployWebsite recipe's own drop. Same schema for every client; the
27
+ * description speaks in capabilities, not names.
12
28
  */
13
29
  import { z } from 'zod';
14
30
  import { AttachmentError, resolveAttachments } from '../code-mode/attachments.js';
15
- import { base64ToBytes, buildFormData, normalizeSiteFiles, parseFileBundle } from './site-files.js';
31
+ import { base64ToBytes, buildFormData, expandSiteFiles, isZip, staticSiteFiles, parseFileBundle, zipStored } from './site-files.js';
32
+ import { buildSourceFiles, findHeadlessBuild, withoutEmptyStatics } from './headless-release.js';
33
+ import { fileParamNote } from '../config/file-params.js';
34
+ import { WIX_BUSINESS_FEATURES } from '../site-widget-tools/constants.js';
35
+ import { NO_PERSONAL_DATA } from '../docs/start-deploy-website.js';
16
36
  import { HEADLESS_GUIDE_URL } from '../docs/start-headless-build.js';
37
+ import { fetchArticleContent } from '../docs/fetch-article.js';
17
38
  import { logger } from '../infra/logger.js';
39
+ import { redactPersonalData } from '../infra/redact-personal-data.js';
18
40
  import { defaultToolDescriptions } from '../config/default-config.js';
19
41
  import { rethrowIfUnauthorized } from '../auth/index.js';
20
42
  import { WixFetchError } from '../wix-fetch-error.js';
21
- import { START_DEPLOY_WEBSITE_PARAM_DESCRIPTIONS, START_DEPLOY_WEBSITE_RECIPE_URL as RECIPE_URL } from '../docs/start-deploy-website.js';
43
+ import { START_DEPLOY_WEBSITE_PARAM_DESCRIPTIONS } from '../docs/start-deploy-website.js';
22
44
  export const UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME = 'UploadHeadlessWebsiteFiles';
23
45
  const HEADLESS_BUSINESS = 'https://www.wixapis.com/headless-business-setup/v1/headless-business';
46
+ /** The largest site zip `bundle-upload-url` presigns (headless-business-setup's MAX_BUNDLE_BYTES). */
47
+ export const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
48
+ /**
49
+ * The static files a build's client folder can hold and still release. Builds of up to 2,550
50
+ * released and every one from 4,818 failed (headless-business-setup logs, October 2026): the
51
+ * deployments service's reply outgrows its 4 MiB gRPC limit well before its 5,000-file cap.
52
+ * Both failures reach us as a bare 500.
53
+ */
54
+ export const MAX_RELEASE_STATIC_FILES = 2_500;
55
+ /** Code Store refuses a larger source zip (`REPOSITORY_ARCHIVE_TOO_LARGE`), and with it the release. */
56
+ export const MAX_SOURCE_ZIP_BYTES = 64 * 1024 * 1024;
24
57
  /**
25
58
  * Attribution the drop endpoint stamps on its upload BI event (`source` from
26
59
  * `campaign`, `agent` from `agent`, both ≤ 64 chars) — the same parser as the
@@ -36,13 +69,36 @@ export function dropAttribution(clientName) {
36
69
  return `?campaign=mcp&agent=${encodeURIComponent(slug || 'wix-mcp')}`;
37
70
  }
38
71
  export const UPLOAD_HEADLESS_WEBSITE_FILES_PARAM_DESCRIPTIONS = {
39
- files: 'The site files as text, so file bytes never pass through a code string. Sections delimited by a line `=== FILE: <path> ===` (text, raw) or `=== FILE: <path> base64 ===` (binary, base64); `<path>` is relative to the site root and must include `index.html`. Use base64 for small binaries (icons, logos, fonts); large images/video belong behind a URL in the HTML, not in the bundle. Optional when `attachments` carries the files.',
40
- attachments: 'ChatGPT only. Files attached in the chat or created in your sandbox — any type: a zip of the whole site, HTML, CSS, JS, images. Pass each file as you have it, its file id or its /mnt/data path, and ChatGPT swaps it for { download_url, file_id, file_name?, mime_type? } before the call reaches Wix. No other client does this swap, so outside ChatGPT a path or file:// URL here fails at once; leave this out and use `files`, or, with a shell that reaches the internet, drop the files with curl as the StartDeployWebsite recipe shows. A zip is unpacked; a top-level folder wrapping the site is stripped. Prefer this over base64 in `files` whenever you have a file handle. Up to 10 MB per call.',
41
- siteId: 'The metaSiteId of the Wix Headless site to upload into: the one returned by the provision call of the StartDeployWebsite recipe for a new site, or of a site deployed earlier to replace its files. Every upload replaces the whole file set, so send all files; the URL stays the same.',
72
+ files: "The site files as text, so file bytes never pass through a code string. Sections delimited by a line `=== FILE: <path> ===` (text, raw) or `=== FILE: <path> base64 ===` (binary, base64); `<path>` is relative to the site root; a static site must include `index.html`. Use base64 for small binaries (icons, logos, fonts); large images/video belong behind a URL in the HTML, not in the bundle. Optional when `attachments` carries the files. A Wix Astro build (`.wix/build-metadata.json` and `dist/` as `astro build` with the Wix hosting adapter wrote them, zipped from the project root) needs no `index.html`: it is released to the site's Wix hosting instead of dropped, and the project's other files in the upload are stored as its source when the site's repository accepts it.",
73
+ attachments: fileParamNote('for a static site use `files`, or drop the files as the StartDeployWebsite recipe shows; for a Wix Astro build from a shell that reaches the internet, zip it, call with `prepare: true` and its `size` and PUT the zips with curl') +
74
+ " Any type: a zip of the whole site, HTML, CSS, JS, images. A zip is unpacked; a top-level folder wrapping the site is stripped. A Wix Astro build zipped with the whole project (minus `node_modules`) is released with its source stored when the site's repository accepts it. Prefer this over base64 in `files` whenever you have a file handle. Up to 10 MB per call.",
75
+ prepare: "Phase one of the two-call upload of a Wix Astro build, for a shell that reaches the internet and no file handle (a sandbox whose network is limited to a few hosts cannot upload to the URLs): zip the build first, then `true` with its `size` returns presigned upload URLs for the build zip and, when the site's repository takes it, the project source zip, as `curl` commands to run verbatim, with the exact second call. The two calls: {siteId, prepare: true, size}, then {siteId, uploadKey, sourceUploadId?, expectedHeadRevisionId?, releaseNotes?}. No `files`, `attachments`, `uploadKey` or `sourceUploadId` on the first; `expectedHeadRevisionId` and `releaseNotes` given on the first are echoed into the second. Not for a static site: send its files in `files` or `attachments`, or drop them as the StartDeployWebsite recipe shows.",
76
+ size: `Phase one, with \`prepare: true\` only, and required there: the build zip's length in bytes (\`wc -c < build.zip\`), 1 to ${MAX_BUNDLE_BYTES} (100 MB). The upload URL takes a body of exactly this length; a zip changed after the prepare needs a new prepare.`,
77
+ uploadKey: 'Phase two of the two-call upload: the `uploadKey` the `prepare` call returned, after the build zip was PUT to its URL. Releases that build to the site: {siteId, uploadKey, sourceUploadId?, expectedHeadRevisionId?, releaseNotes?}. Excludes `files` and `attachments`.',
78
+ sourceUploadId: 'Phase two: the `sourceUploadId` the `prepare` call returned, after the source zip was PUT to its URL. Stores the project source with the release. Needs `uploadKey`; leave it out when the prepare returned no source upload.',
79
+ expectedHeadRevisionId: 'The repository revision your project started from, so the source replaces only that and a commit made since (another chat, a Dev Machine) is never dropped unseen: the revision you downloaded the project at; for a project started here, the `headRevisionId` the `prepare` call returned. Leave it out only for a repository with nothing in it yet. Goes with `uploadKey`, or with `files`/`attachments` holding a build.',
80
+ releaseNotes: 'Optional: one line on what this release changes, at most 200 characters, kept with the deployment of a Wix Astro build, whether it comes in `files`/`attachments` or as the second call with `uploadKey` (given on the `prepare` call it is echoed into the second). A static drop keeps no notes.',
81
+ siteId: 'The metaSiteId of the Wix Headless site to upload into: the one returned by the provision call of the StartDeployWebsite recipe for a new site, or of a site deployed earlier to replace its files. Every upload replaces the whole file set, so send all files; the URL stays the same. A build released here replaces static pages dropped earlier, at the same URL.',
42
82
  calledStartDeployWebsite: 'Whether you called StartDeployWebsite earlier in this conversation and are following the recipe it returned. Answer truthfully; it is recorded, not checked.',
43
83
  agentIdentity: START_DEPLOY_WEBSITE_PARAM_DESCRIPTIONS.agentIdentity,
44
- reason: 'One sentence: the user request that led here and what is being published.'
84
+ reason: `One sentence: the user request that led here and what is being published. ${NO_PERSONAL_DATA}`
45
85
  };
86
+ // The wix-manage skill's recipes, which the success reply points to for what comes next.
87
+ const UPLOAD_RECIPE_SKILL_URL = 'https://www.wix.com/skills/wix-manage/references/sites/upload-static-site.md';
88
+ const DOMAIN_RECIPE_SKILL_URL = 'https://www.wix.com/skills/wix-manage/references/domains/domain-search-purchase-and-connect.md';
89
+ /** The headless guide, inline, after a static drop: the same page StartHeadlessBuild returns. */
90
+ export function formatInlineGuide(guide) {
91
+ if (!guide || !guide.trim())
92
+ return '';
93
+ return [
94
+ '',
95
+ '---',
96
+ '',
97
+ `Wix's headless guide, the build of a site that needs Wix business features (also at https://www.wix.com/headless/skill.md):`,
98
+ '',
99
+ guide.trim()
100
+ ].join('\n');
101
+ }
46
102
  export function formatDropResult(r) {
47
103
  const shown = r.files.slice(0, 60);
48
104
  return [
@@ -52,10 +108,247 @@ export function formatDropResult(r) {
52
108
  `Dashboard: https://manage.wix.com/dashboard/${r.metaSiteId}`,
53
109
  `${r.files.length} file(s): ${shown.join(', ')}${r.files.length > shown.length ? ', …' : ''}`,
54
110
  '',
55
- 'Give the user the live URL and the dashboard link.',
56
- `The upload step is done. For what comes next — changing the site later, connecting a domain, or adding a Wix backend (forms, bookings, a store) to this same site — continue with the guide you are following, from where it left off: the StartDeployWebsite recipe (${RECIPE_URL}) or, for a site built through StartHeadlessBuild, the headless guide (${HEADLESS_GUIDE_URL}).`
111
+ `Give the user both links, each on its own line and named for what it is: the live site, and the site's dashboard. Then offer a custom domain, bought through Wix or one they already own (${DOMAIN_RECIPE_SKILL_URL}).`,
112
+ '',
113
+ `- To change this site later: "Change it later" in ${UPLOAD_RECIPE_SKILL_URL}, with this metaSiteId (${r.metaSiteId}). The same site every time, never a new one.`,
114
+ `- When the site needs Wix business features (${WIX_BUSINESS_FEATURES}), a drop of static pages is not its build: a full Wix Headless site has those features run by Wix and its pages reading from them. That build is Wix's headless guide, https://www.wix.com/headless/skill.md (what StartHeadlessBuild returns; call it with startingPoint "an existing Wix Headless site", this site is one): it installs the solution on this site, seeds its content through the API, and puts up pages that fetch it, uploaded here again. A site with no business features is done.`
115
+ ].join('\n');
116
+ }
117
+ /** The curl that PUTs a zip to its presigned URL, with the headers the route returned. */
118
+ function curlPut(zipName, put, fallback) {
119
+ const headers = Object.entries(put.headers && Object.keys(put.headers).length ? put.headers : fallback);
120
+ return [
121
+ 'curl -X PUT',
122
+ ...headers.map(([k, v]) => `-H "${k}: ${v}"`),
123
+ `--data-binary @${zipName} "${put.putUrl}"`
124
+ ].join(' ');
125
+ }
126
+ const BUILD_ZIP_CONTENTS = 'Build zip, from the project root after `astro build` with the Wix hosting adapter: `dist/` (with `dist/_wix/app-manifest.json`) and `.wix/build-metadata.json`.';
127
+ const SOURCE_ZIP_CONTENTS = 'Source zip, from the project root: the project minus `node_modules`, `dist`, `.wix`, `.astro`, `.env*` and `.agents`.';
128
+ /** The prepare reply: what the agent runs, verbatim, and the exact second call. */
129
+ export function formatPrepareResult(siteId, urls, size, given = {}) {
130
+ const expiry = urls.expiresAt ? ` They expire at ${urls.expiresAt}.` : '';
131
+ const secondCall = {
132
+ siteId,
133
+ uploadKey: urls.uploadKey
134
+ };
135
+ const zipHeaders = {
136
+ 'content-type': 'application/zip',
137
+ ...(size ? { 'content-length': String(size) } : {})
138
+ };
139
+ const lines = [
140
+ `Upload URLs ready for site ${siteId}.${expiry} Two steps, then call this tool again.`,
141
+ '',
142
+ `1. ${BUILD_ZIP_CONTENTS} Its client folder (\`dist/client\`) holds at most ${MAX_RELEASE_STATIC_FILES} files (\`find dist/client -type f | wc -l\`); a larger build fails the release. Upload the zip you measured${size ? ` (${size} bytes)` : ''}; the URL takes a body of exactly that length, so a zip changed since needs a new prepare with its size:`,
143
+ ` ${curlPut('build.zip', urls, zipHeaders)}`
144
+ ];
145
+ // The agent's own revision wins: it is the one its project started from. Given, it is echoed
146
+ // whether or not a source half came back.
147
+ if (given.expectedHeadRevisionId)
148
+ secondCall.expectedHeadRevisionId = given.expectedHeadRevisionId;
149
+ if (urls.source) {
150
+ const s = urls.source;
151
+ secondCall.sourceUploadId = s.uploadId;
152
+ if (!secondCall.expectedHeadRevisionId && s.headRevisionId)
153
+ secondCall.expectedHeadRevisionId = s.headRevisionId;
154
+ lines.push('', `2. ${SOURCE_ZIP_CONTENTS} At most 64 MiB (\`wc -c < source.zip\`): a larger one fails the whole release. Leave large media and generated files out of it; if it is still larger, skip this upload and leave \`sourceUploadId\` out of the second call, and the site is released without its source. Upload it:`, ` ${curlPut('source.zip', s, { 'content-type': 'application/zip' })}`, ...(s.expiresAt && s.expiresAt !== urls.expiresAt
155
+ ? [` This URL expires at ${s.expiresAt}.`]
156
+ : []), ...(s.headRevisionId
157
+ ? [
158
+ ` The repository's head revision is ${s.headRevisionId}. The second call passes \`expectedHeadRevisionId\`: this head for a project started here; if you downloaded the project from the repository, the revision you downloaded it at instead, so a commit made since is never dropped.`
159
+ ]
160
+ : []));
161
+ }
162
+ else {
163
+ lines.push('', "2. The site's repository takes no source this time: the project's source is not uploaded, and the second call carries no `sourceUploadId`.");
164
+ }
165
+ if (given.releaseNotes)
166
+ secondCall.releaseNotes = releaseNotesForService(given.releaseNotes);
167
+ lines.push('', `3. Call UploadHeadlessWebsiteFiles again with ${JSON.stringify(secondCall)} (plus \`calledStartDeployWebsite\`, \`agentIdentity\`, \`reason\`). No \`files\` or \`attachments\` on that call.`);
168
+ if (secondCall.sourceUploadId) {
169
+ const withoutSource = { ...secondCall };
170
+ delete withoutSource.sourceUploadId;
171
+ lines.push(` If you skipped the source upload in step 2, call with ${JSON.stringify(withoutSource)} instead: a \`sourceUploadId\` whose zip was never uploaded fails the release.`);
172
+ }
173
+ return lines.join('\n');
174
+ }
175
+ /** The release-bundle reply, for both the two-call form and a build uploaded as files. */
176
+ export function formatBundleReleaseResult(siteId, r, notes = []) {
177
+ return [
178
+ 'Released; the site is live.',
179
+ `Live URL: ${r.siteUrl ?? '(not returned)'}`,
180
+ `metaSiteId: ${siteId} — pass it as \`siteId\` to release this site again, the same way. The URL stays.`,
181
+ `Dashboard: https://manage.wix.com/dashboard/${siteId}`,
182
+ `Deployment ${r.deploymentId ?? '(id not returned)'}.${r.revision?.id ? ` Source stored as revision ${r.revision.id}.` : ''}`,
183
+ ...notes,
184
+ '',
185
+ 'This release replaced whatever the site served before, at the same URL. A change to the site is a change to the project: edit, build if it is an Astro project, upload again.',
186
+ "Give the user both links, each on its own line and named for what it is: the live site, and the site's dashboard."
57
187
  ].join('\n');
58
188
  }
189
+ const startOver = (retry) => retry === 'prepare'
190
+ ? "call with `prepare: true` and the zip's `size` again, upload the zips to the new URLs, and release with the new `uploadKey`"
191
+ : 'call this tool again with the same files';
192
+ const apiBody = (err) => err.response.data ? ` (${err.response.data.slice(0, 300)})` : '';
193
+ /**
194
+ * The permission every owner route (drop, the upload URL, the release, the files helpers)
195
+ * checks on the caller for the site. A 403 naming it is about the login, not the upload: the
196
+ * connected Wix account is not the one that owns the site, or its role there is too low.
197
+ */
198
+ export const SITE_ACCESS_PERMISSION = 'OAUTH_APP.APP_CREATE';
199
+ /**
200
+ * A 403 for the site itself, as the agent should act on it; null for any other error.
201
+ * `stagedKey` is the uploadKey of a build already PUT when the refusal came from the
202
+ * release: that upload is kept, and counts against the site's unreleased uploads.
203
+ */
204
+ export function describeSiteAccessDenied(err, siteId, stagedKey) {
205
+ if (!(err instanceof WixFetchError) ||
206
+ err.status !== 403 ||
207
+ !err.response.data.includes(SITE_ACCESS_PERMISSION)) {
208
+ return null;
209
+ }
210
+ const site = siteId ? `site ${siteId}` : 'this site';
211
+ const outcome = stagedKey
212
+ ? `Nothing was released; the build you uploaded is still staged under uploadKey ${stagedKey} and counts against the site's unreleased uploads, so do not call \`prepare\` again and do not send the files again. The same call fails the same way until the login changes; once connected as the owning account, release that staged upload by calling with \`uploadKey: "${stagedKey}"\` before preparing a new one.`
213
+ : 'Nothing was uploaded, and the same call fails the same way, so do not retry and do not stage another upload.';
214
+ return `The connected Wix login has no rights on ${site}: Wix refused it the \`${SITE_ACCESS_PERMISSION}\` permission there. That means the site belongs to another Wix account, or this login's role on the owning account is too low (a Basic Team Member cannot upload). ${outcome} Ask the user which Wix account owns the site. Reconnecting Wix helps only if they pick that account, as its owner or an admin, at the consent screen; otherwise the owner has to give this login a higher role on the account, or move the site to it.${apiBody(err)}`;
215
+ }
216
+ /** The bundle-upload-url route's errors, as the agent should act on them. */
217
+ export function describeBundleUploadUrlError(err, siteId) {
218
+ if (!(err instanceof WixFetchError)) {
219
+ return err instanceof Error ? err.message : String(err);
220
+ }
221
+ const denied = describeSiteAccessDenied(err, siteId);
222
+ if (denied)
223
+ return denied;
224
+ const body = apiBody(err);
225
+ switch (err.status) {
226
+ case 400:
227
+ return `The upload URL was refused: \`size\` is the build zip's length in bytes, an integer from 1 to ${MAX_BUNDLE_BYTES} (100 MB). A larger build: leave out what the site does not serve (source maps, unused assets).${body}`;
228
+ case 429:
229
+ return `Too many unreleased uploads are waiting for this site: release one with its \`uploadKey\`, or wait for a running release to finish, then ask again.${body}`;
230
+ default:
231
+ return err.message;
232
+ }
233
+ }
234
+ /**
235
+ * The site token, repeated in the release body: a build whose server code is over the
236
+ * service's gRPC limit is deployed over REST as the caller, the way the CLI deploys, and
237
+ * the gateway does not hand the Authorization header on to the service.
238
+ */
239
+ function deployAuthorization(siteHeaders) {
240
+ const authorization = siteHeaders.Authorization ?? siteHeaders.authorization;
241
+ return authorization ? { deployAuthorization: authorization } : {};
242
+ }
243
+ /** The deployment label the service keeps is 200 characters; longer notes are cut, not refused. */
244
+ export const RELEASE_NOTES_MAX_CHARS = 200;
245
+ export function releaseNotesForService(notes) {
246
+ // Code points, not UTF-16 units, so an emoji at the boundary stays whole.
247
+ const chars = [...notes.replace(/\s+/g, ' ').trim()];
248
+ return chars.length <= RELEASE_NOTES_MAX_CHARS
249
+ ? chars.join('')
250
+ : `${chars
251
+ .slice(0, RELEASE_NOTES_MAX_CHARS - 1)
252
+ .join('')
253
+ .trimEnd()}…`;
254
+ }
255
+ /** The release-bundle route's errors, as the agent should act on them. */
256
+ export function describeBundleReleaseError(err, uploadKey, retry = 'prepare', siteId) {
257
+ if (!(err instanceof WixFetchError)) {
258
+ return err instanceof Error ? err.message : String(err);
259
+ }
260
+ const denied = describeSiteAccessDenied(err, siteId, uploadKey);
261
+ if (denied)
262
+ return denied;
263
+ const body = apiBody(err);
264
+ const code = err.response.data ?? '';
265
+ const sameCall = `calling with \`uploadKey: "${uploadKey}"\`${retry === 'resend' ? ' (plus the `expectedHeadRevisionId` and `releaseNotes` you passed, if any; the source is stored with the next release)' : ' and the same ids and `releaseNotes`'}`;
266
+ // A staged zip is deleted once its release succeeds, and the source it stored moves the
267
+ // repository's head: after a dropped connection, a 404 or a 409 on the same key is the first
268
+ // release having gone through.
269
+ const afterDrop = 'If an earlier call with this `uploadKey` ended in a dropped connection, that release went through: the site is live with this build, so do not upload it again.';
270
+ // The gateway lost the connection or timed out (its own body, not the service's JSON) while
271
+ // the release ran on. The service usually completes it.
272
+ if (err.status >= 502 &&
273
+ err.status <= 504 &&
274
+ !code.includes('RELEASE_NOT_HELD') &&
275
+ !code.includes('{')) {
276
+ return `The connection to Wix dropped while the release ran (HTTP ${err.status}); the release most likely went through. Do not call \`prepare\` again and do not send the files again. Wait two minutes, then check once by ${sameCall}: an answer that nothing is staged for this key, or that the repository moved, means the first release went through and the site is live. Any other answer is about the release itself.${body}`;
277
+ }
278
+ switch (err.status) {
279
+ case 404:
280
+ return `Nothing uploaded for uploadKey ${uploadKey}: the build zip was not PUT to its URL, the URL expired, or a release of it already went through (a staged zip is deleted once released). ${afterDrop} Otherwise, to try again, ${startOver(retry)}.${body}`;
281
+ case 409:
282
+ return `The project's source repository moved since the upload URLs were made; nothing was released by this call. ${afterDrop} Otherwise: ${retry === 'prepare' ? "call with `prepare: true` and the zip's `size` again, re-upload the source zip to the new URL, and release with the new `uploadKey`, `sourceUploadId` and `expectedHeadRevisionId`." : 'download the project again from the repository, carry your change over, and upload with `expectedHeadRevisionId` set to the revision you downloaded.'}${body}`;
283
+ case 403:
284
+ return `Wix refused to deploy this build onto the site's project; nothing was released, and the same call will be refused again. Do not call \`prepare\` again. Tell the user the release is blocked on Wix's side and give them the request id from the error.${body}`;
285
+ case 413:
286
+ case 428:
287
+ return `The build's server code is larger than the release can carry from here; nothing was released. Do not call \`prepare\` again. Tell the user the release is blocked on Wix's side and give them the request id from the error.${body}`;
288
+ case 400:
289
+ if (code.includes('EXPECTED_HEAD_REQUIRED')) {
290
+ return `The site's repository already holds work, and the source replaces only what you started from; nothing was released. ${retry === 'prepare' ? "Call with `prepare: true` and the zip's `size` again and pass the `headRevisionId` it returns as `expectedHeadRevisionId` (with its new `uploadKey` and `sourceUploadId`)." : 'Call again with `expectedHeadRevisionId`: the revision you downloaded the project at, so the source replaces only that. To replace the repository with this upload on purpose, pass its current head.'}${body}`;
291
+ }
292
+ if (code.includes('BUNDLE_TOO_LARGE')) {
293
+ return `The build zip is over ${MAX_BUNDLE_BYTES} bytes (100 MB): leave out what the site does not serve (source maps, unused assets), then ${startOver(retry)}.${body}`;
294
+ }
295
+ if (code.includes('INVALID_RELEASE')) {
296
+ return `The release was refused by Wix's deployment service; nothing was released. The staged upload is kept: call again with the same \`uploadKey\` and the field the message names fixed (release notes are cut to ${RELEASE_NOTES_MAX_CHARS} characters here).${body}`;
297
+ }
298
+ if (code.includes('INVALID_UPLOAD_KEY')) {
299
+ return `\`uploadKey\` is the lowercase UUID the \`prepare\` call returned; pass it as it came.${body}`;
300
+ }
301
+ return `The uploaded zip was not accepted. Zip \`dist/\` and \`.wix/build-metadata.json\` from the project root after \`astro build\` with the Wix hosting adapter, then ${startOver(retry)}. A static site goes in \`files\` or \`attachments\` instead, or is dropped as the StartDeployWebsite recipe shows.${body}`;
302
+ default:
303
+ if (err.status >= 500) {
304
+ const what = code.includes('RELEASE_NOT_HELD')
305
+ ? 'The release could not hold its upload'
306
+ : `Wix's release step failed on its side (HTTP ${err.status})`;
307
+ // The two-call form's zips are the agent's own, so they can be over the limits the
308
+ // service reports only as a 500; the one-call form's upload is capped below them.
309
+ const limits = retry === 'prepare' && !code.includes('RELEASE_NOT_HELD')
310
+ ? ` A build over Wix's limits fails here with no reason given: more than ${MAX_RELEASE_STATIC_FILES} files in its client folder (\`find dist/client -type f | wc -l\`), or a source zip over 64 MiB (\`wc -c < source.zip\`). If either is over, a retry fails the same way: make the build or the source zip smaller, then call with \`prepare: true\` again.`
311
+ : '';
312
+ // Both forms retry the staged upload itself: the two-call form with its uploadKey, the
313
+ // one-call form by switching to that key instead of sending the files again, which would
314
+ // stage another upload. One retry, then the user hears the request id.
315
+ return `${what}; nothing was released.${limits} The staged upload is kept under uploadKey ${uploadKey}, so do not call \`prepare\` again${limits ? ' for the same zips' : ''} and do not send the files again: every upload staged and not released counts against the site, which refuses new ones after a few. Retry once, after a minute, by ${sameCall}. If that fails too, stop and tell the user the release is blocked on Wix's side, with the request id from the error.${body}`;
316
+ }
317
+ return err.message;
318
+ }
319
+ }
320
+ /** The inputs of the two-call form against each other and against the one-call form. */
321
+ function validateBundleArgs(a) {
322
+ const hasContent = !!a.files?.trim() || !!(a.attachments && a.attachments.length);
323
+ if (a.prepare) {
324
+ if (hasContent || a.uploadKey || a.sourceUploadId) {
325
+ return '`prepare: true` goes with `siteId` and `size`: no `files`, `attachments`, `uploadKey` or `sourceUploadId` on that call. Example: {siteId, prepare: true, size}, then {siteId, uploadKey, sourceUploadId?, expectedHeadRevisionId?, releaseNotes?}.';
326
+ }
327
+ if (a.size === undefined) {
328
+ return "`prepare: true` needs `size`: zip the build first and pass the zip's length in bytes (`wc -c < build.zip`).";
329
+ }
330
+ if (!Number.isInteger(a.size) || a.size < 1 || a.size > MAX_BUNDLE_BYTES) {
331
+ return `\`size\` is the build zip's length in bytes, an integer from 1 to ${MAX_BUNDLE_BYTES} (100 MB); got ${a.size}.`;
332
+ }
333
+ return null;
334
+ }
335
+ if (a.size !== undefined) {
336
+ return '`size` goes only with `prepare: true`: it is the length of the build zip that call makes an upload URL for.';
337
+ }
338
+ if (a.uploadKey) {
339
+ if (hasContent) {
340
+ return '`uploadKey` releases a build zip already uploaded to its URL: pass no `files` or `attachments` with it.';
341
+ }
342
+ return null;
343
+ }
344
+ if (a.sourceUploadId) {
345
+ return '`sourceUploadId` goes with the `uploadKey` the `prepare` call returned.';
346
+ }
347
+ if (a.expectedHeadRevisionId && !hasContent) {
348
+ return '`expectedHeadRevisionId` goes with `uploadKey`, or with `files`/`attachments` holding a build.';
349
+ }
350
+ return null;
351
+ }
59
352
  async function readJson(res, what) {
60
353
  const text = await res.text();
61
354
  if (!res.ok)
@@ -67,6 +360,63 @@ async function readJson(res, what) {
67
360
  throw new Error(`${what} returned a non-JSON body: ${text.slice(0, 200)}`);
68
361
  }
69
362
  }
363
+ /** A PUT to a presigned URL; content-length is left to fetch, which sends the body's own length. */
364
+ async function putZip(fetchImpl, put, bytes, what) {
365
+ const headers = Object.fromEntries(Object.entries(put.headers ?? { 'content-type': 'application/zip' }).filter(([k]) => k.toLowerCase() !== 'content-length'));
366
+ const res = await fetchImpl(put.putUrl, {
367
+ method: 'PUT',
368
+ headers,
369
+ body: bytes
370
+ });
371
+ if (!res.ok) {
372
+ const text = await res.text().catch(() => '');
373
+ throw new Error(`The ${what} upload was refused (${res.status}${text ? `: ${text.slice(0, 200)}` : ''}); call this tool again with the same files.`);
374
+ }
375
+ }
376
+ /**
377
+ * A build that came as files, released over the bundle routes: an upload URL for the
378
+ * site zip's size, the zip PUT there, the source zip PUT to the repository's URL when
379
+ * the service offered one and the upload holds source files, then the release with
380
+ * the ids. `onStaged` hears the uploadKey once the site zip is staged.
381
+ */
382
+ export async function releaseBuildByUrl(o) {
383
+ const log = o.log ?? (() => { });
384
+ const headers = { ...o.siteHeaders, 'Content-Type': 'application/json' };
385
+ const urls = (await readJson(await o.fetchImpl(`${HEADLESS_BUSINESS}/${o.siteId}/bundle-upload-url`, {
386
+ method: 'POST',
387
+ headers,
388
+ body: JSON.stringify({ size: o.siteZip.byteLength })
389
+ }), 'bundle-upload-url'));
390
+ if (!urls.uploadKey || !urls.putUrl) {
391
+ throw new Error('bundle-upload-url returned no uploadKey or putUrl.');
392
+ }
393
+ await putZip(o.fetchImpl, urls, o.siteZip, 'site zip');
394
+ o.onStaged?.(urls.uploadKey);
395
+ log(`staged ${urls.uploadKey} (${o.siteZip.byteLength} bytes), source ${urls.source ? `offered, ${o.sourceFiles.length} file(s)` : 'not offered'}`);
396
+ const source = urls.source && o.sourceFiles.length > 0 ? urls.source : undefined;
397
+ if (source) {
398
+ await putZip(o.fetchImpl, source, zipStored(o.sourceFiles), 'source zip');
399
+ }
400
+ return (await readJson(await o.fetchImpl(`${HEADLESS_BUSINESS}/${o.siteId}/release-bundle`, {
401
+ method: 'POST',
402
+ headers,
403
+ body: JSON.stringify({
404
+ uploadKey: urls.uploadKey,
405
+ ...deployAuthorization(o.siteHeaders),
406
+ ...(o.releaseNotes
407
+ ? { releaseNotes: releaseNotesForService(o.releaseNotes) }
408
+ : {}),
409
+ ...(source
410
+ ? {
411
+ sourceUploadId: source.uploadId,
412
+ ...(o.expectedHeadRevisionId
413
+ ? { expectedHeadRevisionId: o.expectedHeadRevisionId }
414
+ : {})
415
+ }
416
+ : {})
417
+ })
418
+ }), 'release-bundle'));
419
+ }
70
420
  function errorResult(message) {
71
421
  return {
72
422
  isError: true,
@@ -84,6 +434,12 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
84
434
  server.clientName?.() ??
85
435
  server.server?.getClientVersion?.()?.name;
86
436
  const fetchImpl = options.fetchImpl ?? ((u, i) => fetch(u, i));
437
+ const guideFetch = options.guideFetch ??
438
+ (async (httpClient) => {
439
+ if (!httpClient)
440
+ return undefined;
441
+ return fetchArticleContent(httpClient, HEADLESS_GUIDE_URL, 'article', true, options.guide?.skillsPortalUrl, options.guide?.repo, options.guide?.commitHash);
442
+ });
87
443
  const d = UPLOAD_HEADLESS_WEBSITE_FILES_PARAM_DESCRIPTIONS;
88
444
  const registered = server.tool(UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME, toolDescriptions?.[UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME] ??
89
445
  defaultToolDescriptions.UploadHeadlessWebsiteFiles, {
@@ -97,6 +453,16 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
97
453
  }))
98
454
  .optional()
99
455
  .describe(d.attachments),
456
+ prepare: z.boolean().optional().describe(d.prepare),
457
+ size: z.number().int().optional().describe(d.size),
458
+ uploadKey: z.string().min(1).optional().describe(d.uploadKey),
459
+ sourceUploadId: z.string().min(1).optional().describe(d.sourceUploadId),
460
+ expectedHeadRevisionId: z
461
+ .string()
462
+ .min(1)
463
+ .optional()
464
+ .describe(d.expectedHeadRevisionId),
465
+ releaseNotes: z.string().optional().describe(d.releaseNotes),
100
466
  siteId: z
101
467
  .string()
102
468
  .uuid('siteId must be a Wix metaSiteId (UUID)')
@@ -110,25 +476,187 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
110
476
  // openWorldHint true: it fetches agent-supplied attachment URLs and publishes a
111
477
  // public website.
112
478
  { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (rawArgs, extra) => {
113
- const { files, attachments, siteId, calledStartDeployWebsite, agentIdentity, reason } = rawArgs;
114
- logger.log(`[UploadHeadlessWebsiteFiles] siteId=${siteId} calledStartDeployWebsite=${calledStartDeployWebsite} agent="${agentIdentity}" files=${files?.length ?? 0} chars attachments=${attachments?.length ?? 0} reason="${reason}"`);
479
+ const { files, attachments, prepare, size, uploadKey, sourceUploadId, expectedHeadRevisionId, releaseNotes, siteId, calledStartDeployWebsite, agentIdentity, reason } = rawArgs;
480
+ const phase = prepare
481
+ ? 'prepare'
482
+ : uploadKey
483
+ ? 'release-bundle'
484
+ : 'files';
485
+ logger.log(`[UploadHeadlessWebsiteFiles] siteId=${siteId} phase=${phase} calledStartDeployWebsite=${calledStartDeployWebsite} agent="${redactPersonalData(agentIdentity)}" files=${files?.length ?? 0} chars attachments=${attachments?.length ?? 0} reason="${redactPersonalData(reason)}"`);
486
+ const invalid = validateBundleArgs(rawArgs);
487
+ if (invalid)
488
+ return errorResult(invalid);
489
+ if (prepare || uploadKey) {
490
+ // The two-call form: both routes take the site token (outside the try so
491
+ // UnauthorizedError reaches the wrapper).
492
+ const siteHeaders = await authStrategy.getSiteAuthHeaders(siteId);
493
+ const headers = {
494
+ ...siteHeaders,
495
+ 'Content-Type': 'application/json'
496
+ };
497
+ try {
498
+ extra?.setToolBiParams?.({ siteId });
499
+ if (prepare) {
500
+ const urls = (await readJson(await fetchImpl(`${HEADLESS_BUSINESS}/${siteId}/bundle-upload-url`, {
501
+ method: 'POST',
502
+ headers,
503
+ body: JSON.stringify({ size })
504
+ }), 'bundle-upload-url'));
505
+ if (!urls.uploadKey || !urls.putUrl) {
506
+ throw new Error('bundle-upload-url returned no uploadKey or putUrl.');
507
+ }
508
+ extra?.setToolBiParams?.({
509
+ siteId,
510
+ upload: { path: 'prepare', sourceOffered: Boolean(urls.source) }
511
+ });
512
+ return {
513
+ content: [
514
+ {
515
+ type: 'text',
516
+ text: formatPrepareResult(siteId, urls, size, {
517
+ expectedHeadRevisionId,
518
+ releaseNotes
519
+ })
520
+ }
521
+ ]
522
+ };
523
+ }
524
+ const released = (await readJson(await fetchImpl(`${HEADLESS_BUSINESS}/${siteId}/release-bundle`, {
525
+ method: 'POST',
526
+ headers,
527
+ body: JSON.stringify({
528
+ uploadKey,
529
+ ...deployAuthorization(siteHeaders),
530
+ ...(sourceUploadId ? { sourceUploadId } : {}),
531
+ ...(expectedHeadRevisionId ? { expectedHeadRevisionId } : {}),
532
+ ...(releaseNotes
533
+ ? { releaseNotes: releaseNotesForService(releaseNotes) }
534
+ : {})
535
+ })
536
+ }), 'release-bundle'));
537
+ extra?.setToolBiParams?.({
538
+ siteId,
539
+ upload: {
540
+ path: 'release-bundle',
541
+ sourceStored: Boolean(released.revision?.id)
542
+ }
543
+ });
544
+ return {
545
+ content: [
546
+ {
547
+ type: 'text',
548
+ text: formatBundleReleaseResult(siteId, released)
549
+ }
550
+ ]
551
+ };
552
+ }
553
+ catch (err) {
554
+ const message = err instanceof Error ? err.message : String(err);
555
+ logger.log(`[UploadHeadlessWebsiteFiles] ${phase} error: ${message}`);
556
+ rethrowIfUnauthorized(err);
557
+ await authStrategy.enforceValidWixToken?.(err);
558
+ return errorResult(uploadKey
559
+ ? describeBundleReleaseError(err, uploadKey, 'prepare', siteId)
560
+ : describeBundleUploadUrlError(err, siteId));
561
+ }
562
+ }
115
563
  if (!files?.trim() && !(attachments && attachments.length)) {
116
- return errorResult('Nothing to publish: pass the site in `files` (text bundle) or `attachments` (files from this chat or your sandbox).');
564
+ return errorResult('Nothing to publish: pass the site in `files` (text bundle) or `attachments` (files from this chat or your sandbox), or, for a Wix Astro build from a shell that reaches the internet, zip it and call with `prepare: true` and its `size`.');
117
565
  }
118
- // 1. Gather bytes: bundle + resolved attachments. All agent-facing errors.
119
- let siteFiles;
566
+ // 1. Gather bytes: bundle + resolved attachments, zips unpacked. A Wix Astro build
567
+ // among them is released (below); anything else is a static site and is dropped.
568
+ // All agent-facing errors.
569
+ let siteFiles = [];
570
+ let uploaded = [];
571
+ let given = [];
572
+ let build = null;
120
573
  try {
121
574
  const fromAttachments = (await resolveAttachments(attachments, fetchImpl)).map((a) => ({ path: a.path, bytes: base64ToBytes(a.content) }));
122
- siteFiles = await normalizeSiteFiles([
123
- ...parseFileBundle(files),
124
- ...fromAttachments
125
- ]);
575
+ given = [...parseFileBundle(files), ...fromAttachments];
576
+ uploaded = await expandSiteFiles(given);
577
+ build = findHeadlessBuild(uploaded);
578
+ if (!build)
579
+ siteFiles = staticSiteFiles(uploaded);
126
580
  }
127
581
  catch (e) {
128
582
  if (e instanceof AttachmentError || e instanceof Error)
129
583
  return errorResult(e.message);
130
584
  throw e;
131
585
  }
586
+ if (!build && expectedHeadRevisionId) {
587
+ return errorResult("`expectedHeadRevisionId` applies to a Wix Astro build, whose source is stored in the site's repository; these files are a static site, which has none. Call again without it.");
588
+ }
589
+ if (build) {
590
+ // 2. Site auth (outside the try so UnauthorizedError reaches the wrapper).
591
+ const siteHeaders = await authStrategy.getSiteAuthHeaders(siteId);
592
+ let stagedKey;
593
+ try {
594
+ extra?.setToolBiParams?.({ siteId });
595
+ // 3. The bundle routes, as the two-call form runs them, from here. The hosting
596
+ // refuses an empty static file, so a build with one is zipped again without it.
597
+ const sourceFiles = buildSourceFiles(uploaded, build);
598
+ const statics = withoutEmptyStatics(uploaded, build);
599
+ const released = await releaseBuildByUrl({
600
+ siteId,
601
+ // One zip as given goes as is; anything else is zipped from the expanded files.
602
+ siteZip: given.length === 1 &&
603
+ isZip(given[0]) &&
604
+ statics.dropped.length === 0
605
+ ? given[0].bytes
606
+ : zipStored(statics.files),
607
+ sourceFiles,
608
+ expectedHeadRevisionId,
609
+ releaseNotes,
610
+ siteHeaders,
611
+ fetchImpl,
612
+ onStaged: (key) => {
613
+ stagedKey = key;
614
+ },
615
+ log: (line) => logger.log(`[UploadHeadlessWebsiteFiles] release ${line}`)
616
+ });
617
+ const sourceStored = Boolean(released.revision?.id);
618
+ extra?.setToolBiParams?.({
619
+ siteId,
620
+ upload: {
621
+ path: 'build',
622
+ sourceStored,
623
+ sourceFiles: sourceFiles.length,
624
+ emptyStaticsDropped: statics.dropped.length
625
+ }
626
+ });
627
+ const notes = [
628
+ ...(statics.dropped.length
629
+ ? [
630
+ `Left out, empty: ${statics.dropped.slice(0, 20).join(', ')} (the hosting serves no file of size 0).`
631
+ ]
632
+ : []),
633
+ ...(sourceStored
634
+ ? []
635
+ : [
636
+ sourceFiles.length
637
+ ? "The project's source was not stored: the site's repository took none this time."
638
+ : "The project's source was not stored: the upload held only the build. A later chat can continue this site only from its source; next time zip the whole project from its root, leaving out node_modules, .env files and the caches."
639
+ ])
640
+ ];
641
+ return {
642
+ content: [
643
+ {
644
+ type: 'text',
645
+ text: formatBundleReleaseResult(siteId, released, notes)
646
+ }
647
+ ]
648
+ };
649
+ }
650
+ catch (err) {
651
+ const message = err instanceof Error ? err.message : String(err);
652
+ logger.log(`[UploadHeadlessWebsiteFiles] release error: ${message}`);
653
+ rethrowIfUnauthorized(err);
654
+ await authStrategy.enforceValidWixToken?.(err);
655
+ return errorResult(stagedKey
656
+ ? describeBundleReleaseError(err, stagedKey, 'resend', siteId)
657
+ : describeBundleUploadUrlError(err, siteId));
658
+ }
659
+ }
132
660
  // 2. Account auth (outside the try so UnauthorizedError reaches the wrapper).
133
661
  const authHeaders = await authStrategy.getAccountAuthHeaders();
134
662
  try {
@@ -140,6 +668,19 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
140
668
  headers: { ...authHeaders }, // fetch sets the multipart boundary itself
141
669
  body: buildFormData(siteFiles)
142
670
  }), 'drop');
671
+ extra?.setToolBiParams?.({
672
+ siteId: metaSiteId,
673
+ upload: { path: 'drop', files: siteFiles.length }
674
+ });
675
+ // The headless guide rides along, inline: a static drop is not the build of a site
676
+ // that needs Wix business features, and the agent that stops here has read nothing else.
677
+ let guide;
678
+ try {
679
+ guide = await guideFetch(extra?.httpClient);
680
+ }
681
+ catch (e) {
682
+ logger.log(`[UploadHeadlessWebsiteFiles] guide fetch failed: ${e instanceof Error ? e.message : String(e)}`);
683
+ }
143
684
  return {
144
685
  content: [
145
686
  {
@@ -149,7 +690,11 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
149
690
  siteUrl: dropped.siteUrl,
150
691
  uploadId: dropped.uploadId,
151
692
  files: siteFiles.map((f) => f.path)
152
- })
693
+ }) +
694
+ (releaseNotes
695
+ ? '\n\nRelease notes are kept for a Wix Astro build only; a static drop has none, so yours were not stored.'
696
+ : '') +
697
+ formatInlineGuide(guide)
153
698
  }
154
699
  ]
155
700
  };
@@ -159,7 +704,7 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
159
704
  logger.log(`[UploadHeadlessWebsiteFiles] Error: ${message}`);
160
705
  rethrowIfUnauthorized(err);
161
706
  await authStrategy.enforceValidWixToken?.(err);
162
- return errorResult(message);
707
+ return errorResult(describeSiteAccessDenied(err, siteId) ?? message);
163
708
  }
164
709
  });
165
710
  // ChatGPT resolves `attachments` into download URLs only for params listed