mcp-magento-cms 1.0.4 → 1.2.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/README.md CHANGED
@@ -1,74 +1,73 @@
1
1
  # Magento CMS MCP (Cursor)
2
2
 
3
- Create/update Magento **CMS Pages / Blocks** with Page Builder **Row → Text** from Cursor chat.
3
+ Create/update Magento **CMS Pages / Blocks** with Page Builder from Cursor chat — including **validate/repair** for stage-safe HTML and **raw Page Builder HTML** saves for complex layouts.
4
4
 
5
- - **AI:** Cursor plans the content
6
- - **This server:** Magento REST via an Integration Access Token
7
- - **Install:** `npx` (no local clone, no `tools/` folder, no `.env` file required)
5
+ - **AI:** Cursor plans content and diagnostics
6
+ - **This server:** Magento REST via Integration Access Token
7
+ - **Local:** run from this repo path in `mcp.json` (point `MAGENTO_BASE_URL` at the target store)
8
+ - **Published:** `npx -y mcp-magento-cms` (npm)
8
9
 
9
10
  Requires **Node.js 18+** and [Cursor](https://cursor.com).
10
11
 
11
- Package: [npmjs.com/package/mcp-magento-cms](https://www.npmjs.com/package/mcp-magento-cms)
12
+ ## CMS workflow (mandatory for agents & teams)
12
13
 
13
- ## Tools
14
+ **Use this MCP as the only way to create or update Magento CMS pages/blocks.**
14
15
 
15
- | Tool | Purpose |
16
- |------|---------|
17
- | `magento_health` | Test base URL + token |
18
- | `get_cms_page` | Load by `page_id` |
19
- | `create_cms_page` | Create from title + content |
20
- | `update_cms_page` | Update by `page_id` |
21
- | `get_cms_block` / `create_cms_block` / `update_cms_block` | Blocks |
22
- | `list_section_types` | Supported layouts (MVP: `simple_text`) |
16
+ | Do | Do not |
17
+ |----|--------|
18
+ | `pagebuilder_rules` → `compose_pagebuilder` / `create_cms_page` / `set_pagebuilder_html` | Project PHP `tools/compose-*.php` / `deploy-*.php` |
19
+ | `validate_pagebuilder` / `repair_pagebuilder` before save | Direct DB edits to `cms_page` / `cms_block` content |
20
+ | Improve **this package** when something is missing or wrong | Bypass MCP with one-off scripts |
23
21
 
24
- ## Setup (any machine)
22
+ Preferred path: `sections[]` → `compose_pagebuilder` (or `create_cms_page` with `sections`) → validate → save. Use `set_pagebuilder_html` only for intentional raw master HTML. After save, verify on the storefront.
25
23
 
26
- You do **not** need to clone this repo or create a `.env` file. Cursor runs the package with `npx` and reads credentials from `mcp.json`.
24
+ Reload/restart the MCP server in Cursor after changing this package so teammates pick up new rules and tools.
27
25
 
28
- ### 1. Magento Integration Access Token
26
+ **REST field note (Magento 2.4.8):** `cmsPage` create/update use `active` (not `is_active`) and reject `store_id` / unknown fields. The client maps `is_active` → `active` so tool args stay familiar.
29
27
 
30
- No clone or `tools/` folder is needed. Create the token in Magento Admin:
28
+ **Store views:** Magento REST cannot set `cms_page_store` / `cms_block_store`. On create, this MCP assigns **All Store Views** (`store_id = 0`) by default via Magento PHP (`scripts/assign-stores.php` + `ddev exec` when `.ddev` exists). Pass `store_ids: [1, 2]` (etc.) only when the user asks for specific store view(s). Requires `MAGENTO_ROOT` (or infer from `MAGENTO_CMS_PROJECT_RULES` → project root).
31
29
 
32
- 1. **System → Extensions → Integrations → Add New Integration**
33
- 2. Name it (e.g. `MCP CMS`) and set your admin password when prompted
34
- 3. Open the **API** tab → set **Resource Access** to **Custom**
35
- 4. Under **Content → Elements**, select:
30
+ **Page layout:** On create, default `page_layout` is **`cms-full-width`** (admin label: Page — Full Width). Pass `page_layout` only when the user asks for a different layout (e.g. `1column`).
36
31
 
37
- | Select | Notes |
38
- |--------|--------|
39
- | **Pages** | Required (parent) |
40
- | → **Save Page** | Create / update pages |
41
- | → **Edit Page Design** | Page Builder / design fields |
42
- | → **Delete Page** | Optional (MCP does not delete today) |
43
- | **Blocks** | Required (create / update blocks) |
32
+ ## Shared rules (all projects — read first)
44
33
 
45
- Leave everything else unchecked (Staging, Widgets, Media Gallery, Amasty, Blog, Design Themes, etc.).
34
+ Returned by `pagebuilder_rules` for **every** store. This package stays free of store-specific hex / class catalogs; those go in optional project rules.
46
35
 
47
- 5. **Save → Activate** → copy the **Access Token** into `mcp.json` (step 3)
36
+ ### Page Builder (stage-safe)
48
37
 
49
- Magento **2.4.4+** blocks integration tokens as Bearer by default. Enable once (either way):
38
+ 1. Rows: use **Full Width** or **Contained**. **Never Full Bleed** unless the user explicitly asks.
39
+ 2. When edge-to-edge hero is intentional: `allow_full_bleed=true` on validate/set/repair.
40
+ 3. `full-width` rows **MUST** include `.row-full-width-inner[data-element=inner]`.
41
+ 4. `contained` rows **MUST** include `[data-element=inner]`.
42
+ 5. Converting `full-bleed` → `full-width` without adding the inner wrapper breaks admin Page Builder.
43
+ 6. Every `[data-pb-style=ID]` in `<style>` must match an element — **orphans crash** the stage.
44
+ 7. Every column needs `width:%` in the style block.
45
+ 8. Avoid raw `<svg>` inside Text — use **Image** (or theme CSS) for icons.
46
+ 9. Hero CTAs: Banner **Show Button=Always** + Link URL.
47
+ 10. Links: prefer `{{store url=path}}` with an unquoted path (avoid `&quot;` in href).
48
+ 11. Prefer native content types; CMS body = page content only (layout owns chrome).
50
49
 
51
- **Admin UI:** **Stores → Configuration → Services → OAuth → Consumer Settings** → set **Allow OAuth Access Tokens to be used as standalone Bearer tokens** to **Yes** → Save Config.
50
+ ### CMS media + theme conventions (all projects)
52
51
 
53
- **CLI:**
52
+ 12. **One flat media folder per CMS page** under `pub/media/wysiwyg/` — no `cards/`, `icons/`, `products/`, `related/` subfolders.
53
+ 13. **Folder name = CMS page URL key** (identifier), e.g. `wysiwyg/markets/market-industrial/hero.jpg`.
54
+ 14. After renaming media folders, update all `{{media url=…}}` in `cms_page` / `cms_block`, then flush cache.
55
+ 15. Sibling pages share **one** Magento_Cms LESS partial + shared classes — do not copy near-identical LESS into theme `_sources.less` per page.
56
+ 16. Page-only LESS → theme `Magento_Cms/web/css/source/` + `_module.less` import.
57
+ 17. Brand primary/secondary once in `_theme.less` (`@theme__color__primary` / `@theme__color__secondary`); use vars, not hex, in page LESS.
58
+ 18. **No module-level re-aliases** of theme vars; **no redundant `font-family`** when body already uses the theme base font.
59
+ 19. Scope page CSS with layout body class and/or content markers (`:has(.…)`).
54
60
 
55
- ```bash
56
- bin/magento config:set oauth/consumer/enable_integration_as_bearer 1
57
- bin/magento cache:flush config
58
- ```
59
-
60
- (With ddev: prefix CLI with `ddev exec`.)
61
+ Use tools `pagebuilder_rules`, `validate_pagebuilder`, and `repair_pagebuilder`.
61
62
 
62
- ### 2. Magento base URL
63
+ ## Per-store project rules (optional)
63
64
 
64
- Use your storefront base URL **without** a trailing slash, for example:
65
+ Keep the published MCP **general**. Put store-specific values (brand hex, Vendor/theme, URL keys, class prefixes) in the **Magento project**, not in this package.
65
66
 
66
- - Production: `https://www.example.com`
67
- - Local ddev: `https://your-project.ddev.site` (`ddev describe` shows the URL)
67
+ **Recommended (project-wise Cursor config):**
68
68
 
69
- ### 3. Add the MCP in Cursor
70
-
71
- **Cursor → Settings → MCP → Edit Config**, or create `.cursor/mcp.json` in your project:
69
+ 1. Commit `.cursor/magento-cms-project-rules.json` in the Magento repo.
70
+ 2. Add project MCP config at `.cursor/mcp.json` (Customize → MCPs → workspace) with `MAGENTO_ROOT` only — rules auto-load from `${MAGENTO_ROOT}/.cursor/magento-cms-project-rules.json`.
72
71
 
73
72
  ```json
74
73
  {
@@ -77,31 +76,109 @@ Use your storefront base URL **without** a trailing slash, for example:
77
76
  "command": "npx",
78
77
  "args": ["-y", "mcp-magento-cms"],
79
78
  "env": {
80
- "MAGENTO_BASE_URL": "https://www.example.com",
81
- "MAGENTO_ACCESS_TOKEN": "YOUR_INTEGRATION_ACCESS_TOKEN"
79
+ "MAGENTO_BASE_URL": "https://your-store.example/",
80
+ "MAGENTO_ACCESS_TOKEN": "${env:MAGENTO_ACCESS_TOKEN}",
81
+ "MAGENTO_ROOT": "${workspaceFolder}"
82
82
  }
83
83
  }
84
84
  }
85
85
  }
86
86
  ```
87
87
 
88
- **Local ddev / self-signed HTTPS** — Node often rejects the local TLS cert and MCP calls fail with `fetch failed`. Add:
88
+ Optional override: `MAGENTO_CMS_PROJECT_RULES=/path/to/file.json` if the rules file is not under `.cursor/`.
89
+
90
+ `pagebuilder_rules` appends project rules after shared rules. JSON shape:
89
91
 
90
92
  ```json
91
- "NODE_TLS_REJECT_UNAUTHORIZED": "0"
93
+ {
94
+ "rules": ["store-only convention"],
95
+ "detail": { "brand_tokens": "optional longer notes" }
96
+ }
92
97
  ```
93
98
 
94
- Example for a ddev store:
99
+ Markdown/text files are also supported (one rule per non-empty, non-`#` line). Projects with no rules file still get all shared conventions.
100
+
101
+
102
+ ## Compose with sections[]
103
+
104
+ Prefer structured trees over project-specific PHP. Theme CSS class names go in `css_classes` (project owns naming).
105
+
106
+ ```js
107
+ // Tool: compose_pagebuilder OR create_cms_page({ title, sections })
108
+ {
109
+ "sections": [
110
+ {
111
+ "type": "row",
112
+ "appearance": "full-bleed",
113
+ "children": [{
114
+ "type": "banner",
115
+ "background_image": "{{media url=wysiwyg/hero.jpg}}",
116
+ "content_html": "<h1>Title</h1>",
117
+ "show_button": "always",
118
+ "button_text": "Contact",
119
+ "link_url": "#contact"
120
+ }]
121
+ },
122
+ {
123
+ "type": "row",
124
+ "appearance": "contained",
125
+ "children": [
126
+ { "type": "heading", "text": "Section", "heading_type": "h2" },
127
+ { "type": "text", "html": "<p>Body</p>" },
128
+ {
129
+ "type": "columns",
130
+ "columns": [
131
+ { "children": [
132
+ { "type": "image", "src": "{{media url=wysiwyg/a.jpg}}", "alt": "A" },
133
+ { "type": "text", "html": "<p>Card</p>" }
134
+ ]}
135
+ ]
136
+ },
137
+ { "type": "buttons", "items": [{ "text": "Shop", "url": "{{store url=shop}}" }] },
138
+ { "type": "divider" },
139
+ { "type": "products", "skus": ["SKU1", "SKU2"] },
140
+ { "type": "block", "identifier": "my-cms-block" }
141
+ ]
142
+ }
143
+ ]
144
+ }
145
+ ```
146
+
147
+ Native types: `row`, `columns` / `column-group` / `column`, `text`, `heading`, `image`, `buttons`, `banner`, `html`, `divider`, `video`, `map`, `tabs`, `slider`, `products`, `block`.
148
+
149
+ Call `list_section_types` for the full parameter catalog. For intentional edge-to-edge heroes use `appearance: "full-bleed"` and `allow_full_bleed: true` on save.
150
+
151
+ ## Tools
152
+
153
+ | Tool | Purpose |
154
+ |------|---------|
155
+ | `magento_health` | Test base URL + token |
156
+ | `pagebuilder_rules` | Return Full Width / inner / orphan-style rules |
157
+ | `list_section_types` | Native element catalog + composition patterns |
158
+ | `compose_pagebuilder` | Build master-format HTML from `sections[]` (all PB types); optional save |
159
+ | `search_cms` | Find pages/blocks by identifier |
160
+ | `get_cms_page` / `get_cms_block` | Load entity (`include_content`, `validate`) |
161
+ | `validate_pagebuilder` | Stage-safety checks (html / page_id / block_id) |
162
+ | `repair_pagebuilder` | Fix orphans, full-bleed, inners, widths, SVG (`apply=true` to save) |
163
+ | `set_pagebuilder_html` | Save **raw** PB HTML without wiping to simple_text |
164
+ | `create_cms_page` / `update_cms_page` | Simple Row→Text composer |
165
+ | `create_cms_block` / `update_cms_block` | Simple Row→Text composer |
166
+
167
+ **Important:** `update_cms_page` / `update_cms_block` with `content` rebuild as **simple_text** only. For complex pages use `set_pagebuilder_html` or `repair_pagebuilder` with `apply=true`.
168
+
169
+ ## Local Cursor config (this machine)
95
170
 
96
171
  ```json
97
172
  {
98
173
  "mcpServers": {
99
174
  "magento-cms": {
100
- "command": "npx",
101
- "args": ["-y", "mcp-magento-cms"],
175
+ "command": "node",
176
+ "args": [
177
+ "/home/ajaykumar.prajapati@brainvire.com/htdocs/mcp-magento-cms/src/index.js"
178
+ ],
102
179
  "env": {
103
- "MAGENTO_BASE_URL": "https://your-project.ddev.site",
104
- "MAGENTO_ACCESS_TOKEN": "YOUR_INTEGRATION_ACCESS_TOKEN",
180
+ "MAGENTO_BASE_URL": "https://your-store.ddev.site",
181
+ "MAGENTO_ACCESS_TOKEN": "YOUR_TOKEN",
105
182
  "NODE_TLS_REJECT_UNAUTHORIZED": "0"
106
183
  }
107
184
  }
@@ -109,57 +186,50 @@ Example for a ddev store:
109
186
  }
110
187
  ```
111
188
 
112
- Use this **only for local/dev**. Prefer a trusted public HTTPS cert in production (omit this variable).
113
-
114
- Optional alternative (if mkcert CA is installed): set `NODE_EXTRA_CA_CERTS` to your mkcert root CA path instead (e.g. `$HOME/.local/share/mkcert/rootCA.pem`).
189
+ After changing this package, **reload MCP** in Cursor (Settings → MCP → refresh / restart).
115
190
 
116
- Optional env vars:
191
+ ## Setup (published npx)
117
192
 
118
- | Variable | Default | Purpose |
119
- |----------|---------|---------|
120
- | `MAGENTO_STORE_CODE` | (empty) | REST store code |
121
- | `MAGENTO_ADMIN_PATH` | `admin` | Admin path used in edit links |
122
- | `NODE_TLS_REJECT_UNAUTHORIZED` | (unset) | Set `"0"` for local ddev / self-signed SSL |
123
-
124
- ### 4. Restart MCP
125
-
126
- In Cursor MCP settings, refresh/restart the `magento-cms` server until it shows connected.
193
+ ### 1. Magento Integration Access Token
127
194
 
128
- ### 5. Verify
195
+ 1. **System → Extensions → Integrations → Add New Integration**
196
+ 2. API → Custom → Content → Pages (Save/Edit Design) + Blocks
197
+ 3. Activate → copy Access Token
129
198
 
130
- In Cursor chat:
199
+ Enable Bearer tokens (2.4.4+):
131
200
 
132
- ```text
133
- Use magento_health to verify Magento.
201
+ ```bash
202
+ bin/magento config:set oauth/consumer/enable_integration_as_bearer 1
203
+ bin/magento cache:flush config
134
204
  ```
135
205
 
136
- Then try:
137
-
138
- ```text
139
- Create a CMS page titled "MCP Demo" with this content:
140
- First paragraph.
141
- Second paragraph.
142
- ```
206
+ ### 2. mcp.json with npx
143
207
 
144
- ```text
145
- Update CMS page_id 8 with content: Updated body for page 8.
208
+ ```json
209
+ {
210
+ "mcpServers": {
211
+ "magento-cms": {
212
+ "command": "npx",
213
+ "args": ["-y", "mcp-magento-cms"],
214
+ "env": {
215
+ "MAGENTO_BASE_URL": "https://www.example.com",
216
+ "MAGENTO_ACCESS_TOKEN": "YOUR_INTEGRATION_ACCESS_TOKEN"
217
+ }
218
+ }
219
+ }
220
+ }
146
221
  ```
147
222
 
148
- ## Security
149
-
150
- - Put tokens only in `mcp.json` / Cursor env (do not commit them)
151
- - Give the Magento integration **CMS-only** permissions
152
-
153
- ## Requirements
223
+ Local ddev TLS: add `"NODE_TLS_REJECT_UNAUTHORIZED": "0"`.
154
224
 
155
- - Node.js **18+** on the machine running Cursor
156
- - Magento 2 with REST and an Integration token
157
- - Network access from that machine to `MAGENTO_BASE_URL`
158
-
159
- ## For package maintainers
160
-
161
- Clone this repo to develop or republish. Bump `version` in `package.json`, then:
225
+ ## Scripts
162
226
 
163
227
  ```bash
164
- npm publish --access public
228
+ npm run test:compose
229
+ npm run test:validate
230
+ npm run health
165
231
  ```
232
+
233
+ ## Version
234
+
235
+ `1.2.0` — compose all native Page Builder content types via `sections[]` + validate/repair/raw HTML.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-magento-cms",
3
- "version": "1.0.4",
4
- "description": "Cursor MCP: Magento CMS pages/blocks with Page Builder Row→Text (zero npm deps)",
3
+ "version": "1.2.0",
4
+ "description": "Cursor MCP: Magento CMS pages/blocks + Page Builder compose/validate/repair for all native content types (zero npm deps)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -14,7 +14,8 @@
14
14
  "scripts": {
15
15
  "start": "node src/index.js",
16
16
  "health": "node scripts/health-check.js",
17
- "test:compose": "node scripts/test-compose.js"
17
+ "test:compose": "node scripts/test-compose.js",
18
+ "test:validate": "node scripts/test-validate-repair.js"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18"
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Assign CMS page/block to Magento store view(s).
3
+ *
4
+ * Magento 2.4.8 REST cmsPage/cmsBlock reject store_id on create/update, so we
5
+ * assign via Magento bootstrap PHP when MAGENTO_ROOT is set (or inferred from
6
+ * MAGENTO_CMS_PROJECT_RULES → project/.cursor/… → project root).
7
+ *
8
+ * Default: [0] = All Store Views.
9
+ * Override: pass store_ids from the tool args when the user names specific stores.
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ export function resolveMagentoRoot() {
20
+ const fromEnv = String(process.env.MAGENTO_ROOT || '').trim();
21
+ if (fromEnv && fs.existsSync(path.join(fromEnv, 'app/bootstrap.php'))) {
22
+ return path.resolve(fromEnv);
23
+ }
24
+ const rules = String(process.env.MAGENTO_CMS_PROJECT_RULES || '').trim();
25
+ if (rules) {
26
+ const candidate = path.resolve(path.dirname(rules), '..');
27
+ if (fs.existsSync(path.join(candidate, 'app/bootstrap.php'))) {
28
+ return candidate;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * @param {object} args
36
+ * @returns {number[]}
37
+ */
38
+ export function resolveStoreIds(args = {}) {
39
+ let ids = [];
40
+ if (Array.isArray(args.store_ids) && args.store_ids.length) {
41
+ ids = args.store_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n));
42
+ } else if (typeof args.store_ids === 'string' && args.store_ids.trim()) {
43
+ ids = args.store_ids
44
+ .split(',')
45
+ .map((s) => Number(s.trim()))
46
+ .filter((n) => Number.isFinite(n));
47
+ } else if (Array.isArray(args.store_codes) && args.store_codes.length) {
48
+ const codes = args.store_codes.map((c) => String(c).toLowerCase());
49
+ if (codes.includes('all') || codes.includes('*')) {
50
+ return [0];
51
+ }
52
+ // Numeric codes still accepted via store_ids; unknown codes → default all
53
+ return [0];
54
+ }
55
+
56
+ if (!ids.length) {
57
+ return [0];
58
+ }
59
+ if (ids.includes(0)) {
60
+ return [0];
61
+ }
62
+ return [...new Set(ids)];
63
+ }
64
+
65
+ /**
66
+ * @param {'page'|'block'} type
67
+ * @param {number} id
68
+ * @param {number[]} storeIds
69
+ */
70
+ export function assignCmsStores(type, id, storeIds = [0]) {
71
+ const root = resolveMagentoRoot();
72
+ if (!root) {
73
+ return {
74
+ ok: false,
75
+ skipped: true,
76
+ error:
77
+ 'Store assignment skipped: set MAGENTO_ROOT in mcp.json (path to Magento) so pages default to All Store Views (store_id 0).',
78
+ };
79
+ }
80
+
81
+ const srcScript = path.join(__dirname, '..', 'scripts', 'assign-stores.php');
82
+ if (!fs.existsSync(srcScript)) {
83
+ return { ok: false, error: `Missing ${srcScript}` };
84
+ }
85
+
86
+ const stores = (storeIds?.length ? storeIds : [0]).join(',');
87
+ const useDdev =
88
+ ['1', 'true', 'yes'].includes(
89
+ String(process.env.MAGENTO_USE_DDEV || '').toLowerCase()
90
+ ) || fs.existsSync(path.join(root, '.ddev'));
91
+
92
+ // Copy script into Magento var/ so ddev exec can run it inside the project mount
93
+ const destDir = path.join(root, 'var');
94
+ const destScript = path.join(destDir, 'mcp-assign-stores.php');
95
+ try {
96
+ fs.mkdirSync(destDir, { recursive: true });
97
+ fs.copyFileSync(srcScript, destScript);
98
+ } catch (e) {
99
+ return { ok: false, error: `Cannot stage assign script: ${e.message}` };
100
+ }
101
+
102
+ const args = [
103
+ 'var/mcp-assign-stores.php',
104
+ `--type=${type}`,
105
+ `--id=${id}`,
106
+ `--stores=${stores}`,
107
+ ];
108
+
109
+ let result;
110
+ if (useDdev) {
111
+ result = spawnSync('ddev', ['exec', 'php', ...args], {
112
+ cwd: root,
113
+ encoding: 'utf8',
114
+ timeout: 120000,
115
+ });
116
+ } else {
117
+ const phpBin = String(process.env.MAGENTO_PHP || 'php').trim() || 'php';
118
+ result = spawnSync(phpBin, [destScript, `--root=${root}`, ...args.slice(1)], {
119
+ encoding: 'utf8',
120
+ timeout: 120000,
121
+ env: process.env,
122
+ });
123
+ }
124
+
125
+ if (result.error) {
126
+ return { ok: false, error: result.error.message };
127
+ }
128
+ const out = String(result.stdout || '').trim();
129
+ const err = String(result.stderr || '').trim();
130
+ if (result.status !== 0) {
131
+ return { ok: false, error: err || out || `exit ${result.status}` };
132
+ }
133
+ try {
134
+ const jsonLine = out
135
+ .split('\n')
136
+ .map((l) => l.trim())
137
+ .filter(Boolean)
138
+ .pop();
139
+ const parsed = JSON.parse(jsonLine);
140
+ return {
141
+ ok: !!parsed.ok,
142
+ assigned_stores: parsed.assigned_stores,
143
+ requested_stores: parsed.requested_stores,
144
+ magento_root: root,
145
+ via: useDdev ? 'ddev' : 'php',
146
+ };
147
+ } catch {
148
+ return { ok: true, assigned_stores: storeIds, raw: out, magento_root: root };
149
+ }
150
+ }