@diviops/mcp-server 1.5.9 → 1.5.11

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/index.js CHANGED
@@ -316,7 +316,7 @@ registerPluginTool("diviops_schema_get_settings", {
316
316
  };
317
317
  });
318
318
  registerPluginTool("diviops_global_color_list", {
319
- description: "Get the global color palette defined in Divi. Returns all global colors that can be referenced by modules. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
319
+ description: "Get the global color palette defined in Divi. Returns `{ colors, customizer }` — `colors` is the user-defined palette stored under `et_divi.et_global_data.global_colors` (read via the #719 priority-ordered probe); `customizer` surfaces the five WP-customizer-bound defaults (gcid-primary-color / gcid-secondary-color / gcid-heading-color / gcid-body-color / gcid-link-color) sourced from `\\ET\\Builder\\Packages\\GlobalData\\GlobalData::$customizer_colors`. Top-level `_meta.source_path` + `_meta.probed_paths` document which storage path yielded the user palette; `_meta.customizer_source` describes the customizer-bound default surface. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
320
320
  annotations: { idempotentHint: true },
321
321
  _meta: { idempotent: "true" },
322
322
  }, async () => {
@@ -327,6 +327,18 @@ registerPluginTool("diviops_global_color_list", {
327
327
  ],
328
328
  };
329
329
  });
330
+ registerPluginTool("diviops_global_color_audit_storage", {
331
+ description: "Audit the global_colors STORAGE LOCATION landscape (#719 contract). Aggregates entries across all candidate paths for the global_colors surface with per-entry provenance via `_meta.entry_sources = { <id>: { path, provenance } }`. Provenance vocabulary: `et_divi_nested` (canonical 5.x — `et_divi.et_global_data.global_colors`), `top_level` (hypothetical standalone option, not observed on tested 5.5.x substrates), `wp_customizer` (the five WP-customizer-bound defaults — gcid-primary-color / gcid-secondary-color / gcid-heading-color / gcid-body-color / gcid-link-color, sourced from GlobalData::$customizer_colors). Warnings: `id_collision` (same id across two paths). The user palette overrides customizer defaults when both present (matches Divi's render-side behavior at GlobalData::get_global_colors). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
332
+ annotations: { idempotentHint: true },
333
+ _meta: { idempotent: "true" },
334
+ }, async () => {
335
+ const result = await wp.requestEnveloped("/global-color/audit-storage");
336
+ return {
337
+ content: [
338
+ { type: "text", text: serializeEnvelope(result, "diviops_global_color_audit_storage") },
339
+ ],
340
+ };
341
+ });
330
342
  registerPluginTool("diviops_global_color_create", {
331
343
  description: "Add a new global color to Divi's palette. The plugin mints a fresh `gcid-<uuid>` ID (the server forwards the color entry without an id and the WP-side handler generates one) and writes to the et_global_data option in the canonical Divi shape `{color, folder, label, lastUpdated, status, usedInPosts}`. The color appears in the VB color picker after save and can be referenced via `$variable({type:color,value:{name:gcid-...}})$` tokens. Note: Divi's AI Agent bundle has a Zod schema gap that drops `label` on its own writes — our PHP path goes around that bug by writing directly to the option. CONCURRENCY: this is a read-modify-write on a single WP option with no conflict detection. If a Visual Builder session holds stale global data, its next save can clobber colors written here in the interim. Coordinate writes when VB sessions are active, or have the user reload VB after MCP color writes. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; input-shape rejections (non-CSS color value, missing required `color` for a new entry) return code 'invalid_input' with `error.data` documenting the failed field." +
332
344
  DRY_RUN_DESC_SUFFIX,
@@ -443,7 +455,7 @@ registerPluginTool("diviops_global_color_delete", {
443
455
  return { content: [{ type: "text", text: serializeEnvelope(result, "diviops_global_color_delete") }] };
444
456
  });
445
457
  registerPluginTool("diviops_global_font_list", {
446
- description: "List the DiviOps-managed global fonts registered under `et_global_data.global_fonts`. ALWAYS returns the normalized shape `{ count: number, fonts: { <gfid>: <record>, ... } }` — even on empty substrates (count:0, fonts:{}), never bare `false`. Distinct from the variable-manager font tokens (`gvid-*` under `et_global_data.global_variables.fonts`, surfaced via `diviops_variable_list({type:\"fonts\"})`) — `global_font_*` is the DiviOps-controlled font catalog presets bind to via canonical `gfid-` slugs. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
458
+ description: "List the DiviOps-managed global fonts registered under `et_divi.et_global_data.global_fonts` (gfid-* Google catalog) AND the local-hosted Pattern B fonts registered under `et_uploaded_fonts` (per #719 AC #9). Returns `{ count, fonts, uploaded_count, uploaded_fonts }` — both maps always emitted as JSON objects (consistent shape across empty/populated substrates). Top-level `_meta.sources` discriminates the two surfaces with `provenance: \"gfid_catalog\"` vs `provenance: \"uploaded_local\"`. Distinct from the variable-manager font tokens (`gvid-*` under `et_global_data.global_variables.fonts`, surfaced via `diviops_variable_list({type:\"fonts\"})`) — `global_font_*` is the DiviOps-controlled font catalog presets bind to via canonical `gfid-` slugs. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
447
459
  annotations: { idempotentHint: true },
448
460
  _meta: { idempotent: "true" },
449
461
  }, async () => {
@@ -454,6 +466,18 @@ registerPluginTool("diviops_global_font_list", {
454
466
  ],
455
467
  };
456
468
  });
469
+ registerPluginTool("diviops_global_font_audit_storage", {
470
+ description: "Audit the global_fonts STORAGE LOCATION landscape (#719 contract). Aggregates entries across the gfid-* catalog (`et_divi.et_global_data.global_fonts`) AND the local-hosted `et_uploaded_fonts` Pattern B surface with per-entry provenance via `_meta.entry_sources = { <id>: { path, provenance } }`. Provenance vocabulary: `gfid_catalog` (Google CDN canonical), `uploaded_local` (file-uploaded local-hosted fonts per `reference_local_hosted_fonts_eu_pattern`). Warnings: `id_collision` (same id in both — upstream contract violation since the two surfaces are key-namespace-disjoint by convention). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
471
+ annotations: { idempotentHint: true },
472
+ _meta: { idempotent: "true" },
473
+ }, async () => {
474
+ const result = await wp.requestEnveloped("/global-font/audit-storage");
475
+ return {
476
+ content: [
477
+ { type: "text", text: serializeEnvelope(result, "diviops_global_font_audit_storage") },
478
+ ],
479
+ };
480
+ });
457
481
  registerPluginTool("diviops_global_font_create", {
458
482
  description: "Create a new global font in DiviOps's registry under `et_global_data.global_fonts`. Mints a fresh `gfid-<uuid>` if `id` is omitted; otherwise uses the supplied id (must match `gfid-[0-9a-z-]{1,80}`; auto-prefixes `gfid-` if missing). Strict create — collision on existing id returns `conflict` (HTTP 409) with `error.data = { id, existing }`; use diviops_global_font_update to modify an existing record. Stored shape: `{ family, source, weights[], subsets[], label, fallback, status, lastUpdated }`. Required: `family` (CSS family name, e.g. \"Sora\") + `source` (one of `google`/`system`/`custom`). Distinct from `diviops_variable_create({type:\"fonts\"})` which writes `gvid-*` font tokens to the variable manager — `global_font_*` is the DiviOps catalog presets bind via `gfid-` slugs. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; input-shape rejections (malformed id, invalid source enum, non-array weights/subsets, missing required `family`/`source` for a new entry) collapse onto `invalid_input` with structured `error.data`." +
459
483
  DRY_RUN_DESC_SUFFIX,
@@ -1244,6 +1268,18 @@ registerPluginTool("diviops_preset_audit", {
1244
1268
  ],
1245
1269
  };
1246
1270
  });
1271
+ registerPluginTool("diviops_preset_audit_storage", {
1272
+ description: "Audit the D5 preset STORAGE LOCATION landscape (#719 contract). Distinct from `diviops_preset_audit` (which audits preset CONTENT — usage refs, orphans, defaults). Aggregates entries across the canonical top-level `et_divi_builder_global_presets_d5` and the legacy nested `et_divi.builder_global_presets_d5` scratchpad on upgraded substrates, with per-entry provenance via `_meta.entry_sources = { <id>: { path, provenance } }`. Provenance vocabulary: `d5_top_level` (canonical), `d5_nested_scratchpad` (upgrade artifact), `legacy_d4_ng` (D4-era `et_divi_builder_global_presets_ng` store — OUT-OF-BAND per the banner, surfaced via entry_sources only, NEVER merged into the D5 aggregate). Warnings: `id_collision` (same id across D5 paths, same top-level shape), `shape_inconsistency` (same id, divergent top-level keys), `ng_non_empty` (legacy D4 store contains content; surface for inventory). Use this to diagnose substrate state before/after upgrades — agents do NOT auto-migrate; surfacing state is the contract. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; the routing-provenance fields sit on top-level `_meta`.",
1273
+ annotations: { idempotentHint: true },
1274
+ _meta: { idempotent: "true" },
1275
+ }, async () => {
1276
+ const result = await wp.requestEnveloped("/preset/audit-storage");
1277
+ return {
1278
+ content: [
1279
+ { type: "text", text: serializeEnvelope(result, "diviops_preset_audit_storage") },
1280
+ ],
1281
+ };
1282
+ });
1247
1283
  registerPluginTool("diviops_preset_cleanup", {
1248
1284
  description: 'Clean up presets. Default: remove spam presets. Optional: dedup=true to also remove duplicates, action="rename_strip_prefix" with prefix to strip a name prefix, or action="remove_orphans" with scope="spam"|"all" to remove unreferenced presets. Use dry_run: true (default) to preview. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Note: dry_run currently returns the route-specific summary shape rather than the standardized `data.plan = { summary, changes[] }` shape used by tools introduced after the dry_run convention was generalized; plan-shape standardization is tracked separately for the pre-existing dry_run wave.',
1249
1285
  inputSchema: {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `divi/button` emitter shape coverage:
3
+ * - fixture-based shape assertion against round-3-button-canonical-complete.json
4
+ * - emit-on-specification discriminator (with vs without font.weight)
5
+ * - hover shape (NO `value` wrapper), radius vocabulary, variable tokens,
6
+ * the do-not-emit list, and the opt-in bypass corner.
7
+ */
8
+ export {};
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `divi/button` emitter shape coverage:
3
+ * - fixture-based shape assertion against round-3-button-canonical-complete.json
4
+ * - emit-on-specification discriminator (with vs without font.weight)
5
+ * - hover shape (NO `value` wrapper), radius vocabulary, variable tokens,
6
+ * the do-not-emit list, and the opt-in bypass corner.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ import { emitButtonGroupPreset, composeButtonAttrs, buildPresetCreateBody, } from "../button-emitter.js";
14
+ import { loadRegistry } from "../registry.js";
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const REPO_ROOT = join(__dirname, "..", "..", "..", "..");
17
+ const FIXTURE = join(REPO_ROOT, "docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-3-button-canonical-complete.json");
18
+ const registry = loadRegistry();
19
+ test("emitter byte-matches the round-3 canonical button fixture attrs", () => {
20
+ const fixture = JSON.parse(readFileSync(FIXTURE, "utf8"));
21
+ const canonicalAttrs = fixture.canonical_button_primary_preset.attrs;
22
+ const entry = emitButtonGroupPreset({
23
+ name: "canonical-button-primary-vb-2026-05-18",
24
+ bg_color: "gcid-primary-color",
25
+ bg_color_hover: "gcid-secondary-color",
26
+ radius: {
27
+ topLeft: "8px",
28
+ topRight: "8px",
29
+ bottomLeft: "8px",
30
+ bottomRight: "8px",
31
+ },
32
+ font: {
33
+ family: "Inter",
34
+ weight: "600",
35
+ color: "gcid-body-color",
36
+ },
37
+ }, registry);
38
+ assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the canonical capture");
39
+ assert.equal(entry.type, "group");
40
+ assert.equal(entry.group_name, "divi/button");
41
+ assert.equal(entry.group_id, "button");
42
+ assert.equal(entry.module_name, "divi/button");
43
+ });
44
+ test("hover bg color emits at desktop.hover.color with NO value wrapper", () => {
45
+ const attrs = composeButtonAttrs({
46
+ name: "hover-only",
47
+ bg_color: "#111111",
48
+ bg_color_hover: "#222222",
49
+ });
50
+ const bg = attrs.button.decoration.background;
51
+ assert.equal(bg.desktop.value.color, "#111111", "desktop is 3-level (value.color)");
52
+ assert.equal(bg.desktop.hover.color, "#222222", "hover is 2-level (hover.color)");
53
+ assert.equal("value" in bg.desktop.hover, false, "hover must NOT carry a `value` wrapper");
54
+ });
55
+ test("emit-on-specification discriminator: font.weight present vs absent", () => {
56
+ const withWeight = composeButtonAttrs({
57
+ name: "with-weight",
58
+ font: { family: "Inter", weight: "600" },
59
+ });
60
+ const withoutWeight = composeButtonAttrs({
61
+ name: "without-weight",
62
+ font: { family: "Inter" },
63
+ });
64
+ const wv = withWeight.button.decoration.font.font.desktop.value;
65
+ const wov = withoutWeight.button.decoration.font.font.desktop.value;
66
+ assert.equal(wv.weight, "600", "weight emitted when specified");
67
+ assert.equal("weight" in wv, true);
68
+ assert.equal("weight" in wov, false, "weight absent when not specified");
69
+ assert.deepEqual(Object.keys(wov), ["family"], "ONLY the touched key emitted");
70
+ });
71
+ test("radius vocabulary is {topLeft,topRight,bottomLeft,bottomRight,sync}", () => {
72
+ const attrs = composeButtonAttrs({
73
+ name: "r",
74
+ radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px", bottomRight: "8px" },
75
+ });
76
+ const radius = attrs.button.decoration.border.desktop.value.radius;
77
+ assert.deepEqual(Object.keys(radius).sort(), [
78
+ "bottomLeft",
79
+ "bottomRight",
80
+ "sync",
81
+ "topLeft",
82
+ "topRight",
83
+ ]);
84
+ assert.equal(radius.sync, "on", "equal corners derive sync=on");
85
+ });
86
+ test("radius sync derives off when corners differ; partial corners stay absent", () => {
87
+ const attrs = composeButtonAttrs({
88
+ name: "r",
89
+ radius: { topLeft: "8px", bottomRight: "4px" },
90
+ });
91
+ const radius = attrs.button.decoration.border.desktop.value.radius;
92
+ assert.equal(radius.sync, "off");
93
+ assert.equal("topRight" in radius, false, "untouched corner absent");
94
+ assert.equal("bottomLeft" in radius, false, "untouched corner absent");
95
+ });
96
+ test("radius sync derives off for a single specified corner (Rule 1a)", () => {
97
+ // Per Rule 1a in canonical-preset-shapes: a partial corner set must derive
98
+ // sync="off". A 1-element corner array is trivially "all equal" — auto-
99
+ // deriving "on" is only correct when all 4 corners are present and identical.
100
+ const attrs = composeButtonAttrs({
101
+ name: "r",
102
+ radius: { topLeft: "8px" },
103
+ });
104
+ const radius = attrs.button.decoration.border.desktop.value.radius;
105
+ assert.equal(radius.sync, "off", "single corner must NOT derive sync=on");
106
+ assert.equal("topRight" in radius, false, "untouched corner absent");
107
+ });
108
+ test("radius sync stays off for 3 identical corners; on only with all 4", () => {
109
+ const three = composeButtonAttrs({
110
+ name: "r",
111
+ radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px" },
112
+ });
113
+ assert.equal(three.button.decoration.border.desktop.value.radius.sync, "off", "3 identical corners is still a partial set — sync=off");
114
+ const all = composeButtonAttrs({
115
+ name: "r",
116
+ radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px", bottomRight: "8px" },
117
+ });
118
+ assert.equal(all.button.decoration.border.desktop.value.radius.sync, "on", "all 4 identical corners derive sync=on");
119
+ });
120
+ test("explicit radius.sync wins over derivation", () => {
121
+ const attrs = composeButtonAttrs({
122
+ name: "r",
123
+ radius: { topLeft: "8px", sync: "on" },
124
+ });
125
+ assert.equal(attrs.button.decoration.border.desktop.value.radius.sync, "on", "an explicitly-passed sync flag is honored regardless of corner count");
126
+ });
127
+ test("variable tokens get the {name,settings} object shape + trailing )$", () => {
128
+ const attrs = composeButtonAttrs({
129
+ name: "v",
130
+ bg_color: "gcid-primary-color",
131
+ });
132
+ const color = attrs.button.decoration.background.desktop.value.color;
133
+ assert.ok(color.endsWith(")$"), "token must end with )$");
134
+ assert.match(color, /^\$variable\(/);
135
+ const payload = JSON.parse(color.slice("$variable(".length, -2));
136
+ assert.deepEqual(payload, {
137
+ type: "color",
138
+ value: { name: "gcid-primary-color", settings: {} },
139
+ });
140
+ });
141
+ test("literal hex color is emitted verbatim, not wrapped", () => {
142
+ const attrs = composeButtonAttrs({ name: "h", bg_color: "#2563eb" });
143
+ assert.equal(attrs.button.decoration.background.desktop.value.color, "#2563eb");
144
+ });
145
+ test("do-not-emit list: no attrs.font/spacing top-level, no renderAttrs, no button slot", () => {
146
+ const entry = emitButtonGroupPreset({
147
+ name: "clean",
148
+ bg_color: "#111",
149
+ font: { family: "Inter" },
150
+ }, registry);
151
+ assert.equal("font" in entry.attrs, false, "no top-level attrs.font");
152
+ assert.equal("spacing" in entry.attrs, false, "no top-level attrs.spacing");
153
+ assert.equal("renderAttrs" in entry, false, "no renderAttrs");
154
+ const decoration = entry.attrs.button.decoration;
155
+ assert.equal("button" in decoration, false, "no button.decoration.button slot by default");
156
+ });
157
+ test("bypass_hover_padding_gate emits ONLY the padding.top corner", () => {
158
+ const attrs = composeButtonAttrs({
159
+ name: "bypass",
160
+ bg_color: "#111",
161
+ bypass_hover_padding_gate: true,
162
+ });
163
+ const buttonSlot = attrs.button.decoration.button;
164
+ assert.deepEqual(buttonSlot, {
165
+ desktop: { value: { padding: { top: "0px" } } },
166
+ });
167
+ });
168
+ test("emitter rejects an empty preset (no styling specified)", () => {
169
+ assert.throws(() => emitButtonGroupPreset({ name: "empty" }, registry), /empty preset/);
170
+ });
171
+ test("emitter rejects a missing name", () => {
172
+ assert.throws(() => emitButtonGroupPreset({ name: "" }, registry), /requires a non-empty `name`/);
173
+ });
174
+ test("buildPresetCreateBody mirrors the diviops_preset_create body shape", () => {
175
+ const entry = emitButtonGroupPreset({ name: "Primary", bg_color: "#111" }, registry);
176
+ const body = buildPresetCreateBody(entry, { dry_run: true });
177
+ assert.deepEqual(body, {
178
+ module_name: "divi/button",
179
+ name: "Primary",
180
+ attrs: entry.attrs,
181
+ type: "group",
182
+ group_name: "divi/button",
183
+ group_id: "button",
184
+ dry_run: true,
185
+ });
186
+ const noDry = buildPresetCreateBody(entry);
187
+ assert.equal("dry_run" in noDry, false, "dry_run omitted when not requested");
188
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CLI integration coverage: --help, arg parsing, structured exit codes,
3
+ * dry-run output, evidence-gate exit, capability-missing exit.
4
+ *
5
+ * Apply-mode here is exercised through the credential-missing path only
6
+ * (no live write — #725 AC #8). The mocked write-path proper lives in
7
+ * write-path.test.ts.
8
+ */
9
+ export {};
@@ -0,0 +1,149 @@
1
+ /**
2
+ * CLI integration coverage: --help, arg parsing, structured exit codes,
3
+ * dry-run output, evidence-gate exit, capability-missing exit.
4
+ *
5
+ * Apply-mode here is exercised through the credential-missing path only
6
+ * (no live write — #725 AC #8). The mocked write-path proper lives in
7
+ * write-path.test.ts.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { run, parseArgs, buildButtonInput, EXIT, UsageError } from "../cli.js";
12
+ /** Capture CLI output without touching real stdio. */
13
+ function capture() {
14
+ const stdout = [];
15
+ const stderr = [];
16
+ return {
17
+ stdout,
18
+ stderr,
19
+ out: (t) => stdout.push(t),
20
+ err: (t) => stderr.push(t),
21
+ };
22
+ }
23
+ test("--help prints usage and exits 0", async () => {
24
+ const io = capture();
25
+ const code = await run(["--help"], io);
26
+ assert.equal(code, EXIT.OK);
27
+ assert.match(io.stdout.join("\n"), /diviops-preset/);
28
+ assert.match(io.stdout.join("\n"), /EXIT CODES/);
29
+ });
30
+ test("no args prints help and exits 0", async () => {
31
+ const io = capture();
32
+ const code = await run([], io);
33
+ assert.equal(code, EXIT.OK);
34
+ assert.match(io.stdout.join("\n"), /USAGE/);
35
+ });
36
+ test("unknown command exits 1 (invalid input)", async () => {
37
+ const io = capture();
38
+ const code = await run(["section", "--name", "X"], io);
39
+ assert.equal(code, EXIT.INVALID_INPUT);
40
+ assert.match(io.stderr.join("\n"), /Unknown command/);
41
+ });
42
+ test("unknown flag exits 1 (invalid input)", async () => {
43
+ const io = capture();
44
+ const code = await run(["button", "--name", "X", "--bogus", "y"], io);
45
+ assert.equal(code, EXIT.INVALID_INPUT);
46
+ assert.match(io.stderr.join("\n"), /Unknown flag/);
47
+ });
48
+ test("button without --name exits 1", async () => {
49
+ const io = capture();
50
+ const code = await run(["button", "--bg-color", "#111"], io);
51
+ assert.equal(code, EXIT.INVALID_INPUT);
52
+ assert.match(io.stderr.join("\n"), /requires --name/);
53
+ });
54
+ test("dry-run is the default and prints canonical JSON, exit 0", async () => {
55
+ const io = capture();
56
+ const code = await run(["button", "--name", "Primary", "--bg-color", "gcid-primary-color"], io);
57
+ assert.equal(code, EXIT.OK);
58
+ const parsed = JSON.parse(io.stdout.join("\n"));
59
+ assert.equal(parsed.type, "group");
60
+ assert.equal(parsed.dry_run, true);
61
+ assert.equal(parsed.module_name, "divi/button");
62
+ assert.equal(parsed.attrs.button.decoration.background.desktop.value.color, '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$');
63
+ });
64
+ test("explicit --dry-run behaves the same as the default", async () => {
65
+ const io = capture();
66
+ const code = await run(["button", "--name", "P", "--bg-color", "#111", "--dry-run"], io);
67
+ assert.equal(code, EXIT.OK);
68
+ assert.equal(JSON.parse(io.stdout.join("\n")).dry_run, true);
69
+ });
70
+ test("dry-run requires no credentials and no network", async () => {
71
+ // No WP_* env vars set in this assertion's expectation: a clean dry-run
72
+ // run must not throw a CredentialsMissingError.
73
+ const io = capture();
74
+ const code = await run(["button", "--name", "P", "--bg-color", "#111"], io);
75
+ assert.equal(code, EXIT.OK);
76
+ assert.equal(io.stderr.length, 0, "no error output on a credential-free dry-run");
77
+ });
78
+ test("--apply without credentials exits 1 with a credentials hint", async () => {
79
+ const saved = {
80
+ WP_URL: process.env.WP_URL,
81
+ WP_USER: process.env.WP_USER,
82
+ WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
83
+ };
84
+ delete process.env.WP_URL;
85
+ delete process.env.WP_USER;
86
+ delete process.env.WP_APP_PASSWORD;
87
+ try {
88
+ const io = capture();
89
+ const code = await run(["button", "--name", "P", "--bg-color", "#111", "--apply"], io);
90
+ assert.equal(code, EXIT.INVALID_INPUT);
91
+ assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
92
+ }
93
+ finally {
94
+ if (saved.WP_URL !== undefined)
95
+ process.env.WP_URL = saved.WP_URL;
96
+ if (saved.WP_USER !== undefined)
97
+ process.env.WP_USER = saved.WP_USER;
98
+ if (saved.WP_APP_PASSWORD !== undefined)
99
+ process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
100
+ }
101
+ });
102
+ test("--apply and --dry-run together exit 1 (mutually exclusive)", async () => {
103
+ const io = capture();
104
+ const code = await run(["button", "--name", "P", "--apply", "--dry-run"], io);
105
+ assert.equal(code, EXIT.INVALID_INPUT);
106
+ assert.match(io.stderr.join("\n"), /mutually exclusive/);
107
+ });
108
+ test("parseArgs: --radius shorthand and per-corner overrides", () => {
109
+ const parsed = parseArgs([
110
+ "button",
111
+ "--name",
112
+ "P",
113
+ "--radius",
114
+ "8px",
115
+ "--radius-top-left",
116
+ "12px",
117
+ ]);
118
+ const input = buildButtonInput(parsed);
119
+ assert.deepEqual(input.radius, {
120
+ topLeft: "12px",
121
+ topRight: "8px",
122
+ bottomLeft: "8px",
123
+ bottomRight: "8px",
124
+ });
125
+ });
126
+ test("parseArgs: --bypass-hover-padding-gate sets the opt-in flag", () => {
127
+ const parsed = parseArgs(["button", "--name", "P", "--bypass-hover-padding-gate"]);
128
+ const input = buildButtonInput(parsed);
129
+ assert.equal(input.bypass_hover_padding_gate, true);
130
+ });
131
+ test("parseArgs: value flag without a value throws UsageError", () => {
132
+ assert.throws(() => parseArgs(["button", "--name"]), (err) => {
133
+ assert.ok(err instanceof UsageError);
134
+ return true;
135
+ });
136
+ });
137
+ test("parseArgs: --radius-sync rejects values outside on|off", () => {
138
+ const parsed = parseArgs(["button", "--name", "P", "--radius-sync", "maybe"]);
139
+ assert.throws(() => buildButtonInput(parsed), /radius-sync must be/);
140
+ });
141
+ test("dry-run output includes the bypass corner when requested", async () => {
142
+ const io = capture();
143
+ const code = await run(["button", "--name", "P", "--bg-color", "#111", "--bypass-hover-padding-gate"], io);
144
+ assert.equal(code, EXIT.OK);
145
+ const parsed = JSON.parse(io.stdout.join("\n"));
146
+ assert.deepEqual(parsed.attrs.button.decoration.button, {
147
+ desktop: { value: { padding: { top: "0px" } } },
148
+ });
149
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * AC #9 — `diviops_preset_create` MCP tool behavior is unchanged.
3
+ *
4
+ * Track 4 adds a standalone CLI; it must NOT modify the existing MCP tool.
5
+ * This test pins two contracts:
6
+ * 1. The CLI posts to the SAME route the MCP tool uses (`/preset/create`)
7
+ * with a body whose keys are a subset of the MCP tool's body keys —
8
+ * i.e. the CLI is a peer consumer of the existing route, not a fork.
9
+ * 2. The `diviops_preset_create` registration block in `src/index.ts` is
10
+ * byte-identical to the committed baseline on `main` (a guard against
11
+ * an accidental edit landing in this PR).
12
+ */
13
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * AC #9 — `diviops_preset_create` MCP tool behavior is unchanged.
3
+ *
4
+ * Track 4 adds a standalone CLI; it must NOT modify the existing MCP tool.
5
+ * This test pins two contracts:
6
+ * 1. The CLI posts to the SAME route the MCP tool uses (`/preset/create`)
7
+ * with a body whose keys are a subset of the MCP tool's body keys —
8
+ * i.e. the CLI is a peer consumer of the existing route, not a fork.
9
+ * 2. The `diviops_preset_create` registration block in `src/index.ts` is
10
+ * byte-identical to the committed baseline on `main` (a guard against
11
+ * an accidental edit landing in this PR).
12
+ */
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { execFileSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, join } from "node:path";
19
+ import { PRESET_CREATE_ROUTE } from "../write-path.js";
20
+ import { buildPresetCreateBody, emitButtonGroupPreset } from "../button-emitter.js";
21
+ import { loadRegistry } from "../registry.js";
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const SERVER_ROOT = join(__dirname, "..", "..", "..");
24
+ test("CLI posts to the same /preset/create route as diviops_preset_create", () => {
25
+ assert.equal(PRESET_CREATE_ROUTE, "/preset/create");
26
+ });
27
+ test("CLI write body keys are a subset of the diviops_preset_create contract", () => {
28
+ // diviops_preset_create accepts: module_name, name, attrs, type,
29
+ // group_name, group_id, primary_attr_name, make_default, priority, dry_run.
30
+ const allowed = new Set([
31
+ "module_name",
32
+ "name",
33
+ "attrs",
34
+ "type",
35
+ "group_name",
36
+ "group_id",
37
+ "primary_attr_name",
38
+ "make_default",
39
+ "priority",
40
+ "dry_run",
41
+ ]);
42
+ const entry = emitButtonGroupPreset({ name: "P", bg_color: "#111" }, loadRegistry());
43
+ const body = buildPresetCreateBody(entry, { dry_run: true });
44
+ for (const key of Object.keys(body)) {
45
+ assert.ok(allowed.has(key), `CLI body key "${key}" is not part of the diviops_preset_create contract`);
46
+ }
47
+ });
48
+ test("diviops_preset_create registration in src/index.ts is unchanged vs main", () => {
49
+ // Diff src/index.ts against main; the preset_create handler region must
50
+ // not appear in the diff. If `main` is unavailable (detached CI checkout)
51
+ // the test is skipped rather than failing spuriously.
52
+ let diff = "";
53
+ try {
54
+ diff = execFileSync("git", ["diff", "main", "--", "src/index.ts"], { cwd: SERVER_ROOT, encoding: "utf8" });
55
+ }
56
+ catch {
57
+ // git/main not resolvable in this environment — fall back to a
58
+ // presence check that the tool is still registered.
59
+ const src = readFileSync(join(SERVER_ROOT, "src", "index.ts"), "utf8");
60
+ assert.match(src, /"diviops_preset_create"/);
61
+ return;
62
+ }
63
+ assert.equal(diff.includes("diviops_preset_create"), false, "src/index.ts diff vs main must not touch the diviops_preset_create tool");
64
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Registry-gating coverage: the min() rule, missing-applicability → 0,
3
+ * and threshold refusal with attr + level + source in the error.
4
+ */
5
+ export {};