mage-remote-run 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,9 @@ import {
44
44
  } from '../lib/command-registry.js';
45
45
  import { getActiveProfile } from '../lib/config.js';
46
46
  import { startMcpServer } from '../lib/mcp.js';
47
+ import { PluginLoader } from '../lib/plugin-loader.js';
48
+ import { eventBus, EVENTS } from '../lib/events.js';
49
+ import { createClient } from '../lib/api/factory.js';
47
50
 
48
51
  // Connection commands are registered dynamically via registerCommands
49
52
  // But we need them registered early if we want them to show up in help even if config fails?
@@ -65,9 +68,31 @@ program.command('mcp [args...]')
65
68
  });
66
69
 
67
70
  const profile = await getActiveProfile();
71
+
72
+ // Load Plugins
73
+ // We construct an initial context.
74
+ // Note: client is not available yet as it is created per command usually,
75
+ // but we can pass a way to get it or just pass null for now if not used at startup.
76
+ // Also mcpServer is not running here unless mcp command is used.
77
+ const appContext = {
78
+ program,
79
+ config: await loadConfig(), // Re-load or reuse config
80
+ profile,
81
+ eventBus,
82
+ EVENTS,
83
+ createClient
84
+ };
85
+
86
+ const pluginLoader = new PluginLoader(appContext);
87
+ await pluginLoader.loadPlugins();
88
+
89
+ eventBus.emit(EVENTS.INIT, appContext);
90
+
68
91
  registerCommands(program, profile);
69
92
 
70
93
  program.hook('preAction', async (thisCommand, actionCommand) => {
94
+ eventBus.emit(EVENTS.BEFORE_COMMAND, { thisCommand, actionCommand, profile });
95
+
71
96
  // Check if we have an active profile and if format is not json/xml
72
97
  // Note: 'options' are available on the command that has them defined.
73
98
  // actionCommand is the command actually being executed.
@@ -84,6 +109,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
84
109
  }
85
110
  });
86
111
 
112
+ program.hook('postAction', async (thisCommand, actionCommand) => {
113
+ eventBus.emit(EVENTS.AFTER_COMMAND, { thisCommand, actionCommand, profile });
114
+ });
115
+
87
116
  import { expandCommandAbbreviations } from '../lib/command-helper.js';
88
117
 
89
118
  // Check for first run (no profiles configured and no arguments or just help)
@@ -17,8 +17,9 @@ import { registerModulesCommands } from './commands/modules.js';
17
17
  import { registerConsoleCommand } from './commands/console.js';
18
18
  import { registerShipmentCommands } from './commands/shipments.js';
19
19
  import { registerRestCommands } from './commands/rest.js';
20
+ import { registerPluginsCommands } from './commands/plugins.js';
20
21
 
21
- export { registerConnectionCommands, registerConsoleCommand, registerShipmentCommands, registerRestCommands };
22
+ export { registerConnectionCommands, registerConsoleCommand, registerShipmentCommands, registerRestCommands, registerPluginsCommands };
22
23
 
23
24
  const GROUPS = {
24
25
  CORE: [
@@ -33,7 +34,8 @@ const GROUPS = {
33
34
  registerInventoryCommands,
34
35
  registerShipmentCommands,
35
36
  registerConsoleCommand,
36
- registerRestCommands
37
+ registerRestCommands,
38
+ registerPluginsCommands
37
39
  ],
38
40
  COMMERCE: [
39
41
  registerCompanyCommands,
@@ -0,0 +1,106 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, saveConfig } from '../config.js';
3
+ import path from 'node:path';
4
+ import { realpath } from 'node:fs/promises';
5
+
6
+
7
+ function isFilesystemPath(pluginRef) {
8
+ const isScopedPackageName = /^@[^/\\]+\/[^/\\]+$/.test(pluginRef);
9
+ const hasPathSeparator = pluginRef.includes('/') || pluginRef.includes('\\');
10
+
11
+ return (
12
+ path.isAbsolute(pluginRef)
13
+ || pluginRef.startsWith('./')
14
+ || pluginRef.startsWith('../')
15
+ || pluginRef.startsWith('.\\')
16
+ || pluginRef.startsWith('..\\')
17
+ || pluginRef.startsWith('~/')
18
+ || pluginRef.startsWith('~\\')
19
+ || pluginRef.startsWith('file:')
20
+ || (hasPathSeparator && !isScopedPackageName)
21
+ );
22
+ }
23
+
24
+ async function resolvePluginReference(pluginRef) {
25
+ if (!isFilesystemPath(pluginRef)) {
26
+ return pluginRef;
27
+ }
28
+
29
+ if (pluginRef.startsWith('~/') || pluginRef.startsWith('~\\')) {
30
+ return realpath(path.join(process.env.HOME || process.env.USERPROFILE || '', pluginRef.slice(2)));
31
+ }
32
+
33
+ if (pluginRef.startsWith('file:')) {
34
+ return realpath(new URL(pluginRef));
35
+ }
36
+
37
+ return realpath(pluginRef);
38
+ }
39
+
40
+
41
+ export function registerPluginsCommands(program) {
42
+ const pluginsCmd = program.command('plugin')
43
+ .description('Manage plugins');
44
+
45
+ pluginsCmd.command('register <package-name>')
46
+ .description('Register an installed plugin in the configuration')
47
+ .action(async (packageName) => {
48
+ try {
49
+ const pluginRef = await resolvePluginReference(packageName);
50
+ const config = await loadConfig();
51
+ if (!config.plugins) {
52
+ config.plugins = [];
53
+ }
54
+
55
+ if (config.plugins.includes(pluginRef)) {
56
+ console.log(chalk.yellow(`Plugin "${pluginRef}" is already registered.`));
57
+ return;
58
+ }
59
+
60
+ config.plugins.push(pluginRef);
61
+ await saveConfig(config);
62
+ console.log(chalk.green(`Plugin "${pluginRef}" successfully registered.`));
63
+ console.log(chalk.gray(`Make sure the package is installed globally or in the local project.`));
64
+
65
+ } catch (error) {
66
+ console.error(chalk.red(`Error registering plugin: ${error.message}`));
67
+ }
68
+ });
69
+
70
+ pluginsCmd.command('unregister <package-name>')
71
+ .description('Unregister a plugin from the configuration')
72
+ .action(async (packageName) => {
73
+ try {
74
+ const pluginRef = await resolvePluginReference(packageName);
75
+ const config = await loadConfig();
76
+ if (!config.plugins || !config.plugins.includes(pluginRef)) {
77
+ console.log(chalk.yellow(`Plugin "${pluginRef}" is not registered.`));
78
+ return;
79
+ }
80
+
81
+ config.plugins = config.plugins.filter(p => p !== pluginRef);
82
+ await saveConfig(config);
83
+ console.log(chalk.green(`Plugin "${pluginRef}" successfully unregistered.`));
84
+
85
+ } catch (error) {
86
+ console.error(chalk.red(`Error unregistering plugin: ${error.message}`));
87
+ }
88
+ });
89
+
90
+ pluginsCmd.command('list')
91
+ .description('List registered plugins')
92
+ .action(async () => {
93
+ const config = await loadConfig();
94
+ const plugins = config.plugins || [];
95
+
96
+ if (plugins.length === 0) {
97
+ console.log(chalk.gray('No plugins registered.'));
98
+ return;
99
+ }
100
+
101
+ console.log(chalk.bold('Registered Plugins:'));
102
+ plugins.forEach(plugin => {
103
+ console.log(`- ${plugin}`);
104
+ });
105
+ });
106
+ }
package/lib/config.js CHANGED
@@ -42,10 +42,14 @@ export async function loadConfig() {
42
42
  try {
43
43
  await migrateOldConfig();
44
44
  if (!fs.existsSync(CONFIG_FILE)) {
45
- return { profiles: {}, activeProfile: null };
45
+ return { profiles: {}, activeProfile: null, plugins: [] };
46
46
  }
47
47
  const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
48
- return JSON.parse(data);
48
+ const config = JSON.parse(data);
49
+ if (!config.plugins) {
50
+ config.plugins = [];
51
+ }
52
+ return config;
49
53
  } catch (e) {
50
54
  console.error("Error loading config:", e.message);
51
55
  return { profiles: {}, activeProfile: null };
package/lib/events.js ADDED
@@ -0,0 +1,12 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ export class AppEventBus extends EventEmitter {}
4
+
5
+ export const eventBus = new AppEventBus();
6
+
7
+ export const EVENTS = {
8
+ INIT: 'init',
9
+ BEFORE_COMMAND: 'beforeCommand',
10
+ AFTER_COMMAND: 'afterCommand',
11
+ MCP_START: 'mcpStart'
12
+ };
package/lib/mcp.js CHANGED
@@ -9,6 +9,9 @@ import { readFileSync } from "fs";
9
9
 
10
10
  // Import command registry
11
11
  import { registerAllCommands } from './command-registry.js';
12
+ import { PluginLoader } from './plugin-loader.js';
13
+ import { loadConfig } from './config.js';
14
+ import { eventBus, AppEventBus, EVENTS } from './events.js';
12
15
 
13
16
  // Helper to strip ANSI codes for cleaner output
14
17
  function stripAnsi(str) {
@@ -28,13 +31,15 @@ export async function startMcpServer(options) {
28
31
  );
29
32
 
30
33
  // 1. Setup a dynamic program to discovery commands
31
- const program = setupProgram();
34
+ const program = await setupProgramAsync();
32
35
 
33
36
  const server = new McpServer({
34
37
  name: "mage-remote-run",
35
38
  version: packageJson.version
36
39
  });
37
40
 
41
+ eventBus.emit(EVENTS.MCP_START, { server, options });
42
+
38
43
  const toolsCount = registerTools(server, program);
39
44
 
40
45
  if (options.transport === 'http') {
@@ -138,7 +143,7 @@ function registerTools(server, program) {
138
143
 
139
144
  // Re-register all commands on a fresh program instance
140
145
  // We export this logic so we can reuse it
141
- function setupProgram() {
146
+ async function setupProgramAsync() {
142
147
  const program = new Command();
143
148
 
144
149
  // Silence output for the main program instance to avoid double printing during parsing
@@ -147,8 +152,33 @@ function setupProgram() {
147
152
  writeErr: (str) => { }
148
153
  });
149
154
 
155
+ const localEventBus = new AppEventBus();
156
+
157
+ const appContext = {
158
+ program,
159
+ config: await loadConfig(),
160
+ profile: null, // MCP uses all commands, usually? Or should we use active profile?
161
+ // registerAllCommands registers everything. Plugins might need to know if they should register.
162
+ // Assuming plugins register globally for now or handle checking config themselves.
163
+ eventBus: localEventBus,
164
+ EVENTS
165
+ };
166
+
167
+ const pluginLoader = new PluginLoader(appContext);
168
+ await pluginLoader.loadPlugins();
169
+
170
+ localEventBus.emit(EVENTS.INIT, appContext);
171
+
150
172
  registerAllCommands(program);
151
173
 
174
+ program.hook('preAction', async (thisCommand, actionCommand) => {
175
+ localEventBus.emit(EVENTS.BEFORE_COMMAND, { thisCommand, actionCommand, profile: null });
176
+ });
177
+
178
+ program.hook('postAction', async (thisCommand, actionCommand) => {
179
+ localEventBus.emit(EVENTS.AFTER_COMMAND, { thisCommand, actionCommand, profile: null });
180
+ });
181
+
152
182
  return program;
153
183
  }
154
184
 
@@ -172,7 +202,7 @@ async function executeCommand(cmdDefinition, args, parentName) {
172
202
  console.error = logInterceptor;
173
203
 
174
204
  try {
175
- const program = setupProgram();
205
+ const program = await setupProgramAsync();
176
206
 
177
207
  // Construct argv
178
208
  // We need to build [node, script, command, subcommand, ..., args, options]
@@ -180,43 +210,6 @@ async function executeCommand(cmdDefinition, args, parentName) {
180
210
 
181
211
  // Reconstruct command path
182
212
  if (parentName) {
183
- // parentName might be "website" or "website_domain" (if nested deeper? current logic supports 1 level nesting)
184
- // Current processCommand logic: `cmdName = parentName ? ${parentName}_${cmd.name()} : cmd.name()`
185
- // But parentName passed to executeCommand is the prefix.
186
- // Wait, registerTools calls: `executeCommand(cmd, args, parentName)`
187
- // If parentName is "website", and cmd is "list", we need "website list"
188
-
189
- // NOTE: parentName in processCommand is built recursively with underscores?
190
- // In processCommand(subCmd, cmdName), cmdName is "parent_sub".
191
- // So if we have website -> list.
192
- // processCommand(website) -> processCommand(list, "website")
193
- // parentName in executeCommand is "website".
194
- // cmd.name() is "list".
195
-
196
- // However, parentName might contain underscores if deeper nesting?
197
- // "store_config_list" -> parent "store_config", cmd "list".
198
- // Commander commands are usually space separated in argv.
199
-
200
- // We need to parse parentName back to argv tokens?
201
- // Or better: store the "command path" as an array in the tool context.
202
-
203
- // Let's rely on standard splitting by underscore, assuming command names don't have underscores.
204
- // Or we can assume parentName matches the command structure.
205
-
206
- // Safest: splitting parentName by UNDERSCORE might be risky if command names have underscores.
207
- // But standard commands here: website, store, etc. don't.
208
-
209
- // Actually, we can just push parentName, then cmd.name()
210
- // But parentName comes from `cmdName` variable passed as `parentName` to recursive call.
211
- // `processCommand(subCmd, cmdName)`
212
- // `cmdName` = `parentName_cmd.name()`.
213
- // So for `website list`:
214
- // `processCommand(website, '')` -> `cmdName="website"`.
215
- // -> `processCommand(list, "website")`.
216
- // -> register tool "website_list". `parentName` passed to execute is "website".
217
-
218
- // So `parentName` is the accumulated prefix with underscores.
219
-
220
213
  const parts = parentName.split('_');
221
214
  argv.push(...parts);
222
215
  }
@@ -0,0 +1,98 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { pathToFileURL } from 'url';
4
+ import globalDirs from 'global-dirs';
5
+ import chalk from 'chalk';
6
+ import { createRequire } from 'module';
7
+ import { loadConfig } from './config.js';
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ export class PluginLoader {
12
+ constructor(appContext) {
13
+ this.appContext = appContext;
14
+ this.plugins = [];
15
+ }
16
+
17
+ async loadPlugins() {
18
+ const config = await loadConfig();
19
+ // If config.plugins is missing, we default to empty array
20
+ const plugins = config.plugins || [];
21
+
22
+ for (const pluginName of plugins) {
23
+ try {
24
+ await this.loadPlugin(pluginName);
25
+ } catch (e) {
26
+ console.error(chalk.red(`Failed to load plugin ${pluginName}: ${e.message}`));
27
+ if (process.env.DEBUG) {
28
+ console.error(e);
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ async loadPlugin(pluginName) {
35
+ let pluginPath;
36
+
37
+ // 1. Try local node_modules
38
+ try {
39
+ pluginPath = require.resolve(pluginName);
40
+ } catch (e) {
41
+ // 2. Try global node_modules (npm)
42
+ try {
43
+ const globalNpmPath = path.join(globalDirs.npm.packages, pluginName);
44
+ if (fs.existsSync(globalNpmPath)) {
45
+ const pkgJsonPath = path.join(globalNpmPath, 'package.json');
46
+ if (fs.existsSync(pkgJsonPath)) {
47
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
48
+ const mainFile = pkg.main || 'index.js';
49
+ pluginPath = path.join(globalNpmPath, mainFile);
50
+ } else {
51
+ pluginPath = path.join(globalNpmPath, 'index.js');
52
+ }
53
+ } else {
54
+ // 3. Try global node_modules (yarn)
55
+ const globalYarnPath = path.join(globalDirs.yarn.packages, pluginName);
56
+ if (fs.existsSync(globalYarnPath)) {
57
+ const pkgJsonPath = path.join(globalYarnPath, 'package.json');
58
+ if (fs.existsSync(pkgJsonPath)) {
59
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
60
+ const mainFile = pkg.main || 'index.js';
61
+ pluginPath = path.join(globalYarnPath, mainFile);
62
+ } else {
63
+ pluginPath = path.join(globalYarnPath, 'index.js');
64
+ }
65
+ }
66
+ }
67
+ } catch (globalErr) {
68
+ // Ignore global errors, proceed to throw if not found
69
+ }
70
+ }
71
+
72
+ if (!pluginPath) {
73
+ throw new Error(`Could not resolve plugin '${pluginName}' locally or globally.`);
74
+ }
75
+
76
+ // Import using file URL for absolute paths in ESM
77
+ // Windows paths need to be converted to file URLs
78
+ const pluginUrl = pathToFileURL(pluginPath).href;
79
+
80
+ if (process.env.DEBUG) {
81
+ console.log(chalk.gray(`Loading plugin from: ${pluginUrl}`));
82
+ }
83
+
84
+ const pluginModule = await import(pluginUrl);
85
+
86
+ if (pluginModule && pluginModule.default) {
87
+ if (typeof pluginModule.default === 'function') {
88
+ await pluginModule.default(this.appContext);
89
+ this.plugins.push({ name: pluginName, module: pluginModule });
90
+ if (process.env.DEBUG) {
91
+ console.log(chalk.gray(`Loaded plugin: ${pluginName}`));
92
+ }
93
+ } else {
94
+ console.warn(chalk.yellow(`Plugin ${pluginName} does not export a default function.`));
95
+ }
96
+ }
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mage-remote-run",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "description": "The remote swiss army knife for Magento Open Source, Mage-OS, Adobe Commerce",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -40,7 +40,8 @@
40
40
  "commander": "^14.0.2",
41
41
  "csv-parse": "^6.1.0",
42
42
  "csv-stringify": "^6.6.0",
43
- "env-paths": "^3.0.0",
43
+ "env-paths": "^4.0.0",
44
+ "global-dirs": "^3.0.1",
44
45
  "html-to-text": "^9.0.5",
45
46
  "inquirer": "^13.1.0",
46
47
  "mkdirp": "^3.0.1",