webcake-storefront-mcp 1.2.0 → 1.4.0

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/dist/api.js CHANGED
@@ -70,6 +70,12 @@ export class WebcakeCmsApi {
70
70
  listMySites(query) {
71
71
  return this.request("GET", `/api/v1/dashboard/site/all`, { query });
72
72
  }
73
+ /** Create a brand-new personal site. The backend seeds sample categories/products/blog
74
+ * but NO pages. Returns { data: { site: { id, site_slug:{slug}, ... } } }.
75
+ * Fails with 403 when the account's site quota is reached (free plan: 4 sites). */
76
+ createSite(params) {
77
+ return this.request("POST", `/api/v1/dashboard/site/create`, { body: params, timeout: 60000 });
78
+ }
73
79
  getSiteInfo() {
74
80
  return this.request("GET", `/api/v1/site/${this.siteId}/`);
75
81
  }
@@ -1,9 +1,9 @@
1
- // Browser login: spins up a loopback server, opens the builder app's /mcp-connect
1
+ // Browser login: spins up a loopback server, opens the builder app's /mcp-storefront
2
2
  // page, and receives the user's token back on the local callback — then saves it to
3
3
  // the local config db so the stdio server picks it up automatically.
4
4
  import { createServer } from "node:http";
5
5
  import { randomBytes } from "node:crypto";
6
- import { spawn } from "node:child_process";
6
+ import { spawn, execFile } from "node:child_process";
7
7
  import { resolveSettings } from "../config.js";
8
8
  import { setConfig } from "../db.js";
9
9
  function parseArgs(argv) {
@@ -24,37 +24,144 @@ function parseArgs(argv) {
24
24
  return opts;
25
25
  }
26
26
  function openBrowser(url) {
27
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
27
+ const platform = process.platform;
28
28
  try {
29
- spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
29
+ if (platform === "win32") {
30
+ // `cmd /c start` parses an unquoted `&` as a command separator, which would cut
31
+ // the connect URL right before `&state=...`. Pass args verbatim with the URL
32
+ // double-quoted; the first quoted arg ("") is `start`'s window title.
33
+ spawn("cmd", ["/c", "start", '""', `"${url}"`], {
34
+ stdio: "ignore",
35
+ detached: true,
36
+ windowsVerbatimArguments: true,
37
+ }).unref();
38
+ return;
39
+ }
40
+ const cmd = platform === "darwin" ? "open" : "xdg-open";
41
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
30
42
  }
31
43
  catch {
32
44
  /* user can open the URL manually */
33
45
  }
34
46
  }
35
- const SUCCESS_HTML = "<!doctype html><meta charset=utf-8><title>Connected</title><body style='font:16px system-ui;padding:3rem;text-align:center'><h2>✓ Connected to WebCake</h2><p>You can close this tab and return to your terminal.</p></body>";
47
+ // Chrome won't let a page close a tab it didn't open (window.close() is blocked),
48
+ // so instead we bring the user's terminal back to the foreground from Node. We
49
+ // snapshot whatever app is frontmost just before opening the browser (that's the
50
+ // terminal/IDE that ran the command) and re-activate it once the token arrives.
51
+ // macOS only (AppleScript); a no-op elsewhere — the success page still shows.
52
+ function captureFrontmostApp() {
53
+ if (process.platform !== "darwin")
54
+ return Promise.resolve(undefined);
55
+ return new Promise((resolve) => {
56
+ let done = false;
57
+ const finish = (v) => {
58
+ if (done)
59
+ return;
60
+ done = true;
61
+ resolve(v);
62
+ };
63
+ const child = execFile("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], (err, stdout) => finish(err ? undefined : stdout.trim() || undefined));
64
+ // Never let login stall: the first run may hang on the macOS Automation
65
+ // permission prompt. Give up after 2s (re-focus is just a nicety).
66
+ setTimeout(() => {
67
+ try {
68
+ child.kill();
69
+ }
70
+ catch { /* noop */ }
71
+ finish(undefined);
72
+ }, 2000).unref();
73
+ });
74
+ }
75
+ function activateApp(name) {
76
+ if (!name || process.platform !== "darwin")
77
+ return;
78
+ // Re-focus by process name via System Events (works for terminals whose app
79
+ // name differs from the process, e.g. iTerm/Terminal/Warp/VS Code).
80
+ execFile("osascript", ["-e", `tell application "System Events" to set frontmost of (first application process whose name is "${name.replace(/"/g, '\\"')}") to true`], () => { });
81
+ }
82
+ const SUCCESS_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
83
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>Connected to WebCake</title>
84
+ <style>
85
+ :root{color-scheme:light dark}
86
+ *{box-sizing:border-box}
87
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
88
+ font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
89
+ background:linear-gradient(135deg,#e7f6f0 0%,#eafaf4 100%);color:#0f2e23}
90
+ @media(prefers-color-scheme:dark){body{background:linear-gradient(135deg,#0b1f18 0%,#06140f 100%);color:#dcf2e8}}
91
+ .card{background:#fff;border-radius:20px;padding:48px 40px;max-width:430px;width:calc(100% - 32px);
92
+ text-align:center;box-shadow:0 20px 60px rgba(16,139,103,.20);animation:rise .5s cubic-bezier(.2,.8,.2,1)}
93
+ @media(prefers-color-scheme:dark){.card{background:#10241c;box-shadow:0 20px 60px rgba(0,0,0,.5)}}
94
+ @keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
95
+ .badge{width:84px;height:84px;margin:0 auto 24px;border-radius:50%;display:flex;align-items:center;justify-content:center;
96
+ background:linear-gradient(135deg,#13a87b,#108B67);box-shadow:0 8px 24px rgba(16,139,103,.42);animation:pop .45s .15s both cubic-bezier(.2,1.4,.4,1)}
97
+ @keyframes pop{from{transform:scale(0)}to{transform:scale(1)}}
98
+ .badge svg{width:44px;height:44px;stroke:#fff;stroke-width:3.5;fill:none;stroke-linecap:round;stroke-linejoin:round}
99
+ .badge path{stroke-dasharray:32;stroke-dashoffset:32;animation:draw .4s .4s forwards ease-out}
100
+ @keyframes draw{to{stroke-dashoffset:0}}
101
+ h1{margin:0 0 10px;font-size:1.55rem;font-weight:700}
102
+ p{margin:0;font-size:1rem;line-height:1.6;color:#4b6a5f}
103
+ @media(prefers-color-scheme:dark){p{color:#9cc7b8}}
104
+ .hint{margin-top:24px;display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;
105
+ background:#f0f7f4;font-size:.9rem;color:#3d5b50}
106
+ @media(prefers-color-scheme:dark){.hint{background:#0b1f18;color:#9cc7b8}}
107
+ .hint code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600;color:#108B67}
108
+ @media(prefers-color-scheme:dark){.hint code{color:#5fd3ac}}
109
+ </style></head>
110
+ <body>
111
+ <main class="card">
112
+ <div class="badge"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div>
113
+ <h1>Connected to WebCake</h1>
114
+ <p>Your storefront account is linked. You can close this tab now.</p>
115
+ <div class="hint">👉 Return to your <code>terminal</code> to continue</div>
116
+ </main>
117
+ <script>
118
+ // The terminal is re-focused from the CLI side. Still try window.close() for
119
+ // browsers that allow it (no-op in Chrome for tabs it didn't open) — no alert,
120
+ // which would only steal focus back from the terminal.
121
+ setTimeout(function(){ try { window.close(); } catch (e) {} }, 800);
122
+ </script>
123
+ </body></html>`;
36
124
  function readQuery(url) {
37
125
  const q = (url ?? "").indexOf("?");
38
126
  return new URLSearchParams(q === -1 ? "" : (url ?? "").slice(q + 1));
39
127
  }
128
+ function pathOf(url) {
129
+ const u = url ?? "/";
130
+ const q = u.indexOf("?");
131
+ return q === -1 ? u : u.slice(0, q);
132
+ }
40
133
  export async function runLogin(argv) {
41
134
  const opts = parseArgs(argv);
42
135
  const settings = resolveSettings({ apiUrl: opts.apiUrl, appUrl: opts.appUrl });
43
136
  const appUrl = settings.appUrl.replace(/\/$/, "");
44
137
  const apiUrl = settings.apiUrl.replace(/\/$/, "");
45
138
  const state = randomBytes(16).toString("hex");
139
+ // Remember the terminal that's frontmost now, so we can re-focus it once the
140
+ // browser hands the token back (Chrome can't auto-close its own tab).
141
+ const terminalApp = await captureFrontmostApp();
46
142
  await new Promise((resolve, reject) => {
143
+ // `close()` only stops NEW connections; a browser keep-alive socket would keep
144
+ // the event loop (and the CLI) alive, so drop the live ones too.
145
+ const shutdown = () => {
146
+ server.close();
147
+ server.closeAllConnections?.();
148
+ };
47
149
  const server = createServer((req, res) => {
150
+ // Ignore stray requests (favicon, etc.) so they don't trip the token check.
151
+ if (pathOf(req.url) !== "/callback") {
152
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
153
+ return;
154
+ }
48
155
  const params = readQuery(req.url);
49
156
  const token = params.get("token") || params.get("jwt");
50
157
  const wsid = params.get("wsid") || params.get("session_id") || "";
51
158
  const returnedState = params.get("state");
52
159
  if (!token) {
53
- res.writeHead(400, { "content-type": "text/html" }).end("<p>Missing token.</p>");
54
- return;
160
+ res.writeHead(400, { "content-type": "text/html" }).end("<p>Missing token — re-run the command.</p>");
161
+ return; // keep listening — the user can retry until the timeout
55
162
  }
56
163
  if (returnedState && returnedState !== state) {
57
- res.writeHead(400, { "content-type": "text/html" }).end("<p>State mismatch.</p>");
164
+ res.writeHead(400, { "content-type": "text/html" }).end("<p>State mismatch (login link truncated or expired) — re-run the command.</p>");
58
165
  return;
59
166
  }
60
167
  setConfig("token", token);
@@ -66,9 +173,12 @@ export async function runLogin(argv) {
66
173
  setConfig("site_id", opts.siteId);
67
174
  res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_HTML);
68
175
  console.error(`\n✓ Connected. Token${wsid ? " + session" : ""} saved to local config (api ${apiUrl || "<unset>"}).`);
69
- server.close();
176
+ // Pull the terminal/IDE back to the front (best-effort; no-op off macOS).
177
+ activateApp(terminalApp);
178
+ shutdown();
70
179
  resolve();
71
180
  });
181
+ server.on("error", reject);
72
182
  server.listen(opts.port ?? 0, "127.0.0.1", () => {
73
183
  const addr = server.address();
74
184
  const port = typeof addr === "object" && addr ? addr.port : opts.port;
@@ -80,7 +190,7 @@ export async function runLogin(argv) {
80
190
  openBrowser(full);
81
191
  });
82
192
  setTimeout(() => {
83
- server.close();
193
+ shutdown();
84
194
  reject(new Error("login timed out after 180s."));
85
195
  }, 180_000).unref();
86
196
  });
@@ -0,0 +1,68 @@
1
+ // Breakpoint + grid model, ported from builderx_spa (composable/grid.js + common/index.js).
2
+ //
3
+ // CRITICAL: BuilderX persists each node's style/layout under per-breakpoint keys
4
+ // `bp1`/`bp2`/`bp3`/`bp4` — each `{ style, config }` — NOT under `runtime`. The
5
+ // `runtime` key the factory emits is only a staging area inside the Vue editor; the
6
+ // storefront renderer reads `node[breakpointActive]` (see getStyle/getConfig in
7
+ // builderx_spa/src/composable/get.js) and does NOT fall back to `runtime`. A node that
8
+ // only has `runtime` renders with no styles/grid placement (a broken-looking page).
9
+ // page.ts:finalizeForRender() converts runtime -> bp1..bp4 before a page is saved.
10
+ /** Site default breakpoints, largest first. [minWidth, maxWidth]. bp1 is the base. */
11
+ export const BREAKPOINTS = {
12
+ bp1: [1320, 1e9], // desktop (base / largest, default active)
13
+ bp2: [993, 1319], // laptop
14
+ bp3: [641, 992], // tablet
15
+ bp4: [320, 640], // mobile
16
+ };
17
+ export const BREAKPOINT_KEYS = Object.keys(BREAKPOINTS); // ['bp1','bp2','bp3','bp4']
18
+ export const BASE_BP = "bp1";
19
+ // Position / layout keys that are breakpoint-specific. The builder does NOT copy these
20
+ // when syncing one breakpoint onto another (placement differs per device). We keep them
21
+ // in whichever breakpoint they were authored, and copy only the non-async keys across.
22
+ export const STYLE_ASYNC = ["top", "left", "right", "bottom", "width", "height", "zIndex", "position", "fontSize"];
23
+ export const CONFIG_ASYNC = [
24
+ "constraintX", "constraintY", "leftUnit", "rightUnit", "relLeft", "relRight", "absRight",
25
+ "relWidth", "widthUnit", "topUnit", "bottomUnit", "relTop", "relBottom", "absBottom", "absLeftCenterX",
26
+ "relLeftCenterX", "leftCenterXUnit", "absRightCenterX", "relRightCenterX", "rightCenterXUnit", "topCenterYUnit",
27
+ "absTopCenterY", "relTopCenterY", "bottomCenterYUnit", "absBottomCenterY", "relBottomCenterY", "heightUnit",
28
+ "relHeight", "vhHeight", "columnStart", "columnEnd", "rowStart", "rowEnd", "isHidden", "columns", "rows", "grid",
29
+ "is_use_width_outer_parent", "area", "lockCellGrid", "slideWidth", "slideWidthUnit", "relSlideWidth",
30
+ "posts_per_row", "is_pin_video", "sizeThumbnail", "layout", "scrollDirection",
31
+ ];
32
+ /**
33
+ * Section "centered content" grid for a given breakpoint width — verbatim port of
34
+ * builderx_spa composable/grid.js:genGridByBp. A section is a 3-column grid: a flexible
35
+ * margin on each side and the page content in the centre column (max 1300px on desktop).
36
+ * `rows` here is a single placeholder row; callers override `rows`/`grid` for the real
37
+ * number of stacked children.
38
+ */
39
+ export function genGridByBp(bp) {
40
+ const rows = [{ unit: "min/max", min: { unit: "px", absValue: 600 }, max: { unit: "max-c" } }];
41
+ if (bp >= 1320) {
42
+ return {
43
+ grid: "3x1",
44
+ columns: [{ unit: "fr", value: 1 }, { unit: "px", absValue: 1300, value: 1 }, { unit: "fr", value: 1 }],
45
+ rows,
46
+ loaded: true,
47
+ };
48
+ }
49
+ else if (bp >= 993) {
50
+ return {
51
+ grid: "3x1",
52
+ columns: [{ unit: "px", absValue: 10 }, { unit: "fr", value: 1 }, { unit: "px", absValue: 10 }],
53
+ rows,
54
+ loaded: true,
55
+ };
56
+ }
57
+ else {
58
+ return {
59
+ grid: "3x1",
60
+ columns: [{ unit: "px", absValue: 5 }, { unit: "fr", value: 1 }, { unit: "px", absValue: 5 }],
61
+ rows,
62
+ loaded: true,
63
+ };
64
+ }
65
+ }
66
+ /** The centre (content) column index in a section's 3-column grid (1-based grid lines). */
67
+ export const SECTION_CONTENT_COL_START = 2;
68
+ export const SECTION_CONTENT_COL_END = 3;
@@ -27,23 +27,40 @@ fills the correct defaults, then edit specials/style.
27
27
  ## Layout = CSS grid (NOT absolute top/left)
28
28
  This is the key difference from landing-page builders. A section/container positions its
29
29
  children with a grid:
30
- - container \`runtime.config\`: \`grid: "1xN"\`, \`columns: [{unit:'fr',value:1}]\`,
31
- \`rows: [{unit:'min/max', min:{unit:'px',absValue:H}, max:{unit:'max-c'}}, ...]\`.
32
- - each child \`runtime.config\`: \`columnStart/columnEnd\`, \`rowStart/rowEnd\` (1-based grid
33
- lines), \`constraintX\` (['left'|'right'|'centerLeft']), \`constraintY\` (['top'|'bottom'|'centerTop']).
34
- new_section does this for you: pass children and they are stacked one row each. To build
35
- multi-column layouts, nest a container child and give it its own grid.
30
+ - A SECTION uses a centred 3-column grid: \`grid: "3xN"\`, columns
31
+ \`[{unit:'fr',value:1}, {unit:'px',absValue:1300,value:1}, {unit:'fr',value:1}]\`
32
+ flexible margin · 1300px content · flexible margin. Children sit in the CENTRE column
33
+ (\`columnStart:2, columnEnd:3\`). \`rows\` = one \`{unit:'min/max', min:{unit:'px',absValue:H}, max:{unit:'max-c'}}\` per child.
34
+ - A nested CONTAINER uses a simple \`grid: "1xN"\` with \`columns:[{unit:'fr',value:1}]\`;
35
+ its children sit in \`columnStart:1, columnEnd:2\`.
36
+ - each child config also has \`rowStart/rowEnd\` (1-based grid lines),
37
+ \`constraintX\` (['left'|'right'|'centerLeft']), \`constraintY\` (['top'|'bottom'|'centerTop']).
38
+ new_section does ALL of this for you: pass children and they are stacked one row each in
39
+ the centre column. To build multi-column layouts, nest a container child with its own grid.
40
+
41
+ ## Where layout/style live: per-breakpoint keys (NOT \`runtime\`)
42
+ new_section / new_element emit a temporary \`runtime: { style, config }\`. That is a
43
+ STAGING shape — the storefront does NOT read \`runtime\`. On save, build_page / add_section
44
+ automatically expand \`runtime\` into the four breakpoint keys the renderer actually reads:
45
+ \`node.bp1\`, \`node.bp2\`, \`node.bp3\`, \`node.bp4\` (each \`{ style, config }\`). You normally
46
+ never write these by hand for new pages. When EDITING an existing page, elements are
47
+ already in this shape — see the \`responsive\` field on get_page_element/update_page_element.
36
48
 
37
49
  ## Styling
38
50
  - \`runtime.style\` holds CSS-ish props: width/height (numbers = px), color, background,
39
51
  fontSize ("16px"), fontWeight, textAlign, border*, boxShadow, etc.
40
52
  - \`runtime.config.heightUnit\`: "auto" lets content set height (default for text/image).
41
- - Colours as hex or rgba(). Use the site theme colours where possible.
53
+ - Colours: prefer the site THEME variables \`var(--color_00)\`, \`var(--color_01)\`, …
54
+ (the published site themes them); plain hex or rgba() also work.
42
55
 
43
56
  ## Responsive breakpoints
44
- Override style/config per breakpoint by adding a key on the node: \`bp1\`, \`tablet\`,
45
- \`laptop\` \`{ style: {...}, config: {...} }\`. Desktop values live in \`runtime\`.
46
- Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
57
+ The four breakpoints (largest smallest), keyed bp1..bp4, are:
58
+ - \`bp1\` ≥1320px (desktop, the base) · \`bp2\` 993–1319 (laptop) · \`bp3\` 641–992 (tablet) · \`bp4\` 320–640 (mobile).
59
+ For NEW pages you author once in \`runtime\` (desktop) and build_page copies it to all four
60
+ breakpoints automatically — the page renders identically across devices. To make a node
61
+ look DIFFERENT on a smaller screen, set that breakpoint's key explicitly, e.g.
62
+ \`node.bp4 = { style: { fontSize: "20px" }, config: {...} }\`. (There is no \`tablet\`/\`laptop\`
63
+ key — only bp1..bp4.)
47
64
 
48
65
  ## Content & data
49
66
  - Text: \`specials.text\` (HTML allowed), \`specials.tag\` ("h1".."p").
@@ -51,8 +68,14 @@ Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
51
68
  - Form: wrap inputs in a \`form\`; set \`form.specials.type\`
52
69
  (form_order | form_login | form_signup | form_discount | order_tracking). Each input
53
70
  needs \`specials.field_name\`.
54
- - Dataset elements (text-dataset, image-dataset, grid-product...) use \`bindings\` to pull
55
- product/category/blog data leave bindings to dataset-driven pages.
71
+ - Dataset elements (text-dataset, image-dataset, rectangle-dataset...) pull live data via
72
+ a \`bindings\` array. Each binding is \`{ id:"BINDING"+random, name:<source>, target:"<source>::<field>" }\`.
73
+ Real target field names (use these EXACTLY — there is no \`product::price\`):
74
+ - product: \`product::product_image\`, \`product::product_name\`, \`product::product_price\`
75
+ - cart_item: \`cart_item::cart_item_image\`, \`cart_item::cart_item_name\`, \`cart_item::cart_item_price\`, \`cart_item::cart_item_total_price\`, \`cart_item::cart_item_prod_attr\`
76
+ - order_item: \`order_item::product_image\`, \`order_item::product_name\`, \`order_item::product_quantity\`, \`order_item::items_sum_up_price\`, \`order_item::product_attrs\`
77
+ - customer_address: \`customer_address::full_name\`, \`customer_address::phone_number\`, \`customer_address::address\`, \`customer_address::pdc\`
78
+ A target only resolves on a page of the matching \`type\` (see below).
56
79
 
57
80
  ## Page types & data sources (IMPORTANT for special pages)
58
81
  A page's \`type\` decides which live data it can bind to. A SPECIAL page only works if the
@@ -61,15 +84,15 @@ but every product/customer/blog binding resolves to NULL (an empty, broken-looki
61
84
  \`build_page\` enables the right flag for you when you pass \`type\`:
62
85
  - \`main\` — homepage / normal content. No flag needed.
63
86
  - \`store\` — product detail, category, cart, checkout, thank-you. Needs \`use_store\`.
64
- Bindings: \`product::*\`, \`cart_item::*\`.
87
+ Bindings: \`product::product_*\`, \`cart_item::cart_item_*\`.
65
88
  - \`member\` — login, register, profile, order history. Needs \`use_member\`.
66
89
  Bindings: \`customer_address::*\`, \`order_item::*\`.
67
90
  - \`blog\` — blog list, article/post. Needs \`use_blog\`.
68
91
  - \`error\` / \`maintain\` — 404 / maintenance. Need \`use_error\` / \`use_maintain\`.
69
92
  - \`custom\` — a free page with no special data. No flag needed.
70
93
  Rule of thumb: if the page shows products, a cart, customer/order data, or blog posts,
71
- set \`type\` accordingly so the binding source is turned on. A binding name like
72
- \`product::price\` REQUIRES its page to be the matching type.
94
+ set \`type\` accordingly so the binding source is turned on. A binding target like
95
+ \`product::product_price\` REQUIRES its page to be the matching type.
73
96
 
74
97
  ## Workflow (do this every time)
75
98
  1. Intake: confirm goal, brand, colours, sections wanted (ask 3-5 questions if unclear).
@@ -6,6 +6,8 @@
6
6
  // produce that structure the same way the builder does, so generated pages render.
7
7
  import { buildElement, isKnownType, ELEMENT_TYPES } from "./catalog.js";
8
8
  import { randomString } from "./factory.js";
9
+ import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
10
+ const clone = (o) => structuredClone(o);
9
11
  /** Walk every node in a source tree (depth-first). Return false from fn to stop. */
10
12
  export function walk(source, fn) {
11
13
  const sections = source && Array.isArray(source.sections) ? source.sections : [];
@@ -42,10 +44,15 @@ export function reassignIds(node) {
42
44
  return node;
43
45
  }
44
46
  /**
45
- * Lay children out vertically inside a section/container using a single-column grid
46
- * the same shape the builder emits. `children` are placed top-to-bottom, one grid row each.
47
+ * Lay children out vertically inside a section/container one grid row per child,
48
+ * top-to-bottom — the same shape the builder emits. Values are written to `runtime`;
49
+ * finalizeForRender() later expands `runtime` into the per-breakpoint keys the
50
+ * storefront actually reads (bp1..bp4).
47
51
  */
48
- export function stackChildren(container, children) {
52
+ export function stackChildren(container, children, opts = {}) {
53
+ const gridCols = opts.gridCols || 1;
54
+ const colStart = opts.contentColStart || 1;
55
+ const colEnd = opts.contentColEnd || 2;
49
56
  const rows = children.map((child) => {
50
57
  const h = (child.runtime && child.runtime.style && child.runtime.style.height) || 50;
51
58
  return { unit: "min/max", min: { unit: "px", absValue: h }, max: { unit: "max-c" } };
@@ -53,8 +60,8 @@ export function stackChildren(container, children) {
53
60
  container.runtime = container.runtime || {};
54
61
  container.runtime.config = {
55
62
  ...(container.runtime.config || {}),
56
- grid: `1x${children.length || 1}`,
57
- columns: [{ unit: "fr", value: 1 }],
63
+ grid: `${gridCols}x${children.length || 1}`,
64
+ columns: opts.columns || [{ unit: "fr", value: 1 }],
58
65
  rows: rows.length ? rows : [{ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } }],
59
66
  heightUnit: "auto",
60
67
  };
@@ -62,8 +69,8 @@ export function stackChildren(container, children) {
62
69
  child.runtime = child.runtime || {};
63
70
  child.runtime.config = {
64
71
  ...(child.runtime.config || {}),
65
- columnStart: 1,
66
- columnEnd: 2,
72
+ columnStart: colStart,
73
+ columnEnd: colEnd,
67
74
  rowStart: i + 1,
68
75
  rowEnd: i + 2,
69
76
  constraintX: (child.runtime.config && child.runtime.config.constraintX) || ["centerLeft"],
@@ -77,11 +84,19 @@ export function stackChildren(container, children) {
77
84
  /**
78
85
  * Build a ready-to-place section from a list of child specs.
79
86
  * Each spec: { type, opts?, children? } where children is a nested array of specs.
87
+ * A section uses the builder's centred 3-column grid (margin · content · margin); the
88
+ * children live in the centre content column. finalizeForRender() sets the correct
89
+ * per-breakpoint column widths via genGridByBp.
80
90
  */
81
91
  export function buildSection(childSpecs = [], sectionOpts = {}) {
82
92
  const section = buildElement("section", sectionOpts);
83
93
  const children = childSpecs.map((spec) => buildFromSpec(spec));
84
- stackChildren(section, children);
94
+ stackChildren(section, children, {
95
+ gridCols: 3,
96
+ columns: genGridByBp(BREAKPOINTS.bp1[0]).columns,
97
+ contentColStart: SECTION_CONTENT_COL_START,
98
+ contentColEnd: SECTION_CONTENT_COL_END,
99
+ });
85
100
  return section;
86
101
  }
87
102
  function buildFromSpec(spec) {
@@ -146,4 +161,49 @@ export function validatePage(source) {
146
161
  stats: { sections: source.sections.length, total_elements: total, element_types: typeCounts },
147
162
  };
148
163
  }
164
+ /**
165
+ * Expand one node's `runtime.{style,config}` into the per-breakpoint keys the storefront
166
+ * renderer reads (bp1..bp4). Mirrors builderx_spa's syncBreakpoint: the authored
167
+ * (desktop) values are copied onto every breakpoint. Sections additionally get their
168
+ * centred 3-column grid recomputed per breakpoint via genGridByBp (the side-margin
169
+ * widths shrink on smaller screens). Nodes already in breakpoint shape are left as-is,
170
+ * so this is safe to run over a mixed source (e.g. add_section onto an existing page).
171
+ */
172
+ function expandNodeToBreakpoints(node) {
173
+ const rt = node && node.runtime;
174
+ if (rt && (rt.style || rt.config)) {
175
+ const baseStyle = rt.style || {};
176
+ const baseConfig = { ...(rt.config || {}), loaded: true };
177
+ const isSection = node.type === "section";
178
+ for (const [bp, [minW]] of Object.entries(BREAKPOINTS)) {
179
+ const style = clone(baseStyle);
180
+ const config = clone(baseConfig);
181
+ if (isSection) {
182
+ const g = genGridByBp(minW);
183
+ const sectionRows = baseConfig.rows && baseConfig.rows.length ? clone(baseConfig.rows) : clone(g.rows);
184
+ config.columns = clone(g.columns);
185
+ config.rows = sectionRows;
186
+ config.grid = `3x${sectionRows.length}`;
187
+ config.heightUnit = config.heightUnit || "auto";
188
+ }
189
+ node[bp] = { style, config };
190
+ }
191
+ delete node.runtime;
192
+ }
193
+ for (const child of node.children || [])
194
+ expandNodeToBreakpoints(child);
195
+ return node;
196
+ }
197
+ /**
198
+ * Convert a freshly-built page source (whose nodes carry `runtime`) into the shape the
199
+ * storefront actually renders: every node gets bp1..bp4 `{style,config}` and `runtime`
200
+ * is removed. MUST be called before saving a page built with new_section/new_element —
201
+ * otherwise the page renders with no styling or grid placement.
202
+ */
203
+ export function finalizeForRender(source) {
204
+ const sections = source && Array.isArray(source.sections) ? source.sections : [];
205
+ for (const s of sections)
206
+ expandNodeToBreakpoints(s);
207
+ return source;
208
+ }
149
209
  export { ELEMENT_TYPES };
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.4.0",
4
+ "d": "23/06/2026",
5
+ "type": "Changed",
6
+ "en": "The install command's interactive wizard now presents numbered choices with ANSI colour output and a completion summary.",
7
+ "vi": "Trình hướng dẫn tương tác của lệnh install nay hiển thị các lựa chọn được đánh số kèm màu ANSI và thông báo tóm tắt sau khi hoàn tất."
8
+ },
9
+ {
10
+ "v": "1.3.0",
11
+ "d": "23/06/2026",
12
+ "type": "Added",
13
+ "en": "New create_site tool creates a brand-new storefront site for the current account (seeded with sample products, categories, and a blog), optionally…",
14
+ "vi": "Tool mới create_site tạo một site storefront hoàn toàn mới cho tài khoản hiện tại (kèm sản phẩm, danh mục và blog mẫu), tự động chuyển sang site vừa…"
15
+ },
2
16
  {
3
17
  "v": "1.2.0",
4
18
  "d": "23/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
41
  "en": "The server no longer crashes at startup in container environments built with npm ci --ignore-scripts; the better-sqlite3 native SQLite module has…",
28
42
  "vi": "Server không còn bị crash khi khởi động trong môi trường container được build bằng npm ci --ignore-scripts; module SQLite native better-sqlite3 đã…"
29
- },
30
- {
31
- "v": "1.1.1",
32
- "d": "23/06/2026",
33
- "type": "Added",
34
- "en": "The serve command's OAuth token store now optionally uses Postgres (via DATABASE_URL) for durable persistence across restarts and shared state…",
35
- "vi": "Kho lưu trữ token OAuth của lệnh serve nay hỗ trợ tùy chọn sử dụng Postgres (qua DATABASE_URL) để lưu token bền vững qua các lần khởi động lại và…"
36
- },
37
- {
38
- "v": "1.1.0",
39
- "d": "23/06/2026",
40
- "type": "Added",
41
- "en": "The serve (remote Streamable-HTTP) mode now embeds a full OAuth 2.1 Authorization Server at /authorize, /token, /revoke, /register, and…",
42
- "vi": "Chế độ serve (remote Streamable-HTTP) nay tích hợp sẵn một Authorization Server OAuth 2.1 đầy đủ tại các endpoint /authorize, /token, /revoke,…"
43
43
  }
44
44
  ]
package/dist/install.js CHANGED
@@ -1,17 +1,51 @@
1
1
  // Installer: writes (or removes) this MCP server's entry in the config files of the
2
- // supported IDEs. Flag-driven; falls back to a couple of interactive prompts on a TTY.
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
- import { homedir } from "node:os";
2
+ // supported IDEs / agents. Flag-driven; falls back to a friendly numbered wizard on a
3
+ // TTY. Mirrors webcake-landing-mcp's installer (numbered choices, colours, multi-IDE).
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir, platform } from "node:os";
5
6
  import { dirname, join } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
- import { createInterface } from "node:readline/promises";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline";
8
10
  import { runLogin } from "./auth/login.js";
9
- const SERVER_KEY = "webcake-storefront";
11
+ const NAME = "webcake-storefront";
12
+ const PKG = "webcake-storefront-mcp";
13
+ const HOME = homedir();
14
+ const PLAT = platform(); // 'darwin' | 'linux' | 'win32'
15
+ const APPDATA = process.env.APPDATA || join(HOME, "AppData", "Roaming");
16
+ const LOCALAPPDATA = process.env.LOCALAPPDATA || join(HOME, "AppData", "Local");
17
+ // ── tiny ANSI palette + log helpers (mirrors webcake-landing-mcp's installer) ──
18
+ const c = {
19
+ reset: "\x1b[0m",
20
+ bold: "\x1b[1m",
21
+ dim: "\x1b[2m",
22
+ red: "\x1b[31m",
23
+ green: "\x1b[32m",
24
+ yellow: "\x1b[33m",
25
+ cyan: "\x1b[36m",
26
+ magenta: "\x1b[35m",
27
+ gray: "\x1b[90m",
28
+ };
29
+ const log = (m = "", color = "") => console.log(`${color}${m}${c.reset}`);
30
+ const info = (m) => log(` ${c.cyan}›${c.reset} ${m}`);
31
+ const ok = (m) => log(` ${c.green}✓${c.reset} ${m}`);
32
+ const warn = (m) => log(` ${c.yellow}!${c.reset} ${m}`);
33
+ /** One-shot prompt on stdout (fresh readline per question — simple + robust). */
34
+ function ask(question) {
35
+ return new Promise((resolve) => {
36
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
37
+ rl.question(question, (answer) => {
38
+ rl.close();
39
+ resolve(answer.trim());
40
+ });
41
+ });
42
+ }
43
+ // ── arg parsing ──────────────────────────────────────────────────────────────
10
44
  function parseArgs(argv) {
11
45
  const o = { uninstall: false, ides: [] };
12
46
  for (let i = 0; i < argv.length; i++) {
13
47
  const a = argv[i];
14
- if (a === "--uninstall")
48
+ if (a === "--uninstall" || a === "uninstall")
15
49
  o.uninstall = true;
16
50
  else if (a === "--ide")
17
51
  o.ides.push(...(argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -32,25 +66,15 @@ function parseArgs(argv) {
32
66
  }
33
67
  return o;
34
68
  }
35
- const HOME = homedir();
36
- const APP_SUPPORT = process.platform === "darwin" ? join(HOME, "Library", "Application Support") : join(HOME, ".config");
37
- // IDE -> config file + whether it nests under "mcpServers" (vs "mcp").
38
- const IDE_CONFIGS = {
39
- "claude-desktop": { path: join(APP_SUPPORT, "Claude", "claude_desktop_config.json"), key: "mcpServers" },
40
- "claude-code": { path: join(HOME, ".claude.json"), key: "mcpServers" },
41
- cursor: { path: join(HOME, ".cursor", "mcp.json"), key: "mcpServers" },
42
- windsurf: { path: join(HOME, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers" },
43
- vscode: { path: join(APP_SUPPORT, "Code", "User", "mcp.json"), key: "mcpServers" },
44
- };
45
- const ALL_IDES = Object.keys(IDE_CONFIGS);
69
+ // ── launch command (npx vs local node) ───────────────────────────────────────
46
70
  function resolveLaunch(opts) {
47
71
  const self = fileURLToPath(import.meta.url);
48
72
  const ranViaNpx = self.includes("/_npx/") || self.includes("\\_npx\\");
49
73
  const useNpx = opts.launch === "npx" || (opts.launch !== "local" && ranViaNpx);
50
74
  if (useNpx)
51
- return { command: "npx", args: ["-y", "webcake-storefront-mcp"] };
75
+ return { command: "npx", args: ["-y", PKG] };
52
76
  const entry = join(dirname(self), "index.js");
53
- return { command: "node", args: [entry] };
77
+ return { command: process.execPath, args: [entry] };
54
78
  }
55
79
  function buildEnv(opts) {
56
80
  const env = {};
@@ -66,118 +90,416 @@ function buildEnv(opts) {
66
90
  env.WEBCAKE_SITE_ID = opts.siteId;
67
91
  return env;
68
92
  }
69
- function readJson(path) {
70
- if (!existsSync(path))
71
- return {};
72
- try {
73
- return JSON.parse(readFileSync(path, "utf-8"));
93
+ // ── generic JSON config (Claude Desktop/Code, Cursor, Windsurf, VS Code, …) ───
94
+ function mergeJson(file, launch, env) {
95
+ mkdirSync(dirname(file), { recursive: true });
96
+ let cfg = {};
97
+ if (existsSync(file)) {
98
+ const raw = readFileSync(file, "utf8").trim();
99
+ if (raw) {
100
+ try {
101
+ cfg = JSON.parse(raw);
102
+ }
103
+ catch (e) {
104
+ warn(`Skip ${file} (invalid JSON: ${e.message})`);
105
+ return false;
106
+ }
107
+ }
74
108
  }
75
- catch {
76
- return {};
77
- }
78
- }
79
- function writeJson(path, data) {
80
- mkdirSync(dirname(path), { recursive: true });
81
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
82
- }
83
- function applyToIde(ide, opts, launch, env) {
84
- const cfg = IDE_CONFIGS[ide];
85
- if (!cfg)
86
- return `skip ${ide} (unknown)`;
87
- const json = readJson(cfg.path);
88
- const bag = (json[cfg.key] ||= {});
89
- if (opts.uninstall) {
90
- if (!(SERVER_KEY in bag))
91
- return `${ide}: nothing to remove`;
92
- delete bag[SERVER_KEY];
93
- writeJson(cfg.path, json);
94
- return `${ide}: removed (${cfg.path})`;
95
- }
96
- bag[SERVER_KEY] = {
109
+ if (typeof cfg.mcpServers !== "object" || !cfg.mcpServers)
110
+ cfg.mcpServers = {};
111
+ cfg.mcpServers[NAME] = {
97
112
  command: launch.command,
98
113
  args: launch.args,
99
114
  ...(Object.keys(env).length ? { env } : {}),
100
115
  };
101
- writeJson(cfg.path, json);
102
- return `${ide}: configured (${cfg.path})`;
116
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
117
+ return true;
118
+ }
119
+ // ── OpenCode config (its own shape, not mcpServers) ──────────────────────────
120
+ // { "mcp": { "<name>": { "type": "local", "command": [cmd, ...args], "environment": {…} } } }
121
+ function mergeOpencodeJson(file, launch, env) {
122
+ mkdirSync(dirname(file), { recursive: true });
123
+ let cfg = {};
124
+ if (existsSync(file)) {
125
+ const raw = readFileSync(file, "utf8").trim();
126
+ if (raw) {
127
+ try {
128
+ cfg = JSON.parse(raw);
129
+ }
130
+ catch (e) {
131
+ warn(`Skip ${file} (invalid JSON: ${e.message})`);
132
+ return false;
133
+ }
134
+ }
135
+ }
136
+ if (typeof cfg.mcp !== "object" || !cfg.mcp)
137
+ cfg.mcp = {};
138
+ cfg.mcp[NAME] = {
139
+ type: "local",
140
+ command: [launch.command, ...launch.args],
141
+ enabled: true,
142
+ ...(Object.keys(env).length ? { environment: env } : {}),
143
+ };
144
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
145
+ return true;
146
+ }
147
+ // ── TOML config (Codex) ──────────────────────────────────────────────────────
148
+ function configureCodex(launch, env) {
149
+ const dir = join(HOME, ".codex");
150
+ const cfg = join(dir, "config.toml");
151
+ mkdirSync(dir, { recursive: true });
152
+ const argsToml = launch.args.map((a) => `"${a}"`).join(", ");
153
+ const envParts = Object.entries(env)
154
+ .map(([k, v]) => `"${k}" = "${v}"`)
155
+ .join(", ");
156
+ const envLine = envParts ? `env = { ${envParts} }\n` : "";
157
+ const block = `\n[mcp_servers.${NAME}]\ncommand = "${launch.command}"\nargs = [${argsToml}]\n${envLine}`;
158
+ let content = existsSync(cfg) ? readFileSync(cfg, "utf8") : "# WebCake Storefront MCP\n";
159
+ content = content.replace(new RegExp(`\\n?\\[mcp_servers\\.${NAME}\\][\\s\\S]*?(?=\\n\\[|$)`), "");
160
+ content = content.trimEnd() + "\n" + block;
161
+ writeFileSync(cfg, content);
162
+ }
163
+ // ── IDE config-file locations ────────────────────────────────────────────────
164
+ function claudeDesktopPath() {
165
+ if (PLAT === "win32") {
166
+ // Microsoft Store build is MSIX-sandboxed: it reads/writes config inside its
167
+ // package container, NOT %APPDATA%\Claude. Detect that container first.
168
+ const packages = join(LOCALAPPDATA, "Packages");
169
+ if (existsSync(packages)) {
170
+ try {
171
+ const pkg = readdirSync(packages).find((n) => /^Claude_/i.test(n));
172
+ if (pkg)
173
+ return join(packages, pkg, "LocalCache", "Roaming", "Claude", "claude_desktop_config.json");
174
+ }
175
+ catch {
176
+ /* fall through to the Win32 default */
177
+ }
178
+ }
179
+ return join(APPDATA, "Claude", "claude_desktop_config.json");
180
+ }
181
+ const mac = join(HOME, "Library", "Application Support", "Claude");
182
+ const dir = existsSync(mac) ? mac : join(HOME, ".config", "Claude");
183
+ return join(dir, "claude_desktop_config.json");
184
+ }
185
+ function vscodeUserDir() {
186
+ if (PLAT === "win32")
187
+ return join(APPDATA, "Code", "User");
188
+ const mac = join(HOME, "Library", "Application Support", "Code", "User");
189
+ if (existsSync(mac))
190
+ return mac;
191
+ const lin = join(HOME, ".config", "Code", "User");
192
+ if (existsSync(lin))
193
+ return lin;
194
+ return join(HOME, ".vscode");
195
+ }
196
+ const vscodeUserPath = () => join(vscodeUserDir(), "mcp.json");
197
+ const cursorPath = () => join(HOME, ".cursor", "mcp.json");
198
+ const windsurfPath = () => join(HOME, ".codeium", "windsurf", "mcp_config.json");
199
+ const claudeJsonPath = () => join(HOME, ".claude.json");
200
+ const antigravityPath = () => join(HOME, ".gemini", "antigravity", "mcp_config.json");
201
+ const geminiPath = () => join(HOME, ".gemini", "settings.json");
202
+ const kiroPath = () => join(HOME, ".kiro", "settings", "mcp.json");
203
+ const clinePath = () => join(vscodeUserDir(), "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json");
204
+ const opencodePath = () => join(HOME, ".config", "opencode", "opencode.json");
205
+ function hasClaudeCli() {
206
+ const probe = spawnSync(PLAT === "win32" ? "where" : "which", ["claude"], { stdio: "ignore" });
207
+ return probe.status === 0;
208
+ }
209
+ // ── per-IDE configure ────────────────────────────────────────────────────────
210
+ function configureClaudeCode(launch, env) {
211
+ info("Claude Code…");
212
+ if (hasClaudeCli()) {
213
+ spawnSync("claude", ["mcp", "remove", NAME], { stdio: "ignore" });
214
+ const envFlags = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
215
+ const r = spawnSync("claude", ["mcp", "add", NAME, ...envFlags, "--", launch.command, ...launch.args], {
216
+ stdio: "inherit",
217
+ });
218
+ if (r.status === 0) {
219
+ ok("Claude Code configured via CLI — verify: claude mcp list");
220
+ return;
221
+ }
222
+ warn("claude CLI failed — falling back to ~/.claude.json");
223
+ }
224
+ if (mergeJson(claudeJsonPath(), launch, env))
225
+ ok(`Claude Code configured (${claudeJsonPath()})`);
226
+ }
227
+ function configureClaudeDesktop(launch, env) {
228
+ info("Claude Desktop…");
229
+ if (mergeJson(claudeDesktopPath(), launch, env)) {
230
+ ok(`Claude Desktop configured (${claudeDesktopPath()})`);
231
+ warn("Restart Claude Desktop to load the server.");
232
+ }
233
+ }
234
+ function configureCursor(launch, env) {
235
+ info("Cursor…");
236
+ if (mergeJson(cursorPath(), launch, env))
237
+ ok(`Cursor configured (${cursorPath()})`);
238
+ }
239
+ function configureWindsurf(launch, env) {
240
+ info("Windsurf…");
241
+ if (mergeJson(windsurfPath(), launch, env))
242
+ ok(`Windsurf configured (${windsurfPath()})`);
243
+ }
244
+ function configureAugment(launch, env) {
245
+ info("Augment / VS Code…");
246
+ if (mergeJson(vscodeUserPath(), launch, env))
247
+ ok(`VS Code configured (${vscodeUserPath()})`);
248
+ }
249
+ function configureCodexIde(launch, env) {
250
+ info("Codex…");
251
+ configureCodex(launch, env);
252
+ ok(`Codex configured (${join(HOME, ".codex", "config.toml")}) — restart Codex.`);
253
+ }
254
+ function configureAntigravity(launch, env) {
255
+ info("Antigravity…");
256
+ if (mergeJson(antigravityPath(), launch, env)) {
257
+ ok(`Antigravity configured (${antigravityPath()})`);
258
+ warn("In Antigravity: Agent Manager → Manage MCP Servers → Refresh (or restart).");
259
+ }
260
+ }
261
+ function configureGemini(launch, env) {
262
+ info("Gemini CLI…");
263
+ if (mergeJson(geminiPath(), launch, env))
264
+ ok(`Gemini CLI configured (${geminiPath()})`);
265
+ }
266
+ function configureCline(launch, env) {
267
+ info("Cline…");
268
+ if (mergeJson(clinePath(), launch, env))
269
+ ok(`Cline configured (${clinePath()})`);
270
+ }
271
+ function configureKiro(launch, env) {
272
+ info("Kiro…");
273
+ if (mergeJson(kiroPath(), launch, env))
274
+ ok(`Kiro configured (${kiroPath()})`);
275
+ }
276
+ function configureOpencode(launch, env) {
277
+ info("OpenCode…");
278
+ if (mergeOpencodeJson(opencodePath(), launch, env))
279
+ ok(`OpenCode configured (${opencodePath()})`);
280
+ }
281
+ const CONFIGURATORS = {
282
+ "claude-desktop": { label: "Claude Desktop", run: configureClaudeDesktop },
283
+ "claude-code": { label: "Claude Code (CLI)", run: configureClaudeCode },
284
+ cursor: { label: "Cursor", run: configureCursor },
285
+ windsurf: { label: "Windsurf", run: configureWindsurf },
286
+ augment: { label: "Augment / VS Code", run: configureAugment },
287
+ codex: { label: "Codex", run: configureCodexIde },
288
+ antigravity: { label: "Antigravity", run: configureAntigravity },
289
+ gemini: { label: "Gemini CLI", run: configureGemini },
290
+ cline: { label: "Cline", run: configureCline },
291
+ kiro: { label: "Kiro", run: configureKiro },
292
+ opencode: { label: "OpenCode", run: configureOpencode },
293
+ };
294
+ // Numbered wizard order = insertion order of CONFIGURATORS.
295
+ const IDE_ORDER = Object.keys(CONFIGURATORS);
296
+ const IDE_ALIASES = {
297
+ "claude-desktop": "claude-desktop",
298
+ desktop: "claude-desktop",
299
+ "claude-code": "claude-code",
300
+ claude: "claude-code",
301
+ code: "claude-code",
302
+ cursor: "cursor",
303
+ windsurf: "windsurf",
304
+ augment: "augment",
305
+ vscode: "augment",
306
+ "vs-code": "augment",
307
+ codex: "codex",
308
+ antigravity: "antigravity",
309
+ gemini: "gemini",
310
+ "gemini-cli": "gemini",
311
+ cline: "cline",
312
+ kiro: "kiro",
313
+ opencode: "opencode",
314
+ all: "all",
315
+ };
316
+ function normalizeIdes(ides) {
317
+ const set = new Set();
318
+ for (const raw of ides) {
319
+ const id = IDE_ALIASES[raw.trim().toLowerCase()];
320
+ if (id === "all")
321
+ return [...IDE_ORDER];
322
+ if (id)
323
+ set.add(id);
324
+ else
325
+ warn(`Unknown IDE: ${raw}`);
326
+ }
327
+ return [...set];
328
+ }
329
+ function runConfigure(ides, launch, env) {
330
+ for (const id of ides) {
331
+ const conf = CONFIGURATORS[id];
332
+ if (conf)
333
+ conf.run(launch, env);
334
+ else
335
+ warn(`Unknown IDE: ${id}`);
336
+ }
337
+ }
338
+ // ── uninstall ────────────────────────────────────────────────────────────────
339
+ function removeFromJson(file) {
340
+ if (!existsSync(file))
341
+ return;
342
+ try {
343
+ const cfg = JSON.parse(readFileSync(file, "utf8"));
344
+ if (cfg.mcpServers && cfg.mcpServers[NAME]) {
345
+ delete cfg.mcpServers[NAME];
346
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
347
+ ok(`Cleaned ${file}`);
348
+ }
349
+ }
350
+ catch {
351
+ /* ignore unparseable files */
352
+ }
353
+ }
354
+ function uninstall() {
355
+ log(`\n${c.bold} Removing ${PKG} from every IDE config${c.reset}\n`);
356
+ if (hasClaudeCli())
357
+ spawnSync("claude", ["mcp", "remove", NAME], { stdio: "ignore" });
358
+ [
359
+ claudeJsonPath(),
360
+ claudeDesktopPath(),
361
+ join(HOME, ".config", "Claude", "claude_desktop_config.json"),
362
+ cursorPath(),
363
+ windsurfPath(),
364
+ vscodeUserPath(),
365
+ antigravityPath(),
366
+ geminiPath(),
367
+ clinePath(),
368
+ kiroPath(),
369
+ ].forEach(removeFromJson);
370
+ // OpenCode keeps the server under its own `mcp` key, not `mcpServers`.
371
+ const oc = opencodePath();
372
+ if (existsSync(oc)) {
373
+ try {
374
+ const cfg = JSON.parse(readFileSync(oc, "utf8"));
375
+ if (cfg.mcp && cfg.mcp[NAME]) {
376
+ delete cfg.mcp[NAME];
377
+ writeFileSync(oc, JSON.stringify(cfg, null, 2) + "\n");
378
+ ok(`Cleaned ${oc}`);
379
+ }
380
+ }
381
+ catch {
382
+ /* ignore unparseable files */
383
+ }
384
+ }
385
+ const codex = join(HOME, ".codex", "config.toml");
386
+ if (existsSync(codex)) {
387
+ let content = readFileSync(codex, "utf8");
388
+ content = content.replace(new RegExp(`\\n?\\[mcp_servers\\.${NAME}\\][\\s\\S]*?(?=\\n\\[|$)`), "");
389
+ writeFileSync(codex, content.trimEnd() + "\n");
390
+ ok("Cleaned Codex config.toml");
391
+ }
392
+ log(`\n${c.green}${c.bold} ✓ Removed. Restart your IDE.${c.reset}\n`);
103
393
  }
104
394
  /**
105
- * Interactive wizard (TTY only): pick environment → authenticate (browser login,
106
- * recommended, or paste a token) → pick IDEs. Mirrors webcake-landing-mcp's
107
- * installer. Returns whether a browser login completed (token then lives in the
108
- * local config db, so it is NOT written into the IDE env block).
395
+ * Interactive numbered wizard (TTY only): pick environment → authenticate (browser
396
+ * login, recommended, or paste a token) → pick IDEs by number. Returns whether a
397
+ * browser login completed (token then lives in the local config db, so it is NOT
398
+ * written into the IDE env block) and a short auth note for the summary.
109
399
  */
110
400
  async function promptInteractive(opts) {
111
401
  let loggedIn = false;
402
+ let authNote = opts.token ? "token (from flag)" : "";
112
403
  if (!process.stdin.isTTY || !process.stdout.isTTY)
113
- return { loggedIn };
114
- const rl = createInterface({ input: process.stdin, output: process.stderr });
115
- try {
116
- // 1) Environment drives which API/app URLs are used (default prod).
117
- if (!opts.uninstall && !opts.env && !process.env.WEBCAKE_ENV) {
118
- const e = (await rl.question("Environment [prod] / staging / local: ")).trim().toLowerCase();
119
- if (e === "staging" || e === "local")
120
- opts.env = e;
121
- }
122
- if (opts.env)
123
- process.env.WEBCAKE_ENV = opts.env; // so the login flow opens the right app
124
- // 2) Authentication browser login (recommended) or paste a token. An explicit
125
- // --token/--jwt or an ambient WEBCAKE_TOKEN skips this entirely.
126
- if (!opts.uninstall && !opts.token && !process.env.WEBCAKE_TOKEN) {
127
- console.error("\nHow do you want to connect your WebCake account?");
128
- console.error(" 1) Log in via browser (recommended — opens WebCake, saves a token)");
129
- console.error(" 2) Paste a token manually");
130
- const choice = (await rl.question("Choose [1]: ")).trim() || "1";
131
- if (choice === "1") {
132
- try {
133
- rl.pause();
134
- await runLogin([]); // loopback browser flow → saves token + session to local config db
135
- loggedIn = true;
136
- }
137
- catch (e) {
138
- console.error(`Login didn't complete (${e?.message ?? e}). Paste a token now, or run \`npx -y webcake-storefront-mcp login\` later.`);
139
- }
404
+ return { loggedIn, authNote };
405
+ // 1) Environment one choice sets the API + app base URLs (default prod).
406
+ if (!opts.env && !process.env.WEBCAKE_ENV) {
407
+ log(`\n${c.bold}1) Environment${c.reset} ${c.gray}(sets the WebCake API + app URLs)${c.reset}`);
408
+ log(` ${c.bold}1${c.reset}) prod ${c.gray}api.storefront.webcake.io${c.reset} ${c.gray}(default)${c.reset}`);
409
+ log(` ${c.bold}2${c.reset}) staging ${c.gray}api.staging.storecake.io${c.reset}`);
410
+ log(` ${c.bold}3${c.reset}) local ${c.gray}localhost:24679${c.reset}`);
411
+ const pick = (await ask(` ${c.cyan}Select [1=prod, Enter to accept]:${c.reset} `)).trim();
412
+ opts.env = { "1": "prod", "2": "staging", "3": "local" }[pick] ?? "prod";
413
+ }
414
+ if (opts.env)
415
+ process.env.WEBCAKE_ENV = opts.env; // so the login flow opens the right app
416
+ // 2) Authentication browser login (recommended), paste a token, or skip.
417
+ if (!opts.token && !process.env.WEBCAKE_TOKEN) {
418
+ log(`\n${c.bold}2) Connect your WebCake account${c.reset}`);
419
+ log(` ${c.bold}1${c.reset}) Log in via browser ${c.gray}(recommended — opens WebCake, saves a token)${c.reset}`);
420
+ log(` ${c.bold}2${c.reset}) Paste a token manually`);
421
+ log(` ${c.bold}3${c.reset}) Skip for now ${c.gray}(set credentials later with the login command)${c.reset}`);
422
+ const choice = (await ask(` ${c.cyan}Select [1]:${c.reset} `)).trim() || "1";
423
+ if (choice === "1") {
424
+ info("Opening your browser to log in…");
425
+ try {
426
+ await runLogin([]); // loopback browser flow → saves token + session to local config db
427
+ loggedIn = true;
428
+ authNote = "browser login (saved to local config)";
140
429
  }
141
- if (!loggedIn && choice !== "1") {
142
- const t = (await rl.question(" Token (paste JWT): ")).trim();
430
+ catch (e) {
431
+ warn(`Login didn't complete (${e?.message ?? e}). Paste a token now, or run \`npx -y ${PKG} login\` later.`);
432
+ const t = (await ask(` ${c.cyan}Token (paste JWT, or Enter to skip):${c.reset} `)).trim();
143
433
  if (t)
144
434
  opts.token = t;
145
- const s = (await rl.question(" Session id (x-session-id): ")).trim();
435
+ const s = t ? (await ask(` ${c.cyan}Session id (x-session-id):${c.reset} `)).trim() : "";
146
436
  if (s)
147
437
  opts.sessionId = s;
148
438
  }
149
439
  }
150
- // 3) IDEs to configure.
151
- if (!opts.ides.length) {
152
- const ans = (await rl.question(`\nIDE(s) to configure [${ALL_IDES.join(", ")}, all]: `)).trim();
153
- opts.ides = ans === "all" || ans === "" ? ALL_IDES : ans.split(",").map((s) => s.trim());
440
+ else if (choice === "2") {
441
+ const t = (await ask(` ${c.cyan}Token (paste JWT):${c.reset} `)).trim();
442
+ if (t)
443
+ opts.token = t;
444
+ const s = (await ask(` ${c.cyan}Session id (x-session-id):${c.reset} `)).trim();
445
+ if (s)
446
+ opts.sessionId = s;
447
+ authNote = opts.token ? "pasted token" : "none";
154
448
  }
155
- // Site is chosen at runtime — use the list_my_sites / switch_site tools in chat.
156
449
  }
157
- finally {
158
- try {
159
- rl.close();
160
- }
161
- catch {
162
- /* already closed */
163
- }
450
+ if (!authNote)
451
+ authNote = opts.token || process.env.WEBCAKE_TOKEN ? "token" : "none — log in later";
452
+ // 3) IDEs to configure — numbered, comma-separated (e.g. 1,2 or the "All" number).
453
+ if (!opts.ides.length) {
454
+ log(`\n${c.bold}3) Which IDE(s) / agent(s) to configure?${c.reset}`);
455
+ IDE_ORDER.forEach((id, i) => {
456
+ const n = `${c.bold}${String(i + 1).padStart(2)}${c.reset}`;
457
+ log(` ${n}) ${CONFIGURATORS[id].label}`);
458
+ });
459
+ const allNum = IDE_ORDER.length + 1;
460
+ log(` ${c.bold}${String(allNum).padStart(2)}${c.reset}) ${c.green}All of the above${c.reset} ${c.gray} 0) Skip${c.reset}`);
461
+ const pick = await ask(` ${c.cyan}Select (comma-separated, e.g. 1,2):${c.reset} `);
462
+ const picks = pick.split(",").map((s) => s.trim()).filter(Boolean);
463
+ if (picks.includes(String(allNum)))
464
+ opts.ides = [...IDE_ORDER];
465
+ else
466
+ opts.ides = picks.map((n) => IDE_ORDER[Number(n) - 1]).filter(Boolean);
467
+ }
468
+ else {
469
+ opts.ides = normalizeIdes(opts.ides);
164
470
  }
165
- return { loggedIn };
471
+ // Site is chosen at runtime — use the list_my_sites / switch_site tools in chat.
472
+ return { loggedIn, authNote };
166
473
  }
167
474
  export async function runInstaller(argv) {
168
475
  const opts = parseArgs(argv);
169
- const { loggedIn } = await promptInteractive(opts);
170
- let ides = opts.ides.length ? opts.ides : ALL_IDES;
171
- if (ides.includes("all"))
172
- ides = ALL_IDES;
476
+ log(`\n${c.magenta}${c.bold} WebCake Storefront MCP${c.reset} ${c.gray}— installer${c.reset}`);
477
+ log(`${c.gray} Build sites, pages, products & orders on WebCake from a prompt.${c.reset}`);
478
+ if (opts.uninstall)
479
+ return uninstall();
480
+ const { loggedIn, authNote } = await promptInteractive(opts);
481
+ // Normalize IDE selection (expands "all", resolves aliases). The wizard already
482
+ // yields canonical keys; this also covers the flag-driven / non-TTY path where
483
+ // promptInteractive returns early without touching opts.ides.
484
+ const ides = normalizeIdes(opts.ides);
485
+ if (!ides.length) {
486
+ if (!process.stdin.isTTY) {
487
+ warn("No --ide given (or not a TTY). Nothing to configure.");
488
+ log(`${c.gray} Try: npx -y ${PKG} install --ide all${c.reset}\n`);
489
+ }
490
+ else {
491
+ warn("No IDE selected — nothing to configure.");
492
+ }
493
+ return;
494
+ }
173
495
  const launch = resolveLaunch(opts);
174
496
  const env = buildEnv(opts);
175
- console.error(opts.uninstall ? "Removing webcake-storefront MCP…" : "Configuring webcake-storefront MCP…");
176
- for (const ide of ides)
177
- console.error(" " + applyToIde(ide, opts, launch, env));
178
- if (!opts.uninstall) {
179
- if (loggedIn)
180
- console.error("\n✓ Logged in — your token is saved to the local config (no token written into IDE files).");
181
- console.error("\nDone. Restart your IDE to pick up the new MCP server.");
182
- }
497
+ log(`\n${c.bold}Writing config${c.reset} ${c.gray}(launch: ${launch.command} ${launch.args.join(" ")})${c.reset}`);
498
+ runConfigure(ides, launch, env);
499
+ log(`\n${c.green}${c.bold} Done.${c.reset}`);
500
+ log(` ${c.gray}Environment : ${opts.env || process.env.WEBCAKE_ENV || "prod"}${c.reset}`);
501
+ log(` ${c.gray}Auth : ${authNote}${c.reset}`);
502
+ if (loggedIn)
503
+ log(` ${c.gray}Token saved to local config (not written into IDE files).${c.reset}`);
504
+ log(` ${c.cyan}Restart your IDE, then ask the AI to build a storefront page.${c.reset}\n`);
183
505
  }
package/dist/server.js CHANGED
@@ -23,7 +23,7 @@ IMPORTANT: When the user asks ANY question about their website, store, products,
23
23
  You can also BUILD pages: use get_build_guide, list_elements, get_element to learn the BuilderX component model, new_section/new_element to compose, validate_page to check, then build_page (dry_run first) to create. Publishing is site-level via publish_site.
24
24
 
25
25
  Workflow:
26
- 1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session).
26
+ 1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session). To start from scratch, create_site makes a new site and switches to it; then build a homepage with build_page (type:'main', is_homepage:true).
27
27
  2. Before answering a site-specific question, query the relevant tool.
28
28
  3. When building a page, read get_build_guide first and validate before saving.
29
29
  4. Always reply in the user's language; keep Vietnamese with full diacritics.`;
package/dist/smoke.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * building blocks so a release can verify them without a client. Run: npm run smoke
4
4
  */
5
5
  import { listElements, getElement, buildElement, ELEMENT_TYPES, isKnownType } from "./builder/catalog.js";
6
- import { newPageSkeleton, buildSection, validatePage, reassignIds, walk, } from "./builder/page.js";
6
+ import { newPageSkeleton, buildSection, validatePage, finalizeForRender, reassignIds, walk, } from "./builder/page.js";
7
7
  let failures = 0;
8
8
  const check = (name, cond, extra) => {
9
9
  if (cond) {
@@ -49,13 +49,22 @@ console.log("== page: grid composition + validation ==");
49
49
  { type: "text", opts: { text: "Welcome" } },
50
50
  { type: "button", opts: { text: "Buy" } },
51
51
  ]);
52
- check("section grid is 1xN", hero.runtime.config.grid === "1x2", hero.runtime.config.grid);
53
- check("children get grid positions", hero.children.every((c) => c.runtime.config.columnStart === 1));
52
+ // A section uses the builder's centred 3-column grid; children sit in the centre column.
53
+ check("section grid is 3xN", hero.runtime.config.grid === "3x2", hero.runtime.config.grid);
54
+ check("children placed in centre column", hero.children.every((c) => c.runtime.config.columnStart === 2));
54
55
  const src = newPageSkeleton();
55
56
  src.sections.push(hero);
56
57
  const v = validatePage(src);
57
58
  check("built page validates", v.valid === true, v.errors);
58
59
  check("stats count elements", v.stats.total_elements === 3, v.stats);
60
+ // finalizeForRender must convert runtime -> bp1..bp4 (the shape the storefront renders).
61
+ finalizeForRender(src);
62
+ const sec0 = src.sections[0];
63
+ check("finalize removes runtime", !("runtime" in sec0), Object.keys(sec0));
64
+ check("finalize adds bp1..bp4", ["bp1", "bp2", "bp3", "bp4"].every((bp) => sec0[bp]?.config), Object.keys(sec0));
65
+ check("section bp4 is mobile grid", sec0.bp4.config.grid === "3x2" && sec0.bp4.config.columns[0].absValue === 5, sec0.bp4.config.columns?.[0]);
66
+ check("child bp1 keeps centre column", sec0.children[0].bp1.config.columnStart === 2, sec0.children[0].bp1?.config);
67
+ check("finalize is idempotent", (finalizeForRender(src), !("runtime" in sec0)));
59
68
  // duplicate ids must fail validation
60
69
  const dup = newPageSkeleton();
61
70
  const a = buildElement("section");
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { BUILD_GUIDE } from "../builder/guide.js";
3
3
  import { listElements, getElement, buildElement } from "../builder/catalog.js";
4
- import { buildSection, newPageSkeleton, validatePage, reassignIds, } from "../builder/page.js";
4
+ import { buildSection, newPageSkeleton, validatePage, finalizeForRender, reassignIds, } from "../builder/page.js";
5
5
  // Recursive spec for new_section / build_page children.
6
6
  const elementSpec = z.object({
7
7
  type: z.string().describe("Element type (see list_elements)"),
@@ -76,8 +76,9 @@ The source must be { sections: [...] } — build sections with new_section. Vali
76
76
  validation,
77
77
  request: { name, slug, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
78
78
  will_enable_feature: requiredFlag ?? null,
79
+ renders_at_breakpoints: ["bp1", "bp2", "bp3", "bp4"],
79
80
  hint: validation.valid
80
- ? `Looks valid. Call again with dry_run=false to create and save the page.${requiredFlag ? ` Will also enable site.settings.${requiredFlag} so its data bindings resolve.` : ""}`
81
+ ? `Looks valid. On save, every node's runtime is expanded into the bp1..bp4 keys the storefront renders. Call again with dry_run=false to create and save the page.${requiredFlag ? ` Will also enable site.settings.${requiredFlag} so its data bindings resolve.` : ""}`
81
82
  : "Fix the errors above before saving.",
82
83
  };
83
84
  }
@@ -100,6 +101,8 @@ The source must be { sections: [...] } — build sections with new_section. Vali
100
101
  if (!pageId) {
101
102
  return { error: "Page created but no id was returned; cannot save source.", created };
102
103
  }
104
+ // Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
105
+ finalizeForRender(parsed);
103
106
  const saved = await api.updatePageSource(pageId, { source: parsed });
104
107
  return {
105
108
  success: true,
@@ -145,6 +148,9 @@ Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
145
148
  }
146
149
  if (!validation.valid)
147
150
  return { error: "Validation failed — not saving.", validation };
151
+ // Expand the newly-added section's runtime -> bp1..bp4 (existing sections are
152
+ // already in breakpoint shape and are left untouched).
153
+ finalizeForRender(source);
148
154
  const saved = await api.updatePageSource(page_id, { source });
149
155
  return {
150
156
  success: true,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getConfig, setConfig } from "../db.js";
3
+ import { resolvePreviewUrl } from "../config.js";
3
4
  /** Read all saved credentials from the local config file for startup */
4
5
  export function getSavedConfig() {
5
6
  return {
@@ -62,6 +63,58 @@ export function registerContextTools(server, api, handle) {
62
63
  page,
63
64
  };
64
65
  }));
66
+ server.tool("create_site", `Create a brand-new storefront site for the current account, then (by default) switch to it.
67
+ The backend seeds sample categories, products and a blog, but creates NO pages — so after this,
68
+ build a homepage: get_build_guide → new_section → build_page (type:'main', is_homepage:true).
69
+ Note: free accounts are limited to 4 sites (creation fails with a quota error past that).`, {
70
+ name: z.string().describe("Display name of the new site, e.g. 'My Coffee Shop'"),
71
+ slug: z
72
+ .string()
73
+ .describe("URL-safe site slug (lowercase letters, digits, hyphens), e.g. 'my-coffee-shop'. Becomes the preview subdomain and must be unique."),
74
+ switch_to: z
75
+ .boolean()
76
+ .default(true)
77
+ .describe("Switch the session to the new site after creating it (saved for next session). Default true."),
78
+ }, ({ name, slug, switch_to }) => handle(async () => {
79
+ let res;
80
+ try {
81
+ res = await api.createSite({ name, slug });
82
+ }
83
+ catch (e) {
84
+ const msg = e instanceof Error ? e.message : String(e);
85
+ if (msg.includes("403")) {
86
+ throw new Error("Cannot create site: your account's site quota is reached (free plan allows up to 4 sites). Delete an unused site or upgrade your plan, then retry.");
87
+ }
88
+ throw new Error(`Site creation failed: ${msg}. Check the slug is unique and URL-safe (lowercase, hyphens).`);
89
+ }
90
+ const site = res?.data?.site || res?.data || res?.site || res;
91
+ const newId = site?.id;
92
+ if (!newId) {
93
+ throw new Error("Site was not created (no id returned by the backend).");
94
+ }
95
+ const createdSlug = site?.site_slug?.slug || slug;
96
+ let switched = false;
97
+ let previewUrl = null;
98
+ const previousSiteId = api.siteId;
99
+ if (switch_to) {
100
+ api.switchSite(newId);
101
+ setConfig("site_id", newId);
102
+ setConfig("site_name", site?.name || name);
103
+ setConfig("site_domain", createdSlug || "");
104
+ switched = true;
105
+ previewUrl = await resolvePreviewUrl(api).catch(() => null);
106
+ }
107
+ return {
108
+ success: true,
109
+ site_id: newId,
110
+ name: site?.name || name,
111
+ slug: createdSlug,
112
+ switched,
113
+ ...(switched ? { current_site_id: api.siteId, previous_site_id: previousSiteId } : {}),
114
+ preview_url: previewUrl,
115
+ next_step: "New site has sample products/categories/blog but NO pages. Create a homepage with build_page (type:'main', is_homepage:true), then add store/member/blog pages as needed. Publish site-level with publish_site.",
116
+ };
117
+ }));
65
118
  server.tool("switch_site", `Switch to a different site by site_id. All subsequent tool calls will target the new site.
66
119
  The choice is saved to local database — next session will auto-connect to this site.
67
120
  Use list_my_sites first to find the site_id`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
5
5
  "mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
6
6
  "license": "MIT",