@yumerijs/loader 3.0.1 → 3.1.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/dist/index.d.ts CHANGED
@@ -7,6 +7,14 @@ interface Plugin {
7
7
  render?: string;
8
8
  config?: Schema<any>;
9
9
  }
10
+ export interface PluginConfigEntry {
11
+ id: string;
12
+ moduleName: string;
13
+ config: any;
14
+ configKey: string;
15
+ enabled: boolean;
16
+ legacy: boolean;
17
+ }
10
18
  export declare class PluginLoader {
11
19
  private pluginsDir;
12
20
  core: Core;
@@ -25,6 +33,13 @@ export declare class PluginLoader {
25
33
  private pluginContexts;
26
34
  private isDev;
27
35
  constructor(core?: Core, pluginsDir?: string);
36
+ /**
37
+ * Normalize both the legacy `{ "package-name": { ...config } }` form and
38
+ * the named instance form `{ "instance-id": { module, config } }`.
39
+ */
40
+ getPluginEntry(pluginId: string): PluginConfigEntry | undefined;
41
+ setPluginConfig(pluginId: string, config: any): boolean;
42
+ private getEnabledPluginEntries;
28
43
  /**
29
44
  * Reloads the config file from disk into memory and emits a 'config-reloaded' event.
30
45
  * This does NOT reload any plugins.
@@ -42,6 +57,12 @@ export declare class PluginLoader {
42
57
  private _unloadSinglePlugin;
43
58
  reloadPlugin(pluginName: string): Promise<void>;
44
59
  private watchPlugin;
60
+ private isMissingModuleError;
61
+ private isNpxInvocation;
62
+ private detectPackageManager;
63
+ private confirmPluginInstall;
64
+ private installMissingPlugin;
65
+ private importPluginModule;
45
66
  loadModule(pluginName: string): Promise<Plugin>;
46
67
  checkPluginDependencies(pluginPath: string): Promise<boolean>;
47
68
  installPluginDependencies(pluginName: string): Promise<void>;
package/dist/index.js CHANGED
@@ -2,11 +2,13 @@ import * as path from 'path';
2
2
  import { Core, Logger, Context, fallback, Schema, I18n, resolvePluginModule } from '@yumerijs/core';
3
3
  import * as fs from 'fs';
4
4
  import { promisify } from 'util';
5
- import { exec } from 'child_process';
5
+ import { execFile } from 'child_process';
6
+ import { createInterface } from 'readline/promises';
7
+ import { stdin as input, stdout as output } from 'process';
6
8
  import { fileURLToPath } from 'url';
7
9
  import * as yaml from 'js-yaml';
8
10
  import * as chokidar from 'chokidar';
9
- const execAsync = promisify(exec);
11
+ const execFileAsync = promisify(execFile);
10
12
  export class PluginLoader {
11
13
  pluginsDir;
12
14
  core;
@@ -25,6 +27,55 @@ export class PluginLoader {
25
27
  this.isDev = process.env.NODE_ENV === 'development';
26
28
  Logger.setCore(this.core);
27
29
  }
30
+ /**
31
+ * Normalize both the legacy `{ "package-name": { ...config } }` form and
32
+ * the named instance form `{ "instance-id": { module, config } }`.
33
+ */
34
+ getPluginEntry(pluginId) {
35
+ const plugins = this.config?.plugins;
36
+ if (!plugins || typeof plugins !== 'object')
37
+ return undefined;
38
+ const configKey = Object.prototype.hasOwnProperty.call(plugins, pluginId)
39
+ ? pluginId
40
+ : Object.prototype.hasOwnProperty.call(plugins, `~${pluginId}`)
41
+ ? `~${pluginId}`
42
+ : undefined;
43
+ if (!configKey)
44
+ return undefined;
45
+ const enabled = !configKey.startsWith('~');
46
+ const id = enabled ? configKey : configKey.substring(1);
47
+ const value = plugins[configKey];
48
+ const isInstance = value && typeof value === 'object' && !Array.isArray(value)
49
+ && typeof value.module === 'string';
50
+ return {
51
+ id,
52
+ moduleName: isInstance ? value.module : id,
53
+ config: isInstance ? (value.config ?? {}) : (value ?? {}),
54
+ configKey,
55
+ enabled,
56
+ legacy: !isInstance,
57
+ };
58
+ }
59
+ setPluginConfig(pluginId, config) {
60
+ const entry = this.getPluginEntry(pluginId);
61
+ if (!entry)
62
+ return false;
63
+ if (entry.legacy) {
64
+ this.config.plugins[entry.configKey] = config;
65
+ }
66
+ else {
67
+ this.config.plugins[entry.configKey].config = config;
68
+ }
69
+ return true;
70
+ }
71
+ getEnabledPluginEntries() {
72
+ if (!this.config || typeof this.config.plugins !== 'object' || this.config.plugins === null) {
73
+ return [];
74
+ }
75
+ return Object.keys(this.config.plugins)
76
+ .map(key => this.getPluginEntry(key.startsWith('~') ? key.substring(1) : key))
77
+ .filter(entry => entry && entry.enabled);
78
+ }
28
79
  /**
29
80
  * Reloads the config file from disk into memory and emits a 'config-reloaded' event.
30
81
  * This does NOT reload any plugins.
@@ -84,18 +135,14 @@ export class PluginLoader {
84
135
  this.logger.info('No plugins configuration found. No plugins to load.');
85
136
  return;
86
137
  }
87
- const allPluginNames = Object.keys(this.config.plugins);
138
+ const allEntries = Object.keys(this.config.plugins)
139
+ .map(key => this.getPluginEntry(key.startsWith('~') ? key.substring(1) : key))
140
+ .filter(Boolean);
88
141
  this.pluginStatus = {}; // Reset status
89
- for (const name of allPluginNames) {
90
- if (name.startsWith('~')) {
91
- const actualName = name.substring(1);
92
- this.pluginStatus[actualName] = "disabled" /* PluginStatus.DISABLED */;
93
- }
94
- else {
95
- this.pluginStatus[name] = "pending" /* PluginStatus.PENDING */;
96
- }
142
+ for (const entry of allEntries) {
143
+ this.pluginStatus[entry.id] = entry.enabled ? "pending" /* PluginStatus.PENDING */ : "disabled" /* PluginStatus.DISABLED */;
97
144
  }
98
- const enabledPlugins = allPluginNames.filter(name => !name.startsWith('~'));
145
+ const enabledPlugins = allEntries.filter(entry => entry.enabled).map(entry => entry.id);
99
146
  const currentlyLoaded = Object.keys(this.plugins);
100
147
  for (const loadedName of currentlyLoaded) {
101
148
  if (!enabledPlugins.includes(loadedName)) {
@@ -170,13 +217,13 @@ export class PluginLoader {
170
217
  return false;
171
218
  }
172
219
  this.plugins[pluginName] = pluginInstance;
173
- // ### NEW CONFIG LOGIC ###
174
- const rawConfig = (this.config.plugins && this.config.plugins[pluginName]) || {};
175
- const schema = pluginInstance.config || Schema.object({}); // The schema is exported as 'config'
176
- const finalConfig = fallback(schema, rawConfig);
177
- // Update the in-memory config with the fully resolved one
178
- this.config.plugins[pluginName] = finalConfig;
179
- // ### END NEW CONFIG LOGIC ###
220
+ const entry = this.getPluginEntry(pluginName);
221
+ if (!entry) {
222
+ throw new Error(`Plugin configuration not found for instance "${pluginName}".`);
223
+ }
224
+ const schema = pluginInstance.config || Schema.object({});
225
+ const finalConfig = fallback(schema, entry.config);
226
+ this.setPluginConfig(pluginName, finalConfig);
180
227
  const context = this.getContext(pluginName, {});
181
228
  if (pluginInstance.render) {
182
229
  context.renderer = this.core.renderers.get(pluginInstance.render || '');
@@ -188,14 +235,15 @@ export class PluginLoader {
188
235
  }
189
236
  if (this.isDev) {
190
237
  let pluginPathToWatch = null;
238
+ const moduleName = entry.moduleName;
191
239
  try {
192
- const packageJsonUrl = import.meta.resolve(`${pluginName}/package.json`);
240
+ const packageJsonUrl = import.meta.resolve(`${moduleName}/package.json`);
193
241
  const pkgJsonPath = fileURLToPath(packageJsonUrl);
194
242
  pluginPathToWatch = path.dirname(pkgJsonPath);
195
243
  }
196
244
  catch (e) {
197
- const localPluginPath = path.resolve(process.cwd(), pluginName);
198
- const localPluginPathInPlugins = path.resolve(process.cwd(), 'plugins', pluginName);
245
+ const localPluginPath = path.resolve(process.cwd(), moduleName);
246
+ const localPluginPathInPlugins = path.resolve(process.cwd(), 'plugins', moduleName);
199
247
  if (fs.existsSync(localPluginPath)) {
200
248
  pluginPathToWatch = localPluginPath;
201
249
  }
@@ -314,29 +362,99 @@ export class PluginLoader {
314
362
  });
315
363
  this.pluginWatchers[pluginName] = watcher;
316
364
  }
317
- async loadModule(pluginName) {
318
- const pluginModule = await import(pluginName);
319
- const context = this.getContext(pluginName, {});
320
- const rawConfig = (this.config.plugins && this.config.plugins[pluginName]) || {};
321
- return resolvePluginModule(pluginModule, context, rawConfig);
365
+ isMissingModuleError(error, moduleName) {
366
+ const candidate = error;
367
+ if (candidate?.code !== 'ERR_MODULE_NOT_FOUND')
368
+ return false;
369
+ return typeof candidate.message === 'string' && candidate.message.includes(moduleName);
322
370
  }
323
- async checkPluginDependencies(pluginPath) {
324
- return true;
371
+ isNpxInvocation() {
372
+ const argv = process.argv.map(value => value.toLowerCase());
373
+ const env = process.env;
374
+ return Boolean(env.npm_config_npx_command ||
375
+ env.npm_command === 'exec' ||
376
+ argv.some(value => /(?:^|[\\/])npx(?:\.cmd)?$/.test(value)) ||
377
+ (env._ && /(?:^|[\\/])npx(?:\.cmd)?$/.test(env._.toLowerCase())));
325
378
  }
326
- async installPluginDependencies(pluginName) {
379
+ detectPackageManager() {
380
+ const cwd = process.cwd();
327
381
  try {
328
- this.logger.info(`Installing dependencies for plugin: ${pluginName}`);
329
- const { stdout, stderr } = await execAsync(`npm install ${pluginName} --save`);
330
- this.logger.info(`stdout: ${stdout}`);
331
- if (stderr) {
332
- this.logger.error(`stderr: ${stderr}`);
382
+ const packageJsonPath = path.join(cwd, 'package.json');
383
+ if (fs.existsSync(packageJsonPath)) {
384
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
385
+ if (typeof packageJson.packageManager === 'string') {
386
+ return packageJson.packageManager.split('@')[0];
387
+ }
333
388
  }
334
- this.logger.info(`Dependencies installed for plugin: ${pluginName}`);
389
+ }
390
+ catch {
391
+ // Fall back to lockfile detection when package.json is unavailable or invalid.
392
+ }
393
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml')))
394
+ return 'pnpm';
395
+ if (fs.existsSync(path.join(cwd, 'yarn.lock')))
396
+ return 'yarn';
397
+ if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock')))
398
+ return 'bun';
399
+ return 'npm';
400
+ }
401
+ async confirmPluginInstall(moduleName) {
402
+ if (!input.isTTY || !output.isTTY) {
403
+ this.logger.warn(`Plugin package "${moduleName}" is not installed and no interactive terminal is available.`);
404
+ return false;
405
+ }
406
+ const scope = this.isNpxInvocation() ? 'globally' : 'in the current project';
407
+ const readline = createInterface({ input, output });
408
+ try {
409
+ const answer = await readline.question(`Plugin package "${moduleName}" is not installed. Install it ${scope}? [y/N] `);
410
+ return /^(y|yes)$/i.test(answer.trim());
411
+ }
412
+ finally {
413
+ readline.close();
414
+ }
415
+ }
416
+ async installMissingPlugin(moduleName) {
417
+ const packageManager = this.detectPackageManager();
418
+ const global = this.isNpxInvocation();
419
+ const argsByManager = {
420
+ npm: global ? ['install', '--global', moduleName] : ['install', moduleName, '--save'],
421
+ yarn: global ? ['global', 'add', moduleName] : ['add', moduleName],
422
+ pnpm: global ? ['add', '--global', moduleName] : ['add', moduleName],
423
+ bun: global ? ['add', '--global', moduleName] : ['add', moduleName],
424
+ };
425
+ const args = argsByManager[packageManager] || argsByManager.npm;
426
+ const command = process.platform === 'win32' ? `${packageManager}.cmd` : packageManager;
427
+ this.logger.info(`Installing missing plugin "${moduleName}" with ${command} ${args.join(' ')}` +
428
+ (global ? ' (global)' : ''));
429
+ await execFileAsync(command, args, { cwd: process.cwd() });
430
+ }
431
+ async importPluginModule(moduleName) {
432
+ try {
433
+ return await import(moduleName);
335
434
  }
336
435
  catch (error) {
337
- this.logger.error(`Error installing dependencies for plugin ${pluginName}:`, error);
338
- throw error;
436
+ if (!this.isMissingModuleError(error, moduleName))
437
+ throw error;
438
+ const shouldInstall = await this.confirmPluginInstall(moduleName);
439
+ if (!shouldInstall)
440
+ throw error;
441
+ await this.installMissingPlugin(moduleName);
442
+ return await import(moduleName);
339
443
  }
340
444
  }
445
+ async loadModule(pluginName) {
446
+ const entry = this.getPluginEntry(pluginName);
447
+ if (!entry)
448
+ throw new Error(`Plugin configuration not found for instance "${pluginName}".`);
449
+ const pluginModule = await this.importPluginModule(entry.moduleName);
450
+ const context = this.getContext(pluginName, {});
451
+ return resolvePluginModule(pluginModule, context, entry.config);
452
+ }
453
+ async checkPluginDependencies(pluginPath) {
454
+ return true;
455
+ }
456
+ async installPluginDependencies(pluginName) {
457
+ await this.installMissingPlugin(pluginName);
458
+ }
341
459
  }
342
460
  export default PluginLoader;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yumerijs/loader",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "description": "Module loader for yumeri",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -29,7 +29,7 @@
29
29
  "devDependencies": {
30
30
  "@types/js-yaml": "^4.0.9",
31
31
  "@types/node": "^22.13.10",
32
- "@yumerijs/core": "^3.0.0",
32
+ "@yumerijs/core": "^3.0.5",
33
33
  "esbuild-register": "^3.6.0",
34
34
  "typescript": "^6.0.3"
35
35
  },
@@ -41,6 +41,6 @@
41
41
  "js-yaml": "^4.1.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@yumerijs/core": "^3.0.0"
44
+ "@yumerijs/core": "^3.0.5"
45
45
  }
46
46
  }