@verdaccio/plugin-verifier 1.0.0-next-9.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +255 -0
  4. package/bin/verdaccio-plugin-verifier +6 -0
  5. package/build/_virtual/_rolldown/runtime.cjs +23 -0
  6. package/build/cli.cjs +15 -0
  7. package/build/cli.cjs.map +1 -0
  8. package/build/cli.d.ts +2 -0
  9. package/build/cli.d.ts.map +1 -0
  10. package/build/cli.js +14 -0
  11. package/build/cli.js.map +1 -0
  12. package/build/commands/verify.cjs +76 -0
  13. package/build/commands/verify.cjs.map +1 -0
  14. package/build/commands/verify.d.ts +11 -0
  15. package/build/commands/verify.d.ts.map +1 -0
  16. package/build/commands/verify.js +74 -0
  17. package/build/commands/verify.js.map +1 -0
  18. package/build/diagnostics.cjs +209 -0
  19. package/build/diagnostics.cjs.map +1 -0
  20. package/build/diagnostics.d.ts +13 -0
  21. package/build/diagnostics.d.ts.map +1 -0
  22. package/build/diagnostics.js +207 -0
  23. package/build/diagnostics.js.map +1 -0
  24. package/build/index.cjs +11 -0
  25. package/build/index.d.ts +5 -0
  26. package/build/index.d.ts.map +1 -0
  27. package/build/index.js +4 -0
  28. package/build/sanity-checks.cjs +27 -0
  29. package/build/sanity-checks.cjs.map +1 -0
  30. package/build/sanity-checks.d.ts +11 -0
  31. package/build/sanity-checks.d.ts.map +1 -0
  32. package/build/sanity-checks.js +22 -0
  33. package/build/sanity-checks.js.map +1 -0
  34. package/build/types.d.ts +80 -0
  35. package/build/types.d.ts.map +1 -0
  36. package/build/verify-plugin.cjs +87 -0
  37. package/build/verify-plugin.cjs.map +1 -0
  38. package/build/verify-plugin.d.ts +19 -0
  39. package/build/verify-plugin.d.ts.map +1 -0
  40. package/build/verify-plugin.js +85 -0
  41. package/build/verify-plugin.js.map +1 -0
  42. package/package.json +63 -0
  43. package/src/cli.ts +14 -0
  44. package/src/commands/verify.ts +100 -0
  45. package/src/diagnostics.ts +274 -0
  46. package/src/index.ts +10 -0
  47. package/src/sanity-checks.ts +27 -0
  48. package/src/types.ts +83 -0
  49. package/src/verify-plugin.ts +115 -0
  50. package/tests/diagnostics.spec.ts +323 -0
  51. package/tests/fixtures/entry-point-bad-json/package.json +1 -0
  52. package/tests/fixtures/entry-point-exports-default/package.json +7 -0
  53. package/tests/fixtures/entry-point-exports-import-default/package.json +9 -0
  54. package/tests/fixtures/entry-point-exports-import-string/package.json +7 -0
  55. package/tests/fixtures/entry-point-exports-string/package.json +5 -0
  56. package/tests/fixtures/entry-point-main/package.json +3 -0
  57. package/tests/fixtures/entry-point-module/package.json +3 -0
  58. package/tests/fixtures/package.json +3 -0
  59. package/tests/fixtures/verdaccio-invalid-plugin/index.js +2 -0
  60. package/tests/fixtures/verdaccio-null-plugin/index.js +4 -0
  61. package/tests/fixtures/verdaccio-throwing-plugin/index.js +4 -0
  62. package/tests/fixtures/verdaccio-valid-auth-es6-plugin/index.js +14 -0
  63. package/tests/fixtures/verdaccio-valid-auth-plugin/index.js +20 -0
  64. package/tests/fixtures/verdaccio-valid-filter-plugin/index.js +12 -0
  65. package/tests/fixtures/verdaccio-valid-middleware-plugin/index.js +10 -0
  66. package/tests/fixtures/verdaccio-valid-storage-plugin/index.js +30 -0
  67. package/tests/fixtures/verdaccio-wrong-category-plugin/index.js +11 -0
  68. package/tests/verify-plugin.spec.ts +218 -0
  69. package/tsconfig.build.json +4 -0
  70. package/tsconfig.json +24 -0
  71. package/vite.config.mjs +69 -0
  72. package/vitest.config.mjs +11 -0
@@ -0,0 +1,274 @@
1
+ import buildDebug from 'debug';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import { PLUGIN_PREFIX } from '@verdaccio/core';
8
+
9
+ import { getSanityCheck } from './sanity-checks';
10
+ import type { DiagnosticStep, VerifyPluginOptions } from './types';
11
+
12
+ const debug = buildDebug('verdaccio:plugin:verifier:diagnostics');
13
+
14
+ // createRequire needs an absolute path; works in both ESM and CJS contexts
15
+ const requireModule = createRequire(
16
+ typeof __filename !== 'undefined' ? __filename : import.meta.url
17
+ );
18
+
19
+ function isValidExport(plugin: any): boolean {
20
+ return typeof plugin === 'function' || typeof plugin?.default === 'function';
21
+ }
22
+
23
+ function isES6(plugin: any): boolean {
24
+ return plugin && typeof plugin === 'object' && 'default' in plugin;
25
+ }
26
+
27
+ /**
28
+ * Resolve the ESM entry point for a directory-based plugin.
29
+ * import() doesn't support directory imports, so we resolve via package.json.
30
+ */
31
+ export function resolveEntryPoint(dirPath: string): string {
32
+ const pkgPath = join(dirPath, 'package.json');
33
+ if (existsSync(pkgPath)) {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
36
+ if (pkg.exports) {
37
+ const dotExport = pkg.exports['.'];
38
+ if (typeof dotExport === 'string') {
39
+ return join(dirPath, dotExport);
40
+ }
41
+ if (dotExport?.import?.default) {
42
+ return join(dirPath, dotExport.import.default);
43
+ }
44
+ if (dotExport?.import && typeof dotExport.import === 'string') {
45
+ return join(dirPath, dotExport.import);
46
+ }
47
+ if (dotExport?.default) {
48
+ return join(dirPath, dotExport.default);
49
+ }
50
+ }
51
+ if (pkg.module) {
52
+ return join(dirPath, pkg.module);
53
+ }
54
+ if (pkg.main) {
55
+ return join(dirPath, pkg.main);
56
+ }
57
+ } catch {
58
+ // fall through
59
+ }
60
+ }
61
+ return join(dirPath, 'index.js');
62
+ }
63
+
64
+ /**
65
+ * Try to load a module, falling back from require() to import() for ESM.
66
+ * Handles CJS require(), ESM require() shim errors (bundlers), and
67
+ * ERR_REQUIRE_ESM — always falls through to dynamic import() on any
68
+ * require() failure.
69
+ */
70
+ async function tryResolve(modulePath: string): Promise<{ module: any; error?: string }> {
71
+ // Try require() first (fast path for CJS modules)
72
+ try {
73
+ return { module: requireModule(modulePath) };
74
+ } catch (requireErr: any) {
75
+ debug('require() failed for %o: %s — trying dynamic import', modulePath, requireErr.message);
76
+ }
77
+
78
+ // Fallback to dynamic import() for ESM modules
79
+ try {
80
+ let importPath = modulePath;
81
+ if (existsSync(modulePath) && existsSync(join(modulePath, 'package.json'))) {
82
+ importPath = resolveEntryPoint(modulePath);
83
+ debug('resolved ESM entry point: %o', importPath);
84
+ }
85
+ const importUrl = importPath.startsWith('/') ? pathToFileURL(importPath).href : importPath;
86
+ const mod = await import(importUrl);
87
+ return { module: mod };
88
+ } catch (importErr: any) {
89
+ return { module: null, error: importErr.message };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Runs step-by-step diagnostics to identify exactly which phase of
95
+ * plugin loading fails. This replicates the same resolution logic
96
+ * as `asyncLoadPlugin` but tests each step independently.
97
+ */
98
+ export async function runDiagnostics(options: VerifyPluginOptions): Promise<DiagnosticStep[]> {
99
+ const {
100
+ pluginPath,
101
+ category,
102
+ pluginConfig = {},
103
+ sanityCheck: customSanityCheck,
104
+ prefix = PLUGIN_PREFIX,
105
+ pluginsFolder,
106
+ } = options;
107
+
108
+ const steps: DiagnosticStep[] = [];
109
+ const isScoped = pluginPath.startsWith('@') && pluginPath.includes('/');
110
+ const pluginName = isScoped ? pluginPath : `${prefix}-${pluginPath}`;
111
+
112
+ debug('running diagnostics for %o (resolved name: %o)', pluginPath, pluginName);
113
+
114
+ // --- Phase 1: Resolve ---
115
+ let pluginModule: any = null;
116
+ let resolvedFrom = '';
117
+
118
+ // Try plugins folder first
119
+ if (pluginsFolder) {
120
+ const absFolder = resolve(pluginsFolder);
121
+ const pluginDir = join(absFolder, pluginName);
122
+ debug('checking plugins folder: %o', pluginDir);
123
+
124
+ if (!existsSync(absFolder)) {
125
+ steps.push({
126
+ phase: 'resolve',
127
+ pass: false,
128
+ message: `Plugins folder does not exist: ${absFolder}`,
129
+ });
130
+ return steps;
131
+ }
132
+
133
+ if (!existsSync(pluginDir)) {
134
+ debug('plugin directory not found: %o', pluginDir);
135
+ steps.push({
136
+ phase: 'resolve',
137
+ pass: false,
138
+ message: `Plugin directory not found: ${pluginDir} — expected a folder named "${pluginName}" inside "${absFolder}"`,
139
+ });
140
+ return steps;
141
+ }
142
+
143
+ const result = await tryResolve(pluginDir);
144
+ if (result.module) {
145
+ pluginModule = result.module;
146
+ resolvedFrom = pluginDir;
147
+ debug('resolved from plugins folder: %o', pluginDir);
148
+ } else {
149
+ const missingDep = parseMissingDependency(result.error ?? '', pluginDir);
150
+ steps.push({
151
+ phase: 'resolve',
152
+ pass: false,
153
+ message: missingDep
154
+ ? `Plugin found at ${pluginDir} but has a missing dependency: ${missingDep}`
155
+ : `Plugin found at ${pluginDir} but failed to load: ${result.error}`,
156
+ });
157
+ return steps;
158
+ }
159
+ }
160
+
161
+ // Try node_modules if not found in plugins folder
162
+ if (!pluginModule) {
163
+ const result = await tryResolve(pluginName);
164
+ if (result.module) {
165
+ pluginModule = result.module;
166
+ resolvedFrom = pluginName;
167
+ debug('resolved from node_modules: %o', pluginName);
168
+ } else {
169
+ const missingDep = parseMissingDependency(result.error ?? '', pluginName);
170
+ steps.push({
171
+ phase: 'resolve',
172
+ pass: false,
173
+ message: missingDep
174
+ ? `Package "${pluginName}" found but has a missing dependency: ${missingDep}`
175
+ : `Package "${pluginName}" not found in node_modules — try: npm install ${pluginName}`,
176
+ });
177
+ return steps;
178
+ }
179
+ }
180
+
181
+ steps.push({
182
+ phase: 'resolve',
183
+ pass: true,
184
+ message: `Module resolved from ${resolvedFrom}`,
185
+ });
186
+
187
+ // --- Phase 2: Export validation ---
188
+ if (!isValidExport(pluginModule)) {
189
+ const exportKeys = pluginModule ? Object.keys(pluginModule).join(', ') : 'none';
190
+ steps.push({
191
+ phase: 'export',
192
+ pass: false,
193
+ message: `Module does not export a function or class (default export). Exported keys: [${exportKeys}]`,
194
+ });
195
+ return steps;
196
+ }
197
+
198
+ const moduleType = isES6(pluginModule) ? 'ES6 (default export)' : 'CommonJS (factory function)';
199
+ steps.push({
200
+ phase: 'export',
201
+ pass: true,
202
+ message: `Valid ${moduleType} plugin export detected`,
203
+ });
204
+
205
+ // --- Phase 3: Instantiation ---
206
+ let instance: any;
207
+ try {
208
+ if (isES6(pluginModule)) {
209
+ instance = new pluginModule.default(pluginConfig, { config: pluginConfig, logger: console });
210
+ } else {
211
+ instance = pluginModule(pluginConfig, { config: pluginConfig, logger: console });
212
+ }
213
+ } catch (err: any) {
214
+ steps.push({
215
+ phase: 'instantiate',
216
+ pass: false,
217
+ message: `Plugin threw during instantiation: ${err.message}`,
218
+ });
219
+ return steps;
220
+ }
221
+
222
+ if (!instance || (typeof instance !== 'object' && typeof instance !== 'function')) {
223
+ steps.push({
224
+ phase: 'instantiate',
225
+ pass: false,
226
+ message: `Plugin constructor/factory returned ${instance === null ? 'null' : typeof instance} instead of an object`,
227
+ });
228
+ return steps;
229
+ }
230
+
231
+ steps.push({
232
+ phase: 'instantiate',
233
+ pass: true,
234
+ message: 'Plugin instantiated successfully',
235
+ });
236
+
237
+ // --- Phase 4: Sanity check ---
238
+ const sanityCheck = customSanityCheck ?? getSanityCheck(category);
239
+ const passed = sanityCheck(instance);
240
+
241
+ if (!passed) {
242
+ const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(instance) ?? {})
243
+ .filter((m) => m !== 'constructor')
244
+ .concat(Object.keys(instance));
245
+ const unique = [...new Set(methods)];
246
+
247
+ steps.push({
248
+ phase: 'sanity-check',
249
+ pass: false,
250
+ message: `Plugin does not implement the required methods for category "${category}". Available methods: [${unique.join(', ')}]`,
251
+ });
252
+ return steps;
253
+ }
254
+
255
+ steps.push({
256
+ phase: 'sanity-check',
257
+ pass: true,
258
+ message: `Plugin passes sanity check for category "${category}"`,
259
+ });
260
+
261
+ return steps;
262
+ }
263
+
264
+ /**
265
+ * When a MODULE_NOT_FOUND error is about a transitive dependency
266
+ * (not the plugin itself), extract the missing module name.
267
+ */
268
+ function parseMissingDependency(message: string, pluginPath: string): string | null {
269
+ const match = message.match(/Cannot find module '([^']+)'/);
270
+ if (match && match[1] && !match[1].includes(pluginPath)) {
271
+ return match[1];
272
+ }
273
+ return null;
274
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { verifyPlugin } from './verify-plugin';
2
+ export {
3
+ authSanityCheck,
4
+ storageSanityCheck,
5
+ middlewareSanityCheck,
6
+ filterSanityCheck,
7
+ getSanityCheck,
8
+ } from './sanity-checks';
9
+ export { runDiagnostics } from './diagnostics';
10
+ export type { VerifyPluginOptions, VerifyResult, PluginCategory, DiagnosticStep } from './types';
@@ -0,0 +1,27 @@
1
+ import { PLUGIN_CATEGORY, pluginUtils } from '@verdaccio/core';
2
+
3
+ import type { PluginCategory } from './types';
4
+
5
+ // Re-export sanity checks from @verdaccio/core for convenience.
6
+ export const authSanityCheck = pluginUtils.authSanityCheck;
7
+ export const storageSanityCheck = pluginUtils.storageSanityCheck;
8
+ export const middlewareSanityCheck = pluginUtils.middlewareSanityCheck;
9
+ export const filterSanityCheck = pluginUtils.filterSanityCheck;
10
+
11
+ /**
12
+ * Returns the appropriate sanity check function for the given plugin category.
13
+ */
14
+ export function getSanityCheck(category: PluginCategory): (plugin: any) => boolean {
15
+ switch (category) {
16
+ case PLUGIN_CATEGORY.AUTHENTICATION:
17
+ return authSanityCheck;
18
+ case PLUGIN_CATEGORY.STORAGE:
19
+ return storageSanityCheck;
20
+ case PLUGIN_CATEGORY.MIDDLEWARE:
21
+ return middlewareSanityCheck;
22
+ case PLUGIN_CATEGORY.FILTER:
23
+ return filterSanityCheck;
24
+ default:
25
+ return () => true;
26
+ }
27
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { PLUGIN_CATEGORY } from '@verdaccio/core';
2
+
3
+ export type PluginCategory = (typeof PLUGIN_CATEGORY)[keyof typeof PLUGIN_CATEGORY];
4
+
5
+ export interface VerifyPluginOptions {
6
+ /**
7
+ * The plugin identifier as it would appear in config.yaml.
8
+ * For file-based plugins, this is the folder name inside `pluginsFolder`.
9
+ * For npm plugins, this is the package name (without the prefix for unscoped packages).
10
+ */
11
+ pluginPath: string;
12
+ /**
13
+ * The plugin category to verify against.
14
+ */
15
+ category: PluginCategory;
16
+ /**
17
+ * Optional plugin configuration passed as the first argument when instantiating the plugin.
18
+ */
19
+ pluginConfig?: Record<string, unknown>;
20
+ /**
21
+ * Optional custom sanity check function. If not provided, the default
22
+ * sanity check for the given category is used.
23
+ */
24
+ sanityCheck?: (plugin: any) => boolean;
25
+ /**
26
+ * Plugin name prefix. Defaults to 'verdaccio'.
27
+ */
28
+ prefix?: string;
29
+ /**
30
+ * Absolute path to a plugins folder for file-based plugin loading.
31
+ * Maps to `config.plugins` in Verdaccio configuration.
32
+ */
33
+ pluginsFolder?: string;
34
+ /**
35
+ * Absolute path to the Verdaccio configuration file.
36
+ * Maps to `config.configPath` — required by plugins that resolve
37
+ * relative paths (e.g. htpasswd resolves its `file` relative to this).
38
+ */
39
+ configPath?: string;
40
+ }
41
+
42
+ export interface DiagnosticStep {
43
+ /**
44
+ * The verification phase this diagnostic refers to.
45
+ */
46
+ phase: 'resolve' | 'export' | 'instantiate' | 'sanity-check';
47
+ /**
48
+ * Whether this phase passed.
49
+ */
50
+ pass: boolean;
51
+ /**
52
+ * Human-readable message describing the result.
53
+ */
54
+ message: string;
55
+ }
56
+
57
+ export interface VerifyResult {
58
+ /**
59
+ * Whether the plugin was successfully loaded and passed all checks.
60
+ */
61
+ success: boolean;
62
+ /**
63
+ * The plugin identifier used.
64
+ */
65
+ pluginName: string;
66
+ /**
67
+ * The plugin category verified against.
68
+ */
69
+ category: PluginCategory;
70
+ /**
71
+ * Number of plugins successfully loaded.
72
+ */
73
+ pluginsLoaded: number;
74
+ /**
75
+ * Error message if loading failed.
76
+ */
77
+ error?: string;
78
+ /**
79
+ * Step-by-step diagnostics showing which phase passed or failed.
80
+ * Only populated when the plugin fails to load.
81
+ */
82
+ diagnostics?: DiagnosticStep[];
83
+ }
@@ -0,0 +1,115 @@
1
+ import buildDebug from 'debug';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { PLUGIN_PREFIX } from '@verdaccio/core';
5
+ import { asyncLoadPlugin } from '@verdaccio/loaders';
6
+ import { logger, setup } from '@verdaccio/logger';
7
+
8
+ import { runDiagnostics } from './diagnostics';
9
+ import { getSanityCheck } from './sanity-checks';
10
+ import type { VerifyPluginOptions, VerifyResult } from './types';
11
+
12
+ const debug = buildDebug('verdaccio:plugin:verifier');
13
+
14
+ /**
15
+ * Verifies that a plugin can be loaded by Verdaccio.
16
+ *
17
+ * Uses `asyncLoadPlugin` from `@verdaccio/loaders` — the same loader
18
+ * Verdaccio uses at startup — so the verification is identical to what
19
+ * happens in production.
20
+ *
21
+ * Steps verified:
22
+ * 1. Module resolution — can the plugin be found/required?
23
+ * 2. Export validation — does it export a function or a class (default export)?
24
+ * 3. Instantiation — can the plugin be instantiated with a config and options?
25
+ * 4. Sanity check — does the instance implement the required methods for its category?
26
+ *
27
+ * When loading fails, detailed diagnostics are included in the result
28
+ * to pinpoint exactly which step failed and why.
29
+ */
30
+ export async function verifyPlugin(options: VerifyPluginOptions): Promise<VerifyResult> {
31
+ const {
32
+ pluginPath,
33
+ category,
34
+ pluginConfig = {},
35
+ sanityCheck: customSanityCheck,
36
+ prefix = PLUGIN_PREFIX,
37
+ pluginsFolder,
38
+ configPath,
39
+ } = options;
40
+
41
+ debug('verifying plugin %o for category %o', pluginPath, category);
42
+ debug('prefix: %o, pluginsFolder: %o', prefix, pluginsFolder);
43
+
44
+ await setup({});
45
+
46
+ const sanityCheck = customSanityCheck ?? getSanityCheck(category);
47
+ debug('using %s sanity check', customSanityCheck ? 'custom' : 'default');
48
+
49
+ const config: any = {
50
+ ...pluginConfig,
51
+ };
52
+
53
+ if (pluginsFolder) {
54
+ config.plugins = resolve(pluginsFolder);
55
+ debug('resolved plugins folder: %o', config.plugins);
56
+ }
57
+
58
+ if (configPath) {
59
+ config.configPath = configPath;
60
+ debug('config path: %o', config.configPath);
61
+ }
62
+
63
+ const pluginConfigs = { [pluginPath]: pluginConfig };
64
+ debug('plugin config: %o', pluginConfigs);
65
+
66
+ try {
67
+ const plugins = await asyncLoadPlugin(
68
+ pluginConfigs,
69
+ { config, logger },
70
+ sanityCheck,
71
+ false,
72
+ prefix,
73
+ category
74
+ );
75
+
76
+ debug('plugins loaded: %o', plugins.length);
77
+
78
+ if (plugins.length > 0) {
79
+ debug('verification succeeded for %o', pluginPath);
80
+ return {
81
+ success: true,
82
+ pluginName: pluginPath,
83
+ category,
84
+ pluginsLoaded: plugins.length,
85
+ };
86
+ }
87
+
88
+ debug('verification failed, running diagnostics for %o', pluginPath);
89
+ const diagnostics = await runDiagnostics(options);
90
+ const failedStep = diagnostics.find((d) => !d.pass);
91
+
92
+ return {
93
+ success: false,
94
+ pluginName: pluginPath,
95
+ category,
96
+ pluginsLoaded: 0,
97
+ error:
98
+ failedStep?.message ??
99
+ `Plugin "${pluginPath}" could not be loaded for category "${category}"`,
100
+ diagnostics,
101
+ };
102
+ } catch (err: any) {
103
+ debug('verification error for %o: %o', pluginPath, err.message);
104
+ const diagnostics = await runDiagnostics(options);
105
+
106
+ return {
107
+ success: false,
108
+ pluginName: pluginPath,
109
+ category,
110
+ pluginsLoaded: 0,
111
+ error: err.message,
112
+ diagnostics,
113
+ };
114
+ }
115
+ }