sbuilder-mcp 0.2.2 → 0.3.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.
@@ -38,6 +38,21 @@ const BOUND_TYPES = Object.keys(BOUND_SPECIALS).sort();
38
38
  function canShowARecord(type) {
39
39
  return (BOUND_SPECIALS[type] ?? []).length > 0;
40
40
  }
41
+ /**
42
+ * Bound specials that carry NAVIGATION rather than what a visitor reads.
43
+ *
44
+ * The distinction decides whether an empty container is a defect. A
45
+ * `dataset-block` binds only these — it is a card that still needs the fields
46
+ * put inside it, so an empty one really is an empty card. A `media-dataset`
47
+ * binds `boundImage` / `boundImages` and DRAWS the record itself, children or
48
+ * not: a bound one with no children publishes a product photo, and calling that
49
+ * "an empty band" is the false positive that teaches a reader to skip the list.
50
+ */
51
+ const LINK_SPECIALS = new Set(['boundHref', 'boundHrefLabel', 'boundProductURL']);
52
+ /** Whether this element's own renderer paints the record, so it needs no children. */
53
+ function drawsItsOwnContent(type) {
54
+ return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
55
+ }
41
56
  /**
42
57
  * Everything wrong with this page that a person would notice.
43
58
  *
@@ -99,9 +114,16 @@ export function reviewDesign(doc) {
99
114
  });
100
115
  continue;
101
116
  }
117
+ const bindings = n.bindings ?? [];
102
118
  // A container with nothing in it is a band of empty space. The commonest way
103
119
  // to ship one is to add the section and then get distracted.
104
- if (meta.isContainer && childrenOf(d, id).length === 0) {
120
+ //
121
+ // Unless the element paints the record ITSELF: a bound `media-dataset` with
122
+ // no children publishes the product's photo and its thumbnail strip, which
123
+ // this rule reported as an empty band on a page that rendered correctly.
124
+ if (meta.isContainer &&
125
+ childrenOf(d, id).length === 0 &&
126
+ !(bindings.length > 0 && drawsItsOwnContent(type))) {
105
127
  out.push({
106
128
  code: 'empty_container',
107
129
  nodeId: id,
@@ -110,7 +132,6 @@ export function reviewDesign(doc) {
110
132
  fix: fill('empty_container', { id }),
111
133
  });
112
134
  }
113
- const bindings = n.bindings ?? [];
114
135
  const repeater = inRepeater.get(id);
115
136
  const boundFields = new Set(bindings.map((b) => b.field));
116
137
  /**
@@ -1,5 +1,5 @@
1
1
  import { FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
2
- import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, } from '../../core/tree.js';
2
+ import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REV, SPEC_OVERLAY_ID, SPEC_OVERLAY_REV, } from '../../core/tree.js';
3
3
  /**
4
4
  * Which band a direct child of ROOT belongs to.
5
5
  *
@@ -120,3 +120,40 @@ export function refuseSecondTemplate(doc, parentId, verb) {
120
120
  'appears on the published page. Design the existing template, or sb_remove it first.');
121
121
  }
122
122
  export { isOverlay };
123
+ /**
124
+ * Re-stamp the composed masters with the revisions the save just reported.
125
+ *
126
+ * THE FENCE MOVES ON EVERY SAVE. Compose stamps `specials.globalRev` /
127
+ * `specials.overlayRev` onto the node it materialises; the save sends that back
128
+ * as `expectRev`; the platform refuses a stale one — and refuses it with a
129
+ * WARNING and a 200, not an error. So the first edit to a shared header or to
130
+ * the cart drawer lands, the fence advances on the server, and every edit after
131
+ * it in the same session is dropped while the tool reports success.
132
+ *
133
+ * Measured: two `sb_remove` calls in one session against the cart drawer. The
134
+ * first removed its subtree; the second answered `{"removed": …}` and changed
135
+ * nothing, and the drawer kept rendering the node in the browser.
136
+ *
137
+ * A master the report does not mention is left alone — it was not part of this
138
+ * save, and inventing a revision for it is how a fence stops being one.
139
+ */
140
+ export function restampPatches(doc, report) {
141
+ const wanted = new Map();
142
+ for (const g of report.globals ?? [])
143
+ wanted.set(g.id, { key: SPEC_GLOBAL_REV, rev: g.rev });
144
+ for (const o of report.overlays ?? [])
145
+ wanted.set(o.id, { key: SPEC_OVERLAY_REV, rev: o.rev });
146
+ if (wanted.size === 0)
147
+ return [];
148
+ const out = [];
149
+ for (const [id, n] of Object.entries(doc.nodes)) {
150
+ const masterId = n.specials?.[SPEC_GLOBAL_ID] ?? n.specials?.[SPEC_OVERLAY_ID];
151
+ if (typeof masterId !== 'string')
152
+ continue;
153
+ const next = wanted.get(masterId);
154
+ if (!next || n.specials?.[next.key] === next.rev)
155
+ continue;
156
+ out.push({ op: 'set', path: ['nodes', id, 'specials', next.key], value: next.rev });
157
+ }
158
+ return out;
159
+ }
@@ -2,12 +2,34 @@ import { targets, detected } from './paths.js';
2
2
  import { mergeInto } from './write.js';
3
3
  /** The name the server appears under in every client. */
4
4
  export const SERVER_NAME = 'sbuilder';
5
+ /**
6
+ * Every flag the CLI understands.
7
+ *
8
+ * NAMED, so an unknown one can be refused. `--site` and `--site-name` were typed
9
+ * at a real install, read by nothing, and reported as success — the caller then
10
+ * spent the session wondering why the site was not selected. A flag that is
11
+ * silently dropped is worse than one that does not exist.
12
+ */
13
+ const FLAGS = [
14
+ '--token',
15
+ '--api',
16
+ '--site',
17
+ '--email',
18
+ '--password',
19
+ '--client',
20
+ '--dry-run',
21
+ ];
5
22
  export function buildEntry(opts, pkg = 'sbuilder-mcp') {
6
23
  const env = {};
7
24
  if (opts.api)
8
25
  env.SB_API = opts.api;
9
26
  if (opts.token)
10
27
  env.SB_TOKEN = opts.token;
28
+ // The site the agent works on. A key belongs to exactly one, so writing it
29
+ // here spares every tool call an id the install already knew — and spares the
30
+ // model the guess it otherwise makes from a page list.
31
+ if (opts.site)
32
+ env.SB_SITE = opts.site;
11
33
  // Only when a key is absent: a key opens everything the agent does day to day,
12
34
  // and writing an account password into six config files to buy the handful of
13
35
  // account-level calls it adds is a bad trade the installer should not make for
@@ -66,9 +88,27 @@ export function runInstallCli(argv) {
66
88
  const i = argv.indexOf(flag);
67
89
  return i >= 0 ? argv[i + 1] : undefined;
68
90
  };
91
+ // REFUSE what we cannot act on. Anything that looks like a flag and is not one
92
+ // is a typo or a flag from another version, and either way the caller believes
93
+ // it took effect.
94
+ const taken = new Set();
95
+ for (const f of FLAGS) {
96
+ const i = argv.indexOf(f);
97
+ if (i >= 0) {
98
+ taken.add(i);
99
+ if (f !== '--dry-run')
100
+ taken.add(i + 1);
101
+ }
102
+ }
103
+ const unknown = argv.filter((a, i) => a.startsWith('--') && !taken.has(i));
104
+ if (unknown.length) {
105
+ console.error(`sbuilder: unknown option(s) ${unknown.join(', ')}. Known: ${FLAGS.join(', ')}.`);
106
+ return 1;
107
+ }
69
108
  const opts = {
70
109
  token: get('--token') ?? process.env.SB_TOKEN,
71
110
  api: get('--api') ?? process.env.SB_API,
111
+ site: get('--site') ?? process.env.SB_SITE,
72
112
  email: get('--email') ?? process.env.SB_EMAIL,
73
113
  password: get('--password') ?? process.env.SB_PASSWORD,
74
114
  clients: get('--client')?.split(','),
package/dist/server.js CHANGED
@@ -41,7 +41,13 @@ export function pkgVersion() {
41
41
  }
42
42
  export function buildContext() {
43
43
  const base = process.env.SB_API ?? 'http://localhost:8080';
44
- return { base, session: new Session(base), apiKey: process.env.SB_TOKEN, notices: new Notices() };
44
+ return {
45
+ base,
46
+ session: new Session(base),
47
+ apiKey: process.env.SB_TOKEN,
48
+ siteId: process.env.SB_SITE,
49
+ notices: new Notices(),
50
+ };
45
51
  }
46
52
  export function createServer(ctx = buildContext()) {
47
53
  const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
package/dist/tools/api.js CHANGED
@@ -173,7 +173,14 @@ export async function callOperation(ctx, args) {
173
173
  const name = m[1];
174
174
  const value = args.path_params?.[name];
175
175
  if (value === undefined) {
176
- throw new Error(`sbuilder: operation ${op.id} needs path param "${name}"`);
176
+ // NAME THE ARGUMENT, not just the parameter. The call sheet lists these
177
+ // under `params` while the call takes them in `path_params`, and a caller
178
+ // who reads the sheet and passes `params` is told only that the param is
179
+ // missing — which is exactly the value they just supplied. Two words of
180
+ // "in path_params" is the difference between one round trip and a loop.
181
+ throw new Error(`sbuilder: operation ${op.id} needs path param "${name}" — pass it in path_params, ` +
182
+ `e.g. path_params: { "${name}": "…" }. The call sheet lists it under "params"; ` +
183
+ 'query values go in `query`.');
177
184
  }
178
185
  path = path.replace(`{${name}}`, encodeURIComponent(value));
179
186
  }
@@ -206,6 +213,13 @@ export async function callOperation(ctx, args) {
206
213
  body: args.body,
207
214
  fetchImpl: ctx.fetchImpl,
208
215
  });
216
+ // A 204 HAS NO BODY, and `null` is not an answer a caller can read: a DELETE
217
+ // that worked and a DELETE that returned nothing looked identical, so sixteen
218
+ // page deletes in a row reported `null` sixteen times and the only way to know
219
+ // they had happened was to list the pages again. Say what the operation did.
220
+ if (raw === null || raw === undefined) {
221
+ return { ok: true, method: op.method, path, note: 'The platform answered with no content.' };
222
+ }
209
223
  return shapeResponse(raw, { pick: args.pick, max_items: args.max_items });
210
224
  }
211
225
  export function registerApiTools(server, ctx) {
@@ -1 +1,18 @@
1
- export {};
1
+ /**
2
+ * The site a call is about: the one it names, or the one the install did.
3
+ *
4
+ * An API key belongs to exactly ONE site, so on a key-only install the id is a
5
+ * constant the environment already holds — and requiring it on every call made
6
+ * the model carry a 32-character string through a whole session, which it can
7
+ * only get by listing pages and reading one back. `SB_SITE` makes it optional
8
+ * without making it implicit: an explicit argument always wins, so a
9
+ * two-site session still works by naming each one.
10
+ */
11
+ export function siteFor(ctx, given) {
12
+ const id = given ?? ctx.siteId;
13
+ if (!id) {
14
+ throw new Error('sbuilder: no site. Pass site_id, or set SB_SITE to the site this install works on ' +
15
+ '(sbuilder-mcp install --site site_…).');
16
+ }
17
+ return id;
18
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { text, images } from '../mcp/response.js';
4
- import { BINDING_SOURCES } from '../catalog/elements.generated.js';
4
+ import { BINDING_SOURCES, ELEMENTS } from '../catalog/elements.generated.js';
5
5
  import { previewUrl } from '../vision/preview.js';
6
6
  import { uploadMedia } from '../transport/media.js';
7
7
  import { request } from '../transport/http.js';
@@ -24,8 +24,24 @@ const DATASET_TYPES = new Set([
24
24
  import { RealtimeSocket } from '../transport/socket.js';
25
25
  import { LiveSession } from '../live/session.js';
26
26
  import { refuseAppBlockInterior } from '../domains/site/builder.js';
27
+ import { childrenOf, isOverlay, subtreeIds } from '../core/tree.js';
27
28
  import { siteToken } from './credentialpick.js';
29
+ import { siteFor } from './context.js';
28
30
  import { projectList, MEDIA_FIELDS } from './project.js';
31
+ /**
32
+ * The reserved binding id a PURCHASE control carries, and the vocabulary its
33
+ * target speaks — `schema/src/elements/datasetBindings.ts:845-852`.
34
+ *
35
+ * The id is reserved so authoring and the editor's own healing never collide,
36
+ * and `buy_now` is stored as builderx's `dynamic_checkout`: the picker's word
37
+ * and the document's word are deliberately different, and hand-mapping either
38
+ * one is how the two drift.
39
+ */
40
+ const PRODUCT_ACTION_BINDING_ID = 'bind-product-action';
41
+ const PURCHASE_TARGETS = {
42
+ add_to_cart: 'add_to_cart',
43
+ buy_now: 'dynamic_checkout',
44
+ };
29
45
  /**
30
46
  * Bind a node's content to real store data.
31
47
  *
@@ -38,7 +54,7 @@ import { projectList, MEDIA_FIELDS } from './project.js';
38
54
  * reads the namespace off the field and `continue`s on anything else — so a
39
55
  * `style.color` binding is stored, saved, published, and ignored forever.
40
56
  */
41
- export function bindNode(doc, id, source, field) {
57
+ export function bindNode(doc, id, source, field, action) {
42
58
  const node = doc.node(id);
43
59
  refuseAppBlockInterior(doc, id, 'binding');
44
60
  if (!BINDING_SOURCES.includes(source)) {
@@ -50,6 +66,36 @@ export function bindNode(doc, id, source, field) {
50
66
  throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
51
67
  'every other namespace, so the binding would be stored and never applied.');
52
68
  }
69
+ // A PURCHASE BINDING, which is what makes a button add to the cart.
70
+ //
71
+ // It is not an ordinary binding and cannot be written as one: the renderer
72
+ // reads `target.action` (`server/render/nodes/helpers.go:1166`) and nothing
73
+ // else, `sb_set` writes only style/config/specials, and this tool's plain path
74
+ // writes no target at all — so before this branch a store built entirely
75
+ // through these tools had no way to author an Add-to-cart button, while
76
+ // `sb_review` reported the gap and named no fix that worked. The one control
77
+ // a shop cannot do without was the one the tools could not make.
78
+ if (action !== undefined) {
79
+ const mapped = PURCHASE_TARGETS[action];
80
+ if (!mapped) {
81
+ throw new Error(`sbuilder: "${action}" is not a purchase action. Use "add_to_cart" or "buy_now" — ` +
82
+ 'those are the two the renderer draws a purchase control for.');
83
+ }
84
+ const value = {
85
+ id: PRODUCT_ACTION_BINDING_ID,
86
+ source,
87
+ field,
88
+ target: { type: 'product', id: '', action: mapped },
89
+ };
90
+ // RESERVED ID, so a second call re-points the control instead of leaving two
91
+ // purchase bindings on one button for the runtime to choose between.
92
+ const at = node.bindings.findIndex((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
93
+ if (at >= 0)
94
+ return [{ op: 'set', path: ['nodes', id, 'bindings', String(at)], value }];
95
+ return [
96
+ { op: 'insert', path: ['nodes', id, 'bindings'], index: node.bindings.length, value },
97
+ ];
98
+ }
53
99
  return [
54
100
  {
55
101
  op: 'insert',
@@ -59,6 +105,77 @@ export function bindNode(doc, id, source, field) {
59
105
  },
60
106
  ];
61
107
  }
108
+ /**
109
+ * The click-action allow-list that is LIVE for this node.
110
+ *
111
+ * `activeEvents` in the platform, whose whole rule is one line in
112
+ * `ActionTrait.vue`: `return action ? def.binding_events : def.events`. A
113
+ * purchase control is a different kind of control — an unbound button navigates,
114
+ * a bound one hands off to the cart or the checkout — and the two sets are
115
+ * mutually exclusive, because "add this product, then go to an arbitrary URL" is
116
+ * not a thing the cart runtime can express.
117
+ */
118
+ function liveEventTable(type, node) {
119
+ const meta = ELEMENTS[type];
120
+ if (!meta?.events)
121
+ return undefined;
122
+ const bound = (node.bindings ?? []).some((b) => b?.id === PRODUCT_ACTION_BINDING_ID);
123
+ return (bound && meta.bindingEvents) || meta.events;
124
+ }
125
+ /**
126
+ * Put a click action on a node, or take one off.
127
+ *
128
+ * THE ONE THING NO TOOL COULD DO. `NodeSpec` carries no `events`, `sb_set`
129
+ * writes only style/config/specials, and `createNode` always minted `events: []`
130
+ * — so `open_cart` could not be authored, and a site built from scratch had no
131
+ * way to open its own cart drawer. `sb_review` reported that gap
132
+ * (`cartTrigger`) and named a fix nothing could apply, which is the same shape
133
+ * the purchase binding had before `sb_bind` grew `action`.
134
+ *
135
+ * A purchase is NOT here. `add_to_cart` and `buy_now` are absent from every
136
+ * element's allow-list, and the button meta says why in as many words: neither
137
+ * is a click action. The intent is the BINDING — `sb_bind` with `action` — and
138
+ * the event is what happens alongside it.
139
+ *
140
+ * ONE ACTION PER TRIGGER, replaced in place. The platform stores a list, but a
141
+ * second `click` on one node is two answers to one question, and picking between
142
+ * them at runtime is the platform's business rather than an authoring choice.
143
+ */
144
+ export function setEvent(doc, id, trigger, action, payload) {
145
+ const node = doc.node(id);
146
+ refuseAppBlockInterior(doc, id, 'setting an event on');
147
+ const events = node.events ?? [];
148
+ const at = events.findIndex((e) => e?.name === trigger);
149
+ if (action === 'none') {
150
+ if (at < 0)
151
+ return [];
152
+ return [{ op: 'remove', path: ['nodes', id, 'events'], index: at }];
153
+ }
154
+ const table = liveEventTable(node.data.type, node);
155
+ if (!table) {
156
+ throw new Error(`sbuilder: a ${node.data.type} declares no click actions, so an event on it would be ` +
157
+ 'stored and never fired. Elements that do: ' +
158
+ Object.keys(ELEMENTS).filter((t) => ELEMENTS[t]?.events).join(', ') + '.');
159
+ }
160
+ const allowed = table[trigger];
161
+ if (!allowed) {
162
+ throw new Error(`sbuilder: a ${node.data.type} offers no "${trigger}" trigger. It offers: ` +
163
+ `${Object.keys(table).join(', ')}.`);
164
+ }
165
+ if (!allowed.includes(action)) {
166
+ const purchase = action === 'add_to_cart' || action === 'buy_now';
167
+ throw new Error(`sbuilder: "${action}" is not an action a ${node.data.type} offers on ${trigger}. ` +
168
+ (purchase
169
+ ? 'A purchase is a BINDING, not a click action — use sb_bind with action:"' +
170
+ action + '". '
171
+ : '') +
172
+ `Allowed: ${allowed.join(', ')}.`);
173
+ }
174
+ const value = { id: `ev_${action}`, name: trigger, action, payload: payload ?? {} };
175
+ if (at >= 0)
176
+ return [{ op: 'set', path: ['nodes', id, 'events', String(at)], value }];
177
+ return [{ op: 'insert', path: ['nodes', id, 'events'], index: events.length, value }];
178
+ }
62
179
  /**
63
180
  * The credential that opens the live-edit room.
64
181
  *
@@ -95,9 +212,10 @@ export function registerLiveTools(server, ctx, session) {
95
212
  'editor as it happens, with the agent shown by the API key\'s own name rather than a ' +
96
213
  "person's. Always yields, so it is safe beside a human. Works with SB_TOKEN or with " +
97
214
  'SB_EMAIL / SB_PASSWORD.',
98
- inputSchema: { site_id: z.string() },
215
+ inputSchema: { site_id: z.string().optional() },
99
216
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
100
- }, async ({ site_id }) => {
217
+ }, async ({ site_id: given }) => {
218
+ const site_id = siteFor(ctx, given);
101
219
  const tokenFn = liveTokenFor(ctx);
102
220
  const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
103
221
  const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, tokenFn);
@@ -166,7 +284,16 @@ export function registerLiveTools(server, ctx, session) {
166
284
  const review = reviewField(ctx, session.current());
167
285
  // Measured on the render, not read off the document — a card that spills
168
286
  // at 390px is invisible to every check that only reads the tree.
169
- const visual = node_id ? [] : measure(shots);
287
+ // The overlay subtree, read off the OPEN DOCUMENT — the boxes come from
288
+ // the render and carry no idea which node is a drawer.
289
+ const doc = session.current().doc;
290
+ const skip = new Set();
291
+ for (const id of childrenOf(doc, doc.root_node_id)) {
292
+ if (isOverlay(doc, id))
293
+ for (const n of subtreeIds(doc, id))
294
+ skip.add(n);
295
+ }
296
+ const visual = node_id ? [] : measure(shots, skip);
170
297
  const layout = compactFindings(visual);
171
298
  const layoutNotice = visual.length > 0 ? ctx.notices.once('measure', MEASURE_NOTICE) : undefined;
172
299
  // The legend rides with the first look only; the shape does not change after.
@@ -204,17 +331,17 @@ export function registerLiveTools(server, ctx, session) {
204
331
  description: "The site's media library. Reuse an image before adding another; search by name, filter " +
205
332
  'by type, page with limit/offset.',
206
333
  inputSchema: {
207
- site_id: z.string(),
334
+ site_id: z.string().optional(),
208
335
  search: z.string().optional(),
209
336
  media_type: z.string().optional().describe('e.g. "image"'),
210
337
  limit: z.number().int().min(1).max(200).optional(),
211
338
  offset: z.number().int().min(0).optional(),
212
339
  },
213
340
  annotations: { readOnlyHint: true },
214
- }, async ({ site_id, search, media_type, limit, offset }) => text(projectList(await request({
341
+ }, async ({ site_id: given, search, media_type, limit, offset }) => text(projectList(await request({
215
342
  base: ctx.base,
216
343
  method: 'GET',
217
- path: `/api/sites/${encodeURIComponent(site_id)}/media`,
344
+ path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/media`,
218
345
  token: siteToken(ctx),
219
346
  query: { search, mediaType: media_type, limit, offset },
220
347
  fetchImpl: ctx.fetchImpl,
@@ -224,7 +351,7 @@ export function registerLiveTools(server, ctx, session) {
224
351
  'local file path or a URL to fetch. This is the ONLY way to add an image: the upload ' +
225
352
  'is multipart, which sb_api_call cannot send.',
226
353
  inputSchema: {
227
- site_id: z.string(),
354
+ site_id: z.string().optional(),
228
355
  path: z.string().optional().describe('A file on this machine'),
229
356
  url: z.string().optional().describe('Fetched, then uploaded'),
230
357
  name: z.string().optional(),
@@ -232,7 +359,8 @@ export function registerLiveTools(server, ctx, session) {
232
359
  dry_run: z.boolean().optional(),
233
360
  },
234
361
  annotations: { readOnlyHint: false, destructiveHint: false },
235
- }, async ({ site_id, path, url, name, folder_id, dry_run }) => {
362
+ }, async ({ site_id: given, path, url, name, folder_id, dry_run }) => {
363
+ const site_id = siteFor(ctx, given);
236
364
  if (!path && !url)
237
365
  throw new Error('sbuilder: give sb_media_upload either a path or a url');
238
366
  if (dry_run !== false) {
@@ -251,9 +379,31 @@ export function registerLiveTools(server, ctx, session) {
251
379
  : 'Uploaded, but the server returned no url — read it back with sb_media_list.',
252
380
  });
253
381
  });
382
+ server.registerTool('sb_event', {
383
+ description: 'Give a node a click action — open the cart, go to a page, open a pop-up. A purchase ' +
384
+ 'is not one: use sb_bind action.',
385
+ inputSchema: {
386
+ id: z.string(),
387
+ action: z
388
+ .string()
389
+ .describe('An action this element allows, or "none" to clear. A wrong one is refused with the list'),
390
+ trigger: z.string().optional().describe('Default "click"'),
391
+ payload: z.record(z.unknown()).optional(),
392
+ dry_run: z.boolean().optional(),
393
+ },
394
+ annotations: { readOnlyHint: false, destructiveHint: false },
395
+ }, async ({ id, action, trigger, payload, dry_run }) => {
396
+ const d = session.current();
397
+ const patches = setEvent(d, id, trigger ?? 'click', action, payload);
398
+ if (dry_run !== false)
399
+ return text({ dry_run: true, patches });
400
+ session.applyAndPublish(patches);
401
+ await session.save();
402
+ return text({ node: id, trigger: trigger ?? 'click', action, rev: d.rev });
403
+ });
254
404
  server.registerTool('sb_bind', {
255
- description: "Bind a node's content to real store data, so the page shows actual products rather than " +
256
- 'placeholder text.',
405
+ description: 'Bind a node to real store data so the page shows actual products, not placeholder ' +
406
+ 'text. action makes a button a purchase control.',
257
407
  inputSchema: {
258
408
  id: z.string(),
259
409
  source: z
@@ -264,16 +414,20 @@ export function registerLiveTools(server, ctx, session) {
264
414
  // unknown one costs one round trip and the schema stays small.
265
415
  `e.g. ${BINDING_SOURCES.slice(0, 4).join(', ')}; ${BINDING_SOURCES.length} in all, and a wrong one is refused with the list`),
266
416
  field: z.string().describe('Where the value lands, always "specials.<key>"'),
417
+ action: z
418
+ .enum(['add_to_cart', 'buy_now'])
419
+ .optional()
420
+ .describe('Pass product.id + specials.boundProductId'),
267
421
  dry_run: z.boolean().optional(),
268
422
  },
269
423
  annotations: { readOnlyHint: false, destructiveHint: false },
270
- }, async ({ id, source, field, dry_run }) => {
424
+ }, async ({ id, source, field, action, dry_run }) => {
271
425
  const d = session.current();
272
- const patches = bindNode(d, id, source, field);
426
+ const patches = bindNode(d, id, source, field, action);
273
427
  if (dry_run !== false)
274
428
  return text({ dry_run: true, patches });
275
429
  session.applyAndPublish(patches);
276
430
  await session.save();
277
- return text({ bound: id, source, field, rev: d.rev });
431
+ return text({ bound: id, source, field, ...(action ? { action } : {}), rev: d.rev });
278
432
  });
279
433
  }
@@ -11,8 +11,9 @@ import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
11
11
  import { compactFindings } from '../domains/site/findings.js';
12
12
  import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
13
13
  import { gatherReadiness } from '../domains/site/readiness-fetch.js';
14
- import { globalWarning, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
14
+ import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
15
15
  import { catalogMatches, traitsFor } from '../catalog/element-search.js';
16
+ import { siteFor } from './context.js';
16
17
  import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
17
18
  /**
18
19
  * Findings, in the shape every surface returns them.
@@ -131,7 +132,15 @@ export class PageSession {
131
132
  if (problems.length > 0) {
132
133
  throw new Error(`sbuilder: refusing to save — ${problems.join(' ')}`);
133
134
  }
134
- await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
135
+ const saved = await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
136
+ // RE-STAMP THE FENCE, or lose every edit after this one.
137
+ //
138
+ // The save reports each shared master's new revision precisely so the client
139
+ // can carry it into the next save; the platform refuses a stale `expectRev`
140
+ // with a warning and a 200. Applied locally rather than published: these are
141
+ // the server's own numbers coming back, not an edit anybody made, and a peer
142
+ // in the room gets them from its own save.
143
+ d.apply(restampPatches(d.doc, { globals: saved.globals, overlays: saved.overlays }));
135
144
  }
136
145
  }
137
146
  const specSchema = z.lazy(() => z.object({
@@ -147,10 +156,10 @@ export function registerPageTools(server, ctx) {
147
156
  server.registerTool('sb_page_open', {
148
157
  description: 'Open a page for editing and return its outline. Call before any sb_add / sb_set / ' +
149
158
  'sb_move / sb_remove. Find page ids with sb_api_find "list pages".',
150
- inputSchema: { site_id: z.string(), page_id: z.string() },
159
+ inputSchema: { site_id: z.string().optional(), page_id: z.string() },
151
160
  annotations: { readOnlyHint: true },
152
- }, async ({ site_id, page_id }) => {
153
- const outline = await session.open(site_id, page_id);
161
+ }, async ({ site_id: given, page_id }) => {
162
+ const outline = await session.open(siteFor(ctx, given), page_id);
154
163
  const doc = session.current();
155
164
  // A page whose stored document named its root under the app-block key
156
165
  // renders as an empty <body> and says nothing about why. Nobody else can
@@ -356,12 +365,12 @@ export function registerPageTools(server, ctx) {
356
365
  server.registerTool('sb_templates', {
357
366
  description: "The store's saved section templates — designed sections a person starts from rather " +
358
367
  'than assembling one. Use sb_template_use to drop one into the open page.',
359
- inputSchema: { site_id: z.string() },
368
+ inputSchema: { site_id: z.string().optional() },
360
369
  annotations: { readOnlyHint: true },
361
- }, async ({ site_id }) => text(projectList(await request({
370
+ }, async ({ site_id: given }) => text(projectList(await request({
362
371
  base: ctx.base,
363
372
  method: 'GET',
364
- path: `/api/sites/${encodeURIComponent(site_id)}/section-templates`,
373
+ path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/section-templates`,
365
374
  token: siteToken(ctx),
366
375
  fetchImpl: ctx.fetchImpl,
367
376
  }), 'sectionTemplates', TEMPLATE_FIELDS)));
@@ -369,13 +378,14 @@ export function registerPageTools(server, ctx) {
369
378
  description: 'Instantiate a saved section template into a page. The server does the copy, so the ' +
370
379
  'section arrives exactly as it was designed — then re-open the page to see it.',
371
380
  inputSchema: {
372
- site_id: z.string(),
381
+ site_id: z.string().optional(),
373
382
  template_id: z.string(),
374
383
  page_id: z.string(),
375
384
  dry_run: z.boolean().optional(),
376
385
  },
377
386
  annotations: { readOnlyHint: false, destructiveHint: false },
378
- }, async ({ site_id, template_id, page_id, dry_run }) => {
387
+ }, async ({ site_id: given, template_id, page_id, dry_run }) => {
388
+ const site_id = siteFor(ctx, given);
379
389
  const path = `/api/sites/${encodeURIComponent(site_id)}/section-templates/${encodeURIComponent(template_id)}/instantiate`;
380
390
  if (dry_run !== false) {
381
391
  return text({ dry_run: true, would_post: path, body: { pageId: page_id } });
@@ -397,12 +407,12 @@ export function registerPageTools(server, ctx) {
397
407
  });
398
408
  server.registerTool('sb_page_list', {
399
409
  description: "Every page on the site, with its slug and whether it is live.",
400
- inputSchema: { site_id: z.string() },
410
+ inputSchema: { site_id: z.string().optional() },
401
411
  annotations: { readOnlyHint: true },
402
- }, async ({ site_id }) => text(projectList(await request({
412
+ }, async ({ site_id: given }) => text(projectList(await request({
403
413
  base: ctx.base,
404
414
  method: 'GET',
405
- path: `/api/sites/${encodeURIComponent(site_id)}/pages`,
415
+ path: `/api/sites/${encodeURIComponent(siteFor(ctx, given))}/pages`,
406
416
  token: siteToken(ctx),
407
417
  fetchImpl: ctx.fetchImpl,
408
418
  }), 'pages', PAGE_FIELDS)));
@@ -411,7 +421,7 @@ export function registerPageTools(server, ctx) {
411
421
  'checkout, product, category, post and course: /checkout and /products/{slug} need a ' +
412
422
  'PUBLISHED page of that type or they 404.',
413
423
  inputSchema: {
414
- site_id: z.string(),
424
+ site_id: z.string().optional(),
415
425
  name: z.string(),
416
426
  type: z.string().optional().describe('page (default), checkout, product, category, post, course'),
417
427
  slug: z.string().optional(),
@@ -420,7 +430,8 @@ export function registerPageTools(server, ctx) {
420
430
  dry_run: z.boolean().optional(),
421
431
  },
422
432
  annotations: { readOnlyHint: false, destructiveHint: false },
423
- }, async ({ site_id, name, type, slug, is_homepage, settings, dry_run }) => {
433
+ }, async ({ site_id: given, name, type, slug, is_homepage, settings, dry_run }) => {
434
+ const site_id = siteFor(ctx, given);
424
435
  const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
425
436
  // TYPE IS THE ROUTE for several kinds of page: /checkout and
426
437
  // /products/{slug} resolve to the site's PUBLISHED page of that type and
@@ -470,9 +481,10 @@ export function registerPageTools(server, ctx) {
470
481
  description: 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
471
482
  'section with others republishes them too, because a header edited once must not go ' +
472
483
  'live on one page and stay stale on the rest.',
473
- inputSchema: { site_id: z.string(), page_id: z.string(), dry_run: z.boolean().optional() },
484
+ inputSchema: { site_id: z.string().optional(), page_id: z.string(), dry_run: z.boolean().optional() },
474
485
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
475
- }, async ({ site_id, page_id, dry_run }) => {
486
+ }, async ({ site_id: given, page_id, dry_run }) => {
487
+ const site_id = siteFor(ctx, given);
476
488
  // PUBLISH IS A SITE-LEVEL CALL that NAMES pages, not a page-level route.
477
489
  // This used to POST /pages/{id}/publish, which the platform answers 404 —
478
490
  // it mounts "publish" as its own resource beside "pages"