sbuilder-mcp 0.52.0 → 0.54.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/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.54.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_review` reports a `mergedAuthPage` readiness gap when a single page carries two or more account forms (login, register, forgot-password, reset, or verify), since none of them then has an address a header can link to or a password-reset email can point at; the fix names `sb_store action:"form"` with a `page_name` to give each its own page.
13
+
14
+ ## [0.53.0] - 2026-09-15
15
+
16
+ ### Added
17
+ - `sb_store action:"form"` now takes a `page_name` (and optional `headline`) that builds a page and places the seeded form on it in the same call, instead of leaving the caller to create a page, `sb_add` a form element, and `sb_set` its `formId` by hand.
18
+ - `sb_page_list`'s advisory for a missing login, register, forgot-password, or contact page now names the single `sb_store action:"form" template:"..." page_name:"..."` call that builds both the form and its page, instead of pointing at the form template alone.
19
+
20
+ ### Changed
21
+ - A page created for a seeded form is a second write and never undoes the first; if the page create is refused, `sb_store` still reports the form it already made, under a `page_failed` field, rather than rolling the form back.
22
+
9
23
  ## [0.52.0] - 2026-09-15
10
24
 
11
25
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,20 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.54.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_review` giờ báo cáo lỗi sẵn sàng `mergedAuthPage` khi một trang duy nhất chứa từ hai form tài khoản trở lên (đăng nhập, đăng ký, quên mật khẩu, đặt lại, hoặc xác thực), vì khi đó không form nào có địa chỉ riêng để header liên kết tới hay để email đặt lại mật khẩu trỏ đến; hướng khắc phục nêu tên `sb_store action:"form"` kèm `page_name` để tách mỗi form ra trang riêng của nó.
13
+
14
+ ## [0.53.0] - 2026-09-15
15
+
16
+ ### Added
17
+ - `sb_store action:"form"` giờ nhận thêm `page_name` (và `headline` tuỳ chọn) để dựng luôn một trang và đặt form vừa tạo lên đó trong cùng một lần gọi, thay vì để người gọi phải tự tạo trang, `sb_add` phần tử form rồi `sb_set` `formId` của nó.
18
+ - Gợi ý của `sb_page_list` khi thiếu trang đăng nhập, đăng ký, quên mật khẩu hoặc liên hệ giờ nêu đích danh một lệnh gọi duy nhất `sb_store action:"form" template:"..." page_name:"..."` để dựng cả form lẫn trang chứa nó, thay vì chỉ trỏ tới mỗi template của form.
19
+
20
+ ### Changed
21
+ - Trang được tạo cho một form vừa dựng là một lần ghi riêng và không bao giờ huỷ lần ghi trước đó; nếu việc tạo trang bị từ chối, `sb_store` vẫn báo cáo form đã tạo thành công, kèm trường `page_failed`, thay vì xoá form đó đi.
22
+
9
23
  ## [0.52.0] - 2026-09-15
10
24
 
11
25
  ### Added
@@ -29,25 +29,25 @@ export const USUAL_PAGES = [
29
29
  key: 'login',
30
30
  match: ['login', 'signin', 'sign-in', 'dang-nhap', 'đăng nhập'],
31
31
  why: 'Nothing for a header, an order email or /account\'s signed-out state to link to. ' +
32
- 'Seed it with sb_store action:"form" template "login" on a page of type "page".',
32
+ 'sb_store action:"form" template:"login" page_name:"Đăng nhập" builds the form AND the page it lives on.',
33
33
  },
34
34
  {
35
35
  key: 'register',
36
36
  match: ['register', 'signup', 'sign-up', 'dang-ky', 'đăng ký'],
37
37
  why: 'A shopper cannot open an account, so order history, addresses and any member-gated ' +
38
- 'page are unreachable. Template "register".',
38
+ 'page are unreachable. sb_store action:"form" template:"register" with a page_name.',
39
39
  },
40
40
  {
41
41
  key: 'forgot',
42
42
  match: ['forgot', 'reset', 'quen-mat-khau', 'quên mật khẩu', 'doi-mat-khau'],
43
43
  why: 'A customer who forgets a password has no way back in and writes to support instead. ' +
44
- 'Template "forgot".',
44
+ 'sb_store action:"form" template:"forgot" with a page_name.',
45
45
  },
46
46
  {
47
47
  key: 'contact',
48
48
  match: ['contact', 'lien-he', 'liên hệ'],
49
49
  why: 'No address, phone or form anywhere, so a shopper with a question about an order has ' +
50
- 'nowhere to put it. Template "contact".',
50
+ 'nowhere to put it. sb_store action:"form" template:"contact" with a page_name.',
51
51
  },
52
52
  {
53
53
  key: 'about',
@@ -24,7 +24,7 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
24
24
  }
25
25
  };
26
26
  const site = encodeURIComponent(siteId);
27
- const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks] = await Promise.all([
27
+ const [pageList, gateways, shipping, globals, productList, categoryList, pageLinks, formList] = await Promise.all([
28
28
  get(`/api/sites/${site}/pages`),
29
29
  get(`/api/sites/${site}/payment-gateways`),
30
30
  get(`/api/sites/${site}/shipping-methods`),
@@ -42,6 +42,12 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
42
42
  // right, and the rest list the whole catalogue.
43
43
  get(`/api/sites/${site}/product-categories`),
44
44
  get(`/api/sites/${site}/page-links`),
45
+ // THE FORMS, BY TYPE. A page document carries only `specials.formId` — which
46
+ // form a node shows, never what KIND of form it is — so nothing reading a
47
+ // page can tell a login form from a register form without this list. That
48
+ // blindness is what let one page quietly become the site's whole account
49
+ // area, which is the shape `mergedAuthPage` reports.
50
+ get(`/api/sites/${site}/forms`),
45
51
  ]);
46
52
  // A gateway counts only when it is BOTH enabled and configured — the editor's
47
53
  // `live` getter also filters by the store's currency, which is a narrowing:
@@ -87,5 +93,6 @@ export async function gatherReadiness(ctx, siteId, pageNodes) {
87
93
  globalKinds,
88
94
  categories,
89
95
  categoryPageLinks,
96
+ forms: formList?.forms ?? null,
90
97
  };
91
98
  }
@@ -132,6 +132,52 @@ export function readinessGaps(input) {
132
132
  'footer and a link back to the home page.',
133
133
  });
134
134
  }
135
+ // ONE PAGE QUIETLY BECAME THE WHOLE ACCOUNT AREA.
136
+ //
137
+ // This server used to tell agents, in as many words, to put the login and the
138
+ // register form behind a member-gate on /account. They did. The result is a
139
+ // page carrying two or three different jobs, and — the part that is not a
140
+ // matter of taste — NEITHER JOB HAS AN ADDRESS. A header can link to one
141
+ // thing, an email that says "reset your password" has nowhere specific to
142
+ // point, and a visitor who came to register meets a login form first because
143
+ // that is what was stacked on top.
144
+ //
145
+ // The rule was fixed; the pages it already built were not, and nothing could
146
+ // see them: a page document carries `specials.formId` and never the KIND of
147
+ // form, so only the site's own form list can tell these apart.
148
+ //
149
+ // COUNTED BY DISTINCT TYPE, not by how many form nodes there are. A form split
150
+ // across segments is several nodes of ONE type and is not this defect.
151
+ const authTypes = new Set(['login', 'register', 'forgot', 'reset', 'verify']);
152
+ if (input.forms && input.forms.length > 0) {
153
+ const typeOf = new Map(input.forms
154
+ .filter((f) => !!f?.id && !!f?.type)
155
+ .map((f) => [f.id, f.type]));
156
+ const onPage = new Set();
157
+ for (const n of input.pageNodes) {
158
+ if (n.data.type !== 'form')
159
+ continue;
160
+ const id = n.specials?.formId;
161
+ const t = typeof id === 'string' ? typeOf.get(id) : undefined;
162
+ if (t && authTypes.has(t))
163
+ onPage.add(t);
164
+ }
165
+ if (onPage.size >= 2) {
166
+ const named = [...onPage].sort().join(', ');
167
+ gaps.push({
168
+ id: 'mergedAuthPage',
169
+ draft: false,
170
+ problem: `This page carries ${onPage.size} different account forms (${named}). Neither has an ` +
171
+ 'address of its own, so a header can link to only one of them, a password-reset mail ' +
172
+ 'has nowhere specific to point, and whoever came to do the second thing meets the ' +
173
+ 'first one stacked on top.',
174
+ fix: 'Give each its own page: sb_store action:"form" with template "login", "register" or ' +
175
+ '"forgot" and a page_name builds the form AND the page in one call. Leave /account ' +
176
+ 'showing the profile behind a member-gate with audience "members", and a short ' +
177
+ 'sign-in prompt LINKING to the login page behind audience "guests".',
178
+ });
179
+ }
180
+ }
135
181
  if (!isStore(input))
136
182
  return gaps;
137
183
  if (pages && !published(pages, 'checkout')) {
@@ -33,6 +33,7 @@ import { request, redact } from '../transport/http.js';
33
33
  import { siteToken } from './credentialpick.js';
34
34
  import { siteFor } from './context.js';
35
35
  import { genId } from '../domains/site/ids.js';
36
+ import { addSubtree } from '../domains/site/builder.js';
36
37
  import { chromeLinks, hasGlobal, shareChrome, sitePages } from './chrome.js';
37
38
  import { tokensFromPage } from '../domains/site/importmap.js';
38
39
  import { CHECKOUT_FORM, CHECKOUT_FORM_DOCUMENT, CHECKOUT_PAGE_DOCUMENT, CHECKOUT_TEXT, FORM_ID_SENTINEL, HEADLINE_SENTINEL, FORM_TEMPLATES, } from '../catalog/checkout.generated.js';
@@ -207,7 +208,54 @@ const FORM_TEMPLATE_KEYS = Object.keys(FORM_TEMPLATES).sort();
207
208
  * returns — and `/account` is the one page that is not a free choice, because
208
209
  * `membersOnlyRedirectTarget` sends every gated visitor there.
209
210
  */
210
- async function seedForm(ctx, siteId, key, formName, dryRun) {
211
+ /**
212
+ * The page a seeded form goes on, built and saved in the same call.
213
+ *
214
+ * WHY THIS EXISTS. seedForm made a form and stopped, and said so: "No page is
215
+ * made. Where a login form belongs is a design decision." True, and it left the
216
+ * caller three steps — create a page, sb_add a form element, sb_set its formId —
217
+ * with nothing insisting they belong together. Measured: a store built with
218
+ * these tools had no login page, no register page and no forgot page, and its
219
+ * own header had nothing to link to. The design decision was never the
220
+ * obstacle; the three steps were.
221
+ *
222
+ * A BLANK PAGE FIRST, THEN THE SUBTREE, rather than a document posted with the
223
+ * create. The checkout path can post one because it has a whole seeded document
224
+ * to post; here the document is one element, and going through the session is
225
+ * how every other builder in this server writes a page — band rules, id
226
+ * minting and the save contract all come with it instead of being re-derived.
227
+ */
228
+ async function placeFormOnPage(ctx, session, siteId, formId, pageName, headline) {
229
+ const made = (await request({
230
+ base: ctx.base,
231
+ method: 'POST',
232
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
233
+ token: siteToken(ctx),
234
+ body: { name: pageName, type: 'page' },
235
+ fetchImpl: ctx.fetchImpl,
236
+ }));
237
+ const id = made.page?.id;
238
+ if (typeof id !== 'string' || !id) {
239
+ throw new Error('sbuilder: the platform accepted the page create and returned no page');
240
+ }
241
+ await session.open(siteId, id);
242
+ const doc = session.current();
243
+ const { patches } = addSubtree(doc, doc.doc.root_node_id, {
244
+ type: 'flex-section',
245
+ children: [
246
+ {
247
+ type: 'flex-block',
248
+ children: [
249
+ ...(headline ? [{ type: 'heading', specials: { text: headline } }] : []),
250
+ { type: 'form', specials: { formId } },
251
+ ],
252
+ },
253
+ ],
254
+ });
255
+ await session.applyAndSave(patches);
256
+ return { id, name: typeof made.page?.name === 'string' ? made.page.name : pageName };
257
+ }
258
+ async function seedForm(ctx, session, siteId, key, formName, pageName, headline, dryRun) {
211
259
  const tpl = FORM_TEMPLATES[key];
212
260
  const site = encodeURIComponent(siteId);
213
261
  const name = formName ?? tpl.key;
@@ -236,6 +284,15 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
236
284
  path: `/api/sites/${site}/forms/{formId}/document`,
237
285
  },
238
286
  ],
287
+ ...(pageName
288
+ ? {
289
+ would_also: `create a page named ${JSON.stringify(pageName)} of type "page" and put ` +
290
+ 'the form on it, so the form has an address a header can link to',
291
+ }
292
+ : {
293
+ no_page: 'Only the form. Pass page_name to have the page made and the form placed ' +
294
+ 'on it in this same call — the three steps that otherwise get skipped.',
295
+ }),
239
296
  preview: redact({ name, type: tpl.type }),
240
297
  };
241
298
  }
@@ -268,12 +325,34 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
268
325
  await send('DELETE', `/api/sites/${site}/forms/${encodeURIComponent(form.id)}`).catch(() => undefined);
269
326
  throw e;
270
327
  }
328
+ // THE PAGE IS A SECOND WRITE AND MUST NOT UNDO THE FIRST. The form EXISTS the
329
+ // moment its three calls land; a refused page create leaves a form the caller
330
+ // can still place by hand, which is exactly what they had before this
331
+ // argument existed. Reporting the failure beats deleting a form they asked
332
+ // for — the same rule the seed follows in sb_page_create.
333
+ let page;
334
+ let page_failed;
335
+ if (pageName) {
336
+ try {
337
+ page = await placeFormOnPage(ctx, session, siteId, form.id, pageName, headline);
338
+ }
339
+ catch (e) {
340
+ page_failed = e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
341
+ }
342
+ }
271
343
  return {
272
344
  form: { id: form.id, type: form.type, name: form.name },
273
345
  fields,
274
- next: `Place it: sb_add a "form" element, then sb_set its specials.formId to "${form.id}". ` +
275
- 'The submit button lives in the FORM DOCUMENT, not on the page, and a page republish is ' +
276
- 'what makes a form-document edit visible.',
346
+ ...(page ? { page } : {}),
347
+ ...(page_failed ? { page_failed } : {}),
348
+ next: page
349
+ ? `The form is on page ${page.id}. Publish it with sb_publish, and link to it from the ` +
350
+ 'header. The submit button lives in the FORM DOCUMENT, not on the page, and a page ' +
351
+ 'republish is what makes a form-document edit visible.'
352
+ : `Place it: sb_add a "form" element, then sb_set its specials.formId to "${form.id}" — ` +
353
+ 'or pass page_name to have that page made for you. The submit button lives in the FORM ' +
354
+ 'DOCUMENT, not on the page, and a page republish is what makes a form-document edit ' +
355
+ 'visible.',
277
356
  };
278
357
  }
279
358
  export function registerStoreTools(server, ctx, session) {
@@ -350,7 +429,7 @@ export function registerStoreTools(server, ctx, session) {
350
429
  if (!template) {
351
430
  throw new Error(`sbuilder: action:"form" needs a template. One of: ${FORM_TEMPLATE_KEYS.join(', ')}.`);
352
431
  }
353
- return text(await seedForm(ctx, siteId, template, name, dry_run !== false));
432
+ return text(await seedForm(ctx, session, siteId, template, name, page_name, headline, dry_run !== false));
354
433
  }
355
434
  const lang = (language ?? 'vi');
356
435
  const t = CHECKOUT_TEXT[lang];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",