@merchantduo/code 0.3.0-beta.0 → 0.3.0-beta.1
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 +4 -4
- package/dist/app/system-prompt.d.ts +2 -1
- package/dist/app/system-prompt.js +4 -2
- package/dist/cli/arguments.d.ts +0 -3
- package/dist/cli/arguments.js +5 -13
- package/dist/cli/commands/agent.d.ts +7 -0
- package/dist/cli/commands/agent.js +46 -1
- package/dist/cli/commands/init.d.ts +14 -1
- package/dist/cli/commands/init.js +36 -2
- package/dist/cli/commands/provision.d.ts +10 -5
- package/dist/cli/commands/provision.js +22 -33
- package/dist/cli/main.js +1 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +2 -1
- package/dist/config/loader.js +1 -5
- package/dist/config/operator-profile.d.ts +5 -0
- package/dist/config/operator-profile.js +28 -0
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +6 -0
- package/dist/integrations/pi/context.js +3 -1
- package/package.json +1 -2
- package/dist/provision/advisor.d.ts +0 -12
- package/dist/provision/advisor.js +0 -28
- package/dist/provision/discovery.d.ts +0 -12
- package/dist/provision/discovery.js +0 -61
- package/dist/provision/interview.d.ts +0 -9
- package/dist/provision/interview.js +0 -29
- package/dist/provision/model.d.ts +0 -82
- package/dist/provision/model.js +0 -5
- package/dist/provision/planner.d.ts +0 -4
- package/dist/provision/planner.js +0 -51
- package/dist/provision/runner.d.ts +0 -35
- package/dist/provision/runner.js +0 -98
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Host shop-stage
|
|
|
58
58
|
IdentityFile ~/.ssh/id_ed25519
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Use that alias as `host: shop-stage`. `merchantduo init` offers local first and Warden when its project `.env` is detected; it prints an SSH example instead of writing an unused remote environment.
|
|
61
|
+
Use that alias as `host: shop-stage`. Every `merchantduo init` asks “Choose what fits you most” and saves the selected role under the current user's `~/.merchantduo/operator-profile.yaml`: store admin, backend developer, frontend developer, or full-stack developer. It then offers local first and Warden when its project `.env` is detected; it prints an SSH example instead of writing an unused remote environment. The role is optional to load and gives the agent context for explanations and recommendations only; it does not change tools or permissions. Project configuration contains no user identity and is safe to share in a repository.
|
|
62
62
|
|
|
63
63
|
## Provisioning
|
|
64
64
|
|
|
@@ -66,10 +66,10 @@ Provision a local or Warden developer store from an empty directory or an existi
|
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
68
|
merchantduo provision
|
|
69
|
-
merchantduo provision --env warden
|
|
69
|
+
merchantduo provision --env warden
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
The
|
|
72
|
+
This opens plain Pi in the host shell, even when the project defaults to Warden. It shares MerchantDuo’s configured Pi authentication, models, and user settings, but does not load the MerchantDuo package or initialize Magento context. The agent first inspects the real project, Warden/local runtime, Composer platform requirements, Magento state, and source/database evidence. It does not run a fixed Composer, database, Magento, lifecycle, or web-server sequence. Before a material mutation, it explains the evidence and intended effect and waits for confirmation. In particular, lock-file PHP incompatibility is diagnosed rather than bypassed with `composer update`. Unlike normal MerchantDuo sessions, provisioning may inspect and configure Magento deployment configuration such as `app/etc/env.php` and `app/etc/config.php` when needed; it must not reveal credentials or tokens in chat, commands, logs, or generated files.
|
|
73
73
|
|
|
74
74
|
## Install and start
|
|
75
75
|
|
|
@@ -86,7 +86,7 @@ Review the generated `.merchantduo.yaml` before the first session. MerchantDuo r
|
|
|
86
86
|
|
|
87
87
|
## Safety and optional services
|
|
88
88
|
|
|
89
|
-
MerchantDuo
|
|
89
|
+
Normal MerchantDuo sessions do not load `app/etc/env.php`, credentials, or tokens into agent context. The separate provisioning session may configure Magento deployment files, but never reveals their secret values. MerchantDuo does not run background tests, cache commands, deployment actions, browser operations, or PHP code. Explicit operations use the confirmation rules described in the [tool reference](docs/tools.md).
|
|
90
90
|
|
|
91
91
|
Set `MERCHANTDUO_MCP_TOKEN` to enable the built-in read-only knowledge tool. Set `MERCHANTDUO_LLM_TOKEN` to enable the built-in MerchantDuo provider. Neither endpoint is project configuration.
|
|
92
92
|
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
import type { OperatorRole } from "#config/index";
|
|
1
2
|
export declare function mage2genRoot(): string;
|
|
2
|
-
export declare function merchantDuoSystemPrompt(mage2genPath?: string): string;
|
|
3
|
+
export declare function merchantDuoSystemPrompt(operatorRole: OperatorRole | undefined, mage2genPath?: string): string;
|
|
@@ -2,10 +2,12 @@ import { packagePath } from "#shared/package-paths";
|
|
|
2
2
|
export function mage2genRoot() {
|
|
3
3
|
return packagePath("vendor/mage2gen");
|
|
4
4
|
}
|
|
5
|
-
export function merchantDuoSystemPrompt(mage2genPath = mage2genRoot()) {
|
|
5
|
+
export function merchantDuoSystemPrompt(operatorRole, mage2genPath = mage2genRoot()) {
|
|
6
6
|
return `You are MerchantDuo, a senior Magento 2.4 architect and implementation partner. Work directly in the selected MerchantDuo environment and theme scope. Before changing code, inspect the relevant module, theme inheritance chain, configuration, and established local conventions. Make focused, production-quality changes; do not preserve obsolete compatibility paths or add speculative abstractions.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
${operatorRole ? `The operator is a ${operatorRole.replace(/-/g, " ")}. Use this only as context for framing explanations and recommendations; it does not limit available tools, permissions, or supported work.
|
|
9
|
+
|
|
10
|
+
` : ""}Ground every design and implementation decision in existing source before introducing a pattern. Find the closest applicable example and read its surrounding call sites, configuration, and tests. Search Magento core under vendor/magento first; if it has no suitable example, search other installed vendor modules or project app/code modules. Adapt the applicable pattern rather than copying blindly, and explain any material intentional difference. Only when no relevant precedent exists may you introduce a new pattern, and state that finding before doing so.
|
|
9
11
|
|
|
10
12
|
Use Magento service contracts, dependency injection, declarative schema/data patches, layout XML, and theme fallback mechanisms when they are the correct native extension points. Respect the active theme scope: all means cover every detected theme, any means identify the relevant theme from the request and code before theme-specific edits, and a concrete theme limits work to that theme and its inheritance chain. Do not use Luma RequireJS/Knockout conventions for Hyva work.
|
|
11
13
|
|
package/dist/cli/arguments.d.ts
CHANGED
package/dist/cli/arguments.js
CHANGED
|
@@ -4,29 +4,19 @@ export function parseArguments(args) {
|
|
|
4
4
|
const piArguments = [];
|
|
5
5
|
let environment;
|
|
6
6
|
let configPath;
|
|
7
|
-
let source;
|
|
8
|
-
let database;
|
|
9
|
-
let yes = false;
|
|
10
7
|
let yolo = false;
|
|
11
8
|
for (let index = 0; index < input.length; index += 1) {
|
|
12
9
|
const value = input[index];
|
|
13
|
-
if (value === "--env" || value === "--config"
|
|
10
|
+
if (value === "--env" || value === "--config") {
|
|
14
11
|
const argument = input[index + 1];
|
|
15
12
|
if (!argument)
|
|
16
13
|
throw new Error(`${value} requires a value`);
|
|
17
14
|
if (value === "--env")
|
|
18
15
|
environment = argument;
|
|
19
|
-
else if (value === "--config")
|
|
20
|
-
configPath = argument;
|
|
21
|
-
else if (value === "--source")
|
|
22
|
-
source = argument;
|
|
23
16
|
else
|
|
24
|
-
|
|
17
|
+
configPath = argument;
|
|
25
18
|
index += 1;
|
|
26
19
|
}
|
|
27
|
-
else if (value === "--yes") {
|
|
28
|
-
yes = true;
|
|
29
|
-
}
|
|
30
20
|
else if (value === "--yolo") {
|
|
31
21
|
yolo = true;
|
|
32
22
|
}
|
|
@@ -36,5 +26,7 @@ export function parseArguments(args) {
|
|
|
36
26
|
}
|
|
37
27
|
if (command && yolo)
|
|
38
28
|
throw new Error("--yolo is only available when starting an agent session");
|
|
39
|
-
|
|
29
|
+
if (command === "provision" && (configPath || piArguments.length))
|
|
30
|
+
throw new Error("merchantduo provision supports only --env local or --env warden");
|
|
31
|
+
return { command, environment, configPath, yolo, piArguments };
|
|
40
32
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { PermissionMode } from "#app/permission-mode";
|
|
2
|
+
type ProvisionEnvironment = "local" | "warden";
|
|
3
|
+
export declare function provisioningPrompt(environment: ProvisionEnvironment): string;
|
|
2
4
|
export declare function launchAgent(piArguments: string[], selectedEnvironment: string, configPath?: string, permissionMode?: PermissionMode): Promise<number>;
|
|
5
|
+
/** Launch a non-persistent provisioning session with exactly the requested environment. */
|
|
6
|
+
export declare function launchProvisioningAgent(environment: ProvisionEnvironment): Promise<number>;
|
|
7
|
+
/** Preserve Pi connection preferences but never load environment-bound MerchantDuo packages. */
|
|
8
|
+
export declare function provisioningSettings(settings: Record<string, unknown>): Record<string, unknown>;
|
|
3
9
|
export declare function agentEnvironment(base: NodeJS.ProcessEnv, piDirectory: string, selectedEnvironment: string, configPath: string | undefined, version: string, permissionMode?: PermissionMode): NodeJS.ProcessEnv;
|
|
10
|
+
export {};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { merchantHome } from "#config/paths";
|
|
6
6
|
import { packagePath } from "#shared/package-paths";
|
|
7
|
+
export function provisioningPrompt(environment) {
|
|
8
|
+
return `Start an agentic Magento provisioning investigation for the selected ${environment} environment. You are running plain Pi in the host shell, not inside a MerchantDuo selected-environment shell. Use host tools to inspect Warden/local project evidence and explicitly route any environment-specific command only after identifying its correct runtime. Do not follow a predetermined command sequence. First inspect the project, its documentation and scripts, the selected environment configuration and status, Composer metadata and lock-file platform requirements, actual runtime versions, Magento state, and available database/source evidence. Search for the smallest project-specific path to a runnable store.
|
|
9
|
+
|
|
10
|
+
Before any material mutation, explain the evidence, the precise intended effect, risks, and the smallest proposed action, then wait for the user's confirmation. Do not run Composer, import a database, run Magento setup commands, start or stop services, create a virtual host, or modify dependencies merely because they are customary. Never use composer update to bypass a lock-file or PHP-platform mismatch. If compatibility fails, identify the exact incompatible packages and recommend the required environment or dependency decision instead. When project evidence requires it, inspect and configure Magento deployment configuration including app/etc/env.php, app/etc/config.php, and related files. Treat their values as sensitive: never disclose, echo, log, or copy credentials or tokens into chat, commands, or persisted artifacts.`;
|
|
11
|
+
}
|
|
7
12
|
export async function launchAgent(piArguments, selectedEnvironment, configPath, permissionMode) {
|
|
8
13
|
const state = merchantHome();
|
|
9
14
|
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
@@ -25,6 +30,46 @@ export async function launchAgent(piArguments, selectedEnvironment, configPath,
|
|
|
25
30
|
child.on("exit", (code) => resolveExit(code ?? 1));
|
|
26
31
|
});
|
|
27
32
|
}
|
|
33
|
+
/** Launch a non-persistent provisioning session with exactly the requested environment. */
|
|
34
|
+
export async function launchProvisioningAgent(environment) {
|
|
35
|
+
const state = merchantHome();
|
|
36
|
+
const merchantPi = resolve(state, "pi");
|
|
37
|
+
const provisionPi = resolve(state, "provision", "pi");
|
|
38
|
+
await mkdir(provisionPi, { recursive: true, mode: 0o700 });
|
|
39
|
+
await linkConnectionFile(merchantPi, provisionPi, "auth.json");
|
|
40
|
+
await linkConnectionFile(merchantPi, provisionPi, "models.json");
|
|
41
|
+
await linkConnectionFile(merchantPi, provisionPi, "models-store.json");
|
|
42
|
+
await writeFile(resolve(provisionPi, "settings.json"), `${JSON.stringify(provisioningSettings(JSON.parse(await readFile(resolve(merchantPi, "settings.json"), "utf8"))), null, 2)}\n`, { mode: 0o600 });
|
|
43
|
+
return launchPlainPi(["--no-extensions", provisioningPrompt(environment)], provisionPi);
|
|
44
|
+
}
|
|
45
|
+
/** Preserve Pi connection preferences but never load environment-bound MerchantDuo packages. */
|
|
46
|
+
export function provisioningSettings(settings) {
|
|
47
|
+
const { packages: _packages, ...connectionSettings } = settings;
|
|
48
|
+
return connectionSettings;
|
|
49
|
+
}
|
|
50
|
+
async function linkConnectionFile(sourceDirectory, targetDirectory, name) {
|
|
51
|
+
const source = resolve(sourceDirectory, name);
|
|
52
|
+
const target = resolve(targetDirectory, name);
|
|
53
|
+
try {
|
|
54
|
+
await symlink(source, target);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code !== "EEXIST")
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function launchPlainPi(piArguments, piDirectory) {
|
|
62
|
+
const piEntry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
63
|
+
const piCli = resolve(dirname(piEntry), "cli.js");
|
|
64
|
+
return new Promise((resolveExit, reject) => {
|
|
65
|
+
const child = spawn(process.execPath, [piCli, ...piArguments], {
|
|
66
|
+
stdio: "inherit",
|
|
67
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: piDirectory },
|
|
68
|
+
});
|
|
69
|
+
child.on("error", reject);
|
|
70
|
+
child.on("exit", (code) => resolveExit(code ?? 1));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
28
73
|
export function agentEnvironment(base, piDirectory, selectedEnvironment, configPath, version, permissionMode) {
|
|
29
74
|
return { ...base, PI_CODING_AGENT_DIR: piDirectory, MERCHANTDUO_SELECTED_ENV: selectedEnvironment, MERCHANTDUO_CONFIG_PATH: configPath ?? "", MERCHANTDUO_PACKAGE_VERSION: version, ...(permissionMode ? { MERCHANTDUO_PERMISSION_MODE: permissionMode } : {}) };
|
|
30
75
|
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
import { type OperatorRole } from "#config/index";
|
|
2
|
+
export declare const operatorRoleChoices: ReadonlyArray<{
|
|
3
|
+
value: OperatorRole;
|
|
4
|
+
label: string;
|
|
5
|
+
}>;
|
|
1
6
|
export interface InitInterview {
|
|
2
7
|
confirm(question: string): Promise<boolean>;
|
|
8
|
+
choose(question: string, choices: ReadonlyArray<{
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}>): Promise<string>;
|
|
3
12
|
}
|
|
4
13
|
export declare class TerminalInitInterview implements InitInterview {
|
|
5
14
|
confirm(question: string): Promise<boolean>;
|
|
15
|
+
choose(question: string, choices: ReadonlyArray<{
|
|
16
|
+
value: string;
|
|
17
|
+
label: string;
|
|
18
|
+
}>): Promise<string>;
|
|
6
19
|
}
|
|
7
20
|
export declare function hasWardenEnvironment(cwd: string): Promise<boolean>;
|
|
8
|
-
export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void): Promise<void>;
|
|
21
|
+
export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
9
22
|
export declare const remoteEnvironmentExample = "\nTo add a remote environment later, add this under environments:\n\n stage:\n type: ssh\n host: shop-stage\n root: /var/www/html\n\nSSH sessions start read-only. Use /duo-switch-permissions normal or yolo only for the current session. Configure its user, port, and identity through ~/.ssh/config:\n\n Host shop-stage\n HostName stage.example.com\n User deploy\n Port 22\n IdentityFile ~/.ssh/id_ed25519";
|
|
@@ -3,6 +3,13 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin, stdout } from "node:process";
|
|
5
5
|
import { stringify } from "yaml";
|
|
6
|
+
import { operatorRoles, saveOperatorRole } from "#config/index";
|
|
7
|
+
export const operatorRoleChoices = [
|
|
8
|
+
{ value: "store-admin", label: "I am STORE ADMIN" },
|
|
9
|
+
{ value: "backend-developer", label: "I am BACKEND developer" },
|
|
10
|
+
{ value: "frontend-developer", label: "I am FRONTEND developer" },
|
|
11
|
+
{ value: "full-stack-developer", label: "I am FULL STACK developer" },
|
|
12
|
+
];
|
|
6
13
|
export class TerminalInitInterview {
|
|
7
14
|
async confirm(question) {
|
|
8
15
|
const terminal = createInterface({ input: stdin, output: stdout });
|
|
@@ -14,6 +21,20 @@ export class TerminalInitInterview {
|
|
|
14
21
|
terminal.close();
|
|
15
22
|
}
|
|
16
23
|
}
|
|
24
|
+
async choose(question, choices) {
|
|
25
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
26
|
+
try {
|
|
27
|
+
stdout.write(`${question}\n${choices.map((choice, index) => ` ${index + 1}. ${choice.label}`).join("\n")}\n`);
|
|
28
|
+
const answer = (await terminal.question(`Choose [1-${choices.length}] `)).trim();
|
|
29
|
+
const selected = choices[Number(answer) - 1];
|
|
30
|
+
if (!selected)
|
|
31
|
+
throw new Error(`Choose a number from 1 to ${choices.length}`);
|
|
32
|
+
return selected.value;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
terminal.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
17
38
|
}
|
|
18
39
|
export async function hasWardenEnvironment(cwd) {
|
|
19
40
|
try {
|
|
@@ -26,16 +47,23 @@ export async function hasWardenEnvironment(cwd) {
|
|
|
26
47
|
throw error;
|
|
27
48
|
}
|
|
28
49
|
}
|
|
29
|
-
export async function initProject(cwd, interview = new TerminalInitInterview(), output = console.log) {
|
|
50
|
+
export async function initProject(cwd, interview = new TerminalInitInterview(), output = console.log, env = process.env) {
|
|
30
51
|
const path = resolve(cwd, ".merchantduo.yaml");
|
|
52
|
+
let exists = false;
|
|
31
53
|
try {
|
|
32
54
|
await access(path);
|
|
33
|
-
|
|
55
|
+
exists = true;
|
|
34
56
|
}
|
|
35
57
|
catch (error) {
|
|
36
58
|
if (error.code !== "ENOENT")
|
|
37
59
|
throw error;
|
|
38
60
|
}
|
|
61
|
+
// Always refresh the caller's optional identity context, even for an existing project.
|
|
62
|
+
await saveOperatorRole(await chooseOperatorRole(interview), env);
|
|
63
|
+
if (exists) {
|
|
64
|
+
output("Updated the current user's operator role");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
39
67
|
const environments = {};
|
|
40
68
|
if (await interview.confirm("Add the local Magento checkout environment?")) {
|
|
41
69
|
environments.local = { type: "local", root: "." };
|
|
@@ -65,4 +93,10 @@ export async function initProject(cwd, interview = new TerminalInitInterview(),
|
|
|
65
93
|
output(`Created ${path}`);
|
|
66
94
|
output(remoteEnvironmentExample);
|
|
67
95
|
}
|
|
96
|
+
async function chooseOperatorRole(interview) {
|
|
97
|
+
const selected = await interview.choose("Choose what fits you most", operatorRoleChoices);
|
|
98
|
+
if (!operatorRoles.includes(selected))
|
|
99
|
+
throw new Error("Choose a listed operator role");
|
|
100
|
+
return selected;
|
|
101
|
+
}
|
|
68
102
|
export const remoteEnvironmentExample = `\nTo add a remote environment later, add this under environments:\n\n stage:\n type: ssh\n host: shop-stage\n root: /var/www/html\n\nSSH sessions start read-only. Use /duo-switch-permissions normal or yolo only for the current session. Configure its user, port, and identity through ~/.ssh/config:\n\n Host shop-stage\n HostName stage.example.com\n User deploy\n Port 22\n IdentityFile ~/.ssh/id_ed25519`;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type ProvisionEnvironment = "local" | "warden";
|
|
2
|
+
export interface ProvisionEnvironmentInterview {
|
|
3
|
+
choose(): Promise<ProvisionEnvironment>;
|
|
4
|
+
}
|
|
5
|
+
export declare class TerminalProvisionEnvironmentInterview implements ProvisionEnvironmentInterview {
|
|
6
|
+
choose(): Promise<ProvisionEnvironment>;
|
|
7
|
+
}
|
|
8
|
+
/** Starts a Pi investigation session; provisioning actions remain agent- and user-directed. */
|
|
9
|
+
export declare function provision(options: {
|
|
2
10
|
environment?: string;
|
|
3
|
-
|
|
4
|
-
database?: string;
|
|
5
|
-
yes: boolean;
|
|
6
|
-
}): Promise<void>;
|
|
11
|
+
}, interview?: ProvisionEnvironmentInterview, launch?: (environment: ProvisionEnvironment) => Promise<number>): Promise<void>;
|
|
@@ -1,35 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const snapshot = await discoverProvision(cwd);
|
|
17
|
-
const initial = { environment: options.environment, source: source(options.source), database: database(options.database) };
|
|
18
|
-
const interview = new TerminalProvisionInterview();
|
|
19
|
-
const input = options.yes ? initial : await interview.resolve(snapshot, initial);
|
|
20
|
-
const advised = await new ProvisionAdvisor().advise(snapshot, createProvisionPlan(snapshot, input));
|
|
21
|
-
if (advised.diagnostic)
|
|
22
|
-
console.error(`merchantduo: ${advised.diagnostic}`);
|
|
23
|
-
if (advised.plan.blocked.length)
|
|
24
|
-
throw new Error(`Provision requires input:\n- ${advised.plan.blocked.join("\n- ")}`);
|
|
25
|
-
if (!options.yes && !(await interview.approve(advised.plan))) {
|
|
26
|
-
console.log("Provision cancelled.");
|
|
27
|
-
return;
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { launchProvisioningAgent } from "#cli/commands/agent";
|
|
4
|
+
export class TerminalProvisionEnvironmentInterview {
|
|
5
|
+
async choose() {
|
|
6
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
7
|
+
try {
|
|
8
|
+
const answer = (await terminal.question("Environment (warden/local): ")).trim();
|
|
9
|
+
if (answer === "warden" || answer === "local")
|
|
10
|
+
return answer;
|
|
11
|
+
throw new Error("Choose environment warden or local");
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
terminal.close();
|
|
15
|
+
}
|
|
28
16
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
17
|
+
}
|
|
18
|
+
/** Starts a Pi investigation session; provisioning actions remain agent- and user-directed. */
|
|
19
|
+
export async function provision(options, interview = new TerminalProvisionEnvironmentInterview(), launch = launchProvisioningAgent) {
|
|
20
|
+
const environment = options.environment ?? await interview.choose();
|
|
21
|
+
if (environment !== "local" && environment !== "warden")
|
|
22
|
+
throw new Error("--env must be local or warden");
|
|
23
|
+
process.exitCode = await launch(environment);
|
|
35
24
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -12,7 +12,7 @@ async function main() {
|
|
|
12
12
|
if (args.command === "init")
|
|
13
13
|
return initProject(cwd);
|
|
14
14
|
if (args.command === "provision")
|
|
15
|
-
return provision(
|
|
15
|
+
return provision(args);
|
|
16
16
|
const loaded = await loadConfig(cwd, { config: args.configPath });
|
|
17
17
|
const selected = selectEnvironment(loaded.config, cwd, args.environment);
|
|
18
18
|
if (loaded.configPath && !(await isTrusted(loaded.configPath))) {
|
package/dist/config/index.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { Environment } from "#environments/model";
|
|
|
2
2
|
import type { MerchantConfig } from "#config/schema";
|
|
3
3
|
export { loadConfig, interpolate, type LoadedConfig } from "#config/loader";
|
|
4
4
|
export { merchantHome } from "#config/paths";
|
|
5
|
+
export { loadOperatorRole, saveOperatorRole } from "#config/operator-profile";
|
|
5
6
|
export { setProjectTheme } from "#config/project-store";
|
|
6
|
-
export { ConfigSchema, type MerchantConfig, } from "#config/schema";
|
|
7
|
+
export { ConfigSchema, type MerchantConfig, operatorRoles, type OperatorRole, } from "#config/schema";
|
|
7
8
|
export { configDigest, isTrusted, trustProject } from "#config/trust-store";
|
|
8
9
|
export declare function selectEnvironment(config: MerchantConfig, cwd: string, requested?: string, env?: NodeJS.ProcessEnv): {
|
|
9
10
|
name: string;
|
package/dist/config/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { loadConfig, interpolate } from "#config/loader";
|
|
2
2
|
export { merchantHome } from "#config/paths";
|
|
3
|
+
export { loadOperatorRole, saveOperatorRole } from "#config/operator-profile";
|
|
3
4
|
export { setProjectTheme } from "#config/project-store";
|
|
4
|
-
export { ConfigSchema, } from "#config/schema";
|
|
5
|
+
export { ConfigSchema, operatorRoles, } from "#config/schema";
|
|
5
6
|
export { configDigest, isTrusted, trustProject } from "#config/trust-store";
|
|
6
7
|
export function selectEnvironment(config, cwd, requested, env = process.env) {
|
|
7
8
|
const name = requested ?? env.MERCHANTDUO_ENV ?? config.defaultEnvironment;
|
package/dist/config/loader.js
CHANGED
|
@@ -49,11 +49,7 @@ export async function loadConfig(cwd, options = {}) {
|
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
if (!globalConfig && !projectConfig)
|
|
52
|
-
|
|
53
|
-
config: ConfigSchema.parse({
|
|
54
|
-
environments: { local: { type: "local", root: "." } },
|
|
55
|
-
}),
|
|
56
|
-
};
|
|
52
|
+
throw new Error("No MerchantDuo configuration found; run merchantduo init first");
|
|
57
53
|
return {
|
|
58
54
|
config: ConfigSchema.parse(expand(raw, env)),
|
|
59
55
|
configPath: projectConfig ? resolve(projectPath) : undefined,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type OperatorRole } from "#config/schema";
|
|
2
|
+
/** Read optional user-only agent context without involving repository configuration. */
|
|
3
|
+
export declare function loadOperatorRole(env?: NodeJS.ProcessEnv): Promise<OperatorRole | undefined>;
|
|
4
|
+
/** Persist the explicitly chosen role in the current user's MerchantDuo state. */
|
|
5
|
+
export declare function saveOperatorRole(role: OperatorRole, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { merchantHome } from "#config/paths";
|
|
5
|
+
import { operatorRoles } from "#config/schema";
|
|
6
|
+
const profileFile = "operator-profile.yaml";
|
|
7
|
+
/** Read optional user-only agent context without involving repository configuration. */
|
|
8
|
+
export async function loadOperatorRole(env = process.env) {
|
|
9
|
+
const path = resolve(merchantHome(env), profileFile);
|
|
10
|
+
try {
|
|
11
|
+
const profile = parse(await readFile(path, "utf8"));
|
|
12
|
+
const role = profile?.operatorRole;
|
|
13
|
+
if (!operatorRoles.includes(role))
|
|
14
|
+
throw new Error(`Invalid operator role in ${path}`);
|
|
15
|
+
return role;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === "ENOENT")
|
|
19
|
+
return undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Persist the explicitly chosen role in the current user's MerchantDuo state. */
|
|
24
|
+
export async function saveOperatorRole(role, env = process.env) {
|
|
25
|
+
const directory = merchantHome(env);
|
|
26
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
await writeFile(resolve(directory, profileFile), stringify({ operatorRole: role }), { mode: 0o600 });
|
|
28
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Environment } from "#environments/model";
|
|
3
|
+
export declare const operatorRoles: readonly ["store-admin", "backend-developer", "frontend-developer", "full-stack-developer"];
|
|
4
|
+
export type OperatorRole = (typeof operatorRoles)[number];
|
|
3
5
|
export declare const ConfigSchema: z.ZodObject<{
|
|
4
6
|
defaultEnvironment: z.ZodOptional<z.ZodString>;
|
|
5
7
|
activeTheme: z.ZodDefault<z.ZodString>;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { refreshStatus, setInitializingStatus } from "#app/status";
|
|
2
2
|
import { merchantDuoSystemPrompt } from "#app/system-prompt";
|
|
3
|
+
import { loadOperatorRole } from "#config/index";
|
|
3
4
|
import { discoverTesting } from "#testing/index";
|
|
4
5
|
import { discoverMagerun2 } from "#features/magerun2/index";
|
|
5
6
|
import { discoverHostExecutable, NodeHostProcessRunner } from "#testing/host";
|
|
@@ -58,8 +59,9 @@ export default function context(pi) {
|
|
|
58
59
|
});
|
|
59
60
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
60
61
|
const state = await runtime.boot(ctx.cwd);
|
|
62
|
+
const operatorRole = await loadOperatorRole();
|
|
61
63
|
const testing = state.testing;
|
|
62
|
-
return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt()}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${state.environmentStatus === "stopped" ? "Use environment_start only after the user directly asks to start it; do not use project tools until it is running." : "Testing URLs are discovered but unverified. Frontend: " + (testing?.frontendUrl ?? "unavailable") + "; Admin: " + (testing?.adminUrl ?? "unavailable") + ". HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant."}` };
|
|
64
|
+
return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt(operatorRole)}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${state.environmentStatus === "stopped" ? "Use environment_start only after the user directly asks to start it; do not use project tools until it is running." : "Testing URLs are discovered but unverified. Frontend: " + (testing?.frontendUrl ?? "unavailable") + "; Admin: " + (testing?.adminUrl ?? "unavailable") + ". HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant."}` };
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
export function statusMessage(state) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@merchantduo/code",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
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,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;
|
package/dist/provision/model.js
DELETED
|
@@ -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[];
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
const sourceMissing = (snapshot, input) => !snapshot.workspace.magentoRoot && (!input.source || input.source === "existing");
|
|
2
|
-
const databaseMissing = (snapshot, input) => !input.database && snapshot.database.dumps.length !== 1;
|
|
3
|
-
/** Deterministic planner. It has no process or filesystem side effects. */
|
|
4
|
-
export function createProvisionPlan(snapshot, input) {
|
|
5
|
-
const blocked = [];
|
|
6
|
-
const rationale = [];
|
|
7
|
-
const actions = [];
|
|
8
|
-
const environment = input.environment;
|
|
9
|
-
if (!environment)
|
|
10
|
-
blocked.push("Select --env local or --env warden.");
|
|
11
|
-
if (sourceMissing(snapshot, input))
|
|
12
|
-
blocked.push("Provide --source existing, a Git URL, archive path, or HTTPS archive URL.");
|
|
13
|
-
if (databaseMissing(snapshot, input))
|
|
14
|
-
blocked.push("Provide --database current, a dump path, or an HTTPS dump URL.");
|
|
15
|
-
if (input.database === "current" && !snapshot.database.envConfigured)
|
|
16
|
-
blocked.push("--database current requires an existing usable Magento database configuration.");
|
|
17
|
-
if (environment === "warden" && !snapshot.environments.warden.available)
|
|
18
|
-
blocked.push("Warden is not installed or available on PATH.");
|
|
19
|
-
if (environment === "local" && (!snapshot.environments.local.php || !snapshot.environments.local.composer || !snapshot.environments.local.mysql))
|
|
20
|
-
blocked.push("Local provisioning requires PHP, Composer, and the MySQL client.");
|
|
21
|
-
if (environment === "local" && snapshot.environments.local.activeServers.length === 0)
|
|
22
|
-
blocked.push("Start nginx or Apache before local provisioning.");
|
|
23
|
-
if (environment === "local" && snapshot.environments.local.activeServers.length > 1 && !input.localDomain)
|
|
24
|
-
blocked.push("Both nginx and Apache are active; choose a local domain/server interactively.");
|
|
25
|
-
if (blocked.length)
|
|
26
|
-
return { actions, rationale, blocked, requiresSudo: false };
|
|
27
|
-
if (!snapshot.workspace.magentoRoot && input.source && input.source !== "existing")
|
|
28
|
-
actions.push({ type: "acquire-source", source: input.source });
|
|
29
|
-
actions.push({ type: "install-dependencies" });
|
|
30
|
-
if (environment === "warden" && !snapshot.environments.warden.running)
|
|
31
|
-
actions.push({ type: "start-warden" });
|
|
32
|
-
const database = input.database ?? (snapshot.database.dumps.length === 1 ? `dump:${snapshot.database.dumps[0]}` : undefined);
|
|
33
|
-
if (environment === "local" && !snapshot.database.envConfigured)
|
|
34
|
-
actions.push({ type: "configure-database" });
|
|
35
|
-
if (database && database !== "current")
|
|
36
|
-
actions.push({ type: "import-database", database });
|
|
37
|
-
actions.push({ type: "magento-setup", command: "app:config:import" }, { type: "magento-setup", command: "setup:upgrade" }, { type: "magento-setup", command: "cache:clean" }, { type: "magento-setup", command: "indexer:reindex" }, { type: "magento-setup", command: "maintenance:disable" });
|
|
38
|
-
const vhost = snapshot.environments.local.vhost;
|
|
39
|
-
if (environment === "local" && !vhost) {
|
|
40
|
-
const server = snapshot.environments.local.activeServers[0];
|
|
41
|
-
const domain = input.localDomain ?? `${snapshot.workspace.root.split("/").filter(Boolean).pop().replace(/[^a-z0-9-]/gi, "-").toLowerCase()}.test`;
|
|
42
|
-
actions.push({ type: "configure-web-server", server, domain });
|
|
43
|
-
rationale.push(`Create a minimal ${server} virtual host for ${domain}.`);
|
|
44
|
-
}
|
|
45
|
-
actions.push({ type: "verify-storefront", url: environment === "warden" ? snapshot.environments.warden.domain : vhost?.domain ?? input.localDomain });
|
|
46
|
-
rationale.push("Composer uses the lock file; Magento deployment mode is preserved.");
|
|
47
|
-
return { actions, rationale, blocked, requiresSudo: actions.some((action) => action.type === "configure-web-server") };
|
|
48
|
-
}
|
|
49
|
-
export function unresolvedInputs(snapshot, input) {
|
|
50
|
-
return createProvisionPlan(snapshot, input).blocked;
|
|
51
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { CommandExecutor } from "#environments/executor";
|
|
2
|
-
import type { ProvisionAction, ProvisionEnvironment, ProvisionInput } from "#provision/model";
|
|
3
|
-
export type ProvisionRunner = {
|
|
4
|
-
run(argv: string[], options?: {
|
|
5
|
-
cwd?: string;
|
|
6
|
-
input?: string;
|
|
7
|
-
}): Promise<{
|
|
8
|
-
exitCode: number;
|
|
9
|
-
stdout: string;
|
|
10
|
-
stderr: string;
|
|
11
|
-
}>;
|
|
12
|
-
};
|
|
13
|
-
export declare class BackendProvisionRunner implements ProvisionRunner {
|
|
14
|
-
private readonly backend;
|
|
15
|
-
constructor(backend: CommandExecutor);
|
|
16
|
-
run(argv: string[], options?: {
|
|
17
|
-
cwd?: string;
|
|
18
|
-
input?: string;
|
|
19
|
-
}): Promise<import("#environments/executor").CommandResult>;
|
|
20
|
-
}
|
|
21
|
-
export declare function validateArchiveEntry(entry: string): boolean;
|
|
22
|
-
export declare function vhostConfig(server: "nginx" | "apache", projectRoot: string, domain: string): string;
|
|
23
|
-
export declare class DefaultProvisionExecutor {
|
|
24
|
-
private readonly runner;
|
|
25
|
-
private readonly root;
|
|
26
|
-
private readonly environment;
|
|
27
|
-
private readonly magentoRunner;
|
|
28
|
-
constructor(runner: ProvisionRunner, root: string, environment: ProvisionEnvironment, magentoRunner?: ProvisionRunner);
|
|
29
|
-
execute(action: ProvisionAction, input: ProvisionInput): Promise<void>;
|
|
30
|
-
private acquire;
|
|
31
|
-
private importDatabase;
|
|
32
|
-
private configureVhost;
|
|
33
|
-
private verify;
|
|
34
|
-
private must;
|
|
35
|
-
}
|
package/dist/provision/runner.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
-
import { basename, join, resolve } from "node:path";
|
|
3
|
-
export class BackendProvisionRunner {
|
|
4
|
-
backend;
|
|
5
|
-
constructor(backend) {
|
|
6
|
-
this.backend = backend;
|
|
7
|
-
}
|
|
8
|
-
run(argv, options = {}) { return this.backend.run(argv, { input: options.input }); }
|
|
9
|
-
}
|
|
10
|
-
export function validateArchiveEntry(entry) {
|
|
11
|
-
return entry.length > 0 && !entry.startsWith("/") && !entry.split(/[\\/]+/).includes("..");
|
|
12
|
-
}
|
|
13
|
-
export function vhostConfig(server, projectRoot, domain) {
|
|
14
|
-
if (!/^[a-z0-9-]+\.test$/i.test(domain))
|
|
15
|
-
throw new Error("Local domain must be a sanitized .test hostname");
|
|
16
|
-
const root = join(resolve(projectRoot), "pub");
|
|
17
|
-
return server === "nginx" ? `server { listen 80; server_name ${domain}; root ${root}; index index.php; location / { try_files $uri $uri/ /index.php?$args; } location ~ \\.php$ { try_files $uri =404; fastcgi_pass unix:/run/php/php-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }\n` : `<VirtualHost *:80>\n ServerName ${domain}\n DocumentRoot ${root}\n <Directory ${root}>\n AllowOverride All\n Require all granted\n </Directory>\n</VirtualHost>\n`;
|
|
18
|
-
}
|
|
19
|
-
export class DefaultProvisionExecutor {
|
|
20
|
-
runner;
|
|
21
|
-
root;
|
|
22
|
-
environment;
|
|
23
|
-
magentoRunner;
|
|
24
|
-
constructor(runner, root, environment, magentoRunner = runner) {
|
|
25
|
-
this.runner = runner;
|
|
26
|
-
this.root = root;
|
|
27
|
-
this.environment = environment;
|
|
28
|
-
this.magentoRunner = magentoRunner;
|
|
29
|
-
}
|
|
30
|
-
async execute(action, input) {
|
|
31
|
-
switch (action.type) {
|
|
32
|
-
case "acquire-source": return this.acquire(action.source);
|
|
33
|
-
case "install-dependencies": return this.must(["composer", "install", "--no-interaction", "--prefer-dist", "--no-progress"], this.magentoRunner);
|
|
34
|
-
case "start-warden": return this.must(["warden", "env", "config"]).then(() => this.must(["warden", "env", "start"]));
|
|
35
|
-
case "import-database": return this.importDatabase(action.database);
|
|
36
|
-
case "configure-database": throw new Error("Database configuration is required but no safe credential source was supplied. Add app/etc/env.php or use a current configured database.");
|
|
37
|
-
case "magento-setup":
|
|
38
|
-
if (action.command === "app:config:import") {
|
|
39
|
-
const result = await this.magentoRunner.run(["bin/magento", action.command, "--no-interaction"], { cwd: this.root });
|
|
40
|
-
if (result.exitCode !== 0 && !/does not exist|Nothing to import/i.test(result.stderr + result.stdout))
|
|
41
|
-
throw new Error(result.stderr || result.stdout);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
return this.must(["bin/magento", action.command, "--no-interaction"], this.magentoRunner);
|
|
45
|
-
case "configure-web-server": return this.configureVhost(action.server, action.domain);
|
|
46
|
-
case "verify-storefront": return this.verify(action.url);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
async acquire(source) {
|
|
50
|
-
const entries = await stat(this.root).then(() => import("node:fs/promises").then(({ readdir }) => readdir(this.root))).catch(() => []);
|
|
51
|
-
if (entries.length)
|
|
52
|
-
throw new Error("Refusing to acquire source into a non-empty non-Magento directory.");
|
|
53
|
-
if (source.startsWith("git:"))
|
|
54
|
-
return this.must(["git", "clone", source.slice(4), "."]);
|
|
55
|
-
const location = source.replace(/^(archive|https):/, "");
|
|
56
|
-
const home = process.env.MERCHANTDUO_HOME ?? join(process.env.XDG_STATE_HOME ?? "/tmp", "merchantduo");
|
|
57
|
-
const stage = join(home, "provisions", `${Date.now()}-${basename(location).replace(/[^a-z0-9._-]/gi, "-")}`);
|
|
58
|
-
await mkdir(stage, { recursive: true });
|
|
59
|
-
try {
|
|
60
|
-
const archive = source.startsWith("https:") ? join(stage, "source.archive") : resolve(location);
|
|
61
|
-
if (source.startsWith("https:"))
|
|
62
|
-
await this.must(["curl", "--fail", "--location", "--output", archive, location]);
|
|
63
|
-
const listed = await this.runner.run(["tar", "-tzf", archive]);
|
|
64
|
-
if (listed.exitCode !== 0 || listed.stdout.split("\n").filter(Boolean).some((entry) => !validateArchiveEntry(entry)))
|
|
65
|
-
throw new Error("Archive is invalid or contains unsafe paths.");
|
|
66
|
-
await this.must(["tar", "-xzf", archive, "-C", this.root, "--strip-components=1"]);
|
|
67
|
-
const magento = await this.runner.run(["sh", "-lc", "test -f composer.json && (test -x bin/magento || grep -q 'magento/' composer.json)"], { cwd: this.root });
|
|
68
|
-
if (magento.exitCode !== 0)
|
|
69
|
-
throw new Error("Extracted archive does not contain a Magento root.");
|
|
70
|
-
}
|
|
71
|
-
finally {
|
|
72
|
-
await rm(stage, { recursive: true, force: true });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
async importDatabase(database) {
|
|
76
|
-
const source = database.replace(/^(dump|https):/, "");
|
|
77
|
-
if (this.environment === "warden")
|
|
78
|
-
return this.must(["warden", "db", "import", source]);
|
|
79
|
-
if (database.startsWith("https:"))
|
|
80
|
-
throw new Error("Download the local database dump before local import; database credentials are never accepted as CLI arguments.");
|
|
81
|
-
return this.must(["sh", "-lc", `${source.endsWith(".gz") ? "gzip -dc --" : "cat --"} "$1" | mysql`, "merchantduo-db-import", source]);
|
|
82
|
-
}
|
|
83
|
-
async configureVhost(server, domain) {
|
|
84
|
-
const config = vhostConfig(server, this.root, domain);
|
|
85
|
-
const file = server === "nginx" ? `/etc/nginx/sites-available/${domain}` : `/etc/apache2/sites-available/${domain}.conf`;
|
|
86
|
-
const encoded = Buffer.from(config).toString("base64");
|
|
87
|
-
await this.must(["sudo", "sh", "-lc", `echo ${encoded} | base64 -d > ${file}`]);
|
|
88
|
-
if (server === "nginx")
|
|
89
|
-
await this.must(["sudo", "sh", "-lc", `ln -sfn ${file} /etc/nginx/sites-enabled/${domain} && nginx -t && systemctl reload nginx`]);
|
|
90
|
-
else
|
|
91
|
-
await this.must(["sudo", "sh", "-lc", `a2ensite ${domain}.conf && apachectl configtest && systemctl reload apache2`]);
|
|
92
|
-
await this.must(["sudo", "sh", "-lc", `grep -qE '^[[:space:]]*127\\.0\\.0\\.1[[:space:]].*\\b${domain}\\b' /etc/hosts || echo '127.0.0.1 ${domain}' >> /etc/hosts`]);
|
|
93
|
-
}
|
|
94
|
-
async verify(url) { if (url)
|
|
95
|
-
await this.must(["curl", "--fail", "--location", "--max-time", "20", `http://${url}`]); }
|
|
96
|
-
async must(argv, runner = this.runner) { const result = await runner.run(argv, { cwd: this.root }); if (result.exitCode !== 0)
|
|
97
|
-
throw new Error(`${argv.join(" ")} failed: ${result.stderr || result.stdout}`); }
|
|
98
|
-
}
|