@merchantduo/code 0.2.0-beta.3 → 0.2.0-beta.5
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 +3 -4
- package/dist/app/native-operations.d.ts +19 -0
- package/dist/app/native-operations.js +50 -0
- package/dist/app/permission-mode.d.ts +5 -0
- package/dist/app/permission-mode.js +10 -0
- package/dist/app/runtime.d.ts +5 -1
- package/dist/app/runtime.js +26 -5
- package/dist/app/status.js +1 -7
- package/dist/app/system-prompt.js +2 -0
- package/dist/cli/arguments.d.ts +1 -0
- package/dist/cli/arguments.js +7 -1
- package/dist/cli/commands/agent.d.ts +3 -1
- package/dist/cli/commands/agent.js +5 -8
- package/dist/cli/commands/doctor.js +2 -2
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/main.js +1 -1
- package/dist/config/schema.d.ts +0 -5
- package/dist/config/schema.js +0 -1
- package/dist/environments/backend.d.ts +3 -1
- package/dist/environments/backend.js +6 -1
- package/dist/environments/model.d.ts +0 -2
- package/dist/environments/model.js +1 -3
- package/dist/features/magerun2/service.d.ts +2 -1
- package/dist/features/magerun2/service.js +5 -3
- package/dist/integrations/pi/context.d.ts +2 -0
- package/dist/integrations/pi/context.js +37 -8
- package/dist/integrations/pi/magerun2.d.ts +1 -1
- package/dist/integrations/pi/magerun2.js +2 -4
- package/dist/integrations/pi/permissions.d.ts +4 -2
- package/dist/integrations/pi/permissions.js +46 -21
- package/dist/integrations/pi/testing.js +0 -2
- package/dist/integrations/pi/theme.d.ts +2 -0
- package/dist/integrations/pi/theme.js +23 -4
- package/dist/integrations/pi/workflows.js +1 -1
- package/dist/magento/inspector.d.ts +3 -3
- package/dist/magento/inspector.js +16 -11
- package/dist/magento/model.d.ts +0 -1
- package/dist/magento/prompt.d.ts +2 -1
- package/dist/magento/prompt.js +2 -2
- package/dist/testing/discovery.d.ts +3 -3
- package/dist/testing/discovery.js +10 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,13 +10,13 @@ MerchantDuo is a Pi coding agent for Magento 2.4 teams. It starts from the store
|
|
|
10
10
|
- Provides guided local/Warden store provisioning.
|
|
11
11
|
- Keeps remote writes and Magento operations behind explicit execution boundaries.
|
|
12
12
|
|
|
13
|
-
At session start, MerchantDuo produces a Magento snapshot from the selected environment. Frontend and admin URLs are discovered but remain unverified until an explicit test. The compact status line shows MerchantDuo and Magento versions, environment lifecycle,
|
|
13
|
+
At session start, MerchantDuo produces a Magento snapshot from the selected environment. Frontend and admin URLs are discovered but remain unverified until an explicit test. The compact status line shows MerchantDuo and Magento versions, environment lifecycle, session permission mode, an explicit theme scope when one is selected, and the frontend URL when available.
|
|
14
14
|
|
|
15
15
|
## Commands and tools
|
|
16
16
|
|
|
17
17
|
Extension slash commands are namespaced with `duo-` to avoid collisions with other Pi extensions.
|
|
18
18
|
|
|
19
|
-
- [Command reference](docs/commands.md) covers
|
|
19
|
+
- [Command reference](docs/commands.md) covers session theme and permission switching, lifecycle, and navigation commands.
|
|
20
20
|
- [Tool reference](docs/tools.md) covers workspace, environment, Magento workflow, magerun2, testing, PHP-console, and optional knowledge tools, including parameters and confirmation boundaries.
|
|
21
21
|
|
|
22
22
|
## Environments
|
|
@@ -46,10 +46,9 @@ environments:
|
|
|
46
46
|
type: ssh
|
|
47
47
|
host: shop-stage
|
|
48
48
|
root: /var/www/html
|
|
49
|
-
writable: false
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
Local uses the checkout directly. Warden routes project and Magento execution through its configured service. SSH
|
|
51
|
+
Local uses the checkout directly. Warden routes project and Magento execution through its configured service. Local and Warden sessions start in `normal`; SSH starts in `read-only`. Use `/duo-switch-permissions <read-only|normal|yolo>` to change only the current session, or `merchantduo --yolo` to start in `yolo`. Configure SSH user, port, and key through an OpenSSH host alias:
|
|
53
52
|
|
|
54
53
|
```sshconfig
|
|
55
54
|
Host shop-stage
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommandResult, CommandSpec } from "#environments/executor";
|
|
2
|
+
export type NativeLogContext = {
|
|
3
|
+
project: string;
|
|
4
|
+
session: string;
|
|
5
|
+
environment: string;
|
|
6
|
+
};
|
|
7
|
+
/** Append-only, fail-soft evidence for automatic native probes only. */
|
|
8
|
+
export declare class NativeProbeLog {
|
|
9
|
+
#private;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
constructor(path?: string);
|
|
12
|
+
get warning(): string | undefined;
|
|
13
|
+
start(context: NativeLogContext): Promise<void>;
|
|
14
|
+
record(spec: CommandSpec, result: CommandResult): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function runNativeProbe(log: NativeProbeLog, executor: {
|
|
17
|
+
command(argv: string[], target?: string): CommandSpec;
|
|
18
|
+
run(argv: string[], options?: Parameters<import("#environments/executor").CommandExecutor["run"]>[1]): Promise<CommandResult>;
|
|
19
|
+
}, argv: string[], options?: Parameters<import("#environments/executor").CommandExecutor["run"]>[1]): Promise<CommandResult>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const MAX_STREAM = 8_000;
|
|
5
|
+
/** Append-only, fail-soft evidence for automatic native probes only. */
|
|
6
|
+
export class NativeProbeLog {
|
|
7
|
+
path;
|
|
8
|
+
#started = false;
|
|
9
|
+
#warning;
|
|
10
|
+
constructor(path = join(homedir(), ".merchantduo", "native-operations.log")) {
|
|
11
|
+
this.path = path;
|
|
12
|
+
}
|
|
13
|
+
get warning() { return this.#warning; }
|
|
14
|
+
async start(context) {
|
|
15
|
+
if (this.#started)
|
|
16
|
+
return;
|
|
17
|
+
this.#started = true;
|
|
18
|
+
await this.#append(`\n=== MerchantDuo native session ${new Date().toISOString()} ===\nsession: ${redact(context.session)}\nproject: ${redact(context.project)}\nenvironment: ${redact(context.environment)}\n`);
|
|
19
|
+
}
|
|
20
|
+
async record(spec, result) {
|
|
21
|
+
const stdout = stream(result.stdout);
|
|
22
|
+
const stderr = stream(result.stderr);
|
|
23
|
+
await this.#append(`\n[${new Date().toISOString()}]\ncommand: ${redact([spec.file, ...spec.args].join(" "))}\nexit_code: ${result.exitCode}\nstdout:\n${stdout.value}${result.truncated || stdout.truncated ? "\n[stdout truncated]" : ""}\nstderr:\n${stderr.value}${result.truncated || stderr.truncated ? "\n[stderr truncated]" : ""}\n`);
|
|
24
|
+
}
|
|
25
|
+
async #append(record) {
|
|
26
|
+
try {
|
|
27
|
+
const directory = dirname(this.path);
|
|
28
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
await chmod(directory, 0o700);
|
|
30
|
+
await appendFile(this.path, record, { encoding: "utf8", mode: 0o600 });
|
|
31
|
+
await chmod(this.path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
this.#warning = "Native operation logging is unavailable.";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function runNativeProbe(log, executor, argv, options) {
|
|
39
|
+
const spec = executor.command(argv, options?.target);
|
|
40
|
+
const result = await executor.run(argv, options);
|
|
41
|
+
await log.record(spec, result);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function stream(value) {
|
|
45
|
+
const redacted = redact(value);
|
|
46
|
+
return { value: redacted.slice(0, MAX_STREAM), truncated: redacted.length > MAX_STREAM };
|
|
47
|
+
}
|
|
48
|
+
function redact(value) {
|
|
49
|
+
return value.replace(/((?:password|passwd|secret|token|api[_-]?key)\s*(?:=|:|=>)\s*["']?)[^\s"']+/gi, "$1[redacted]");
|
|
50
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Environment } from "#environments/model";
|
|
2
|
+
export declare const permissionModes: readonly ["read-only", "normal", "yolo"];
|
|
3
|
+
export type PermissionMode = (typeof permissionModes)[number];
|
|
4
|
+
/** Resolve one non-persistent session policy. Invalid launch input is rejected, never relaxed. */
|
|
5
|
+
export declare function resolvePermissionMode(environment: Environment, override?: string): PermissionMode;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const permissionModes = ["read-only", "normal", "yolo"];
|
|
2
|
+
/** Resolve one non-persistent session policy. Invalid launch input is rejected, never relaxed. */
|
|
3
|
+
export function resolvePermissionMode(environment, override) {
|
|
4
|
+
if (override !== undefined) {
|
|
5
|
+
if (!permissionModes.includes(override))
|
|
6
|
+
throw new Error(`Invalid MERCHANTDUO_PERMISSION_MODE: ${override}. Expected read-only, normal, or yolo.`);
|
|
7
|
+
return override;
|
|
8
|
+
}
|
|
9
|
+
return environment.type === "ssh" ? "read-only" : "normal";
|
|
10
|
+
}
|
package/dist/app/runtime.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type MerchantConfig } from "#config/index";
|
|
2
2
|
import { EnvironmentBackend } from "#environments/backend";
|
|
3
3
|
import type { Environment } from "#environments/model";
|
|
4
|
+
import { type PermissionMode } from "#app/permission-mode";
|
|
4
5
|
import type { EnvironmentStatus } from "#environments/model";
|
|
5
6
|
import { type MagentoInspector, type MagentoSnapshot } from "#magento/index";
|
|
6
7
|
import { ChangeTracker } from "#workflows/change-tracker";
|
|
7
8
|
import type { TestingSnapshot } from "#testing/model";
|
|
8
9
|
import type { Magerun2Capability } from "#features/magerun2/index";
|
|
10
|
+
import { NativeProbeLog } from "#app/native-operations";
|
|
9
11
|
export type SessionState = {
|
|
10
12
|
config: MerchantConfig;
|
|
11
13
|
configPath?: string;
|
|
@@ -14,7 +16,9 @@ export type SessionState = {
|
|
|
14
16
|
environment: Environment;
|
|
15
17
|
};
|
|
16
18
|
backend: EnvironmentBackend;
|
|
19
|
+
nativeLog: NativeProbeLog;
|
|
17
20
|
environmentStatus: EnvironmentStatus;
|
|
21
|
+
permissionMode: PermissionMode;
|
|
18
22
|
magento: MagentoSnapshot;
|
|
19
23
|
testing?: TestingSnapshot;
|
|
20
24
|
magerun2?: Magerun2Capability;
|
|
@@ -29,7 +33,7 @@ export declare class MerchantDuoRuntime {
|
|
|
29
33
|
resetInjection(): void;
|
|
30
34
|
injectionKey(skillId: string, theme: string): string;
|
|
31
35
|
changeTheme(cwd: string, activeTheme: string): Promise<SessionState>;
|
|
32
|
-
|
|
36
|
+
setPermissionMode(mode: PermissionMode): SessionState;
|
|
33
37
|
setTesting(snapshot: TestingSnapshot): void;
|
|
34
38
|
setMagerun2(capability: Magerun2Capability): void;
|
|
35
39
|
setEnvironmentStatus(status: EnvironmentStatus): SessionState;
|
package/dist/app/runtime.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { loadConfig, selectEnvironment, setProjectTheme, } from "#config/index";
|
|
2
2
|
import { EnvironmentBackend } from "#environments/backend";
|
|
3
|
-
import {
|
|
3
|
+
import { resolvePermissionMode } from "#app/permission-mode";
|
|
4
4
|
import { DefaultMagentoInspector, } from "#magento/index";
|
|
5
5
|
import { ChangeTracker } from "#workflows/change-tracker";
|
|
6
|
+
import { NativeProbeLog } from "#app/native-operations";
|
|
7
|
+
import { wardenRunning } from "#environments/backend";
|
|
6
8
|
export class MerchantDuoRuntime {
|
|
7
9
|
inspector;
|
|
8
10
|
#state;
|
|
@@ -20,13 +22,17 @@ export class MerchantDuoRuntime {
|
|
|
20
22
|
});
|
|
21
23
|
const selected = selectEnvironment(loaded.config, cwd, process.env.MERCHANTDUO_SELECTED_ENV);
|
|
22
24
|
const backend = new EnvironmentBackend(selected.environment, cwd);
|
|
23
|
-
const
|
|
25
|
+
const nativeLog = new NativeProbeLog();
|
|
26
|
+
await nativeLog.start({ project: cwd, session: `${process.pid}`, environment: selected.name });
|
|
27
|
+
const environmentStatus = await nativeStatus(backend, nativeLog);
|
|
24
28
|
this.#state = {
|
|
25
29
|
...loaded,
|
|
26
30
|
selected,
|
|
27
31
|
backend,
|
|
32
|
+
nativeLog,
|
|
28
33
|
environmentStatus,
|
|
29
|
-
|
|
34
|
+
permissionMode: resolvePermissionMode(selected.environment, process.env.MERCHANTDUO_PERMISSION_MODE),
|
|
35
|
+
magento: environmentStatus === "running" ? await this.inspector.inspect(backend, (argv, result) => nativeLog.record(backend.command(argv), result)) : stoppedMagentoSnapshot(),
|
|
30
36
|
};
|
|
31
37
|
return this.#state;
|
|
32
38
|
}
|
|
@@ -47,8 +53,11 @@ export class MerchantDuoRuntime {
|
|
|
47
53
|
this.resetInjection();
|
|
48
54
|
return state;
|
|
49
55
|
}
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
setPermissionMode(mode) {
|
|
57
|
+
if (!this.#state)
|
|
58
|
+
throw new Error("Session has not started");
|
|
59
|
+
this.#state.permissionMode = mode;
|
|
60
|
+
return this.#state;
|
|
52
61
|
}
|
|
53
62
|
setTesting(snapshot) {
|
|
54
63
|
if (!this.#state)
|
|
@@ -98,3 +107,15 @@ export class MerchantDuoRuntime {
|
|
|
98
107
|
function stoppedMagentoSnapshot() {
|
|
99
108
|
return { cacheTypes: [], warnings: ["Environment is stopped"], evidence: [], themes: [] };
|
|
100
109
|
}
|
|
110
|
+
async function nativeStatus(backend, log) {
|
|
111
|
+
if (backend.environment.type === "ssh")
|
|
112
|
+
return "running";
|
|
113
|
+
if (backend.environment.type === "local")
|
|
114
|
+
return "unknown";
|
|
115
|
+
const spec = backend.lifecycleCommand("status");
|
|
116
|
+
if (!spec)
|
|
117
|
+
return "stopped";
|
|
118
|
+
const result = await backend.runCommand(spec);
|
|
119
|
+
await log.record(spec, result);
|
|
120
|
+
return result.exitCode === 0 && wardenRunning(result.stdout) ? "running" : "stopped";
|
|
121
|
+
}
|
package/dist/app/status.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
export function refreshStatus(ctx, state) {
|
|
2
|
-
const environment = state.selected.environment;
|
|
3
|
-
const writePolicy = environment.type === "ssh"
|
|
4
|
-
? environment.writable
|
|
5
|
-
? "✎+?"
|
|
6
|
-
: "RO"
|
|
7
|
-
: "✎";
|
|
8
2
|
const parts = [
|
|
9
3
|
`MerchantDuo ${process.env.MERCHANTDUO_PACKAGE_VERSION ?? "?"}`,
|
|
10
4
|
`M${state.magento.version ?? "?"}`,
|
|
11
5
|
`${state.selected.name} (${state.environmentStatus})`,
|
|
12
|
-
|
|
6
|
+
state.permissionMode === "read-only" ? "r-o" : state.permissionMode,
|
|
13
7
|
...(state.config.activeTheme === "all" ? [] : [state.config.activeTheme]),
|
|
14
8
|
...(state.testing?.frontendUrl ? [state.testing.frontendUrl] : []),
|
|
15
9
|
];
|
|
@@ -13,6 +13,8 @@ After every change, inspect the changed module and files and choose the smallest
|
|
|
13
13
|
|
|
14
14
|
For cache clean/flush, maintenance enable/disable, static content deployment, compilation, setup upgrade, indexing, tests, or deployment: when the user directly requests execution, invoke \`magento_workflow\` with \`execute: true\` immediately. Use only this workflow tool for operational Magento commands. Do not ask the user for a separate conversational confirmation: the harness dialog is the sole authoritative confirmation. When execution was not requested, explain the operation and offer a preview.
|
|
15
15
|
|
|
16
|
+
Session permission mode is shown in the startup context and can be changed only with \`/duo-switch-permissions read-only|normal|yolo\`. Read-only blocks direct write/edit but an approved shell or explicit operation may still mutate. Normal retains ordinary local/Warden direct workspace behavior and SSH confirmations. Yolo removes MerchantDuo dialogs only; it never removes the explicit \`execute: true\` boundary, stopped-environment block, SSH lifecycle restriction, Mage2Gen SSH restriction, remote workflow restriction, PHP-console boundary, or credential and URL protections.
|
|
17
|
+
|
|
16
18
|
For a supported cache, deploy, indexing, or test operation, prefer \`magento_workflow\`. For a different n98-magerun2 subcommand, use \`magerun2\` with an exact \`args: string[]\` vector, never a shell command or interpolation. When the user did not directly request execution, return its preview without \`execute: true\`; an executed generic magerun2 call requires \`execute: true\` and uses the same sole harness confirmation. Never send \`dev:console\` through \`magerun2\`: use the typed \`magento_php_repl\` tool instead.
|
|
17
19
|
|
|
18
20
|
Environment lifecycle is explicit. Warden has known direct controls. SSH is always reported running and must never be started, stopped, or restarted. A local environment is agent-directed: when the user requests a start or stop, inspect the actual project lifecycle first, then call \`environment_start\` or \`environment_stop\` with the exact smallest command. Never guess a generic Docker, Compose, npm, or service command, and never affect unrelated host services. Use \`environment_set_status\` after a status inspection that does not itself start or stop the stack. Never inspect app/etc/env.php or credentials.`;
|
package/dist/cli/arguments.d.ts
CHANGED
package/dist/cli/arguments.js
CHANGED
|
@@ -7,6 +7,7 @@ export function parseArguments(args) {
|
|
|
7
7
|
let source;
|
|
8
8
|
let database;
|
|
9
9
|
let yes = false;
|
|
10
|
+
let yolo = false;
|
|
10
11
|
for (let index = 0; index < input.length; index += 1) {
|
|
11
12
|
const value = input[index];
|
|
12
13
|
if (value === "--env" || value === "--config" || value === "--source" || value === "--database") {
|
|
@@ -26,9 +27,14 @@ export function parseArguments(args) {
|
|
|
26
27
|
else if (value === "--yes") {
|
|
27
28
|
yes = true;
|
|
28
29
|
}
|
|
30
|
+
else if (value === "--yolo") {
|
|
31
|
+
yolo = true;
|
|
32
|
+
}
|
|
29
33
|
else {
|
|
30
34
|
piArguments.push(value);
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
|
-
|
|
37
|
+
if (command && yolo)
|
|
38
|
+
throw new Error("--yolo is only available when starting an agent session");
|
|
39
|
+
return { command, environment, configPath, source, database, yes, yolo, piArguments };
|
|
34
40
|
}
|
|
@@ -1 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
import type { PermissionMode } from "#app/permission-mode";
|
|
2
|
+
export declare function launchAgent(piArguments: string[], selectedEnvironment: string, configPath?: string, permissionMode?: PermissionMode): Promise<number>;
|
|
3
|
+
export declare function agentEnvironment(base: NodeJS.ProcessEnv, piDirectory: string, selectedEnvironment: string, configPath: string | undefined, version: string, permissionMode?: PermissionMode): NodeJS.ProcessEnv;
|
|
@@ -4,7 +4,7 @@ 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 async function launchAgent(piArguments, selectedEnvironment, configPath) {
|
|
7
|
+
export async function launchAgent(piArguments, selectedEnvironment, configPath, permissionMode) {
|
|
8
8
|
const state = merchantHome();
|
|
9
9
|
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
10
10
|
await mkdir(resolve(state, "pi"), { recursive: true, mode: 0o700 });
|
|
@@ -19,15 +19,12 @@ export async function launchAgent(piArguments, selectedEnvironment, configPath)
|
|
|
19
19
|
...piArguments,
|
|
20
20
|
], {
|
|
21
21
|
stdio: "inherit",
|
|
22
|
-
env:
|
|
23
|
-
...process.env,
|
|
24
|
-
PI_CODING_AGENT_DIR: resolve(state, "pi"),
|
|
25
|
-
MERCHANTDUO_SELECTED_ENV: selectedEnvironment,
|
|
26
|
-
MERCHANTDUO_CONFIG_PATH: configPath ?? "",
|
|
27
|
-
MERCHANTDUO_PACKAGE_VERSION: packageJson.version,
|
|
28
|
-
},
|
|
22
|
+
env: agentEnvironment(process.env, resolve(state, "pi"), selectedEnvironment, configPath, packageJson.version, permissionMode),
|
|
29
23
|
});
|
|
30
24
|
child.on("error", reject);
|
|
31
25
|
child.on("exit", (code) => resolveExit(code ?? 1));
|
|
32
26
|
});
|
|
33
27
|
}
|
|
28
|
+
export function agentEnvironment(base, piDirectory, selectedEnvironment, configPath, version, permissionMode) {
|
|
29
|
+
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
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { EnvironmentBackend } from "#environments/backend";
|
|
2
|
-
import {
|
|
2
|
+
import { resolvePermissionMode } from "#app/permission-mode";
|
|
3
3
|
import { DefaultMagentoInspector } from "#magento/inspector";
|
|
4
4
|
export async function doctor(cwd, name, environment, config) {
|
|
5
5
|
const magento = await new DefaultMagentoInspector().inspect(new EnvironmentBackend(environment, cwd));
|
|
6
6
|
console.log(JSON.stringify({
|
|
7
7
|
environment: name,
|
|
8
8
|
type: environment.type,
|
|
9
|
-
|
|
9
|
+
defaultPermissionMode: resolvePermissionMode(environment),
|
|
10
10
|
magento,
|
|
11
11
|
activeTheme: config.activeTheme,
|
|
12
12
|
knowledge: { connected: false },
|
|
@@ -6,4 +6,4 @@ export declare class TerminalInitInterview implements InitInterview {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function hasWardenEnvironment(cwd: string): Promise<boolean>;
|
|
8
8
|
export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void): Promise<void>;
|
|
9
|
-
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
|
|
9
|
+
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";
|
|
@@ -65,4 +65,4 @@ export async function initProject(cwd, interview = new TerminalInitInterview(),
|
|
|
65
65
|
output(`Created ${path}`);
|
|
66
66
|
output(remoteEnvironmentExample);
|
|
67
67
|
}
|
|
68
|
-
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
|
|
68
|
+
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`;
|
package/dist/cli/main.js
CHANGED
|
@@ -23,7 +23,7 @@ async function main() {
|
|
|
23
23
|
}
|
|
24
24
|
if (args.command === "doctor")
|
|
25
25
|
return doctor(cwd, selected.name, selected.environment, loaded.config);
|
|
26
|
-
process.exitCode = await launchAgent(args.piArguments, selected.name, loaded.configPath);
|
|
26
|
+
process.exitCode = await launchAgent(args.piArguments, selected.name, loaded.configPath, args.yolo ? "yolo" : undefined);
|
|
27
27
|
}
|
|
28
28
|
main().catch((error) => {
|
|
29
29
|
console.error(`merchantduo: ${error.message}`);
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -93,7 +93,6 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
93
93
|
type: z.ZodLiteral<"ssh">;
|
|
94
94
|
host: z.ZodString;
|
|
95
95
|
root: z.ZodString;
|
|
96
|
-
writable: z.ZodDefault<z.ZodBoolean>;
|
|
97
96
|
testing: z.ZodOptional<z.ZodObject<{
|
|
98
97
|
frontendUrl: z.ZodOptional<z.ZodString>;
|
|
99
98
|
adminUrl: z.ZodOptional<z.ZodString>;
|
|
@@ -111,7 +110,6 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
111
110
|
type: "ssh";
|
|
112
111
|
root: string;
|
|
113
112
|
host: string;
|
|
114
|
-
writable: boolean;
|
|
115
113
|
testing?: {
|
|
116
114
|
allowInsecureTls: boolean;
|
|
117
115
|
frontendUrl?: string | undefined;
|
|
@@ -126,7 +124,6 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
126
124
|
adminUrl?: string | undefined;
|
|
127
125
|
allowInsecureTls?: boolean | undefined;
|
|
128
126
|
} | undefined;
|
|
129
|
-
writable?: boolean | undefined;
|
|
130
127
|
}>]>>>;
|
|
131
128
|
}, "strict", z.ZodTypeAny, {
|
|
132
129
|
testing: {
|
|
@@ -158,7 +155,6 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
158
155
|
type: "ssh";
|
|
159
156
|
root: string;
|
|
160
157
|
host: string;
|
|
161
|
-
writable: boolean;
|
|
162
158
|
testing?: {
|
|
163
159
|
allowInsecureTls: boolean;
|
|
164
160
|
frontendUrl?: string | undefined;
|
|
@@ -202,7 +198,6 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
202
198
|
adminUrl?: string | undefined;
|
|
203
199
|
allowInsecureTls?: boolean | undefined;
|
|
204
200
|
} | undefined;
|
|
205
|
-
writable?: boolean | undefined;
|
|
206
201
|
}> | undefined;
|
|
207
202
|
}>;
|
|
208
203
|
export type MerchantConfig = Omit<z.infer<typeof ConfigSchema>, "environments"> & {
|
package/dist/config/schema.js
CHANGED
|
@@ -11,8 +11,10 @@ export declare class EnvironmentBackend implements CommandExecutor {
|
|
|
11
11
|
path(path: string): string;
|
|
12
12
|
command(argv: string[], target?: string): CommandSpec;
|
|
13
13
|
run(argv: string[], options?: RunOptions): Promise<CommandResult>;
|
|
14
|
+
/** Execute a pre-routed adapter command, used by automatic lifecycle probing. */
|
|
15
|
+
runCommand(spec: CommandSpec, options?: RunOptions): Promise<CommandResult>;
|
|
14
16
|
lifecycleCommand(action: "status" | "start" | "stop"): CommandSpec | undefined;
|
|
15
|
-
status(): Promise<EnvironmentStatus>;
|
|
17
|
+
status(onCommand?: (spec: CommandSpec, result: CommandResult) => void): Promise<EnvironmentStatus>;
|
|
16
18
|
start(): Promise<CommandResult | undefined>;
|
|
17
19
|
stop(): Promise<CommandResult | undefined>;
|
|
18
20
|
}
|
|
@@ -22,12 +22,16 @@ export class EnvironmentBackend {
|
|
|
22
22
|
}
|
|
23
23
|
async run(argv, options = {}) {
|
|
24
24
|
const spec = this.command(argv, options.target);
|
|
25
|
+
return this.runCommand(spec, options);
|
|
26
|
+
}
|
|
27
|
+
/** Execute a pre-routed adapter command, used by automatic lifecycle probing. */
|
|
28
|
+
runCommand(spec, options = {}) {
|
|
25
29
|
return this.#runSpec(spec, options);
|
|
26
30
|
}
|
|
27
31
|
lifecycleCommand(action) {
|
|
28
32
|
return this.#adapter.lifecycleCommand?.(action);
|
|
29
33
|
}
|
|
30
|
-
async status() {
|
|
34
|
+
async status(onCommand) {
|
|
31
35
|
if (this.environment.type === "ssh")
|
|
32
36
|
return "running";
|
|
33
37
|
if (this.environment.type === "local")
|
|
@@ -36,6 +40,7 @@ export class EnvironmentBackend {
|
|
|
36
40
|
if (!spec)
|
|
37
41
|
return "stopped";
|
|
38
42
|
const output = await this.#runSpec(spec);
|
|
43
|
+
onCommand?.(spec, output);
|
|
39
44
|
return output.exitCode === 0 && wardenRunning(output.stdout) ? "running" : "stopped";
|
|
40
45
|
}
|
|
41
46
|
async start() {
|
|
@@ -22,9 +22,7 @@ export type SshEnvironment = {
|
|
|
22
22
|
type: "ssh";
|
|
23
23
|
host: string;
|
|
24
24
|
root: string;
|
|
25
|
-
writable: boolean;
|
|
26
25
|
testing?: EnvironmentTesting;
|
|
27
26
|
};
|
|
28
27
|
export type Environment = LocalEnvironment | WardenEnvironment | SshEnvironment;
|
|
29
|
-
export declare function environmentWritable(environment: Environment): boolean;
|
|
30
28
|
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { EnvironmentBackend } from "#environments/backend";
|
|
2
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
3
|
import type { Environment } from "#environments/model";
|
|
3
4
|
import { type HostProcessRunner } from "#testing/host";
|
|
4
5
|
import type { Magerun2Capability, Magerun2Result } from "#features/magerun2/model";
|
|
5
6
|
/** Resolve magerun once for the selected Magento environment without altering the project. */
|
|
6
|
-
export declare function discoverMagerun2(environment: Environment, backend:
|
|
7
|
+
export declare function discoverMagerun2(environment: Environment, backend: CommandExecutor, cwd: string, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<Magerun2Capability>;
|
|
7
8
|
/** Run a structured argument vector. Local commands are host processes; Warden and SSH use their selected backend. */
|
|
8
9
|
export declare function runMagerun2(capability: Magerun2Capability, environment: Environment, backend: EnvironmentBackend, cwd: string, args: string[], stdin?: string, signal?: AbortSignal, host?: HostProcessRunner): Promise<Magerun2Result>;
|
|
9
10
|
/** Stable display only: this is never passed to a shell. */
|
|
@@ -6,20 +6,22 @@ import { NodeHostProcessRunner } from "#testing/host";
|
|
|
6
6
|
const RELEASE_URL = "https://api.github.com/repos/netz98/n98-magerun2/releases/latest";
|
|
7
7
|
const MAX_OUTPUT = 64 * 1024;
|
|
8
8
|
/** Resolve magerun once for the selected Magento environment without altering the project. */
|
|
9
|
-
export async function discoverMagerun2(environment, backend, cwd) {
|
|
9
|
+
export async function discoverMagerun2(environment, backend, cwd, record) {
|
|
10
10
|
if (environment.type === "warden")
|
|
11
11
|
return { available: true, executable: "/usr/local/bin/mr" };
|
|
12
12
|
const candidates = environment.type === "local"
|
|
13
13
|
? ["vendor/bin/n98-magerun2", "bin/n98-magerun2", "n98-magerun2", "magerun2"]
|
|
14
14
|
: ["n98-magerun2", "magerun2"];
|
|
15
15
|
for (const candidate of candidates) {
|
|
16
|
-
const
|
|
16
|
+
const argv = ["sh", "-lc", `command -v -- ${candidate} 2>/dev/null || { test -x ${candidate} && printf '%s' ${candidate}; }`];
|
|
17
|
+
const result = await backend.run(argv);
|
|
18
|
+
await record?.(argv, result);
|
|
17
19
|
const executable = result.stdout.trim();
|
|
18
20
|
if (executable)
|
|
19
21
|
return { available: true, executable };
|
|
20
22
|
}
|
|
21
23
|
if (environment.type === "ssh")
|
|
22
|
-
return { available: false, diagnostic:
|
|
24
|
+
return { available: false, diagnostic: "Install n98-magerun2 on the remote host." };
|
|
23
25
|
try {
|
|
24
26
|
return { available: true, executable: await installMagerun2(merchantHome(), cwd) };
|
|
25
27
|
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { runtime } from "#integrations/pi/session";
|
|
2
3
|
export default function context(pi: ExtensionAPI): void;
|
|
4
|
+
export declare function statusMessage(state: Awaited<ReturnType<typeof runtime.boot>>): string;
|
|
@@ -2,7 +2,7 @@ import { refreshStatus } from "#app/status";
|
|
|
2
2
|
import { merchantDuoSystemPrompt } from "#app/system-prompt";
|
|
3
3
|
import { discoverTesting } from "#testing/index";
|
|
4
4
|
import { discoverMagerun2 } from "#features/magerun2/index";
|
|
5
|
-
import { discoverHostExecutable } from "#testing/host";
|
|
5
|
+
import { discoverHostExecutable, NodeHostProcessRunner } from "#testing/host";
|
|
6
6
|
import { runtime, sessionEvents } from "#integrations/pi/session";
|
|
7
7
|
export default function context(pi) {
|
|
8
8
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -11,26 +11,37 @@ export default function context(pi) {
|
|
|
11
11
|
if (state.environmentStatus === "stopped") {
|
|
12
12
|
refreshStatus(ctx, state);
|
|
13
13
|
sessionEvents.emit("merchantduo.context", state);
|
|
14
|
-
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus });
|
|
14
|
+
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode });
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
|
-
const
|
|
17
|
+
const record = (argv, result) => state.nativeLog.record(state.backend.command(argv), result);
|
|
18
|
+
const magerun2 = await discoverMagerun2(state.selected.environment, state.backend, ctx.cwd, record);
|
|
18
19
|
// HTTP and Chromium deliberately run on the host against Warden's routed URL.
|
|
19
20
|
// Do not use EnvironmentBackend here: its Warden adapter would probe php-fpm.
|
|
20
21
|
// Prefer non-Snap Chrome: Snap Chromium cannot write MerchantDuo artifacts
|
|
21
22
|
// and its private /tmp makes generated output unavailable to the host.
|
|
23
|
+
const hostRunner = new NodeHostProcessRunner();
|
|
24
|
+
const nativeHost = { run: async (file, args, options) => {
|
|
25
|
+
const result = await hostRunner.run(file, args, options);
|
|
26
|
+
await state.nativeLog.record({ file, args, cwd: options.cwd }, { stdout: result.output, stderr: "", exitCode: result.code, truncated: result.truncated ?? false });
|
|
27
|
+
return result;
|
|
28
|
+
} };
|
|
22
29
|
const [http, browser] = await Promise.all([
|
|
23
|
-
discoverHostExecutable(["curl"]),
|
|
24
|
-
discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]),
|
|
30
|
+
discoverHostExecutable(["curl"], nativeHost),
|
|
31
|
+
discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"], nativeHost),
|
|
25
32
|
]);
|
|
26
33
|
runtime.setMagerun2(magerun2);
|
|
27
|
-
runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, { ...state.config.testing, ...state.selected.environment.testing, allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ?? state.config.testing.allowInsecureTls }, { http, browser }));
|
|
34
|
+
runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, { ...state.config.testing, ...state.selected.environment.testing, allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ?? state.config.testing.allowInsecureTls }, { http, browser }, record));
|
|
28
35
|
refreshStatus(ctx, state);
|
|
29
36
|
sessionEvents.emit("merchantduo.context", state);
|
|
30
|
-
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, testing: state.testing, magerun2: state.magerun2 });
|
|
37
|
+
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode, testing: state.testing, magerun2: state.magerun2 });
|
|
31
38
|
});
|
|
32
39
|
pi.on("session_compact", () => runtime.resetInjection());
|
|
33
40
|
pi.on("session_shutdown", () => sessionEvents.clear());
|
|
41
|
+
pi.registerCommand("duo-status", { description: "Show the current MerchantDuo session status.", handler: async (_args, ctx) => {
|
|
42
|
+
const state = await runtime.boot(ctx.cwd);
|
|
43
|
+
pi.sendMessage({ customType: "merchantduo.status", content: statusMessage(state), display: true, details: {} });
|
|
44
|
+
} });
|
|
34
45
|
pi.on("tool_call", (event) => {
|
|
35
46
|
if (event.toolName !== "write" && event.toolName !== "edit")
|
|
36
47
|
return;
|
|
@@ -47,6 +58,24 @@ export default function context(pi) {
|
|
|
47
58
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
48
59
|
const state = await runtime.boot(ctx.cwd);
|
|
49
60
|
const testing = state.testing;
|
|
50
|
-
return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt()}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}. ${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."}` };
|
|
61
|
+
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."}` };
|
|
51
62
|
});
|
|
52
63
|
}
|
|
64
|
+
export function statusMessage(state) {
|
|
65
|
+
const lines = [
|
|
66
|
+
`Environment: ${state.selected.name} (${state.environmentStatus})`,
|
|
67
|
+
`Permission mode: ${state.permissionMode}`,
|
|
68
|
+
`Magento: ${state.magento.edition ?? "unknown"} ${state.magento.version ?? "version unavailable"}`,
|
|
69
|
+
`PHP: ${state.magento.phpVersion ?? "unavailable"}`,
|
|
70
|
+
`Deploy mode: ${state.magento.mode ?? "unavailable"}`,
|
|
71
|
+
`Active theme: ${state.config.activeTheme}`,
|
|
72
|
+
`Frontend URL: ${state.testing?.frontendUrl ?? "unavailable"}`,
|
|
73
|
+
`Admin URL: ${state.testing?.adminUrl ?? "unavailable"}`,
|
|
74
|
+
`Capabilities: HTTP ${state.testing?.http.available ? "available" : "unavailable"}; browser ${state.testing?.browser.available ? "available" : "unavailable"}; magerun2 ${state.magerun2?.available ? "available" : "unavailable"}`,
|
|
75
|
+
...state.magento.warnings.map((warning) => `Warning: ${warning}`),
|
|
76
|
+
...(state.testing?.diagnostics.map((warning) => `Warning: ${warning}`) ?? []),
|
|
77
|
+
...(state.magerun2?.diagnostic ? [`Warning: ${state.magerun2.diagnostic}`] : []),
|
|
78
|
+
...(state.nativeLog.warning ? [`Warning: ${state.nativeLog.warning}`] : []),
|
|
79
|
+
];
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
/** Generic magerun2 is intentionally separate from the typed PHP console. */
|
|
3
3
|
export default function magerun2(pi: ExtensionAPI): void;
|
|
4
|
-
export declare function magerun2Refusal(args: string[]
|
|
4
|
+
export declare function magerun2Refusal(args: string[]): string | undefined;
|
|
@@ -14,7 +14,7 @@ export default function magerun2(pi) {
|
|
|
14
14
|
}),
|
|
15
15
|
async execute(_id, params, signal, _update, ctx) {
|
|
16
16
|
const state = await runtime.boot(ctx.cwd);
|
|
17
|
-
const refusal = magerun2Refusal(params.args
|
|
17
|
+
const refusal = magerun2Refusal(params.args);
|
|
18
18
|
if (refusal)
|
|
19
19
|
return text(refusal);
|
|
20
20
|
const capability = state.magerun2;
|
|
@@ -28,10 +28,8 @@ export default function magerun2(pi) {
|
|
|
28
28
|
},
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
export function magerun2Refusal(args
|
|
31
|
+
export function magerun2Refusal(args) {
|
|
32
32
|
if (args.includes("dev:console"))
|
|
33
33
|
return "Refused: dev:console belongs to magento_php_repl, which accepts a typed snippet and optional area.";
|
|
34
|
-
if (readOnlySsh)
|
|
35
|
-
return "Refused: generic magerun2 is unavailable for read-only SSH because arbitrary subcommands cannot be classified safely.";
|
|
36
34
|
return undefined;
|
|
37
35
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type PermissionMode } from "#app/permission-mode";
|
|
2
3
|
import type { Environment } from "#environments/model";
|
|
3
4
|
export declare function requiresExecuteConfirmation(toolName: string, input: Record<string, unknown>): boolean;
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/** Return block/confirmation behavior; callers retain all hard capability constraints. */
|
|
6
|
+
export declare function permissionDecision(mode: PermissionMode, environment: Environment, toolName: string, input: Record<string, unknown>): "allow" | "block" | "confirm";
|
|
7
|
+
/** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
|
|
6
8
|
export default function permissions(pi: ExtensionAPI): void;
|
|
@@ -1,37 +1,62 @@
|
|
|
1
|
+
import { permissionModes } from "#app/permission-mode";
|
|
2
|
+
import { refreshStatus } from "#app/status";
|
|
1
3
|
import { runtime } from "#integrations/pi/session";
|
|
4
|
+
const workspaceMutations = new Set(["write", "edit"]);
|
|
5
|
+
const stoppedAllowed = new Set(["environment_status", "environment_start", "environment_stop", "environment_set_status"]);
|
|
2
6
|
export function requiresExecuteConfirmation(toolName, input) {
|
|
3
7
|
return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
|
|
4
8
|
}
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
/** Return block/confirmation behavior; callers retain all hard capability constraints. */
|
|
10
|
+
export function permissionDecision(mode, environment, toolName, input) {
|
|
11
|
+
if (mode === "yolo")
|
|
12
|
+
return "allow";
|
|
13
|
+
if (mode === "read-only" && workspaceMutations.has(toolName))
|
|
14
|
+
return "block";
|
|
15
|
+
if (mode === "read-only" && (toolName === "bash" || toolName === "magento_php_repl" || requiresExecuteConfirmation(toolName, input)))
|
|
16
|
+
return "confirm";
|
|
17
|
+
if (requiresExecuteConfirmation(toolName, input))
|
|
18
|
+
return "confirm";
|
|
19
|
+
if (mode === "normal" && environment.type === "ssh" && (workspaceMutations.has(toolName) || toolName === "bash"))
|
|
20
|
+
return "confirm";
|
|
21
|
+
return "allow";
|
|
7
22
|
}
|
|
8
|
-
|
|
23
|
+
async function confirm(ctx, title, message) {
|
|
24
|
+
return ctx.hasUI && await ctx.ui.confirm(title, message);
|
|
25
|
+
}
|
|
26
|
+
/** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
|
|
9
27
|
export default function permissions(pi) {
|
|
28
|
+
pi.registerCommand("duo-switch-permissions", { description: "Set this session's permission mode: read-only, normal, or yolo.", handler: async (args, ctx) => {
|
|
29
|
+
const mode = args.trim();
|
|
30
|
+
if (!permissionModes.includes(mode)) {
|
|
31
|
+
ctx.ui.notify("Choose one of: read-only, normal, yolo", "error");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await runtime.boot(ctx.cwd);
|
|
35
|
+
const state = runtime.setPermissionMode(mode);
|
|
36
|
+
refreshStatus(ctx, state);
|
|
37
|
+
ctx.ui.notify(`Session permission mode set to ${mode}. It will reset when this session ends.`, "info");
|
|
38
|
+
} });
|
|
10
39
|
pi.on("tool_call", async (event, ctx) => {
|
|
11
40
|
const state = await runtime.boot(ctx.cwd);
|
|
12
|
-
|
|
13
|
-
if (state.environmentStatus === "stopped" && !["environment_status", "environment_start", "environment_stop", "environment_set_status"].includes(event.toolName))
|
|
41
|
+
if (state.environmentStatus === "stopped" && !stoppedAllowed.has(event.toolName))
|
|
14
42
|
return { block: true, reason: "MerchantDuo: selected environment is stopped. Start it first." };
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const ok = !requiresSshConfirmation(state.selected.environment, event.toolName) || (ctx.hasUI && await ctx.ui.confirm("SSH workspace action", `Run ${event.toolName} on ${state.selected.environment.host}?`));
|
|
23
|
-
return ok ? undefined : { block: true, reason: "Cancelled: SSH confirmation required." };
|
|
24
|
-
}
|
|
43
|
+
const decision = permissionDecision(state.permissionMode, state.selected.environment, event.toolName, event.input);
|
|
44
|
+
if (decision === "allow")
|
|
45
|
+
return;
|
|
46
|
+
if (decision === "block")
|
|
47
|
+
return { block: true, reason: "MerchantDuo: read-only mode blocks direct workspace writes and edits." };
|
|
48
|
+
const ok = await confirm(ctx, "Confirm Magento action", `Run ${event.toolName} in ${state.permissionMode} mode?`);
|
|
49
|
+
return ok ? undefined : { block: true, reason: "Cancelled: confirmation required." };
|
|
25
50
|
});
|
|
26
51
|
pi.on("user_bash", async (event, ctx) => {
|
|
27
52
|
const state = await runtime.boot(ctx.cwd);
|
|
28
|
-
if (state.
|
|
29
|
-
return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const ok = ctx.hasUI && await ctx.ui.confirm("SSH command", `Run command on ${state.selected.environment.host}?`);
|
|
33
|
-
if (!ok)
|
|
53
|
+
if (state.environmentStatus === "stopped")
|
|
54
|
+
return { result: { output: "MerchantDuo: selected environment is stopped. Start it first.", exitCode: 1, cancelled: true, truncated: false } };
|
|
55
|
+
const decision = state.permissionMode === "yolo" ? "allow" : state.permissionMode === "read-only" || state.selected.environment.type === "ssh" ? "confirm" : "allow";
|
|
56
|
+
if (decision === "confirm" && !(await confirm(ctx, "Confirm shell command", `Run command in ${state.permissionMode} mode?`)))
|
|
34
57
|
return { result: { output: "Cancelled: confirmation required.", exitCode: 1, cancelled: true, truncated: false } };
|
|
58
|
+
if (decision === "allow" && state.selected.environment.type !== "ssh" && state.permissionMode !== "read-only")
|
|
59
|
+
return;
|
|
35
60
|
const result = await state.backend.run(["sh", "-lc", event.command]);
|
|
36
61
|
return { result: { output: `${result.stdout}${result.stderr}`, exitCode: result.exitCode, cancelled: false, truncated: result.truncated } };
|
|
37
62
|
});
|
|
@@ -56,8 +56,6 @@ export default function testing(pi) {
|
|
|
56
56
|
} });
|
|
57
57
|
pi.registerTool({ name: "magento_php_repl", label: "Magento PHP console", description: "Explicit one-shot magerun2 PHP snippet execution.", parameters: Type.Object({ snippet: Type.String({ minLength: 1 }), area: Type.Optional(Type.Union([Type.Literal("adminhtml"), Type.Literal("frontend"), Type.Literal("crontab"), Type.Literal("webapi_rest"), Type.Literal("graphql")])) }), async execute(_id, params, signal, _update, ctx) {
|
|
58
58
|
const state = await runtime.boot(ctx.cwd);
|
|
59
|
-
if (state.selected.environment.type === "ssh" && !state.selected.environment.writable)
|
|
60
|
-
return text("PHP REPL is unavailable for read-only SSH.");
|
|
61
59
|
const executable = state.magerun2?.executable;
|
|
62
60
|
if (!executable)
|
|
63
61
|
return text(state.magerun2?.diagnostic ?? "PHP REPL unavailable.");
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { refreshStatus } from "#app/status";
|
|
2
2
|
import { runtime } from "#integrations/pi/session";
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export function themeChoices(state) {
|
|
4
|
+
return ["all", "any", ...state.magento.themes.map((theme) => theme.code)];
|
|
5
|
+
}
|
|
6
|
+
export default function theme(pi) {
|
|
7
|
+
pi.registerCommand("duo-switch-theme", {
|
|
8
|
+
description: "Choose a detected Magento theme, all, or any.",
|
|
9
|
+
handler: async (args, ctx) => {
|
|
10
|
+
const state = await runtime.boot(ctx.cwd);
|
|
11
|
+
const choices = themeChoices(state);
|
|
12
|
+
const typed = args.trim();
|
|
13
|
+
const value = typed || (ctx.hasUI ? await ctx.ui.select("Select Magento theme scope", choices) : undefined);
|
|
14
|
+
if (!value)
|
|
15
|
+
return;
|
|
16
|
+
if (!choices.includes(value)) {
|
|
17
|
+
ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const changed = await runtime.changeTheme(ctx.cwd, value);
|
|
21
|
+
refreshStatus(ctx, changed);
|
|
22
|
+
ctx.ui.notify(`Theme scope set to ${value}.`, "info");
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -6,7 +6,7 @@ export default function workflows(pi) {
|
|
|
6
6
|
pi.registerTool({ name: "magento_workflow", label: "Magento workflow", description: "Preview or explicitly execute a supported Magento operational action.", parameters: Type.Object({ action: Type.String(), execute: Type.Optional(Type.Boolean()) }), async execute(_id, params, _signal, _update, ctx) {
|
|
7
7
|
const state = await runtime.boot(ctx.cwd);
|
|
8
8
|
if (params.action === "status")
|
|
9
|
-
return text(JSON.stringify({ snapshot: state.magento, testing: state.testing, magerun2: state.magerun2, pendingFiles: runtime.changes.files(), workflows: builtInWorkflows.map(({ id, description }) => ({ id, description })) }, null, 2));
|
|
9
|
+
return text(JSON.stringify({ snapshot: state.magento, permissionMode: state.permissionMode, testing: state.testing, magerun2: state.magerun2, pendingFiles: runtime.changes.files(), workflows: builtInWorkflows.map(({ id, description }) => ({ id, description })) }, null, 2));
|
|
10
10
|
if (params.action === "syntax-check")
|
|
11
11
|
return text((await syntaxCheck(state.backend, runtime.changes.files())).join("\n") || "No supported changed files require syntax checks.");
|
|
12
12
|
const workflow = builtInWorkflows.find(({ id }) => id === params.action);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { CommandExecutor } from "#environments/executor";
|
|
1
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
2
|
import type { MagentoSnapshot } from "#magento/model";
|
|
3
3
|
export interface MagentoInspector {
|
|
4
|
-
inspect(executor: CommandExecutor): Promise<MagentoSnapshot>;
|
|
4
|
+
inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
|
|
5
5
|
}
|
|
6
6
|
/** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
|
|
7
7
|
export declare class DefaultMagentoInspector implements MagentoInspector {
|
|
8
|
-
inspect(executor: CommandExecutor): Promise<MagentoSnapshot>;
|
|
8
|
+
inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
|
|
9
9
|
}
|
|
@@ -2,13 +2,18 @@ import { parseDeployMode } from "#magento/deploy-mode";
|
|
|
2
2
|
import { parseRegisteredThemes, resolveThemes } from "#magento/themes";
|
|
3
3
|
/** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
|
|
4
4
|
export class DefaultMagentoInspector {
|
|
5
|
-
async inspect(executor) {
|
|
5
|
+
async inspect(executor, record) {
|
|
6
6
|
const warnings = [];
|
|
7
7
|
const evidence = [];
|
|
8
|
-
const
|
|
8
|
+
const run = async (argv) => {
|
|
9
|
+
const result = await executor.run(argv);
|
|
10
|
+
await record?.(argv, result);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
const composer = await run([
|
|
9
14
|
"sh",
|
|
10
15
|
"-lc",
|
|
11
|
-
|
|
16
|
+
`test -f composer.lock && php -r '$x=json_decode(file_get_contents("composer.lock"), true, flags: JSON_THROW_ON_ERROR); echo json_encode($x["packages"] ?? []);'`,
|
|
12
17
|
]);
|
|
13
18
|
const match = composer.stdout.match(/magento\/(product-community-edition|product-enterprise-edition).*?"version":"([^"]+)/s);
|
|
14
19
|
const edition = match?.[1] === "product-enterprise-edition"
|
|
@@ -19,20 +24,21 @@ export class DefaultMagentoInspector {
|
|
|
19
24
|
const composerVersion = match?.[2];
|
|
20
25
|
if (composerVersion)
|
|
21
26
|
evidence.push("composer.lock");
|
|
22
|
-
const cli = await
|
|
27
|
+
const cli = await run(["bin/magento", "--version"]);
|
|
23
28
|
const cliVersion = cli.stdout.match(/(?:Magento|Adobe Commerce)[^0-9]*(\d+\.\d+\.\d+(?:-[^\s]+)?)/)?.[1];
|
|
24
29
|
if (cliVersion)
|
|
25
30
|
evidence.push("bin/magento --version");
|
|
31
|
+
if (!composerVersion && !cliVersion)
|
|
32
|
+
warnings.push("Magento version unavailable");
|
|
26
33
|
if (composerVersion &&
|
|
27
34
|
cliVersion &&
|
|
28
35
|
!cliVersion.startsWith(composerVersion))
|
|
29
36
|
warnings.push(`Composer (${composerVersion}) and CLI (${cliVersion}) disagree`);
|
|
30
|
-
const [php, mode, cache,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
executor.run(["sh", "-lc", themeDiscoveryScript]),
|
|
37
|
+
const [php, mode, cache, registeredThemes] = await Promise.all([
|
|
38
|
+
run(["php", "-r", "echo PHP_VERSION;"]),
|
|
39
|
+
run(["bin/magento", "deploy:mode:show"]),
|
|
40
|
+
run(["bin/magento", "cache:status", "--no-ansi"]),
|
|
41
|
+
run(["sh", "-lc", themeDiscoveryScript]),
|
|
36
42
|
]);
|
|
37
43
|
return {
|
|
38
44
|
version: composerVersion ?? cliVersion,
|
|
@@ -43,7 +49,6 @@ export class DefaultMagentoInspector {
|
|
|
43
49
|
.split("\n")
|
|
44
50
|
.map((line) => line.match(/^([^:]+):/)?.[1])
|
|
45
51
|
.filter((value) => Boolean(value)),
|
|
46
|
-
modulesEnabled: modules.stdout.split("\n").filter(Boolean).length || undefined,
|
|
47
52
|
warnings,
|
|
48
53
|
evidence,
|
|
49
54
|
themes: resolveThemes(parseRegisteredThemes(registeredThemes.stdout)),
|
package/dist/magento/model.d.ts
CHANGED
package/dist/magento/prompt.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { MagentoSnapshot } from "#magento/model";
|
|
2
|
-
|
|
2
|
+
import type { PermissionMode } from "#app/permission-mode";
|
|
3
|
+
export declare function snapshotPrompt(envName: string, permissionMode: PermissionMode, status: string, snapshot: MagentoSnapshot): string;
|
package/dist/magento/prompt.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export function snapshotPrompt(envName,
|
|
2
|
-
return `[MerchantDuo: environment=${envName};
|
|
1
|
+
export function snapshotPrompt(envName, permissionMode, status, snapshot) {
|
|
2
|
+
return `[MerchantDuo: environment=${envName}; permission mode=${permissionMode}; Magento=${snapshot.edition ?? "unknown"} ${snapshot.version ?? "unknown"}; PHP=${snapshot.phpVersion ?? "unknown"}; mode=${snapshot.mode ?? "unknown"}; knowledge=${status}; pending actions are controlled by magento_workflow. Do not inspect app/etc/env.php or credentials.${snapshot.warnings.length ? ` Warnings: ${snapshot.warnings.join("; ")}` : ""}]`;
|
|
3
3
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
2
|
import type { Environment } from "#environments/model";
|
|
3
3
|
import type { TestingOptions, TestingSnapshot } from "#testing/model";
|
|
4
|
-
export declare function discoverTesting(environment: Environment, backend:
|
|
4
|
+
export declare function discoverTesting(environment: Environment, backend: CommandExecutor, cwd: string, options: TestingOptions, tools?: {
|
|
5
5
|
http?: string;
|
|
6
6
|
browser?: string;
|
|
7
|
-
}): Promise<TestingSnapshot>;
|
|
7
|
+
}, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<TestingSnapshot>;
|
|
8
8
|
export declare function wardenRouting(projectRoot: string): Promise<string | undefined>;
|
|
9
9
|
export declare function findExecutable(candidates: string[]): Promise<string | undefined>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { canonicalUrl, joinedUrl } from "#testing/url";
|
|
4
|
-
export async function discoverTesting(environment, backend, cwd, options, tools = {}) {
|
|
4
|
+
export async function discoverTesting(environment, backend, cwd, options, tools = {}, record) {
|
|
5
5
|
const diagnostics = [];
|
|
6
6
|
let frontendUrl = canonicalUrl(options.frontendUrl ?? "");
|
|
7
7
|
let adminUrl = canonicalUrl(options.adminUrl ?? "");
|
|
@@ -17,15 +17,21 @@ export async function discoverTesting(environment, backend, cwd, options, tools
|
|
|
17
17
|
diagnostics.push("Warden routing was not found in .env.");
|
|
18
18
|
}
|
|
19
19
|
if (!frontendUrl && environment.type !== "warden") {
|
|
20
|
-
const
|
|
20
|
+
const secure = ["bin/magento", "config:show", "web/secure/base_url"];
|
|
21
|
+
const result = await backend.run(secure);
|
|
22
|
+
await record?.(secure, result);
|
|
21
23
|
frontendUrl = canonicalUrl(result.stdout.trim());
|
|
22
24
|
if (!frontendUrl) {
|
|
23
|
-
const
|
|
25
|
+
const unsecure = ["bin/magento", "config:show", "web/unsecure/base_url"];
|
|
26
|
+
const fallback = await backend.run(unsecure);
|
|
27
|
+
await record?.(unsecure, fallback);
|
|
24
28
|
frontendUrl = canonicalUrl(fallback.stdout.trim());
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
if (!adminUrl) {
|
|
28
|
-
const
|
|
32
|
+
const argv = ["bin/magento", "info:adminuri"];
|
|
33
|
+
const result = await backend.run(argv);
|
|
34
|
+
await record?.(argv, result);
|
|
29
35
|
const path = result.stdout.trim().split(/\s+/).pop();
|
|
30
36
|
adminUrl = joinedUrl(frontendUrl, path?.startsWith("/") ? path : `/${path ?? ""}`);
|
|
31
37
|
}
|