@merchantduo/code 0.3.0-beta.0 → 0.3.0-beta.2

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.
Files changed (42) hide show
  1. package/README.md +5 -5
  2. package/dist/app/system-prompt.d.ts +2 -1
  3. package/dist/app/system-prompt.js +10 -4
  4. package/dist/cli/arguments.d.ts +0 -3
  5. package/dist/cli/arguments.js +5 -13
  6. package/dist/cli/commands/agent.d.ts +7 -0
  7. package/dist/cli/commands/agent.js +46 -1
  8. package/dist/cli/commands/init.d.ts +14 -1
  9. package/dist/cli/commands/init.js +36 -2
  10. package/dist/cli/commands/provision.d.ts +10 -5
  11. package/dist/cli/commands/provision.js +22 -33
  12. package/dist/cli/main.js +1 -1
  13. package/dist/config/index.d.ts +2 -1
  14. package/dist/config/index.js +2 -1
  15. package/dist/config/loader.js +1 -5
  16. package/dist/config/operator-profile.d.ts +5 -0
  17. package/dist/config/operator-profile.js +28 -0
  18. package/dist/config/schema.d.ts +2 -0
  19. package/dist/config/schema.js +6 -0
  20. package/dist/environments/adapters/warden.d.ts +7 -1
  21. package/dist/environments/adapters/warden.js +24 -3
  22. package/dist/integrations/pi/context.js +3 -1
  23. package/dist/integrations/pi/permissions.d.ts +1 -0
  24. package/dist/integrations/pi/permissions.js +10 -3
  25. package/dist/integrations/pi/workspace.d.ts +1 -0
  26. package/dist/integrations/pi/workspace.js +36 -6
  27. package/dist/workflows/post-edit.d.ts +7 -0
  28. package/dist/workflows/post-edit.js +51 -0
  29. package/package.json +1 -2
  30. package/skills/magento-2.4/SKILL.md +241 -2
  31. package/dist/provision/advisor.d.ts +0 -12
  32. package/dist/provision/advisor.js +0 -28
  33. package/dist/provision/discovery.d.ts +0 -12
  34. package/dist/provision/discovery.js +0 -61
  35. package/dist/provision/interview.d.ts +0 -9
  36. package/dist/provision/interview.js +0 -29
  37. package/dist/provision/model.d.ts +0 -82
  38. package/dist/provision/model.js +0 -5
  39. package/dist/provision/planner.d.ts +0 -4
  40. package/dist/provision/planner.js +0 -51
  41. package/dist/provision/runner.d.ts +0 -35
  42. package/dist/provision/runner.js +0 -98
@@ -16,6 +16,7 @@ type SearchParameters = {
16
16
  */
17
17
  export declare function searchCommand(params: SearchParameters, path: string): string;
18
18
  export declare function workspacePath(backend: EnvironmentBackend, cwd: string, candidate: string): string;
19
+ export declare function wardenHostPathWarning(backend: EnvironmentBackend, cwd: string, candidate: string): string | undefined;
19
20
  /** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
20
21
  export declare function packagedSkillPath(candidate: string): string | undefined;
21
22
  export {};
@@ -1,9 +1,10 @@
1
1
  import { access as hostAccess, readFile as hostReadFile } from "node:fs/promises";
2
- import { relative, resolve } from "node:path";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "@earendil-works/pi-coding-agent";
5
5
  import { runtime } from "#integrations/pi/session";
6
6
  import { packagePath } from "#shared/package-paths";
7
+ import { postEditHints, validateMagentoXml } from "#workflows/post-edit";
7
8
  const shell = (value) => `'${value.replace(/'/g, "'\\''")}'`;
8
9
  const result = (value) => ({ content: [{ type: "text", text: value }], details: {} });
9
10
  const packageSkillRoots = [
@@ -34,9 +35,19 @@ function registerFileTools(pi) {
34
35
  const localRead = createReadToolDefinition(process.cwd());
35
36
  const localWrite = createWriteToolDefinition(process.cwd());
36
37
  const localEdit = createEditToolDefinition(process.cwd());
37
- pi.registerTool({ ...localRead, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createReadToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
38
- pi.registerTool({ ...localWrite, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createWriteToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
39
- pi.registerTool({ ...localEdit, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createEditToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
38
+ pi.registerTool({ ...localRead, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createReadToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendNotice(response, wardenHostPathWarning(state.backend, ctx.cwd, params.path)); } });
39
+ pi.registerTool({ ...localWrite, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createWriteToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendPostEdit(response, state.backend, ctx.cwd, params.path, signal); } });
40
+ pi.registerTool({ ...localEdit, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createEditToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendPostEdit(response, state.backend, ctx.cwd, params.path, signal); } });
41
+ }
42
+ async function appendPostEdit(response, backend, cwd, path, signal) {
43
+ const target = workspacePath(backend, cwd, path);
44
+ const validation = await validateMagentoXml(backend, target, signal);
45
+ return appendNotice(response, [wardenHostPathWarning(backend, cwd, path), ...postEditHints(path), validation].filter((value) => Boolean(value)).join("\n"));
46
+ }
47
+ function appendNotice(response, notice) {
48
+ if (!notice)
49
+ return response;
50
+ return { ...response, content: [...response.content, { type: "text", text: `MerchantDuo: ${notice}` }] };
40
51
  }
41
52
  function fileOps(backend, cwd) {
42
53
  const readFile = async (path) => {
@@ -58,8 +69,27 @@ function fileOps(backend, cwd) {
58
69
  throw new Error("Path is inaccessible"); }, mkdir: async (path) => { const response = await backend.run(["mkdir", "-p", workspacePath(backend, cwd, path)]); if (response.exitCode)
59
70
  throw new Error(response.stderr || "Cannot create directory"); } };
60
71
  }
61
- export function workspacePath(backend, cwd, candidate) { const full = resolve(cwd, candidate); const value = relative(resolve(cwd), full); if (value === ".." || value.startsWith("../"))
62
- throw new Error(`Path escapes workspace: ${candidate}`); return backend.path(value); }
72
+ export function workspacePath(backend, cwd, candidate) {
73
+ if (backend.environment.type === "warden" && isAbsolute(candidate)) {
74
+ const containerPath = resolve(candidate);
75
+ if (contains(resolve(backend.environment.root), containerPath))
76
+ return containerPath;
77
+ }
78
+ const full = resolve(cwd, candidate);
79
+ const value = relative(resolve(cwd), full);
80
+ if (value === ".." || value.startsWith("../"))
81
+ throw new Error(`Path escapes workspace: ${candidate}`);
82
+ return backend.path(value);
83
+ }
84
+ export function wardenHostPathWarning(backend, cwd, candidate) {
85
+ if (backend.environment.type !== "warden" || !isAbsolute(candidate))
86
+ return undefined;
87
+ const hostPath = resolve(candidate);
88
+ if (!contains(resolve(cwd), hostPath))
89
+ return undefined;
90
+ const value = relative(resolve(cwd), hostPath);
91
+ return `Warden path warning: ${candidate} was mapped to ${backend.path(value)}. This session runs inside Warden; use the container path next time.`;
92
+ }
63
93
  /** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
64
94
  export function packagedSkillPath(candidate) {
65
95
  const full = resolve(candidate);
@@ -0,0 +1,7 @@
1
+ import type { EnvironmentBackend } from "#environments/backend";
2
+ /** Keep immediate post-edit guidance narrow; operational work remains an explicit request. */
3
+ export declare function postEditHints(path: string): string[];
4
+ export declare function isMagentoXml(path: string): boolean;
5
+ /** Validate changed Magento XML in its own selected environment using Magento's URN resolver. */
6
+ export declare function validateMagentoXml(backend: EnvironmentBackend, path: string, signal?: AbortSignal): Promise<string>;
7
+ export declare function xmlValidationCommand(path: string): string[];
@@ -0,0 +1,51 @@
1
+ const xsdValidator = String.raw `
2
+ $file = $argv[1] ?? '';
3
+ libxml_use_internal_errors(true);
4
+ $dom = new DOMDocument();
5
+ if (!$dom->load($file)) {
6
+ foreach (libxml_get_errors() as $error) fwrite(STDERR, trim($error->message) . " at line " . $error->line . "\n");
7
+ exit(2);
8
+ }
9
+ $root = $dom->documentElement;
10
+ $schema = $root ? $root->getAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'noNamespaceSchemaLocation') : '';
11
+ if (!$schema || strpos($schema, 'urn:magento:') !== 0) {
12
+ fwrite(STDERR, "Magento XML must declare xsi:noNamespaceSchemaLocation with a urn:magento schema.\n");
13
+ exit(2);
14
+ }
15
+ require getcwd() . '/app/bootstrap.php';
16
+ $errors = Magento\Framework\Config\Dom::validateDomDocument($dom, $schema);
17
+ if ($errors) {
18
+ foreach ($errors as $error) fwrite(STDERR, (string)$error . "\n");
19
+ exit(2);
20
+ }
21
+ echo "Magento XML XSD validation passed.\n";
22
+ `;
23
+ /** Keep immediate post-edit guidance narrow; operational work remains an explicit request. */
24
+ export function postEditHints(path) {
25
+ const value = path.toLowerCase();
26
+ const hints = ["Inspect the changed file and its closest Magento precedent before continuing."];
27
+ if (value.endsWith(".php") || value.endsWith(".phtml"))
28
+ hints.push("Use magento_workflow action=syntax-check when PHP syntax verification is relevant.");
29
+ if (value.endsWith(".xml"))
30
+ hints.push("Review the XSD result below; preview a cache clean only when this configuration change requires it.");
31
+ if (value.endsWith("etc/module.xml") || value.endsWith("db_schema.xml") || value.endsWith("composer.json"))
32
+ hints.push("This may require setup:upgrade; explain the evidence and use magento_workflow only after the user directly requests execution.");
33
+ return hints;
34
+ }
35
+ export function isMagentoXml(path) {
36
+ return path.toLowerCase().endsWith(".xml")
37
+ && /(?:^|\/)(?:app\/(?:code|design)|vendor)\//.test(path.replace(/\\/g, "/"));
38
+ }
39
+ /** Validate changed Magento XML in its own selected environment using Magento's URN resolver. */
40
+ export async function validateMagentoXml(backend, path, signal) {
41
+ if (!isMagentoXml(path))
42
+ return "";
43
+ const output = await backend.run(xmlValidationCommand(path), { signal });
44
+ const detail = `${output.stdout}${output.stderr}`.trim();
45
+ return output.exitCode === 0
46
+ ? detail || "Magento XML XSD validation passed."
47
+ : `Warning: Magento XML XSD validation failed for ${path}: ${detail || "validation could not run."}`;
48
+ }
49
+ export function xmlValidationCommand(path) {
50
+ return ["php", "-r", xsdValidator, path];
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merchantduo/code",
3
- "version": "0.3.0-beta.0",
3
+ "version": "0.3.0-beta.2",
4
4
  "private": false,
5
5
  "description": "Magento-native Pi-based Coding Agent",
6
6
  "type": "module",
@@ -12,7 +12,6 @@
12
12
  "#features/*": "./dist/features/*.js",
13
13
  "#integrations/*": "./dist/integrations/*.js",
14
14
  "#magento/*": "./dist/magento/*.js",
15
- "#provision/*": "./dist/provision/*.js",
16
15
  "#shared/*": "./dist/shared/*.js",
17
16
  "#skills/*": "./dist/skills/*.js",
18
17
  "#testing/*": "./dist/testing/*.js",
@@ -1,8 +1,247 @@
1
1
  ---
2
- name: magento-24x
3
- description: Magento Open Source and Adobe Commerce 2.4 implementation guidance.
2
+ name: magento-extension-best-practices
3
+ description: Choose and review Magento Open Source / Adobe Commerce 2.4.x extension architecture. Load for non-trivial work involving DI, plugins, preferences, observers, service contracts, persistence, schema/XML, caching, indexers, queues, Admin UI, Luma, Hyvä, REST, GraphQL, security, or testing.
4
4
  ---
5
5
 
6
+ # Magento Extension Best Practices
7
+
8
+ ## Extension Decision Order
9
+
10
+ When changing existing behavior, prefer the first mechanism that fully solves the requirement:
11
+
12
+ 1. XML or declarative configuration.
13
+ 2. Public `@api` contract or documented extension point.
14
+ 3. Existing strategy, pool, composite, provider, resolver, or other composition mechanism.
15
+ 4. Semantic event + observer for an independent reaction.
16
+ 5. `before` / `after` plugin for a narrow public-method change.
17
+ 6. `around` plugin only when execution must be wrapped, skipped, or replaced.
18
+ 7. Preference/inheritance for deliberate implementation replacement or non-interceptable behavior.
19
+ 8. Undocumented internals only as an isolated, upgrade-sensitive last resort.
20
+
21
+ ## Module Boundaries
22
+
23
+ - Prefer public `@api` contracts across modules.
24
+ - Isolate unavoidable concrete non-`@api` dependencies behind your own adapter/service and regression coverage.
25
+ - Put package dependencies in `composer.json`.
26
+ - Use `<sequence>` only when Magento config/setup/view load order matters.
27
+ - Create interfaces for public contracts, substitution boundaries, multiple implementations, or deliberately stable module boundaries; not for every service.
28
+
29
+ ## DI and Object Creation
30
+
31
+ - **DI:** normal dependency known at wiring time.
32
+ - **Factory:** runtime/transient/entity instance creation.
33
+ - **Proxy:** lazy-load an expensive dependency that is often unused.
34
+ - **Virtual type:** same class with different constructor arguments in a specific injection context.
35
+ - **Preference:** deliberate implementation binding/replacement.
36
+
37
+ Do not use factories to hide ordinary dependencies or proxies as generic performance decoration.
38
+
39
+ ## Plugins, Preferences, Events
40
+
41
+ ### Plugins
42
+
43
+ - `before`: small argument changes.
44
+ - `after`: result changes.
45
+ - `around`: only when wrapping/skipping/replacing execution is required.
46
+ - If `before` or `after` works, do not use `around`.
47
+ - Keep plugins small and stateless.
48
+ - Delegate business logic to services.
49
+ - Do not plugin your own module when direct composition/refactoring is available.
50
+ - Interception is for supported public methods; final/non-public/static methods and constructors are not normal plugin targets.
51
+
52
+ ### Preferences / Inheritance
53
+
54
+ Preferences are valid for deliberate implementation binding or full replacement.
55
+
56
+ Avoid a preference merely to change one public method. If replacement is necessary:
57
+
58
+ - override the minimum surface;
59
+ - do not copy whole core/vendor classes;
60
+ - isolate dependency on internals;
61
+ - add upgrade regression coverage.
62
+
63
+ ### Events
64
+
65
+ Use observers for existing semantic events and independent side effects. Use plugins when changing a method's arguments, result, or execution.
66
+
67
+ Keep observers small and delegate work. Prefer area-specific `events.xml` when applicable. Do not rely on event-payload mutation as a hidden behavior override.
68
+
69
+ ## Data Access
70
+
71
+ Repositories are module/API boundaries, not a universal internal persistence rule.
72
+
73
+ Use repositories/service contracts for public or cross-module entity access, standard CRUD/list contracts, `SearchCriteria`, API data interfaces, and extension attributes.
74
+
75
+ Internal code may use:
76
+
77
+ - collections for filtering/list/batch reads;
78
+ - resource models for persistence;
79
+ - dedicated query services for joins, reports, projections, or aggregates;
80
+ - `ResourceConnection` for deliberate bulk/index/import work.
81
+
82
+ Avoid new Active Record-style `$model->load()` / `$model->save()` usage.
83
+
84
+ ### Direct SQL
85
+
86
+ Direct SQL is acceptable in resource/query layers when Magento entity lifecycle is intentionally unnecessary.
87
+
88
+ Use Magento DB adapters, resolved table names, and parameter bindings. Do not bypass required validation, business invariants, cache invalidation, events, or indexer/MView behavior.
89
+
90
+ ### Performance and Concurrency
91
+
92
+ - Avoid repository/entity loads in large loops.
93
+ - Prevent N+1 extension-attribute and GraphQL loading.
94
+ - Prefer batch reads, joins, collections, or query services.
95
+ - Keep DB transactions small; avoid network calls inside long transactions.
96
+ - Use DB constraints, atomic updates, locks, or retries for concurrency-critical invariants.
97
+ - Make retryable queue/cron/bulk handlers idempotent where duplicate delivery or overlap is possible.
98
+
99
+ ## Declarative Configuration
100
+
101
+ Before runtime PHP, check for an existing Magento mechanism:
102
+
103
+ `di.xml`, `events.xml`, `routes.xml`, `webapi.xml`, `acl.xml`, `system.xml`, `config.xml`, `menu.xml`, `email_templates.xml`, `cron.xml`, `communication.xml`, `queue_*`, `indexer.xml`, `mview.xml`, `extension_attributes.xml`, layout XML, UI component XML, `db_schema.xml`.
104
+
105
+ Respect global vs area-scoped configuration. Do not assume every XML type merges identically.
106
+
107
+ ## Schema Changes
108
+
109
+ For new Magento 2.4.x modules:
110
+
111
+ - prefer `db_schema.xml` for schema;
112
+ - use data patches for one-time data changes;
113
+ - use schema patches only when imperative schema work is genuinely needed;
114
+ - treat `InstallSchema`, `UpgradeSchema`, `InstallData`, and `UpgradeData` as legacy patterns.
115
+
116
+ For large production tables, review locking, backfills, deployment safety, indexes, and foreign-key behavior.
117
+
118
+ ## Cache and Indexers
119
+
120
+ Treat FPC as an architectural constraint.
121
+
122
+ - Avoid broad `cacheable="false"`.
123
+ - Keep customer-specific data out of public FPC output; use the proper private-content mechanism.
124
+ - Return correct cache identities/tags.
125
+ - Never global `cache:flush` after normal writes.
126
+ - Invalidate only relevant cache data or rely on Magento lifecycle invalidation.
127
+ - Include store/customer/website/authorization dimensions in cache variation when required.
128
+
129
+ Code must work with indexers in both **Update on Save** and **Update by Schedule** modes.
130
+
131
+ Do not full-reindex after every write. With direct SQL, verify index invalidation and MView changelog behavior.
132
+
133
+ ## Async Work
134
+
135
+ Use message queues for slow, high-volume, retryable, or integration-heavy work.
136
+
137
+ Use cron mainly for scheduling/discovery; use consumers for scalable units of work. Prefer Magento queue configuration over bespoke polling infrastructure.
138
+
139
+ ## Frontend
140
+
141
+ ### Luma
142
+
143
+ Prefer:
144
+
145
+ 1. Layout XML.
146
+ 2. ViewModel.
147
+ 3. Minimal template override.
148
+ 4. RequireJS mixin for existing AMD behavior.
149
+ 5. New JS module/component with declarative initialization.
150
+ 6. Full replacement only when necessary.
151
+
152
+ Use ViewModels for template-facing behavior/data. Prefer `data-mage-init` / `x-magento-init`. Avoid copying whole vendor PHTML/JS files. Do not instantiate services in templates.
153
+
154
+ Use Knockout/UI Components where the existing Magento surface already uses them, especially checkout and complex Admin UI.
155
+
156
+ ### Hyvä
157
+
158
+ Treat Hyvä as a separate frontend runtime.
159
+
160
+ - Do not assume RequireJS, Knockout, jQuery, or Luma `customer-data` on normal Hyvä pages.
161
+ - Prefer Alpine.js and Hyvä ViewModels/private-content mechanisms.
162
+ - Check installed Hyvä versions before relying on Tailwind/Alpine details.
163
+ - Use compatibility modules or isolated Luma fallback when appropriate.
164
+
165
+ ## Admin
166
+
167
+ Use UI Components for standard Magento grids/forms where data sources, filters, bookmarks, mass actions, or existing component hierarchies are useful.
168
+
169
+ For simple bespoke pages, layout + block/ViewModel/template may be clearer.
170
+
171
+ Authorization must be backend-enforced. Controllers/routes and relevant UI data endpoints need proper ACL; hiding UI elements is not authorization.
172
+
173
+ ## REST / SOAP / GraphQL
174
+
175
+ Expose stable service-contract methods through `webapi.xml` with correct ACL. Reuse business services across transports.
176
+
177
+ Keep GraphQL resolvers thin. Delegate logic, batch-load to avoid N+1, enforce authentication plus ownership/resource access, and return cache identities when required. Prefer token-based API auth over PHP-session-dependent API designs.
178
+
179
+ ## State Boundaries
180
+
181
+ Do not inject checkout/customer/backend sessions, `RequestInterface`, cookies, or implicit request state into reusable business services.
182
+
183
+ Adapters should extract explicit context such as `customerId`, `quoteId`, `storeId`, or `websiteId` and pass it to services.
184
+
185
+ ## Security
186
+
187
+ Verify:
188
+
189
+ - context-appropriate output escaping;
190
+ - CSRF/form-key and correct HTTP method for browser state changes;
191
+ - Admin/API/GraphQL ACL and object ownership;
192
+ - bound SQL parameters;
193
+ - safe filesystem/upload handling and validation;
194
+ - no unsafe unserialization or command execution on untrusted input.
195
+
196
+ ## Testing
197
+
198
+ - **Unit:** pure services, value objects, algorithms.
199
+ - **Integration:** DI/XML, DB, repositories, plugins, observers, indexers, cache.
200
+ - **API functional:** REST/GraphQL contracts, auth, serialization.
201
+ - **MFTF:** critical browser workflows.
202
+
203
+ Test observable behavior, not interceptor implementation details. Add regression tests for upgrade-sensitive overrides.
204
+
205
+ ## Red Flags
206
+
207
+ Redesign or explicitly justify:
208
+
209
+ - `ObjectManager::get()` in feature code;
210
+ - `$model->load()` / `$model->save()` in new code;
211
+ - large observers;
212
+ - business workflows in plugins;
213
+ - `around` for simple argument/result changes;
214
+ - preference for one public method;
215
+ - copied core/vendor classes or unnecessary copied PHTML/JS;
216
+ - repository/entity loads in large loops;
217
+ - global cache flush after normal writes;
218
+ - session/request state deep in business logic;
219
+ - direct SQL bypassing required Magento lifecycle;
220
+ - UI Components for every Admin page;
221
+ - direct edits under `vendor/`.
222
+
223
+ ## Final Review
224
+
225
+ Before finalizing an implementation, verify:
226
+
227
+ 1. Is there already an XML/declarative solution?
228
+ 2. Is there a public API or narrower documented extension point?
229
+ 3. Am I replacing more code than necessary?
230
+ 4. Am I relying on non-public internals?
231
+ 5. Could composition replace inheritance/preference?
232
+ 6. Is `around` truly required?
233
+ 7. Is business logic trapped in a plugin/observer/resolver/controller?
234
+ 8. Is the data-access method appropriate, or merely habitual?
235
+ 9. Can this create N+1 queries?
236
+ 10. Does request/session state leak into reusable services?
237
+ 11. Is it correct under FPC, indexers/MView, retries, concurrency, and production DI compilation?
238
+ 12. Are ACL, ownership, escaping, SQL bindings, and CSRF correct?
239
+ 13. Will it survive the next Magento/vendor patch update?
240
+
241
+ For production-sensitive changes, validate relevant paths with `setup:di:compile`, production mode, static content deployment where applicable, cache/indexer modes, cron/consumers, and Magento Coding Standard checks.
242
+
243
+ ## MerchantDuo Magento 2.4 session workflow
244
+
6
245
  # Magento 2.4
7
246
 
8
247
  Confirm the installed Magento version, edition, and deployment mode before changing framework behavior. Prefer a focused module, declarative schema and data patches, dependency injection, service contracts, layout XML, and Magento CLI validation. Do not read `app/etc/env.php`.
@@ -1,12 +0,0 @@
1
- import { type ProvisionPlan, type ProvisionSnapshot } from "#provision/model";
2
- export declare const PROVISION_ADVISOR_BASE_URL = "https://merchantduo.invalid/v1";
3
- export declare const PROVISION_ADVISOR_MODEL = "merchantduo-mock";
4
- export declare class ProvisionAdvisor {
5
- private readonly token;
6
- private readonly fetcher;
7
- constructor(token?: string | undefined, fetcher?: typeof fetch);
8
- advise(snapshot: ProvisionSnapshot, plan: ProvisionPlan): Promise<{
9
- plan: ProvisionPlan;
10
- diagnostic?: string;
11
- }>;
12
- }
@@ -1,28 +0,0 @@
1
- import { redactProvisionSnapshot } from "#provision/model";
2
- export const PROVISION_ADVISOR_BASE_URL = "https://merchantduo.invalid/v1";
3
- export const PROVISION_ADVISOR_MODEL = "merchantduo-mock";
4
- export class ProvisionAdvisor {
5
- token;
6
- fetcher;
7
- constructor(token = process.env.MERCHANTDUO_LLM_TOKEN, fetcher = fetch) {
8
- this.token = token;
9
- this.fetcher = fetcher;
10
- }
11
- async advise(snapshot, plan) {
12
- if (!this.token)
13
- return { plan };
14
- try {
15
- const response = await this.fetcher(`${PROVISION_ADVISOR_BASE_URL}/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, body: JSON.stringify({ model: PROVISION_ADVISOR_MODEL, response_format: { type: "json_object" }, messages: [{ role: "user", content: JSON.stringify({ snapshot: redactProvisionSnapshot(snapshot), allowedActions: plan.actions }) }] }) });
16
- if (!response.ok)
17
- throw new Error(`advisor returned HTTP ${response.status}`);
18
- const body = await response.json();
19
- const selected = JSON.parse(body.choices?.[0]?.message?.content ?? "{}");
20
- if (!Array.isArray(selected.actions) || !selected.actions.every((action) => plan.actions.some((allowed) => JSON.stringify(allowed) === JSON.stringify(action))))
21
- throw new Error("advisor selected an action outside the approved plan");
22
- return { plan: { ...plan, actions: selected.actions } };
23
- }
24
- catch (error) {
25
- return { plan, diagnostic: `Provision advisor unavailable: ${error.message}; using deterministic plan.` };
26
- }
27
- }
28
- }
@@ -1,12 +0,0 @@
1
- import type { ProvisionSnapshot } from "#provision/model";
2
- export type ProvisionProbe = {
3
- run(argv: string[], cwd?: string): Promise<{
4
- exitCode: number;
5
- stdout: string;
6
- stderr: string;
7
- }>;
8
- };
9
- export declare class SystemProvisionProbe implements ProvisionProbe {
10
- run(argv: string[], cwd?: string): Promise<import("#environments/executor").CommandResult>;
11
- }
12
- export declare function discoverProvision(root: string, probe?: ProvisionProbe): Promise<ProvisionSnapshot>;
@@ -1,61 +0,0 @@
1
- import { access, readdir, readFile } from "node:fs/promises";
2
- import { constants } from "node:fs";
3
- import { basename, join, resolve } from "node:path";
4
- const exists = async (path) => access(path, constants.F_OK).then(() => true).catch(() => false);
5
- export class SystemProvisionProbe {
6
- async run(argv, cwd) {
7
- const { EnvironmentBackend } = await import("#environments/backend");
8
- const backend = new EnvironmentBackend({ type: "local", root: cwd ?? process.cwd() }, cwd ?? process.cwd());
9
- return backend.run(argv);
10
- }
11
- }
12
- async function command(probe, name, cwd) {
13
- return (await probe.run(["sh", "-lc", `command -v ${name} >/dev/null`], cwd)).exitCode === 0;
14
- }
15
- async function serviceActive(probe, name, cwd) {
16
- return (await probe.run(["sh", "-lc", `systemctl is-active --quiet ${name} 2>/dev/null || pgrep -x ${name} >/dev/null 2>&1`], cwd)).exitCode === 0;
17
- }
18
- function parseVhosts(text, root) {
19
- const normalized = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
- const rootMatch = text.match(new RegExp(`(?:root|DocumentRoot)\\s+["']?(${normalized}(?:/pub)?)["']?`, "i"));
21
- if (!rootMatch)
22
- return undefined;
23
- const domain = text.match(/(?:server_name|ServerName)\s+([^\s;]+)/i)?.[1];
24
- return domain ? { server: /server_name/i.test(text) ? "nginx" : "apache", domain, documentRoot: rootMatch[1] } : undefined;
25
- }
26
- export async function discoverProvision(root, probe = new SystemProvisionProbe()) {
27
- const resolved = resolve(root);
28
- const entries = await readdir(resolved).catch(() => []);
29
- const composer = await exists(join(resolved, "composer.json"));
30
- const binMagento = await exists(join(resolved, "bin/magento"));
31
- const composerText = composer ? await readFile(join(resolved, "composer.json"), "utf8").catch(() => "") : "";
32
- const detected = binMagento || /magento\/(product|project|composer)/.test(composerText);
33
- const dumps = entries.filter((name) => /\.sql(?:\.gz)?$/i.test(name)).map((name) => join(resolved, name));
34
- const [php, composerCommand, mysql, warden, nginx, apache, nginxActive, apacheActive, wardenRunning] = await Promise.all([
35
- command(probe, "php", resolved), command(probe, "composer", resolved), command(probe, "mysql", resolved), command(probe, "warden", resolved),
36
- command(probe, "nginx", resolved), command(probe, "apache2", resolved).then((v) => v || command(probe, "httpd", resolved)),
37
- serviceActive(probe, "nginx", resolved), serviceActive(probe, "apache2", resolved), serviceActive(probe, "warden", resolved),
38
- ]);
39
- const wardenConfigured = await exists(join(resolved, ".warden", "warden-env.yml")) || await exists(join(resolved, ".env"));
40
- const env = await readFile(join(resolved, ".env"), "utf8").catch(() => "");
41
- const domain = env.match(/^TRAEFIK_(?:SUB)?DOMAIN=(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
42
- const servers = [nginx && "nginx", apache && "apache"].filter(Boolean);
43
- const activeServers = [nginxActive && "nginx", apacheActive && "apache"].filter(Boolean);
44
- const serverConfig = await Promise.all(servers.map(async (server) => {
45
- const paths = server === "nginx" ? ["/etc/nginx/sites-enabled", "/etc/nginx/conf.d"] : ["/etc/apache2/sites-enabled", "/etc/httpd/conf.d"];
46
- for (const path of paths) {
47
- const result = await probe.run(["sh", "-lc", `test -d ${path} && grep -R -h -E 'server_name|ServerName|root|DocumentRoot' ${path} 2>/dev/null || true`], resolved);
48
- const vhost = parseVhosts(result.stdout, resolved);
49
- if (vhost)
50
- return { ...vhost, server };
51
- }
52
- return undefined;
53
- }));
54
- const magentoMode = detected ? await probe.run(["sh", "-lc", "test -x bin/magento && bin/magento deploy:mode:show --no-ansi || true"], resolved) : undefined;
55
- return {
56
- workspace: { root: resolved, empty: entries.length === 0, magentoRoot: detected }, source: { composer, binMagento },
57
- database: { envConfigured: await exists(join(resolved, "app/etc/env.php")), dumps },
58
- environments: { local: { available: php && composerCommand && mysql && activeServers.length > 0, php, composer: composerCommand, mysql, servers, activeServers, vhost: serverConfig.find(Boolean) }, warden: { available: warden, configured: wardenConfigured, running: wardenRunning, domain } },
59
- magento: { detected, mode: magentoMode?.stdout.match(/(?:Current|Mode):\s*(\w+)/i)?.[1] }, diagnostics: detected ? [] : [`${basename(resolved)} is not a Magento root`]
60
- };
61
- }
@@ -1,9 +0,0 @@
1
- import type { ProvisionInput, ProvisionPlan, ProvisionSnapshot } from "#provision/model";
2
- export interface ProvisionInterview {
3
- resolve(snapshot: ProvisionSnapshot, initial: ProvisionInput): Promise<ProvisionInput>;
4
- approve(plan: ProvisionPlan): Promise<boolean>;
5
- }
6
- export declare class TerminalProvisionInterview implements ProvisionInterview {
7
- resolve(snapshot: ProvisionSnapshot, initial: ProvisionInput): Promise<ProvisionInput>;
8
- approve(plan: ProvisionPlan): Promise<boolean>;
9
- }
@@ -1,29 +0,0 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin, stdout } from "node:process";
3
- export class TerminalProvisionInterview {
4
- async resolve(snapshot, initial) {
5
- const rl = createInterface({ input: stdin, output: stdout });
6
- try {
7
- const environment = initial.environment ?? await rl.question(`Environment (${snapshot.environments.warden.available ? "warden" : "local"}/local): `);
8
- const source = initial.source ?? (snapshot.workspace.magentoRoot ? "existing" : await rl.question("Magento source (git URL, archive path, or HTTPS URL): ").then(parseSource));
9
- const database = initial.database ?? (snapshot.database.dumps.length === 1 ? `dump:${snapshot.database.dumps[0]}` : await rl.question("Database (current, dump path, or HTTPS URL): ").then(parseDatabase));
10
- const localDomain = environment === "local" && !snapshot.environments.local.vhost ? await rl.question("Local domain (blank for directory.test): ") : initial.localDomain;
11
- return { environment, source, database, localDomain: localDomain || undefined };
12
- }
13
- finally {
14
- rl.close();
15
- }
16
- }
17
- async approve(plan) {
18
- stdout.write("\nProvision plan:\n" + plan.actions.map((action, index) => `${index + 1}. ${action.type}${"command" in action ? ` (${action.command})` : ""}`).join("\n") + `\nSudo required: ${plan.requiresSudo ? "yes" : "no"}\n`);
19
- const rl = createInterface({ input: stdin, output: stdout });
20
- try {
21
- return (await rl.question("Execute this plan? [y/N] ")).trim().toLowerCase() === "y";
22
- }
23
- finally {
24
- rl.close();
25
- }
26
- }
27
- }
28
- function parseSource(value) { return /^https?:\/\//.test(value) ? (value.endsWith(".git") ? `git:${value}` : `https:${value}`) : `archive:${value}`; }
29
- function parseDatabase(value) { return value === "current" ? "current" : /^https?:\/\//.test(value) ? `https:${value}` : `dump:${value}`; }
@@ -1,82 +0,0 @@
1
- /** Public, redacted state used to make a provision decision. */
2
- export type ProvisionEnvironment = "local" | "warden";
3
- export type SourceInput = "existing" | `git:${string}` | `archive:${string}` | `https:${string}`;
4
- export type DatabaseInput = "current" | `dump:${string}` | `https:${string}`;
5
- export type ProvisionInput = {
6
- environment?: ProvisionEnvironment;
7
- source?: SourceInput;
8
- database?: DatabaseInput;
9
- localDomain?: string;
10
- };
11
- export type WebServer = "nginx" | "apache";
12
- export type ProvisionSnapshot = {
13
- workspace: {
14
- root: string;
15
- empty: boolean;
16
- magentoRoot: boolean;
17
- };
18
- source: {
19
- composer: boolean;
20
- binMagento: boolean;
21
- };
22
- database: {
23
- envConfigured: boolean;
24
- dumps: string[];
25
- };
26
- environments: {
27
- local: {
28
- available: boolean;
29
- php: boolean;
30
- composer: boolean;
31
- mysql: boolean;
32
- servers: WebServer[];
33
- activeServers: WebServer[];
34
- vhost?: {
35
- server: WebServer;
36
- domain: string;
37
- documentRoot: string;
38
- };
39
- };
40
- warden: {
41
- available: boolean;
42
- configured: boolean;
43
- running: boolean;
44
- domain?: string;
45
- };
46
- };
47
- magento: {
48
- detected: boolean;
49
- mode?: string;
50
- };
51
- diagnostics: string[];
52
- };
53
- export type ProvisionAction = {
54
- type: "acquire-source";
55
- source: Exclude<SourceInput, "existing">;
56
- } | {
57
- type: "install-dependencies";
58
- } | {
59
- type: "start-warden";
60
- } | {
61
- type: "import-database";
62
- database: Exclude<DatabaseInput, "current">;
63
- } | {
64
- type: "configure-database";
65
- } | {
66
- type: "magento-setup";
67
- command: "app:config:import" | "setup:upgrade" | "cache:clean" | "indexer:reindex" | "maintenance:disable";
68
- } | {
69
- type: "configure-web-server";
70
- server: WebServer;
71
- domain: string;
72
- } | {
73
- type: "verify-storefront";
74
- url?: string;
75
- };
76
- export type ProvisionPlan = {
77
- actions: ProvisionAction[];
78
- rationale: string[];
79
- blocked: string[];
80
- requiresSudo: boolean;
81
- };
82
- export declare function redactProvisionSnapshot(snapshot: ProvisionSnapshot): ProvisionSnapshot;
@@ -1,5 +0,0 @@
1
- export function redactProvisionSnapshot(snapshot) {
2
- // The contract intentionally has no credential fields. Copying also prevents callers
3
- // from accidentally attaching private process data to the advisor payload.
4
- return JSON.parse(JSON.stringify(snapshot));
5
- }
@@ -1,4 +0,0 @@
1
- import type { ProvisionInput, ProvisionPlan, ProvisionSnapshot } from "#provision/model";
2
- /** Deterministic planner. It has no process or filesystem side effects. */
3
- export declare function createProvisionPlan(snapshot: ProvisionSnapshot, input: ProvisionInput): ProvisionPlan;
4
- export declare function unresolvedInputs(snapshot: ProvisionSnapshot, input: ProvisionInput): string[];