@yumerijs/loader 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ interface Plugin {
3
3
  apply: (ctx: Context, config: Config) => Promise<void>;
4
4
  disable: (ctx: Context) => Promise<void>;
5
5
  depend: Array<string>;
6
+ optional?: Array<string>;
6
7
  provide: Array<string>;
7
8
  render?: string;
8
9
  config?: Schema<any>;
@@ -23,6 +24,7 @@ export declare class PluginLoader {
23
24
  plugins: {
24
25
  [name: string]: Plugin & {
25
26
  depend?: string[];
27
+ optional?: string[];
26
28
  provide?: string[];
27
29
  };
28
30
  };
@@ -51,12 +53,18 @@ export declare class PluginLoader {
51
53
  unregall(pluginName: string): void;
52
54
  loadConfig(configPath: string): Promise<void>;
53
55
  loadPlugins(): Promise<void>;
54
- loadSinglePlugin(pluginName: string, triggerPendingCheck?: boolean, onlypending?: boolean): Promise<boolean>;
56
+ loadSinglePlugin(pluginName: string, triggerPendingCheck?: boolean, onlypending?: boolean, requireOptionalDependencies?: boolean): Promise<boolean>;
55
57
  private _loadPendingPlugins;
56
58
  unloadPlugin(pluginNameToUnload: string, ispending?: boolean): Promise<void>;
57
59
  private _unloadSinglePlugin;
58
60
  reloadPlugin(pluginName: string): Promise<void>;
59
61
  private watchPlugin;
62
+ private isMissingModuleError;
63
+ private isNpxInvocation;
64
+ private detectPackageManager;
65
+ private confirmPluginInstall;
66
+ private installMissingPlugin;
67
+ private importPluginModule;
60
68
  loadModule(pluginName: string): Promise<Plugin>;
61
69
  checkPluginDependencies(pluginPath: string): Promise<boolean>;
62
70
  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;
@@ -151,26 +153,20 @@ export class PluginLoader {
151
153
  this.logger.info('No enabled plugins found in configuration.');
152
154
  return;
153
155
  }
154
- let loadedInLastPass = true;
155
- while (loadedInLastPass) {
156
- loadedInLastPass = false;
157
- for (const pluginName of enabledPlugins) {
158
- if (this.pluginStatus[pluginName] === "enabled" /* PluginStatus.ENABLED */) {
159
- continue;
160
- }
161
- const success = await this.loadSinglePlugin(pluginName, false);
162
- if (success) {
163
- loadedInLastPass = true;
164
- }
165
- }
156
+ // 第一阶段:将 optional 与 depend 一样处理,尽量让可选服务先完成加载并注入。
157
+ while (await this._loadPendingPlugins(true)) {
158
+ // Continue scanning until no plugin can satisfy all required and optional dependencies.
159
+ }
160
+ // 第二阶段:严格扫描无法推进后,忽略尚未提供的 optional;depend 仍必须满足。
161
+ while (await this._loadPendingPlugins(false)) {
162
+ // Continue scanning in relaxed mode until only genuinely missing dependencies remain.
166
163
  }
167
- await this._loadPendingPlugins();
168
164
  const pendingPlugins = Object.keys(this.pluginStatus).filter(p => this.pluginStatus[p] === "pending" /* PluginStatus.PENDING */);
169
165
  if (pendingPlugins.length > 0) {
170
- // this.logger.warn('Some plugins could not be loaded due to unresolved dependencies:', pendingPlugins);
166
+ this.logger.warn('Some plugins could not be loaded due to unresolved required dependencies:', pendingPlugins);
171
167
  }
172
168
  }
173
- async loadSinglePlugin(pluginName, triggerPendingCheck = true, onlypending = false) {
169
+ async loadSinglePlugin(pluginName, triggerPendingCheck = true, onlypending = false, requireOptionalDependencies = true) {
174
170
  if (!this.pluginStatus[pluginName]) {
175
171
  this.pluginStatus[pluginName] = "pending" /* PluginStatus.PENDING */;
176
172
  }
@@ -209,7 +205,10 @@ export class PluginLoader {
209
205
  }
210
206
  }
211
207
  }
212
- const deps = pluginInstance.depend || [];
208
+ const deps = [
209
+ ...(pluginInstance.depend || []),
210
+ ...(requireOptionalDependencies ? (pluginInstance.optional || []) : []),
211
+ ];
213
212
  const unmetDependencies = deps.filter(dep => !this.core.components[dep] && !this.core.services[dep]);
214
213
  if (unmetDependencies.length > 0) {
215
214
  return false;
@@ -229,7 +228,7 @@ export class PluginLoader {
229
228
  await this.core.plugin(pluginInstance, context, finalConfig);
230
229
  this.pluginStatus[pluginName] = "enabled" /* PluginStatus.ENABLED */;
231
230
  if (triggerPendingCheck) {
232
- await this._loadPendingPlugins();
231
+ await this._loadPendingPlugins(requireOptionalDependencies);
233
232
  }
234
233
  if (this.isDev) {
235
234
  let pluginPathToWatch = null;
@@ -263,13 +262,14 @@ export class PluginLoader {
263
262
  return false;
264
263
  }
265
264
  }
266
- async _loadPendingPlugins() {
265
+ async _loadPendingPlugins(requireOptionalDependencies = true) {
267
266
  const pendingPlugins = Object.keys(this.pluginStatus).filter(p => this.pluginStatus[p] === "pending" /* PluginStatus.PENDING */);
268
- if (pendingPlugins.length === 0)
269
- return;
267
+ let loadedAny = false;
270
268
  for (const pluginName of pendingPlugins) {
271
- await this.loadSinglePlugin(pluginName, false);
269
+ const loaded = await this.loadSinglePlugin(pluginName, false, true, requireOptionalDependencies);
270
+ loadedAny ||= loaded;
272
271
  }
272
+ return loadedAny;
273
273
  }
274
274
  async unloadPlugin(pluginNameToUnload, ispending = false) {
275
275
  const dependents = [];
@@ -323,7 +323,10 @@ export class PluginLoader {
323
323
  this.logger.info(`Reloading plugin: "${pluginName}"...`);
324
324
  await this.reloadConfigFile();
325
325
  await this.unloadPlugin(pluginName, true);
326
- const success = await this.loadSinglePlugin(pluginName, true, false);
326
+ let success = await this.loadSinglePlugin(pluginName, true, false, true);
327
+ if (!success) {
328
+ success = await this.loadSinglePlugin(pluginName, true, false, false);
329
+ }
327
330
  if (success) {
328
331
  this.logger.info(`Plugin "${pluginName}" reloaded successfully.`);
329
332
  this.core.emit('plugin-reloaded', pluginName);
@@ -360,11 +363,91 @@ export class PluginLoader {
360
363
  });
361
364
  this.pluginWatchers[pluginName] = watcher;
362
365
  }
366
+ isMissingModuleError(error, moduleName) {
367
+ const candidate = error;
368
+ if (candidate?.code !== 'ERR_MODULE_NOT_FOUND')
369
+ return false;
370
+ return typeof candidate.message === 'string' && candidate.message.includes(moduleName);
371
+ }
372
+ isNpxInvocation() {
373
+ const argv = process.argv.map(value => value.toLowerCase());
374
+ const env = process.env;
375
+ return Boolean(env.npm_config_npx_command ||
376
+ env.npm_command === 'exec' ||
377
+ argv.some(value => /(?:^|[\\/])npx(?:\.cmd)?$/.test(value)) ||
378
+ (env._ && /(?:^|[\\/])npx(?:\.cmd)?$/.test(env._.toLowerCase())));
379
+ }
380
+ detectPackageManager() {
381
+ const cwd = process.cwd();
382
+ try {
383
+ const packageJsonPath = path.join(cwd, 'package.json');
384
+ if (fs.existsSync(packageJsonPath)) {
385
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
386
+ if (typeof packageJson.packageManager === 'string') {
387
+ return packageJson.packageManager.split('@')[0];
388
+ }
389
+ }
390
+ }
391
+ catch {
392
+ // Fall back to lockfile detection when package.json is unavailable or invalid.
393
+ }
394
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml')))
395
+ return 'pnpm';
396
+ if (fs.existsSync(path.join(cwd, 'yarn.lock')))
397
+ return 'yarn';
398
+ if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock')))
399
+ return 'bun';
400
+ return 'npm';
401
+ }
402
+ async confirmPluginInstall(moduleName) {
403
+ if (!input.isTTY || !output.isTTY) {
404
+ this.logger.warn(`Plugin package "${moduleName}" is not installed and no interactive terminal is available.`);
405
+ return false;
406
+ }
407
+ const scope = this.isNpxInvocation() ? 'globally' : 'in the current project';
408
+ const readline = createInterface({ input, output });
409
+ try {
410
+ const answer = await readline.question(`Plugin package "${moduleName}" is not installed. Install it ${scope}? [y/N] `);
411
+ return /^(y|yes)$/i.test(answer.trim());
412
+ }
413
+ finally {
414
+ readline.close();
415
+ }
416
+ }
417
+ async installMissingPlugin(moduleName) {
418
+ const packageManager = this.detectPackageManager();
419
+ const global = this.isNpxInvocation();
420
+ const argsByManager = {
421
+ npm: global ? ['install', '--global', moduleName] : ['install', moduleName, '--save'],
422
+ yarn: global ? ['global', 'add', moduleName] : ['add', moduleName],
423
+ pnpm: global ? ['add', '--global', moduleName] : ['add', moduleName],
424
+ bun: global ? ['add', '--global', moduleName] : ['add', moduleName],
425
+ };
426
+ const args = argsByManager[packageManager] || argsByManager.npm;
427
+ const command = process.platform === 'win32' ? `${packageManager}.cmd` : packageManager;
428
+ this.logger.info(`Installing missing plugin "${moduleName}" with ${command} ${args.join(' ')}` +
429
+ (global ? ' (global)' : ''));
430
+ await execFileAsync(command, args, { cwd: process.cwd() });
431
+ }
432
+ async importPluginModule(moduleName) {
433
+ try {
434
+ return await import(moduleName);
435
+ }
436
+ catch (error) {
437
+ if (!this.isMissingModuleError(error, moduleName))
438
+ throw error;
439
+ const shouldInstall = await this.confirmPluginInstall(moduleName);
440
+ if (!shouldInstall)
441
+ throw error;
442
+ await this.installMissingPlugin(moduleName);
443
+ return await import(moduleName);
444
+ }
445
+ }
363
446
  async loadModule(pluginName) {
364
447
  const entry = this.getPluginEntry(pluginName);
365
448
  if (!entry)
366
449
  throw new Error(`Plugin configuration not found for instance "${pluginName}".`);
367
- const pluginModule = await import(entry.moduleName);
450
+ const pluginModule = await this.importPluginModule(entry.moduleName);
368
451
  const context = this.getContext(pluginName, {});
369
452
  return resolvePluginModule(pluginModule, context, entry.config);
370
453
  }
@@ -372,19 +455,7 @@ export class PluginLoader {
372
455
  return true;
373
456
  }
374
457
  async installPluginDependencies(pluginName) {
375
- try {
376
- this.logger.info(`Installing dependencies for plugin: ${pluginName}`);
377
- const { stdout, stderr } = await execAsync(`npm install ${pluginName} --save`);
378
- this.logger.info(`stdout: ${stdout}`);
379
- if (stderr) {
380
- this.logger.error(`stderr: ${stderr}`);
381
- }
382
- this.logger.info(`Dependencies installed for plugin: ${pluginName}`);
383
- }
384
- catch (error) {
385
- this.logger.error(`Error installing dependencies for plugin ${pluginName}:`, error);
386
- throw error;
387
- }
458
+ await this.installMissingPlugin(pluginName);
388
459
  }
389
460
  }
390
461
  export default PluginLoader;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yumerijs/loader",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Module loader for yumeri",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",