webspresso 0.0.90 → 0.0.93

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 (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Built-in Media & File Upload Services
3
+ * @module src/services/builtins/media
4
+ */
5
+
6
+ const fs = require('fs/promises');
7
+ const path = require('path');
8
+ const { z } = require('zod');
9
+ const { ValidationError, NotFoundError } = require('../../../core/errors');
10
+ const { createLocalFileProvider, canonicalExtFromMime } = require('../../../plugins/upload/local-file-provider');
11
+
12
+ /**
13
+ * Convert base64 data (including data URLs) to Buffer
14
+ * @param {string} base64Str
15
+ * @returns {{ buffer: Buffer, mimeType?: string }}
16
+ */
17
+ function parseBase64(base64Str) {
18
+ const match = base64Str.match(/^data:([^;]+);base64,(.+)$/);
19
+ if (match) {
20
+ const mimeType = match[1];
21
+ const buffer = Buffer.from(match[2], 'base64');
22
+ return { buffer, mimeType };
23
+ }
24
+ return { buffer: Buffer.from(base64Str, 'base64') };
25
+ }
26
+
27
+ /**
28
+ * Validate storage key to prevent directory traversal
29
+ * @param {string} key
30
+ * @param {string} baseDir
31
+ * @returns {string} Absolute resolved file path
32
+ */
33
+ function resolveSafeKeyPath(key, baseDir) {
34
+ if (!key || typeof key !== 'string' || key.includes('..') || path.isAbsolute(key)) {
35
+ throw new ValidationError('Invalid file key or path traversal attempt', [
36
+ { field: 'key', message: 'Path traversal attempt detected' },
37
+ ]);
38
+ }
39
+
40
+ const resolvedBase = path.resolve(baseDir);
41
+ const targetPath = path.resolve(resolvedBase, key);
42
+
43
+ if (!targetPath.startsWith(resolvedBase + path.sep) && targetPath !== resolvedBase) {
44
+ throw new ValidationError('Invalid file key or path traversal attempt', [
45
+ { field: 'key', message: 'Path traversal attempt detected' },
46
+ ]);
47
+ }
48
+
49
+ return targetPath;
50
+ }
51
+
52
+ /**
53
+ * Create built-in media services map
54
+ * @param {Object} [options]
55
+ * @param {import('../../../plugins/upload').UploadStorageProvider} [options.provider]
56
+ * @param {string} [options.destDir]
57
+ * @param {string} [options.publicBasePath]
58
+ * @returns {Record<string, Object>}
59
+ */
60
+ function createMediaServices(options = {}) {
61
+ const destDir = path.resolve(
62
+ options.destDir || path.join(process.cwd(), 'public', 'uploads')
63
+ );
64
+ const provider = options.provider || createLocalFileProvider({
65
+ destDir,
66
+ publicBasePath: options.publicBasePath,
67
+ });
68
+
69
+ return {
70
+ 'media.upload': {
71
+ schema: z.object({
72
+ buffer: z.any().optional(),
73
+ base64: z.string().optional(),
74
+ filePath: z.string().optional(),
75
+ originalName: z.string().min(1).default('upload.bin'),
76
+ mimeType: z.string().optional(),
77
+ destDir: z.string().optional(),
78
+ publicBasePath: z.string().optional(),
79
+ }),
80
+ async handler(input, ctx) {
81
+ let { buffer, base64, filePath, originalName, mimeType, destDir: customDest, publicBasePath: customBase } = input;
82
+ let finalMime = mimeType || 'application/octet-stream';
83
+
84
+ if (buffer && !(buffer instanceof Buffer) && !(buffer instanceof Uint8Array)) {
85
+ if (typeof buffer === 'string') {
86
+ buffer = Buffer.from(buffer);
87
+ } else {
88
+ throw new ValidationError('Invalid buffer provided', [
89
+ { field: 'buffer', message: 'Expected Buffer or Uint8Array' },
90
+ ]);
91
+ }
92
+ }
93
+
94
+ if (!buffer && base64) {
95
+ const parsed = parseBase64(base64);
96
+ buffer = parsed.buffer;
97
+ if (parsed.mimeType && (!mimeType || mimeType === 'application/octet-stream')) {
98
+ finalMime = parsed.mimeType;
99
+ }
100
+ } else if (!buffer && filePath) {
101
+ try {
102
+ buffer = await fs.readFile(path.resolve(filePath));
103
+ if (!originalName || originalName === 'upload.bin') {
104
+ originalName = path.basename(filePath);
105
+ }
106
+ } catch (err) {
107
+ throw new NotFoundError(`Source file not found: ${filePath}`, { cause: err });
108
+ }
109
+ }
110
+
111
+ if (!buffer || buffer.length === 0) {
112
+ throw new ValidationError('No file content provided (buffer, base64, or filePath is required)', [
113
+ { field: 'buffer', message: 'No file content provided' },
114
+ ]);
115
+ }
116
+
117
+ const activeProvider = (customDest || customBase)
118
+ ? createLocalFileProvider({ destDir: customDest || destDir, publicBasePath: customBase })
119
+ : provider;
120
+
121
+ const uploadResult = await activeProvider.put({
122
+ buffer,
123
+ originalName,
124
+ mimeType: finalMime,
125
+ size: buffer.length,
126
+ req: ctx.req || null,
127
+ });
128
+
129
+ return {
130
+ publicUrl: uploadResult.publicUrl,
131
+ key: uploadResult.key || path.basename(uploadResult.publicUrl),
132
+ size: buffer.length,
133
+ mimeType: finalMime,
134
+ originalName,
135
+ };
136
+ },
137
+ },
138
+
139
+ 'media.delete': {
140
+ schema: z.object({
141
+ key: z.string().min(1, 'Key is required'),
142
+ destDir: z.string().optional(),
143
+ }),
144
+ async handler(input, ctx) {
145
+ const { key, destDir: customDest } = input;
146
+ const targetDir = customDest ? path.resolve(customDest) : destDir;
147
+ const targetPath = resolveSafeKeyPath(key, targetDir);
148
+
149
+ try {
150
+ await fs.unlink(targetPath);
151
+ return { success: true, key };
152
+ } catch (err) {
153
+ if (err.code === 'ENOENT') {
154
+ return { success: false, key, message: 'File not found' };
155
+ }
156
+ throw err;
157
+ }
158
+ },
159
+ },
160
+
161
+ 'media.info': {
162
+ schema: z.object({
163
+ key: z.string().min(1, 'Key is required'),
164
+ destDir: z.string().optional(),
165
+ }),
166
+ async handler(input, ctx) {
167
+ const { key, destDir: customDest } = input;
168
+ const targetDir = customDest ? path.resolve(customDest) : destDir;
169
+ const targetPath = resolveSafeKeyPath(key, targetDir);
170
+
171
+ try {
172
+ const stats = await fs.stat(targetPath);
173
+ const ext = path.extname(targetPath).toLowerCase();
174
+ return {
175
+ exists: true,
176
+ key,
177
+ size: stats.size,
178
+ mtime: stats.mtime,
179
+ ext,
180
+ };
181
+ } catch (err) {
182
+ if (err.code === 'ENOENT') {
183
+ return { exists: false, key };
184
+ }
185
+ throw err;
186
+ }
187
+ },
188
+ },
189
+ };
190
+ }
191
+
192
+ module.exports = {
193
+ createMediaServices,
194
+ resolveSafeKeyPath,
195
+ parseBase64,
196
+ };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Built-in System, Maintenance & Health Check Services
3
+ * @module src/services/builtins/system
4
+ */
5
+
6
+ const os = require('os');
7
+ const { z } = require('zod');
8
+ const { cleanExpiredTokens, createKnexAuthTokensAdapter } = require('../../../core/auth/tokens');
9
+
10
+ /**
11
+ * Format memory usage bytes into human-readable MB
12
+ * @param {number} bytes
13
+ * @returns {string}
14
+ */
15
+ function formatMb(bytes) {
16
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
17
+ }
18
+
19
+ /**
20
+ * Create built-in system services map
21
+ * @param {Object} [options]
22
+ * @param {Object} [options.db] - Database or knex instance
23
+ * @param {import('../registry').ServiceRegistry} [options.serviceRegistry] - ServiceRegistry instance
24
+ * @param {Object} [options.pluginManager] - PluginManager instance
25
+ * @returns {Record<string, Object>}
26
+ */
27
+ function createSystemServices(options = {}) {
28
+ const { db, serviceRegistry, pluginManager } = options;
29
+
30
+ return {
31
+ 'system.health': {
32
+ schema: z.object({
33
+ detailed: z.boolean().default(false),
34
+ }),
35
+ async handler(input, ctx) {
36
+ const activeDb = ctx.db || db;
37
+ const memory = process.memoryUsage();
38
+ const uptime = process.uptime();
39
+ const timestamp = new Date();
40
+
41
+ let dbStatus = 'disconnected';
42
+ let dbLatencyMs = null;
43
+
44
+ if (activeDb) {
45
+ const knex = activeDb.knex || activeDb;
46
+ const startPing = Date.now();
47
+ try {
48
+ await knex.raw('SELECT 1');
49
+ dbStatus = 'connected';
50
+ dbLatencyMs = Date.now() - startPing;
51
+ } catch (err) {
52
+ dbStatus = 'error';
53
+ }
54
+ }
55
+
56
+ const isHealthy = dbStatus !== 'error';
57
+
58
+ const result = {
59
+ status: isHealthy ? 'ok' : 'degraded',
60
+ uptime,
61
+ uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s`,
62
+ timestamp,
63
+ database: {
64
+ status: dbStatus,
65
+ latencyMs: dbLatencyMs,
66
+ },
67
+ memory: {
68
+ rss: formatMb(memory.rss),
69
+ heapUsed: formatMb(memory.heapUsed),
70
+ heapTotal: formatMb(memory.heapTotal),
71
+ external: formatMb(memory.external),
72
+ },
73
+ };
74
+
75
+ if (activeDb?.cache && typeof activeDb.cache.stats === 'function') {
76
+ result.cache = activeDb.cache.stats();
77
+ }
78
+
79
+ return result;
80
+ },
81
+ },
82
+
83
+ 'system.cleanup': {
84
+ schema: z.object({
85
+ olderThanDays: z.number().int().positive().default(90),
86
+ purgeAuthTokens: z.boolean().default(true),
87
+ purgeAuditLogs: z.boolean().default(true),
88
+ }),
89
+ auth: 'admin',
90
+ async handler(input, ctx) {
91
+ const { olderThanDays, purgeAuthTokens, purgeAuditLogs } = input;
92
+ const activeDb = ctx.db || db;
93
+ if (!activeDb) {
94
+ throw new Error('Database instance is required for system.cleanup');
95
+ }
96
+
97
+ const knex = activeDb.knex || activeDb;
98
+ const purged = {};
99
+
100
+ // 1. Purge expired auth tokens
101
+ if (purgeAuthTokens) {
102
+ try {
103
+ const hasAuthTokensTable = await knex.schema.hasTable('auth_tokens');
104
+ if (hasAuthTokensTable) {
105
+ const now = new Date();
106
+ const count = await knex('auth_tokens').where('expires_at', '<', now).delete();
107
+ purged.authTokens = count;
108
+ } else {
109
+ purged.authTokens = 0;
110
+ }
111
+ } catch (err) {
112
+ purged.authTokens = 0;
113
+ }
114
+ }
115
+
116
+ // 2. Purge old audit logs if table exists
117
+ if (purgeAuditLogs) {
118
+ try {
119
+ const hasAuditTable = await knex.schema.hasTable('audit_logs');
120
+ if (hasAuditTable) {
121
+ const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
122
+ const deleted = await knex('audit_logs').where('created_at', '<', cutoff).delete();
123
+ purged.auditLogs = deleted;
124
+ } else {
125
+ purged.auditLogs = 0;
126
+ }
127
+ } catch (err) {
128
+ purged.auditLogs = 0;
129
+ }
130
+ }
131
+
132
+ return {
133
+ success: true,
134
+ purged,
135
+ timestamp: new Date(),
136
+ };
137
+ },
138
+ },
139
+
140
+ 'system.cache-flush': {
141
+ schema: z.object({
142
+ scope: z.enum(['all', 'orm', 'services']).default('all'),
143
+ model: z.string().optional(),
144
+ }),
145
+ auth: 'admin',
146
+ async handler(input, ctx) {
147
+ const { scope, model } = input;
148
+ const activeDb = ctx.db || db;
149
+ const registry = ctx.serviceRegistry || serviceRegistry;
150
+
151
+ let ormCachePurged = false;
152
+ let serviceCacheCleared = false;
153
+
154
+ // 1. Flush ORM Cache
155
+ if (scope === 'all' || scope === 'orm') {
156
+ if (activeDb?.cache && typeof activeDb.cache.purge === 'function') {
157
+ activeDb.cache.purge(model);
158
+ ormCachePurged = true;
159
+ } else if (activeDb?.cache && typeof activeDb.cache.clear === 'function') {
160
+ activeDb.cache.clear();
161
+ ormCachePurged = true;
162
+ }
163
+ }
164
+
165
+ // 2. Clear Service Memoization Caches
166
+ if (scope === 'all' || scope === 'services') {
167
+ if (registry && typeof registry.clearCache === 'function') {
168
+ registry.clearCache();
169
+ serviceCacheCleared = true;
170
+ }
171
+ }
172
+
173
+ return {
174
+ success: true,
175
+ scope,
176
+ model: model || null,
177
+ ormCachePurged,
178
+ serviceCacheCleared,
179
+ timestamp: new Date(),
180
+ };
181
+ },
182
+ },
183
+
184
+ 'system.info': {
185
+ schema: z.object({}),
186
+ auth: 'admin',
187
+ async handler(input, ctx) {
188
+ const activeDb = ctx.db || db;
189
+ const registry = ctx.serviceRegistry || serviceRegistry;
190
+ const pm = ctx.pluginManager || pluginManager;
191
+
192
+ const models = activeDb && typeof activeDb.getAllModelInstances === 'function'
193
+ ? activeDb.getAllModelInstances().map(m => m.name)
194
+ : [];
195
+
196
+ const services = registry && typeof registry.list === 'function'
197
+ ? registry.list()
198
+ : [];
199
+
200
+ const plugins = pm && pm.plugins
201
+ ? Array.from(pm.plugins.keys())
202
+ : [];
203
+
204
+ return {
205
+ nodeVersion: process.version,
206
+ platform: os.platform(),
207
+ arch: os.arch(),
208
+ pid: process.pid,
209
+ uptime: process.uptime(),
210
+ models,
211
+ services,
212
+ plugins,
213
+ };
214
+ },
215
+ },
216
+ };
217
+ }
218
+
219
+ module.exports = {
220
+ createSystemServices,
221
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Webspresso Services Discovery
3
+ * Recursively scans directory and maps file paths to service names with cross-platform normalization & camelCase aliases
4
+ * @module src/services/discovery
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
10
+
11
+ const VALID_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
12
+
13
+ /**
14
+ * Convert kebab-case or snake_case segment to camelCase
15
+ * @param {string} str
16
+ * @returns {string}
17
+ */
18
+ function toCamelCase(str) {
19
+ return str.replace(/[-_]+([a-zA-Z0-9])/g, (_, char) => char.toUpperCase());
20
+ }
21
+
22
+ /**
23
+ * Check if a file should be included as a service
24
+ * @param {string} filename - Base name of the file
25
+ * @returns {boolean}
26
+ */
27
+ function isValidServiceFile(filename) {
28
+ if (filename.startsWith('.') || filename.startsWith('_')) {
29
+ return false;
30
+ }
31
+ if (filename.endsWith('.test.js') || filename.endsWith('.spec.js') || filename.endsWith('.d.ts')) {
32
+ return false;
33
+ }
34
+ const ext = path.extname(filename);
35
+ return VALID_EXTENSIONS.has(ext);
36
+ }
37
+
38
+ /**
39
+ * Convert relative file path to dot-separated service name with cross-platform normalization & aliases
40
+ * @example
41
+ * filePathToServiceName('user/get.js') -> { name: 'user.get', aliases: [] }
42
+ * filePathToServiceName('order-items/get-by-id.js') -> { name: 'order-items.get-by-id', aliases: ['orderItems.getById'] }
43
+ * filePathToServiceName('user/index.js') -> { name: 'user', aliases: ['user.index'] }
44
+ *
45
+ * @param {string} relativePath - Path relative to services root
46
+ * @returns {{ name: string, aliases: string[] }}
47
+ */
48
+ function filePathToServiceName(relativePath) {
49
+ if (!relativePath || typeof relativePath !== 'string') {
50
+ return { name: '', aliases: [] };
51
+ }
52
+
53
+ // Cross-platform slash normalization (Windows \ to Unix /)
54
+ const normalized = trimUrlPathSlashes(relativePath.replace(/\\/g, '/'));
55
+ const ext = path.extname(normalized);
56
+ const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
57
+ const segments = withoutExt.split('/').filter(Boolean);
58
+
59
+ if (segments.length === 0) {
60
+ return { name: '', aliases: [] };
61
+ }
62
+
63
+ const aliases = new Set();
64
+ const isIndex = segments[segments.length - 1] === 'index';
65
+
66
+ let primarySegments;
67
+ if (isIndex) {
68
+ primarySegments = segments.slice(0, -1);
69
+ if (primarySegments.length === 0) {
70
+ primarySegments = ['index'];
71
+ } else {
72
+ aliases.add(segments.join('.')); // e.g. user.index alias
73
+ }
74
+ } else {
75
+ primarySegments = segments;
76
+ }
77
+
78
+ const primaryName = primarySegments.join('.');
79
+
80
+ // Generate camelCase alias if segments contain kebab-case or snake_case
81
+ const camelSegments = primarySegments.map(toCamelCase);
82
+ const camelName = camelSegments.join('.');
83
+ if (camelName !== primaryName) {
84
+ aliases.add(camelName);
85
+ }
86
+
87
+ return {
88
+ name: primaryName,
89
+ aliases: Array.from(aliases),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Recursively discover all service files in a directory
95
+ * @param {string} servicesDir - Absolute or relative path to services directory
96
+ * @returns {Array<{ name: string, aliases: string[], filePath: string, relativePath: string }>}
97
+ */
98
+ function discoverServices(servicesDir) {
99
+ if (!servicesDir || !fs.existsSync(servicesDir)) {
100
+ return [];
101
+ }
102
+
103
+ const results = [];
104
+
105
+ function scan(currentDir, relativePrefix = '') {
106
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
107
+
108
+ for (const entry of entries) {
109
+ if (entry.name.startsWith('.') || entry.name.startsWith('_') || entry.name === 'node_modules') {
110
+ continue;
111
+ }
112
+
113
+ const fullPath = path.join(currentDir, entry.name);
114
+ const relPath = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name;
115
+
116
+ if (entry.isDirectory()) {
117
+ scan(fullPath, relPath);
118
+ } else if (entry.isFile() && isValidServiceFile(entry.name)) {
119
+ const { name, aliases } = filePathToServiceName(relPath);
120
+ if (name) {
121
+ results.push({
122
+ name,
123
+ aliases,
124
+ filePath: fullPath,
125
+ relativePath: relPath.replace(/\\/g, '/'),
126
+ });
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ scan(path.resolve(servicesDir));
133
+ return results;
134
+ }
135
+
136
+ module.exports = {
137
+ isValidServiceFile,
138
+ toCamelCase,
139
+ filePathToServiceName,
140
+ discoverServices,
141
+ };