webcake-landing-mcp 1.0.62 → 1.0.63
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.
- package/README.md +7 -4
- package/dist/changelog.json +7 -7
- package/dist/domains/landing/index.js +37 -0
- package/dist/domains/landing/instructions.js +2 -2
- package/dist/domains/landing/validate.js +55 -2
- package/dist/persistence/draft-cache.js +5 -1
- package/dist/persistence/webcake-client.js +131 -49
- package/dist/smoke.js +66 -6
- package/dist/tools/persistence.js +103 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,7 +80,8 @@ MCP (Model Context Protocol) server that teaches AI agents how to build a comple
|
|
|
80
80
|
It exposes the element catalog, per-element usage hints + `specials`, the full page JSON Schema,
|
|
81
81
|
valid element/page skeletons, a page validator, and tools to create or edit pages on the backend.
|
|
82
82
|
The AI agent produces the full `{ page, popup, settings, options, cartConfigs }` JSON; `create_page`
|
|
83
|
-
persists it
|
|
83
|
+
persists it and auto-publishes (build + `publish_html`) so the preview renders immediately (the edit
|
|
84
|
+
tools save source-only — re-publish via `publish_page` after edits).
|
|
84
85
|
|
|
85
86
|
| Method | Best for | Auth |
|
|
86
87
|
|--------|----------|------|
|
|
@@ -152,10 +153,12 @@ npx -y webcake-landing-mcp login # opens the browser once, saves the token to
|
|
|
152
153
|
|
|
153
154
|
…or set `WEBCAKE_ENV` (`local` | `staging` | `prod` — fills in all base URLs) + `WEBCAKE_JWT`.
|
|
154
155
|
|
|
155
|
-
For `publish_page` to
|
|
156
|
-
|
|
156
|
+
For `publish_page` to actually put a page **live**, a build host is needed (it renders the
|
|
157
|
+
`app`/`app_css` that the live `publish_html` route requires):
|
|
158
|
+
- `prod` preset auto-configures `https://build.webcake.io` — no extra setup (the preset applies when the env resolves to `prod`: `WEBCAKE_ENV=prod`, `--env prod`, or `x-webcake-env: prod`).
|
|
157
159
|
- For staging/local, set `WEBCAKE_BUILD_BASE=<url>` or send the `x-webcake-build-base` header per request.
|
|
158
|
-
- Without it, `publish_page` falls back to source-only with `rendered:false` + a warning.
|
|
160
|
+
- Without it, `publish_page` falls back to a legacy source-only save with `rendered:false, live:false` + a warning — nothing goes live.
|
|
161
|
+
- A page is only **permanently** live with a `custom_domain`; without one the returned `/preview/<page_id>` link expires ~10 minutes after the publish.
|
|
159
162
|
|
|
160
163
|
Everything else — the full env-var table, environment presets, per-request headers for the hosted
|
|
161
164
|
server, the `login` browser flow (+ backend contract), and how to grab a JWT by hand — lives in
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.63",
|
|
4
|
+
"d": "11/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "create_page now auto-publishes after a successful create: builds the rendered app via the build host and calls the editor's publish_html route so…",
|
|
7
|
+
"vi": "create_page nay tự động publish sau khi tạo thành công: build rendered app qua build host rồi gọi route publish_html của editor để preview trang mới…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.62",
|
|
4
11
|
"d": "11/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Changed",
|
|
34
41
|
"en": "html-box descriptor (get_element) has been rewritten to document COMPOSITE VISUALS as the primary use case: intricate non-interactive mockups such…",
|
|
35
42
|
"vi": "Descriptor html-box (get_element) được viết lại để ghi lại COMPOSITE VISUALS là trường hợp sử dụng chính: các mockup phi tương tác phức tạp như…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.57",
|
|
39
|
-
"d": "11/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New upload_images tool re-hosts up to 20 external image URLs or data: URIs as Webcake-hosted URLs (statics.pancake.vn) by downloading and uploading…",
|
|
42
|
-
"vi": "Công cụ upload_images mới tải lại tối đa 20 URL ảnh ngoài hoặc data: URI thành URL do Webcake lưu trữ (statics.pancake.vn) bằng cách tải về và…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -171,6 +171,42 @@ function normalizeBackgrounds(node) {
|
|
|
171
171
|
normalizeBackgrounds(child);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// misplaced-animation normalization: models regularly emit the animation object
|
|
176
|
+
// directly under responsive.<bp> instead of responsive.<bp>.config.animation —
|
|
177
|
+
// the single most common "must NOT have additional properties" schema error,
|
|
178
|
+
// and one a patch op:'update' can never fix (update merges; it cannot delete
|
|
179
|
+
// the stray key). The intent is unambiguous, so move it where the editor reads
|
|
180
|
+
// it: into config.animation (the author's explicit non-'none' config.animation
|
|
181
|
+
// wins if both are set), then drop the stray key. Deterministic + idempotent,
|
|
182
|
+
// so the expand(compact(x)) == expand(x) invariant holds.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
/** Walk a tree node and relocate responsive.<bp>.animation → config.animation in-place (mutates). */
|
|
185
|
+
function normalizeMisplacedAnimation(node) {
|
|
186
|
+
if (!node || typeof node !== "object")
|
|
187
|
+
return;
|
|
188
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
189
|
+
const rbp = node.responsive?.[bp];
|
|
190
|
+
if (!rbp || typeof rbp !== "object")
|
|
191
|
+
continue;
|
|
192
|
+
const stray = rbp.animation;
|
|
193
|
+
if (stray !== undefined) {
|
|
194
|
+
if (stray && typeof stray === "object") {
|
|
195
|
+
rbp.config = rbp.config && typeof rbp.config === "object" ? rbp.config : {};
|
|
196
|
+
const existing = rbp.config.animation;
|
|
197
|
+
const existingWins = existing && typeof existing === "object" && typeof existing.name === "string" && existing.name !== "none";
|
|
198
|
+
if (!existingWins) {
|
|
199
|
+
rbp.config.animation = { name: "none", delay: 0, duration: 3, repeat: null, ...stray };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
delete rbp.animation;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(node.children)) {
|
|
206
|
+
for (const child of node.children)
|
|
207
|
+
normalizeMisplacedAnimation(child);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
/** Apply all post-expand normalizations to every node in a page source. */
|
|
175
211
|
function normalizeSource(source) {
|
|
176
212
|
if (!source || typeof source !== "object")
|
|
@@ -178,6 +214,7 @@ function normalizeSource(source) {
|
|
|
178
214
|
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
179
215
|
if (Array.isArray(source[arr])) {
|
|
180
216
|
for (const node of source[arr]) {
|
|
217
|
+
normalizeMisplacedAnimation(node);
|
|
181
218
|
normalizeImageBlocks(node);
|
|
182
219
|
normalizeBorderRadius(node);
|
|
183
220
|
normalizeBackgrounds(node);
|
|
@@ -21,7 +21,7 @@ RULES (follow for every request):
|
|
|
21
21
|
· patch_page (live-page) timed out → response returns a draft_id (patched source cached). Retry patch_page({ draft_id, dry_run:false }) with no patches — the COMMIT-AS-IS path.
|
|
22
22
|
· update_page validation failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
|
|
23
23
|
COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
|
|
24
|
-
A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~
|
|
24
|
+
A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
|
|
25
25
|
- DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
|
|
26
26
|
- Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
|
|
27
27
|
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), CSS custom-property palette, background_images from stylesheets, and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta), li lists, and gradients — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), every real image URL found in the AST (images, background_images, og_image) MUST be re-hosted via upload_images and reused in the corresponding slot; do NOT replace them with search_images stock photos. Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
@@ -37,6 +37,6 @@ MODEL (essentials):
|
|
|
37
37
|
- Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
|
|
38
38
|
- IMAGES: include them (hero/product, feature icons, about photo). SOURCE PRIORITY: (1) images the user provided or that exist in the reference HTML/URL → re-host via upload_images and use those exact images; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
|
|
39
39
|
|
|
40
|
-
- PREVIEW vs PUBLISH: the
|
|
40
|
+
- PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
|
|
41
41
|
|
|
42
42
|
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
@@ -125,6 +125,57 @@ export function coercePage(input) {
|
|
|
125
125
|
return JSON.parse(input);
|
|
126
126
|
return input;
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve an ajv instancePath (e.g. "/page/1/children/2/responsive/desktop") to
|
|
130
|
+
* the deepest ELEMENT (object with string id + type) along it, plus the final
|
|
131
|
+
* value the path lands on. The positional path alone is the #1 reason a model
|
|
132
|
+
* patches the WRONG element after a schema error — indices are easy to miscount;
|
|
133
|
+
* ids are not.
|
|
134
|
+
*/
|
|
135
|
+
function describeInstancePath(page, instancePath) {
|
|
136
|
+
if (!instancePath || instancePath === "/")
|
|
137
|
+
return {};
|
|
138
|
+
let cur = page;
|
|
139
|
+
let el;
|
|
140
|
+
for (const rawSeg of instancePath.split("/").slice(1)) {
|
|
141
|
+
if (cur == null || typeof cur !== "object")
|
|
142
|
+
return { id: el?.id, type: el?.type };
|
|
143
|
+
const seg = rawSeg.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
144
|
+
cur = Array.isArray(cur) ? cur[Number(seg)] : cur[seg];
|
|
145
|
+
if (cur && typeof cur === "object" && typeof cur.id === "string" && typeof cur.type === "string")
|
|
146
|
+
el = cur;
|
|
147
|
+
}
|
|
148
|
+
return { id: el?.id, type: el?.type, value: cur };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Format one ajv error as an ACTIONABLE message: positional path + ajv message,
|
|
152
|
+
* plus the offending property name (additionalProperties), the actual bad value
|
|
153
|
+
* (enum/type), and — crucially — the enclosing element's id/type so the fix can
|
|
154
|
+
* target the right element by id on the first try. For stray-key errors it also
|
|
155
|
+
* names the only op that can fix them: patch update MERGES, so deleting a key
|
|
156
|
+
* needs op:'replace' (or, on a rebuild, simply omitting the key).
|
|
157
|
+
*/
|
|
158
|
+
function describeSchemaError(page, err) {
|
|
159
|
+
const path = err.instancePath || "/";
|
|
160
|
+
let msg = `schema ${path} ${err.message}`;
|
|
161
|
+
const at = describeInstancePath(page, path);
|
|
162
|
+
const extraKey = err.params?.additionalProperty;
|
|
163
|
+
if (extraKey)
|
|
164
|
+
msg += ` — offending key: "${extraKey}"`;
|
|
165
|
+
if ((err.keyword === "enum" || err.keyword === "type" || err.keyword === "const") &&
|
|
166
|
+
(typeof at.value === "string" || typeof at.value === "number" || typeof at.value === "boolean")) {
|
|
167
|
+
msg += ` — got: ${JSON.stringify(at.value)}`;
|
|
168
|
+
}
|
|
169
|
+
if (at.id)
|
|
170
|
+
msg += ` — element id="${at.id}"${at.type ? ` (type ${at.type})` : ""}`;
|
|
171
|
+
if (extraKey && at.id) {
|
|
172
|
+
msg +=
|
|
173
|
+
`. patch_page op:'update' MERGES and cannot delete this key — fix via ` +
|
|
174
|
+
`{op:'replace', id:'${at.id}', element:<the clean node without "${extraKey}">}` +
|
|
175
|
+
(extraKey === "animation" ? ` (animation belongs in responsive.<bp>.config.animation, not responsive.<bp>)` : "");
|
|
176
|
+
}
|
|
177
|
+
return msg;
|
|
178
|
+
}
|
|
128
179
|
export function validatePage(input) {
|
|
129
180
|
const errors = [];
|
|
130
181
|
const warnings = [];
|
|
@@ -135,11 +186,13 @@ export function validatePage(input) {
|
|
|
135
186
|
catch (e) {
|
|
136
187
|
return { valid: false, errors: [`Invalid JSON: ${e.message}`], warnings: [], stats: { sections: 0, popups: 0, elements: 0, ids: 0 } };
|
|
137
188
|
}
|
|
138
|
-
// 1) Structural (JSON Schema)
|
|
189
|
+
// 1) Structural (JSON Schema) — each error names the enclosing ELEMENT (id +
|
|
190
|
+
// type) and the offending key/value, so a fix can target the right element
|
|
191
|
+
// by id instead of decoding positional indices.
|
|
139
192
|
const ok = validateSchema(page);
|
|
140
193
|
if (!ok && validateSchema.errors) {
|
|
141
194
|
for (const err of validateSchema.errors) {
|
|
142
|
-
errors.push(
|
|
195
|
+
errors.push(describeSchemaError(page, err));
|
|
143
196
|
}
|
|
144
197
|
}
|
|
145
198
|
// 2) Semantic
|
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
* CALLER's own creds, so a draft only ever yields a page in the caller's account.
|
|
30
30
|
*/
|
|
31
31
|
import { randomUUID } from "node:crypto";
|
|
32
|
-
|
|
32
|
+
/** Draft lifetime — default 2 hours. Override via WEBCAKE_DRAFT_TTL_MS env. */
|
|
33
|
+
const TTL_MS = (() => {
|
|
34
|
+
const v = parseInt(process.env.WEBCAKE_DRAFT_TTL_MS ?? "", 10);
|
|
35
|
+
return Number.isFinite(v) && v > 0 ? v : 2 * 60 * 60 * 1000;
|
|
36
|
+
})();
|
|
33
37
|
const MAX_ENTRIES = 50;
|
|
34
38
|
const store = new Map();
|
|
35
39
|
function sweep(now) {
|
|
@@ -27,13 +27,22 @@ const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
|
|
|
27
27
|
const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
|
|
28
28
|
const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
|
|
29
29
|
const APPEND_ENDPOINT = "/api/v1/ai/append_section";
|
|
30
|
-
// The editor's own publish
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
// The editor's own publish routes (NOT under /api/v1/ai). Both scopes are
|
|
31
|
+
// host-constrained to the BUILDER host (router scope `host: "builder."`), so
|
|
32
|
+
// requests go to config.builderBase, not the API base.
|
|
33
|
+
//
|
|
34
|
+
// /edit/publish_html is what the editor's publish button calls and the ONLY
|
|
35
|
+
// route that creates/updates the PagePublishedV2 record — the record EVERY
|
|
36
|
+
// public serving path reads (render_custom_domain → get_published_by_domain_path_v2
|
|
37
|
+
// → serves page_published_v2.app). It expects the rendered app/app_css in the body.
|
|
38
|
+
//
|
|
39
|
+
// /edit/publish is LEGACY: it saves the source (+app/app_css onto the page_source
|
|
40
|
+
// row, which only the ~10-minute /preview/:id window serves) and writes a
|
|
41
|
+
// PagePublished v1 record that no serving path reads. Kept only as the
|
|
42
|
+
// source-only fallback when no build host is available.
|
|
43
|
+
const publishHtmlEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish_html`;
|
|
44
|
+
const legacyPublishEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish`;
|
|
45
|
+
const publishUrl = (config, pageId, rendered) => `${(config.builderBase ?? config.base).replace(/\/+$/, "")}${rendered ? publishHtmlEndpoint(pageId) : legacyPublishEndpoint(pageId)}`;
|
|
37
46
|
function authHeaders(config, orgId) {
|
|
38
47
|
const headers = {
|
|
39
48
|
"Content-Type": "application/json",
|
|
@@ -73,14 +82,36 @@ export function toEditorUrl(config, raw) {
|
|
|
73
82
|
pathQuery = `/${pathQuery}`;
|
|
74
83
|
return `${builder.replace(/\/+$/, "")}${pathQuery}`;
|
|
75
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Build a SELF-LOGGING-IN editor link. The bare `/editor/v2/<id>` route sits
|
|
87
|
+
* behind the backend's `:passport` pipeline (jwt COOKIE or Bearer header), so a
|
|
88
|
+
* plain editor URL 401s ("Token not found") in any browser that isn't already
|
|
89
|
+
* logged in to Webcake. The builder host exposes `GET /transport?token=&redirect_uri=`
|
|
90
|
+
* (public; AuthController.transport) which sets the `jwt` cookie and redirects —
|
|
91
|
+
* so we wrap the editor URL in it, carrying the SAME jwt the MCP call used
|
|
92
|
+
* (env / auth.json / per-request header). The token is the caller's own
|
|
93
|
+
* credential, but the link logs into their account — share it with the page
|
|
94
|
+
* owner only, never publish it. Preview links stay UNWRAPPED (they're public).
|
|
95
|
+
*/
|
|
96
|
+
export function toEditorLoginUrl(config, raw) {
|
|
97
|
+
const editor = toEditorUrl(config, raw);
|
|
98
|
+
if (!editor || !config.jwt)
|
|
99
|
+
return editor;
|
|
100
|
+
const builder = config.builderBase ?? config.appBase;
|
|
101
|
+
if (!builder)
|
|
102
|
+
return editor;
|
|
103
|
+
return `${builder.replace(/\/+$/, "")}/transport?token=${encodeURIComponent(config.jwt)}&redirect_uri=${encodeURIComponent(editor)}`;
|
|
104
|
+
}
|
|
76
105
|
/**
|
|
77
106
|
* Resolve the public preview link (`/preview/<page_id>`) onto the PREVIEW host
|
|
78
107
|
* (config.previewBase) — NOT the builder subdomain. The /preview/:id route only
|
|
79
108
|
* exists on the root preview hosts (preview.localhost:5800 local /
|
|
80
|
-
* staging.webcake.me staging / www.webcake.me prod); the v4 renderer there
|
|
81
|
-
* the STORED `app`/`app_css` build columns
|
|
82
|
-
*
|
|
83
|
-
*
|
|
109
|
+
* staging.webcake.me staging / www.webcake.me prod); the v4 renderer there
|
|
110
|
+
* serves the page_source row's STORED `app`/`app_css` build columns, and only
|
|
111
|
+
* for ~10 minutes after the last source save (then "Preview page is expired").
|
|
112
|
+
* An MCP-created page's preview is blank until a rendered publish_page runs or
|
|
113
|
+
* the page is re-saved in the Webcake editor — and even then the link is
|
|
114
|
+
* ephemeral; only a custom_domain publish gives a permanent URL.
|
|
84
115
|
*/
|
|
85
116
|
export function toPreviewUrl(config, raw) {
|
|
86
117
|
if (!raw)
|
|
@@ -191,7 +222,7 @@ export async function createPage(config, name, source, orgId) {
|
|
|
191
222
|
ok: true,
|
|
192
223
|
status: res.status,
|
|
193
224
|
page_id: pageId,
|
|
194
|
-
editor_url:
|
|
225
|
+
editor_url: toEditorLoginUrl(config, editorPath),
|
|
195
226
|
preview_url: toPreviewUrl(config, previewPath),
|
|
196
227
|
organization_id: (orgId ?? config.orgId) ?? null,
|
|
197
228
|
raw: data,
|
|
@@ -337,7 +368,7 @@ export async function appendSection(config, pageId, sections) {
|
|
|
337
368
|
ok: true,
|
|
338
369
|
status: res.status,
|
|
339
370
|
page_id: pageIdOut,
|
|
340
|
-
editor_url:
|
|
371
|
+
editor_url: toEditorLoginUrl(config, data?.editor_url),
|
|
341
372
|
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
342
373
|
organization_id: data?.organization_id ?? null,
|
|
343
374
|
section_count: data?.section_count,
|
|
@@ -383,7 +414,7 @@ export async function updatePageSource(config, pageId, source) {
|
|
|
383
414
|
ok: true,
|
|
384
415
|
status: res.status,
|
|
385
416
|
page_id: pageIdOut,
|
|
386
|
-
editor_url:
|
|
417
|
+
editor_url: toEditorLoginUrl(config, data?.editor_url),
|
|
387
418
|
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
388
419
|
organization_id: data?.organization_id ?? null,
|
|
389
420
|
raw: data,
|
|
@@ -467,39 +498,70 @@ export async function buildPageApp(buildBase, pageId, source) {
|
|
|
467
498
|
}
|
|
468
499
|
return { ok: true, app, app_css, status: res.status };
|
|
469
500
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Body for the editor's /edit/publish_html route — mirrors the payload the
|
|
503
|
+
* editor's PublishModal sends (see landing_page_backend assets/editor
|
|
504
|
+
* PublishModal.vue): the saved source rides as the `data_node` JSON STRING (the
|
|
505
|
+
* route Jason.decode!s it and stores it via save_page_with_source), there is NO
|
|
506
|
+
* `source` key, and `settings` (with `mobile_only` folded in from
|
|
507
|
+
* options.mobileOnly) is stored on the PagePublishedV2 record the public
|
|
508
|
+
* serving paths read. `render_type: "v4"` only when a domain is attached —
|
|
509
|
+
* exactly what the editor sends. `auto: false` so the publish creates a version.
|
|
510
|
+
*/
|
|
511
|
+
function publishHtmlBody(source, opts = {}) {
|
|
512
|
+
const hasDomain = !!opts.customDomain;
|
|
513
|
+
return JSON.stringify({
|
|
514
|
+
custom_domain: opts.customDomain ?? "",
|
|
515
|
+
custom_path: opts.customPath ?? "",
|
|
516
|
+
selected_custom_domain: hasDomain,
|
|
517
|
+
data_node: JSON.stringify(source),
|
|
518
|
+
render_type: hasDomain ? "v4" : null,
|
|
519
|
+
app: opts.app,
|
|
520
|
+
app_css: opts.app_css ?? "",
|
|
521
|
+
settings: { ...(source?.settings ?? {}), mobile_only: source?.options?.mobileOnly ?? false },
|
|
522
|
+
type: 1,
|
|
523
|
+
auto: false,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Body for the LEGACY /edit/publish route (source-only fallback): `source` as a
|
|
528
|
+
* JSON STRING plus custom_domain/custom_path; is_publish marks the save as a
|
|
529
|
+
* publish in save_page_with_source. No PagePublishedV2 record is written, so
|
|
530
|
+
* nothing goes live — the page only renders in the editor / the short-lived
|
|
531
|
+
* /preview window after a build.
|
|
532
|
+
*/
|
|
533
|
+
function legacyPublishBody(source, opts = {}) {
|
|
534
|
+
return JSON.stringify({
|
|
535
|
+
source: JSON.stringify(source),
|
|
477
536
|
custom_domain: opts.customDomain ?? "",
|
|
478
537
|
custom_path: opts.customPath ?? "",
|
|
479
538
|
is_publish: true,
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
payload["app_css"] = opts.app_css;
|
|
485
|
-
return JSON.stringify(payload);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function publishRequestBody(source, opts, rendered) {
|
|
542
|
+
return rendered ? publishHtmlBody(source, opts) : legacyPublishBody(source, opts);
|
|
486
543
|
}
|
|
487
|
-
/**
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Build (but do not send) the publish request with the token masked — for
|
|
546
|
+
* dry-run previews. `willRender` says whether the real run will call the build
|
|
547
|
+
* host and take the publish_html path (dry_run itself never builds, so the
|
|
548
|
+
* preview stands in a size hint / placeholder for app/app_css).
|
|
549
|
+
*/
|
|
550
|
+
export function buildPublishRequestRedacted(config, pageId, source, opts = {}, willRender = opts.app != null) {
|
|
551
|
+
// Replace actual app/app_css content with size hints (or a placeholder when
|
|
552
|
+
// the build runs later) so the preview stays readable.
|
|
491
553
|
const previewOpts = { ...opts };
|
|
492
|
-
if (
|
|
493
|
-
previewOpts.app = `<${opts.app.length} bytes
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const body =
|
|
554
|
+
if (willRender) {
|
|
555
|
+
previewOpts.app = opts.app != null ? `<${opts.app.length} bytes>` : "<built by the build host on dry_run=false>";
|
|
556
|
+
previewOpts.app_css = opts.app_css != null ? `<${opts.app_css.length} bytes>` : "<built by the build host on dry_run=false>";
|
|
557
|
+
}
|
|
558
|
+
const body = publishRequestBody(source, previewOpts, willRender);
|
|
497
559
|
return {
|
|
498
560
|
method: "POST",
|
|
499
|
-
url: publishUrl(config, pageId),
|
|
561
|
+
url: publishUrl(config, pageId, willRender),
|
|
500
562
|
headers: { ...authHeaders(config), Authorization: "Bearer ***JWT***", Cookie: "jwt=***JWT***" },
|
|
501
563
|
body: body.replace(config.jwt, "***JWT***").slice(0, 600) + (body.length > 600 ? `… (${body.length} bytes)` : ""),
|
|
502
|
-
rendered:
|
|
564
|
+
rendered: willRender,
|
|
503
565
|
};
|
|
504
566
|
}
|
|
505
567
|
// ---------------------------------------------------------------------------
|
|
@@ -580,22 +642,27 @@ async function postToHost(url, headers, body) {
|
|
|
580
642
|
});
|
|
581
643
|
}
|
|
582
644
|
/**
|
|
583
|
-
* Publish a page
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
645
|
+
* Publish a page. With opts.app/app_css (built by buildPageApp) it POSTs the
|
|
646
|
+
* editor's /edit/publish_html route, which creates/updates the PagePublishedV2
|
|
647
|
+
* record — the one the public serving paths read — so the page actually goes
|
|
648
|
+
* LIVE. Without them it falls back to the legacy /edit/publish route, which
|
|
649
|
+
* only saves the source as a new version (nothing goes live).
|
|
650
|
+
*
|
|
651
|
+
* Returns the resulting public URL — `https://<domain>/<path>` when a custom
|
|
652
|
+
* domain is attached, else the preview-host link
|
|
653
|
+
* (`<previewBase>/preview/<page_id>`), which the backend only serves for ~10
|
|
654
|
+
* minutes after the publish (then "Preview page is expired").
|
|
589
655
|
*/
|
|
590
|
-
export async function publishPage(config, pageId,
|
|
591
|
-
const
|
|
656
|
+
export async function publishPage(config, pageId, source, opts = {}) {
|
|
657
|
+
const rendered = opts.app != null;
|
|
658
|
+
const url = publishUrl(config, pageId, rendered);
|
|
592
659
|
let status;
|
|
593
660
|
let text;
|
|
594
661
|
try {
|
|
595
662
|
// The builder-host pipeline runs an `accepts ["html"]` plug (it serves the
|
|
596
663
|
// editor SPA); a literal application/json Accept gets a 406, so send */*
|
|
597
664
|
// like the browser does — the action still returns JSON.
|
|
598
|
-
({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" },
|
|
665
|
+
({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishRequestBody(source, opts, rendered)));
|
|
599
666
|
}
|
|
600
667
|
catch (e) {
|
|
601
668
|
const e2 = timeoutOrNetworkError(url, e);
|
|
@@ -624,6 +691,20 @@ export async function publishPage(config, pageId, sourceString, opts = {}) {
|
|
|
624
691
|
const path = data?.path ?? null;
|
|
625
692
|
const previewUrl = toPreviewUrl(config, `/preview/${pageId}`);
|
|
626
693
|
const publishedUrl = domain ? `https://${domain}${path ? `/${String(path).replace(/^\/+/, "")}` : ""}` : previewUrl;
|
|
694
|
+
// The publish_html response data is PagePublishedV2.json — it carries the full
|
|
695
|
+
// app/app_css/source/data_node columns. Keep only the small identifying fields.
|
|
696
|
+
const raw = data && typeof data === "object"
|
|
697
|
+
? {
|
|
698
|
+
id: data.id,
|
|
699
|
+
page_id: data.page_id,
|
|
700
|
+
domain: data.domain,
|
|
701
|
+
path: data.path,
|
|
702
|
+
status: data.status,
|
|
703
|
+
version_id: data.version_id,
|
|
704
|
+
render_type: data.render_type,
|
|
705
|
+
type: data.type,
|
|
706
|
+
}
|
|
707
|
+
: data;
|
|
627
708
|
return {
|
|
628
709
|
ok: true,
|
|
629
710
|
status,
|
|
@@ -632,7 +713,8 @@ export async function publishPage(config, pageId, sourceString, opts = {}) {
|
|
|
632
713
|
preview_url: previewUrl,
|
|
633
714
|
domain,
|
|
634
715
|
path,
|
|
635
|
-
rendered
|
|
636
|
-
|
|
716
|
+
rendered,
|
|
717
|
+
live: rendered,
|
|
718
|
+
raw,
|
|
637
719
|
};
|
|
638
720
|
}
|
package/dist/smoke.js
CHANGED
|
@@ -10,7 +10,7 @@ import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
|
|
|
10
10
|
import { parseHtml } from "./persistence/html-ingest.js";
|
|
11
11
|
import { warningsField } from "./mcp/response.js";
|
|
12
12
|
import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
|
|
13
|
-
import { toEditorUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
|
|
13
|
+
import { toEditorUrl, toEditorLoginUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
|
|
14
14
|
import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
|
|
15
15
|
import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
|
|
16
16
|
import { buildConnectUrl, parseCallback } from "./auth/login.js";
|
|
@@ -132,6 +132,45 @@ check("bad page detects duplicate id", r2.errors.some((e) => e.includes("Duplica
|
|
|
132
132
|
check("bad page detects children-on-noncontainer", r2.errors.some((e) => e.includes("not a container")), r2.errors);
|
|
133
133
|
check("bad page detects missing mobile", r2.errors.some((e) => e.toLowerCase().includes("mobile")), r2.errors);
|
|
134
134
|
check("bad page warns missing field_name", r2.warnings.some((w) => w.includes("field_name")), r2.warnings);
|
|
135
|
+
console.log("== validate: schema errors name the element id + offending key ==");
|
|
136
|
+
{
|
|
137
|
+
// A stray key directly under responsive.<bp> is the classic schema error a
|
|
138
|
+
// model cannot act on from the positional path alone — the message must name
|
|
139
|
+
// the element id, the stray key, and the only op that can delete it (replace).
|
|
140
|
+
const strayed = structuredClone(good);
|
|
141
|
+
strayed.page[0].children[0].responsive.desktop.zoomy = 1;
|
|
142
|
+
const rs = validatePage(strayed);
|
|
143
|
+
check("stray key fails schema", !rs.valid, rs);
|
|
144
|
+
const schemaErr = rs.errors.find((e) => e.includes("additional properties")) ?? "";
|
|
145
|
+
check("schema error names the offending key", schemaErr.includes('offending key: "zoomy"'), schemaErr);
|
|
146
|
+
check("schema error names the element id", schemaErr.includes('element id="btn1"'), schemaErr);
|
|
147
|
+
check("schema error prescribes op:'replace'", schemaErr.includes("op:'replace'") && schemaErr.includes("cannot delete"), schemaErr);
|
|
148
|
+
// A bad enum value reports what was actually there.
|
|
149
|
+
const badType = structuredClone(good);
|
|
150
|
+
badType.page[0].children[0].type = "txt-block";
|
|
151
|
+
const rt = validatePage(badType);
|
|
152
|
+
const enumErr = rt.errors.find((e) => e.includes("got:")) ?? "";
|
|
153
|
+
check("enum error reports the bad value + element id", enumErr.includes('"txt-block"') && enumErr.includes('element id="btn1"'), rt.errors);
|
|
154
|
+
}
|
|
155
|
+
console.log("== expand: relocates misplaced responsive.<bp>.animation into config ==");
|
|
156
|
+
{
|
|
157
|
+
// Models regularly emit animation at the breakpoint level (schema error a
|
|
158
|
+
// patch update can never fix) — domain.expand moves it where the editor
|
|
159
|
+
// reads it, so the page validates on the FIRST create_page.
|
|
160
|
+
const misplaced = structuredClone(good);
|
|
161
|
+
misplaced.page[0].children[0].responsive.desktop.animation = { name: "fadeInUp", delay: 0, duration: 2, repeat: null };
|
|
162
|
+
const fixed = landingDomain.expand(misplaced);
|
|
163
|
+
const bp = fixed.page[0].children[0].responsive.desktop;
|
|
164
|
+
check("stray animation key removed", bp.animation === undefined, bp);
|
|
165
|
+
check("animation moved into config", bp.config?.animation?.name === "fadeInUp" && bp.config?.animation?.duration === 2, bp.config);
|
|
166
|
+
check("relocated page validates", validatePage(fixed).valid, validatePage(fixed).errors);
|
|
167
|
+
// An explicit non-'none' config.animation wins over the stray key.
|
|
168
|
+
const both = structuredClone(good);
|
|
169
|
+
both.page[0].children[0].responsive.desktop.animation = { name: "fadeInUp" };
|
|
170
|
+
both.page[0].children[0].responsive.desktop.config.animation = { name: "zoomIn", delay: 1, duration: 4, repeat: null };
|
|
171
|
+
const kept = landingDomain.expand(both).page[0].children[0].responsive.desktop;
|
|
172
|
+
check("explicit config.animation wins over the stray key", kept.config.animation.name === "zoomIn" && kept.animation === undefined, kept.config);
|
|
173
|
+
}
|
|
135
174
|
console.log("== validate: accepts JSON string input ==");
|
|
136
175
|
const r3 = validatePage(JSON.stringify(good));
|
|
137
176
|
check("string input parsed & valid", r3.valid, r3.errors);
|
|
@@ -540,6 +579,15 @@ console.log("== config: named environment presets (local/staging/prod) ==");
|
|
|
540
579
|
check("editor url from a path → builder host", toEditorUrl(localCfg, "/editor/v2/abc") === "http://builder.localhost:5800/editor/v2/abc");
|
|
541
580
|
check("editor url from an absolute api url → builder host", toEditorUrl(localCfg, "http://localhost:5800/editor/v2/abc?x=1") === "http://builder.localhost:5800/editor/v2/abc?x=1");
|
|
542
581
|
check("editor url passthrough when empty", toEditorUrl(localCfg, undefined) === undefined);
|
|
582
|
+
// The RETURNED editor link must sign the browser in: the /editor route sits
|
|
583
|
+
// behind the jwt-cookie passport, so the link is wrapped in the builder
|
|
584
|
+
// host's public /transport?token=&redirect_uri= cookie-setting redirect.
|
|
585
|
+
const loginUrl = toEditorLoginUrl(localCfg, "/editor/v2/abc");
|
|
586
|
+
check("editor login url goes through /transport on the builder host", loginUrl.startsWith("http://builder.localhost:5800/transport?token="), loginUrl);
|
|
587
|
+
check("editor login url carries the jwt as token", loginUrl.includes("token=t&"), loginUrl);
|
|
588
|
+
check("editor login url percent-encodes the redirect target", loginUrl.endsWith(`redirect_uri=${encodeURIComponent("http://builder.localhost:5800/editor/v2/abc")}`), loginUrl);
|
|
589
|
+
check("editor login url passthrough when empty", toEditorLoginUrl(localCfg, undefined) === undefined);
|
|
590
|
+
check("editor login url stays bare without a jwt", toEditorLoginUrl({ ...localCfg, jwt: "" }, "/editor/v2/abc") === "http://builder.localhost:5800/editor/v2/abc");
|
|
543
591
|
// The PREVIEW link lives on its own root host (NOT the builder subdomain):
|
|
544
592
|
// preview.localhost:5800 / staging.webcake.me / www.webcake.me.
|
|
545
593
|
check("env presets carry preview bases", resolveEnv("local")?.previewBase === "http://preview.localhost:5800" && resolveEnv("staging")?.previewBase === "https://staging.webcake.me" && resolveEnv("prod")?.previewBase === "https://www.webcake.me");
|
|
@@ -552,11 +600,23 @@ console.log("== config: named environment presets (local/staging/prod) ==");
|
|
|
552
600
|
check("preview url from an absolute api url → preview host", toPreviewUrl(localCfg, "http://localhost:5800/preview/abc?x=1") === "http://preview.localhost:5800/preview/abc?x=1");
|
|
553
601
|
check("preview url passthrough when empty", toPreviewUrl(localCfg, undefined) === undefined);
|
|
554
602
|
check("preview url falls back to builder when previewBase missing", toPreviewUrl({ ...localCfg, previewBase: undefined }, "/preview/abc") === "http://builder.localhost:5800/preview/abc");
|
|
555
|
-
// publish request preview: JWT must be masked everywhere.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
check("publish
|
|
559
|
-
check("publish
|
|
603
|
+
// publish request preview: JWT must be masked everywhere. Without a build
|
|
604
|
+
// (willRender=false) the LEGACY source-only route is used…
|
|
605
|
+
const pub = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", { page: [] }, { customDomain: "shop.example.com", customPath: "sale" });
|
|
606
|
+
check("legacy publish preview hits /edit/publish on the BUILDER host", pub.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish", pub.url);
|
|
607
|
+
check("legacy publish preview masks the JWT", !JSON.stringify(pub).includes("SECRETJWT"), pub);
|
|
608
|
+
check("legacy publish preview carries domain/path + source string", pub.body.includes("shop.example.com") && pub.body.includes("custom_path") && pub.body.includes("is_publish"), pub.body);
|
|
609
|
+
check("legacy publish preview is marked rendered:false", pub.rendered === false, pub);
|
|
610
|
+
// …with a build (willRender=true) the editor's publish_html route — the only
|
|
611
|
+
// one that writes the PagePublishedV2 record public serving reads — is used,
|
|
612
|
+
// with the editor-shaped body (data_node, no `source` key).
|
|
613
|
+
const pubHtml = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", { page: [], options: { mobileOnly: true } }, { customDomain: "shop.example.com" }, true);
|
|
614
|
+
check("rendered publish preview hits /edit/publish_html on the BUILDER host", pubHtml.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish_html", pubHtml.url);
|
|
615
|
+
check("rendered publish preview masks the JWT", !JSON.stringify(pubHtml).includes("SECRETJWT"), pubHtml);
|
|
616
|
+
check("rendered publish preview uses the editor body shape", pubHtml.body.includes("data_node") && pubHtml.body.includes("selected_custom_domain") && pubHtml.body.includes("render_type"), pubHtml.body);
|
|
617
|
+
check("rendered publish preview folds mobile_only into settings", pubHtml.body.includes('"mobile_only":true'), pubHtml.body);
|
|
618
|
+
check("rendered publish preview stands in a placeholder for the unbuilt app", pubHtml.body.includes("built by the build host"), pubHtml.body);
|
|
619
|
+
check("rendered publish preview is marked rendered:true", pubHtml.rendered === true, pubHtml);
|
|
560
620
|
}
|
|
561
621
|
console.log("== login: connect URL + loopback callback parsing (offline) ==");
|
|
562
622
|
{
|
|
@@ -17,6 +17,46 @@ import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/dra
|
|
|
17
17
|
export function registerPersistenceTools(server, domain) {
|
|
18
18
|
// Resolve config from THIS request's headers (remote per-user JWT) first, then env.
|
|
19
19
|
const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
|
|
20
|
+
// After a successful CREATE, build the rendered app and publish via the
|
|
21
|
+
// editor's publish_html route so the page renders immediately — without this
|
|
22
|
+
// a fresh page's preview is a blank shell until publish_page runs or the page
|
|
23
|
+
// is re-saved in the editor. Failures here never fail the create (the page
|
|
24
|
+
// exists either way); the result tells the caller how to retry. Skipped when
|
|
25
|
+
// no build host is configured (a source-only legacy publish renders nothing).
|
|
26
|
+
const autoPublish = async (config, pageId, source) => {
|
|
27
|
+
if (!config.buildBase) {
|
|
28
|
+
return {
|
|
29
|
+
published: false,
|
|
30
|
+
skipped: true,
|
|
31
|
+
note: "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset auto-configures https://build.webcake.io) — created source-only; the preview stays blank until publish_page runs with a build host or the page is re-saved in the editor.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
console.error(`[create_page] auto-publish: building ${pageId} via ${config.buildBase}`);
|
|
35
|
+
const build = await buildPageApp(config.buildBase, pageId, source);
|
|
36
|
+
if (!build.ok) {
|
|
37
|
+
console.error(`[create_page] auto-publish build failed: ${build.error}`);
|
|
38
|
+
return {
|
|
39
|
+
published: false,
|
|
40
|
+
error: `Build host failed (${build.error ?? "unknown error"})`,
|
|
41
|
+
hint: `The page was CREATED fine — only the rendering publish failed. Retry via publish_page({ page_id: "${pageId}", dry_run: false }).`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const outcome = await publishPage(config, pageId, source, { app: build.app, app_css: build.app_css });
|
|
45
|
+
if (!outcome.ok) {
|
|
46
|
+
console.error(`[create_page] auto-publish publish failed: ${outcome.error}`);
|
|
47
|
+
return {
|
|
48
|
+
published: false,
|
|
49
|
+
status: outcome.status,
|
|
50
|
+
error: outcome.error,
|
|
51
|
+
hint: `The page was CREATED fine — only the rendering publish failed. Retry via publish_page({ page_id: "${pageId}", dry_run: false }).`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
published: true,
|
|
56
|
+
rendered: true,
|
|
57
|
+
note: "Auto-published (no domain): the preview link renders for ~10 minutes after each publish, then expires. For a permanent public URL attach a domain via publish_page({ page_id, custom_domain, dry_run:false }).",
|
|
58
|
+
};
|
|
59
|
+
};
|
|
20
60
|
// 8) List organizations -----------------------------------------------------
|
|
21
61
|
server.tool("list_organizations", "Returns the account's Webcake organizations (id, name, is_default). The default org (type===1, usually the personal workspace) is where pages normally go. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, { title: "List Webcake Organizations", readOnlyHint: true, openWorldHint: true }, async (_args, extra) => {
|
|
22
62
|
const { config, missing } = cfgFor(extra);
|
|
@@ -31,7 +71,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
31
71
|
return text(await listOrganizations(config));
|
|
32
72
|
});
|
|
33
73
|
// 9) Create page (persist) --------------------------------------------------
|
|
34
|
-
server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page
|
|
74
|
+
server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page, saves the source, then AUTO-PUBLISHES it (builds the rendered app on the build host + publishes via the editor's publish_html route) so the preview renders immediately — set publish:false to skip, and note the no-domain preview link still expires ~10 minutes after each publish (publish_page with custom_domain gives a permanent URL). A failed auto-publish never fails the create (result.publish says how to retry). Validates first. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
35
75
|
source: z
|
|
36
76
|
.any()
|
|
37
77
|
.optional()
|
|
@@ -49,7 +89,11 @@ export function registerPersistenceTools(server, domain) {
|
|
|
49
89
|
.boolean()
|
|
50
90
|
.optional()
|
|
51
91
|
.describe("Default TRUE — validate, cache the source as draft_id, and preview the request without sending. Set false to actually create."),
|
|
52
|
-
|
|
92
|
+
publish: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Default TRUE — after a successful create, automatically build the rendered app and publish (publish_html) so the preview renders immediately. Set false to create source-only (blank preview until publish_page runs)."),
|
|
96
|
+
}, { title: "Create Webcake Page", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ source, draft_id, name, organization_id, dry_run, publish }, extra) => {
|
|
53
97
|
const isDry = dry_run !== false; // default true (safe)
|
|
54
98
|
// --- Resolve source: from cache (draft_id) or from the argument ---
|
|
55
99
|
let expanded;
|
|
@@ -60,7 +104,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
60
104
|
return text({
|
|
61
105
|
created: false,
|
|
62
106
|
reason: "draft_expired",
|
|
63
|
-
hint: "The cached draft is gone (expired after ~
|
|
107
|
+
hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via create_page({ source:…, dry_run:false }).",
|
|
64
108
|
});
|
|
65
109
|
}
|
|
66
110
|
if (cached.kind != null && cached.kind !== "page") {
|
|
@@ -104,7 +148,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
104
148
|
errors: result.errors,
|
|
105
149
|
...warningsField(result.warnings),
|
|
106
150
|
draft_id: existingDraftId,
|
|
107
|
-
hint: "Do NOT rebuild the whole source — it is cached as draft_id.
|
|
151
|
+
hint: "Do NOT rebuild the whole source — it is cached as draft_id. Each error names the offending element id — fix ONLY those elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure). A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
|
|
108
152
|
});
|
|
109
153
|
}
|
|
110
154
|
const parsed = domain.coerce(expanded);
|
|
@@ -149,6 +193,11 @@ export function registerPersistenceTools(server, domain) {
|
|
|
149
193
|
missing_env: missing,
|
|
150
194
|
target_organization_id: orgId ?? config?.orgId ?? null,
|
|
151
195
|
organization_note: organizationNote,
|
|
196
|
+
publish_step: publish === false
|
|
197
|
+
? { would_run: false, note: "publish:false — the real run will create source-only (blank preview until publish_page)." }
|
|
198
|
+
: config?.buildBase
|
|
199
|
+
? { would_run: true, build_host: config.buildBase, note: "After creating, the real run auto-builds the rendered app and publishes (publish_html) so the preview renders immediately." }
|
|
200
|
+
: { would_run: false, note: "No build host configured — the real run creates source-only (blank preview). Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header (prod preset auto-configures) to enable auto-publish." },
|
|
152
201
|
draft_id: existingDraftId,
|
|
153
202
|
request: config
|
|
154
203
|
? buildRequestRedacted(config, pageName, parsed, orgId)
|
|
@@ -238,9 +287,15 @@ export function registerPersistenceTools(server, domain) {
|
|
|
238
287
|
const outcome = await createPage(config, pageName, parsed, resolvedOrgId);
|
|
239
288
|
if (outcome.ok) {
|
|
240
289
|
deleteDraft(existingDraftId); // created — drop the draft
|
|
290
|
+
// Auto-publish (default): build + publish_html so the preview renders
|
|
291
|
+
// immediately. Never fails the create — result.publish carries the state.
|
|
292
|
+
const publishOutcome = publish === false
|
|
293
|
+
? { published: false, skipped: true, note: "publish:false — created source-only; the preview stays blank until publish_page runs." }
|
|
294
|
+
: await autoPublish(config, outcome.page_id, parsed);
|
|
241
295
|
return text({
|
|
242
296
|
created: true,
|
|
243
297
|
...outcome,
|
|
298
|
+
publish: publishOutcome,
|
|
244
299
|
...warningsField(result.warnings),
|
|
245
300
|
...(organizationAutoSelected ? { organization_auto_selected: true } : {}),
|
|
246
301
|
...(organizationNote ? { note: organizationNote } : {}),
|
|
@@ -248,12 +303,21 @@ export function registerPersistenceTools(server, domain) {
|
|
|
248
303
|
}
|
|
249
304
|
// Failure (including timeout): keep the draft so the model can retry.
|
|
250
305
|
updateDraft(existingDraftId, expanded);
|
|
306
|
+
// A backend 404/5xx on a route that normally works is usually a transient
|
|
307
|
+
// deploy/restart window — the fix is to RETRY THE SAME REQUEST, not to
|
|
308
|
+
// change parameters. (Observed failure mode: a transient 404 with an
|
|
309
|
+
// organization_id made the model "work around" it by dropping the org —
|
|
310
|
+
// the page then landed in personal instead of the requested workspace.)
|
|
311
|
+
const transient = outcome.status === 404 || (outcome.status ?? 0) >= 500 || outcome.status === 0;
|
|
251
312
|
return text({
|
|
252
313
|
created: false,
|
|
253
314
|
...outcome,
|
|
254
315
|
...warningsField(result.warnings),
|
|
255
316
|
draft_id: existingDraftId,
|
|
256
|
-
hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~
|
|
317
|
+
hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~2 h.` +
|
|
318
|
+
(transient
|
|
319
|
+
? ` A ${outcome.status === 0 ? "network error" : outcome.status} from the backend is usually TRANSIENT (deploy/restart window) — retry the SAME draft with the SAME organization_id after a short pause. Do NOT drop or change organization_id to work around it: the page would land in the wrong workspace.`
|
|
320
|
+
: ""),
|
|
257
321
|
});
|
|
258
322
|
});
|
|
259
323
|
// 10) List pages ------------------------------------------------------------
|
|
@@ -354,7 +418,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
354
418
|
return text({
|
|
355
419
|
updated: false,
|
|
356
420
|
reason: "draft_expired",
|
|
357
|
-
hint: "The cached draft is gone (expired after ~
|
|
421
|
+
hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via update_page({ page_id, source:…, dry_run:false }).",
|
|
358
422
|
});
|
|
359
423
|
}
|
|
360
424
|
if (cached.kind !== "update") {
|
|
@@ -441,7 +505,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
441
505
|
...outcome,
|
|
442
506
|
...warningsField(result.warnings),
|
|
443
507
|
draft_id: existingDraftId,
|
|
444
|
-
hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~
|
|
508
|
+
hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~2 h.`,
|
|
445
509
|
});
|
|
446
510
|
});
|
|
447
511
|
// 13) Add section (incremental build) ---------------------------------------
|
|
@@ -514,7 +578,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
514
578
|
return text({
|
|
515
579
|
added: false,
|
|
516
580
|
reason: "draft_expired",
|
|
517
|
-
hint: "The cached section draft is gone (expired after ~
|
|
581
|
+
hint: "The cached section draft is gone (expired after ~2 h or evicted). Re-send the sections via add_section({ page_id, sections:[…] }).",
|
|
518
582
|
});
|
|
519
583
|
}
|
|
520
584
|
if (cached.kind !== "sections") {
|
|
@@ -571,7 +635,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
571
635
|
errors: result.errors,
|
|
572
636
|
...warningsField(result.warnings),
|
|
573
637
|
draft_id: existingDraftId,
|
|
574
|
-
hint: "Do NOT rebuild the section batch — it is cached as draft_id.
|
|
638
|
+
hint: "Do NOT rebuild the section batch — it is cached as draft_id. Each error names the offending element id — fix ONLY those elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged shell and appends the sections. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' }. A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
|
|
575
639
|
});
|
|
576
640
|
}
|
|
577
641
|
const { config, missing } = cfgFor(extra);
|
|
@@ -756,13 +820,13 @@ export function registerPersistenceTools(server, domain) {
|
|
|
756
820
|
}
|
|
757
821
|
return touched;
|
|
758
822
|
};
|
|
759
|
-
server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached draft source (draft_id). Draft sources come from: (a) create_page — failed validation or timed-out network call → patched/committed tree is CREATED as a new page once valid; (b) add_section dry_run or validation/network failure → patched/committed shell is APPENDED to the stored page once valid; (c) update_page or live-page patch_page — timed-out/failed network call → re-committed via updatePageSource. Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves. Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. EMPTY/OMITTED patches with a draft_id = commit the cached draft as-is (skip apply, still validate, then honor dry_run) — this is the RETRY PATH after a timeout. Use this to fix the elements a failed create_page/update_page/add_section reported instead of rebuilding. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id sections-patch only needs creds to actually append; a page_id patch reads the live page so needs creds even on dry_run).", {
|
|
823
|
+
server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached draft source (draft_id). Draft sources come from: (a) create_page — failed validation or timed-out network call → patched/committed tree is CREATED as a new page once valid; (b) add_section dry_run or validation/network failure → patched/committed shell is APPENDED to the stored page once valid; (c) update_page or live-page patch_page — timed-out/failed network call → re-committed via updatePageSource. Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves. Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type; update CANNOT delete an existing/stray key — schema 'additional properties' errors need op:'replace'), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. EMPTY/OMITTED patches with a draft_id = commit the cached draft as-is (skip apply, still validate, then honor dry_run) — this is the RETRY PATH after a timeout. Use this to fix the elements a failed create_page/update_page/add_section reported instead of rebuilding. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id sections-patch only needs creds to actually append; a page_id patch reads the live page so needs creds even on dry_run).", {
|
|
760
824
|
page_id: z.string().optional().describe("Edit a LIVE page by id (from create_page, list_pages, or find_pages; must be owned by the account). Provide page_id OR draft_id. For a sections or update draft_id you may also pass page_id here to override the stored page target."),
|
|
761
825
|
draft_id: z.string().optional().describe("Commit or fix a CACHED source: from create_page (failed/timed-out → new page created once valid), add_section (dry_run or failure → sections appended), or update_page/live-page patch (timed-out/failed → updatePageSource retried). The originating tool's error/dry_run response returns draft_id. Provide page_id OR draft_id. Empty/omitted patches = commit the cached draft as-is (the RETRY PATH after a timeout)."),
|
|
762
826
|
patches: z
|
|
763
827
|
.any()
|
|
764
828
|
.optional()
|
|
765
|
-
.describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults. OMIT (or pass empty array) when draft_id is given and you just want to commit/retry the cached draft as-is."),
|
|
829
|
+
.describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type; update MERGES — it cannot DELETE an existing/stray key, so 'must NOT have additional properties' errors need op:'replace' with a clean node); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults. OMIT (or pass empty array) when draft_id is given and you just want to commit/retry the cached draft as-is."),
|
|
766
830
|
dry_run: z
|
|
767
831
|
.boolean()
|
|
768
832
|
.optional()
|
|
@@ -787,7 +851,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
787
851
|
return text({
|
|
788
852
|
patched: false,
|
|
789
853
|
reason: "draft_expired",
|
|
790
|
-
hint: "The cached draft is gone (expired after ~
|
|
854
|
+
hint: "The cached draft is gone (expired after ~2 h or evicted). Re-send the full source via create_page.",
|
|
791
855
|
});
|
|
792
856
|
}
|
|
793
857
|
base = draft.source; // already an expanded full tree
|
|
@@ -931,7 +995,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
931
995
|
...warningsField(result.warnings),
|
|
932
996
|
patches_applied: applied,
|
|
933
997
|
draft_id,
|
|
934
|
-
hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] })
|
|
998
|
+
hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] }); your applied fixes are kept in the draft. Each error names the offending element id — target THAT id. A stray/extra key ('must NOT have additional properties') needs op:'replace' with a clean node (op:'update' merges; it cannot delete a key). Do NOT rebuild with create_page — the draft already holds everything.",
|
|
935
999
|
});
|
|
936
1000
|
}
|
|
937
1001
|
const parsed = domain.coerce(expanded);
|
|
@@ -1071,6 +1135,9 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1071
1135
|
else {
|
|
1072
1136
|
updateDraft(draft_id, base); // keep for retry
|
|
1073
1137
|
}
|
|
1138
|
+
// A page created via the fix-after-error path gets the same auto-publish
|
|
1139
|
+
// as a direct create_page (build + publish_html so the preview renders).
|
|
1140
|
+
const publishOutcome = outcome.ok ? await autoPublish(config, outcome.page_id, parsed) : undefined;
|
|
1074
1141
|
return text({
|
|
1075
1142
|
patched: outcome.ok,
|
|
1076
1143
|
created: outcome.ok,
|
|
@@ -1081,6 +1148,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1081
1148
|
preview_url: outcome.preview_url,
|
|
1082
1149
|
status: outcome.status,
|
|
1083
1150
|
error: outcome.error,
|
|
1151
|
+
...(publishOutcome ? { publish: publishOutcome } : {}),
|
|
1084
1152
|
...warningsField(result.warnings),
|
|
1085
1153
|
...(outcome.ok ? {} : { draft_id, hint: `Create failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) or resolve the error first.` }),
|
|
1086
1154
|
});
|
|
@@ -1134,11 +1202,11 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1134
1202
|
error: outcome.error,
|
|
1135
1203
|
...warningsField(result.warnings),
|
|
1136
1204
|
draft_id: liveDraftId,
|
|
1137
|
-
hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~
|
|
1205
|
+
hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~2 h.`,
|
|
1138
1206
|
});
|
|
1139
1207
|
});
|
|
1140
1208
|
// 15) Publish page (go live) -------------------------------------------------
|
|
1141
|
-
server.tool("publish_page", "Publishes an EXISTING page: builds the rendered app
|
|
1209
|
+
server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake). WITHOUT a domain there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
1142
1210
|
page_id: z.string().describe("The page id to publish (must be owned by the account)."),
|
|
1143
1211
|
custom_domain: z
|
|
1144
1212
|
.string()
|
|
@@ -1152,15 +1220,18 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1152
1220
|
if (!config)
|
|
1153
1221
|
return text({ published: false, reason: "missing_env", missing_env: missing });
|
|
1154
1222
|
// Publish re-saves the page's CURRENT stored source (the publish endpoint
|
|
1155
|
-
// requires
|
|
1156
|
-
//
|
|
1223
|
+
// requires it in the request), so read it first — even on dry_run, to show
|
|
1224
|
+
// the real payload.
|
|
1157
1225
|
const res = await getPageSource(config, page_id);
|
|
1158
1226
|
if (!res.ok || res.source == null) {
|
|
1159
1227
|
return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
|
|
1160
1228
|
}
|
|
1161
|
-
const sourceString = JSON.stringify(res.source);
|
|
1162
1229
|
const opts = { customDomain: custom_domain, customPath: custom_path };
|
|
1163
1230
|
const buildBase = config.buildBase;
|
|
1231
|
+
// Only the preview window serves a domain-less publish, and only briefly.
|
|
1232
|
+
const previewExpiryNote = custom_domain
|
|
1233
|
+
? undefined
|
|
1234
|
+
: "No custom_domain — the page has NO permanent public URL. The preview link only renders for ~10 minutes after the publish, then shows 'Preview page is expired'. Attach a custom_domain (already pointed at Webcake) for a lasting URL.";
|
|
1164
1235
|
if (isDry) {
|
|
1165
1236
|
// dry_run is network-free — do NOT call the build host.
|
|
1166
1237
|
return text({
|
|
@@ -1171,13 +1242,16 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1171
1242
|
? `https://${custom_domain}${custom_path ? `/${custom_path}` : ""}`
|
|
1172
1243
|
: toPreviewUrl(config, `/preview/${page_id}`),
|
|
1173
1244
|
build_step: buildBase
|
|
1174
|
-
? { would_run: true, build_host: buildBase, note: "Build host will be called on dry_run=false to produce rendered app/app_css." }
|
|
1175
|
-
: { would_run: false, note: "No build host configured — publish will
|
|
1176
|
-
|
|
1245
|
+
? { would_run: true, build_host: buildBase, note: "Build host will be called on dry_run=false to produce rendered app/app_css, then the page is published live via the editor's publish_html route." }
|
|
1246
|
+
: { would_run: false, note: "No build host configured — publish will fall back to the LEGACY source-only route (nothing goes live). Set WEBCAKE_BUILD_BASE env or x-webcake-build-base header (prod preset: https://build.webcake.io) to publish for real." },
|
|
1247
|
+
...(previewExpiryNote ? { note: previewExpiryNote } : {}),
|
|
1248
|
+
request: buildPublishRequestRedacted(config, page_id, res.source, opts, !!buildBase),
|
|
1177
1249
|
hint: "Re-run with dry_run=false to actually publish.",
|
|
1178
1250
|
});
|
|
1179
1251
|
}
|
|
1180
|
-
// Real publish:
|
|
1252
|
+
// Real publish: build app/app_css first — required for the publish_html
|
|
1253
|
+
// (live) route. Without a successful build we fall back to the legacy
|
|
1254
|
+
// source-only route rather than publishing a blank PagePublishedV2 record.
|
|
1181
1255
|
let app;
|
|
1182
1256
|
let app_css;
|
|
1183
1257
|
let rendered = false;
|
|
@@ -1192,17 +1266,20 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1192
1266
|
console.error(`[publish_page] build ok — app ${app?.length ?? 0}B css ${app_css?.length ?? 0}B`);
|
|
1193
1267
|
}
|
|
1194
1268
|
else {
|
|
1195
|
-
buildWarning = `Build host failed (${buildResult.error ?? "unknown error"}) —
|
|
1269
|
+
buildWarning = `Build host failed (${buildResult.error ?? "unknown error"}) — fell back to the legacy source-only publish: a version was saved but NOTHING WENT LIVE (the live publish_html route needs the rendered app). Fix the build host and re-run publish_page.`;
|
|
1196
1270
|
console.error(`[publish_page] build failed: ${buildResult.error}`);
|
|
1197
1271
|
}
|
|
1198
1272
|
}
|
|
1199
1273
|
else {
|
|
1200
|
-
buildWarning = "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset has https://build.webcake.io automatically).
|
|
1274
|
+
buildWarning = "No build host configured (WEBCAKE_BUILD_BASE env / x-webcake-build-base header; prod preset has https://build.webcake.io automatically). Fell back to the legacy source-only publish: a version was saved but NOTHING WENT LIVE (the live publish_html route needs the rendered app).";
|
|
1201
1275
|
}
|
|
1202
1276
|
const publishOpts = { ...opts, app, app_css };
|
|
1203
|
-
const outcome = await publishPage(config, page_id,
|
|
1277
|
+
const outcome = await publishPage(config, page_id, res.source, publishOpts);
|
|
1204
1278
|
return text({
|
|
1205
1279
|
published: outcome.ok,
|
|
1280
|
+
// live = the PagePublishedV2 record public serving reads was written
|
|
1281
|
+
// (publish_html route). The legacy fallback only saves a version.
|
|
1282
|
+
live: outcome.ok && rendered,
|
|
1206
1283
|
rendered,
|
|
1207
1284
|
page_id,
|
|
1208
1285
|
url: outcome.published_url,
|
|
@@ -1211,6 +1288,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1211
1288
|
path: outcome.path,
|
|
1212
1289
|
status: outcome.status,
|
|
1213
1290
|
error: outcome.error,
|
|
1291
|
+
...(outcome.ok && previewExpiryNote ? { note: previewExpiryNote } : {}),
|
|
1214
1292
|
...(buildWarning ? { warning: buildWarning } : {}),
|
|
1215
1293
|
});
|
|
1216
1294
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/webcake-landing-mcp",
|
|
6
6
|
"type": "module",
|