servcraft 0.1.7 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servcraft",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "A modular, production-ready Node.js backend framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,6 +16,8 @@ import {
16
16
  import { EnvManager } from '../utils/env-manager.js';
17
17
  import { TemplateManager } from '../utils/template-manager.js';
18
18
  import { InteractivePrompt } from '../utils/interactive-prompt.js';
19
+ import { DryRunManager } from '../utils/dry-run.js';
20
+ import { ErrorTypes, displayError, validateProject } from '../utils/error-handler.js';
19
21
 
20
22
  // Pre-built modules that can be added
21
23
  const AVAILABLE_MODULES = {
@@ -160,10 +162,17 @@ export const addModuleCommand = new Command('add')
160
162
  .option('-f, --force', 'Force overwrite existing module')
161
163
  .option('-u, --update', 'Update existing module (smart merge)')
162
164
  .option('--skip-existing', 'Skip if module already exists')
165
+ .option('--dry-run', 'Preview changes without writing files')
163
166
  .action(
164
167
  async (
165
168
  moduleName?: string,
166
- options?: { list?: boolean; force?: boolean; update?: boolean; skipExisting?: boolean }
169
+ options?: {
170
+ list?: boolean;
171
+ force?: boolean;
172
+ update?: boolean;
173
+ skipExisting?: boolean;
174
+ dryRun?: boolean;
175
+ }
167
176
  ) => {
168
177
  if (options?.list || !moduleName) {
169
178
  console.log(chalk.bold('\nšŸ“¦ Available Modules:\n'));
@@ -180,11 +189,24 @@ export const addModuleCommand = new Command('add')
180
189
  return;
181
190
  }
182
191
 
192
+ // Enable dry-run mode if specified
193
+ const dryRun = DryRunManager.getInstance();
194
+ if (options?.dryRun) {
195
+ dryRun.enable();
196
+ console.log(chalk.yellow('\n⚠ DRY RUN MODE - No files will be written\n'));
197
+ }
198
+
183
199
  const module = AVAILABLE_MODULES[moduleName as keyof typeof AVAILABLE_MODULES];
184
200
 
185
201
  if (!module) {
186
- error(`Unknown module: ${moduleName}`);
187
- info('Run "servcraft add --list" to see available modules');
202
+ displayError(ErrorTypes.MODULE_NOT_FOUND(moduleName));
203
+ return;
204
+ }
205
+
206
+ // Validate project structure
207
+ const projectError = validateProject();
208
+ if (projectError) {
209
+ displayError(projectError);
188
210
  return;
189
211
  }
190
212
 
@@ -319,10 +341,17 @@ export const addModuleCommand = new Command('add')
319
341
  }
320
342
  }
321
343
 
322
- console.log('\nšŸ“Œ Next steps:');
323
- info(' 1. Configure environment variables in .env (if needed)');
324
- info(' 2. Register the module in your main app file');
325
- info(' 3. Run database migrations if needed');
344
+ if (!options?.dryRun) {
345
+ console.log('\nšŸ“Œ Next steps:');
346
+ info(' 1. Configure environment variables in .env (if needed)');
347
+ info(' 2. Register the module in your main app file');
348
+ info(' 3. Run database migrations if needed');
349
+ }
350
+
351
+ // Show dry-run summary if enabled
352
+ if (options?.dryRun) {
353
+ dryRun.printSummary();
354
+ }
326
355
  } catch (err) {
327
356
  spinner.fail('Failed to add module');
328
357
  error(err instanceof Error ? err.message : String(err));
@@ -0,0 +1,8 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+
4
+ export const doctorCommand = new Command('doctor')
5
+ .description('Diagnose project configuration and dependencies')
6
+ .action(async () => {
7
+ console.log(chalk.bold.cyan('\nServCraft Doctor - Coming soon!\n'));
8
+ });
@@ -14,7 +14,26 @@ import {
14
14
  info,
15
15
  getModulesDir,
16
16
  } from '../utils/helpers.js';
17
+ import { DryRunManager } from '../utils/dry-run.js';
18
+ import chalk from 'chalk';
17
19
  import { parseFields, type FieldDefinition } from '../utils/field-parser.js';
20
+ import { ErrorTypes, displayError } from '../utils/error-handler.js';
21
+
22
+ // Helper to enable dry-run mode
23
+ function enableDryRunIfNeeded(options: { dryRun?: boolean }): void {
24
+ const dryRun = DryRunManager.getInstance();
25
+ if (options.dryRun) {
26
+ dryRun.enable();
27
+ console.log(chalk.yellow('\n⚠ DRY RUN MODE - No files will be written\n'));
28
+ }
29
+ }
30
+
31
+ // Helper to show dry-run summary
32
+ function showDryRunSummary(options: { dryRun?: boolean }): void {
33
+ if (options.dryRun) {
34
+ DryRunManager.getInstance().printSummary();
35
+ }
36
+ }
18
37
  import { controllerTemplate } from '../templates/controller.js';
19
38
  import { serviceTemplate } from '../templates/service.js';
20
39
  import { repositoryTemplate } from '../templates/repository.js';
@@ -43,7 +62,9 @@ generateCommand
43
62
  .option('--prisma', 'Generate Prisma model suggestion')
44
63
  .option('--validator <type>', 'Validator type: zod, joi, yup', 'zod')
45
64
  .option('-i, --interactive', 'Interactive mode to define fields')
65
+ .option('--dry-run', 'Preview changes without writing files')
46
66
  .action(async (name: string, fieldsArgs: string[], options) => {
67
+ enableDryRunIfNeeded(options);
47
68
  let fields: FieldDefinition[] = [];
48
69
 
49
70
  // Parse fields from command line or interactive mode
@@ -161,6 +182,9 @@ generateCommand
161
182
  info(` ${hasFields ? '3' : '4'}. Add the Prisma model to schema.prisma`);
162
183
  info(` ${hasFields ? '4' : '5'}. Run: npm run db:migrate`);
163
184
  }
185
+
186
+ // Show dry-run summary if enabled
187
+ showDryRunSummary(options);
164
188
  } catch (err) {
165
189
  spinner.fail('Failed to generate module');
166
190
  error(err instanceof Error ? err.message : String(err));
@@ -173,7 +197,9 @@ generateCommand
173
197
  .alias('c')
174
198
  .description('Generate a controller')
175
199
  .option('-m, --module <module>', 'Target module name')
200
+ .option('--dry-run', 'Preview changes without writing files')
176
201
  .action(async (name: string, options) => {
202
+ enableDryRunIfNeeded(options);
177
203
  const spinner = ora('Generating controller...').start();
178
204
 
179
205
  try {
@@ -187,7 +213,7 @@ generateCommand
187
213
 
188
214
  if (await fileExists(filePath)) {
189
215
  spinner.stop();
190
- error(`Controller "${kebabName}" already exists`);
216
+ displayError(ErrorTypes.FILE_ALREADY_EXISTS(`${kebabName}.controller.ts`));
191
217
  return;
192
218
  }
193
219
 
@@ -195,6 +221,7 @@ generateCommand
195
221
 
196
222
  spinner.succeed(`Controller "${pascalName}Controller" generated!`);
197
223
  success(` src/modules/${moduleName}/${kebabName}.controller.ts`);
224
+ showDryRunSummary(options);
198
225
  } catch (err) {
199
226
  spinner.fail('Failed to generate controller');
200
227
  error(err instanceof Error ? err.message : String(err));
@@ -207,7 +234,9 @@ generateCommand
207
234
  .alias('s')
208
235
  .description('Generate a service')
209
236
  .option('-m, --module <module>', 'Target module name')
237
+ .option('--dry-run', 'Preview changes without writing files')
210
238
  .action(async (name: string, options) => {
239
+ enableDryRunIfNeeded(options);
211
240
  const spinner = ora('Generating service...').start();
212
241
 
213
242
  try {
@@ -229,6 +258,7 @@ generateCommand
229
258
 
230
259
  spinner.succeed(`Service "${pascalName}Service" generated!`);
231
260
  success(` src/modules/${moduleName}/${kebabName}.service.ts`);
261
+ showDryRunSummary(options);
232
262
  } catch (err) {
233
263
  spinner.fail('Failed to generate service');
234
264
  error(err instanceof Error ? err.message : String(err));
@@ -241,7 +271,9 @@ generateCommand
241
271
  .alias('r')
242
272
  .description('Generate a repository')
243
273
  .option('-m, --module <module>', 'Target module name')
274
+ .option('--dry-run', 'Preview changes without writing files')
244
275
  .action(async (name: string, options) => {
276
+ enableDryRunIfNeeded(options);
245
277
  const spinner = ora('Generating repository...').start();
246
278
 
247
279
  try {
@@ -264,6 +296,7 @@ generateCommand
264
296
 
265
297
  spinner.succeed(`Repository "${pascalName}Repository" generated!`);
266
298
  success(` src/modules/${moduleName}/${kebabName}.repository.ts`);
299
+ showDryRunSummary(options);
267
300
  } catch (err) {
268
301
  spinner.fail('Failed to generate repository');
269
302
  error(err instanceof Error ? err.message : String(err));
@@ -276,7 +309,9 @@ generateCommand
276
309
  .alias('t')
277
310
  .description('Generate types/interfaces')
278
311
  .option('-m, --module <module>', 'Target module name')
312
+ .option('--dry-run', 'Preview changes without writing files')
279
313
  .action(async (name: string, options) => {
314
+ enableDryRunIfNeeded(options);
280
315
  const spinner = ora('Generating types...').start();
281
316
 
282
317
  try {
@@ -297,6 +332,7 @@ generateCommand
297
332
 
298
333
  spinner.succeed(`Types for "${pascalName}" generated!`);
299
334
  success(` src/modules/${moduleName}/${kebabName}.types.ts`);
335
+ showDryRunSummary(options);
300
336
  } catch (err) {
301
337
  spinner.fail('Failed to generate types');
302
338
  error(err instanceof Error ? err.message : String(err));
@@ -309,7 +345,9 @@ generateCommand
309
345
  .alias('v')
310
346
  .description('Generate validation schemas')
311
347
  .option('-m, --module <module>', 'Target module name')
348
+ .option('--dry-run', 'Preview changes without writing files')
312
349
  .action(async (name: string, options) => {
350
+ enableDryRunIfNeeded(options);
313
351
  const spinner = ora('Generating schemas...').start();
314
352
 
315
353
  try {
@@ -331,6 +369,7 @@ generateCommand
331
369
 
332
370
  spinner.succeed(`Schemas for "${pascalName}" generated!`);
333
371
  success(` src/modules/${moduleName}/${kebabName}.schemas.ts`);
372
+ showDryRunSummary(options);
334
373
  } catch (err) {
335
374
  spinner.fail('Failed to generate schemas');
336
375
  error(err instanceof Error ? err.message : String(err));
@@ -342,7 +381,9 @@ generateCommand
342
381
  .command('routes <name>')
343
382
  .description('Generate routes')
344
383
  .option('-m, --module <module>', 'Target module name')
384
+ .option('--dry-run', 'Preview changes without writing files')
345
385
  .action(async (name: string, options) => {
386
+ enableDryRunIfNeeded(options);
346
387
  const spinner = ora('Generating routes...').start();
347
388
 
348
389
  try {
@@ -365,6 +406,7 @@ generateCommand
365
406
 
366
407
  spinner.succeed(`Routes for "${pascalName}" generated!`);
367
408
  success(` src/modules/${moduleName}/${kebabName}.routes.ts`);
409
+ showDryRunSummary(options);
368
410
  } catch (err) {
369
411
  spinner.fail('Failed to generate routes');
370
412
  error(err instanceof Error ? err.message : String(err));
@@ -6,6 +6,7 @@ import inquirer from 'inquirer';
6
6
  import chalk from 'chalk';
7
7
  import { execSync } from 'child_process';
8
8
  import { ensureDir, writeFile, error, warn } from '../utils/helpers.js';
9
+ import { DryRunManager } from '../utils/dry-run.js';
9
10
 
10
11
  interface InitOptions {
11
12
  name: string;
@@ -27,6 +28,7 @@ export const initCommand = new Command('init')
27
28
  .option('--esm', 'Use ES Modules (import/export) - default')
28
29
  .option('--cjs, --commonjs', 'Use CommonJS (require/module.exports)')
29
30
  .option('--db <database>', 'Database type (postgresql, mysql, sqlite, mongodb, none)')
31
+ .option('--dry-run', 'Preview changes without writing files')
30
32
  .action(
31
33
  async (
32
34
  name?: string,
@@ -37,8 +39,16 @@ export const initCommand = new Command('init')
37
39
  esm?: boolean;
38
40
  commonjs?: boolean;
39
41
  db?: string;
42
+ dryRun?: boolean;
40
43
  }
41
44
  ) => {
45
+ // Enable dry-run mode if specified
46
+ const dryRun = DryRunManager.getInstance();
47
+ if (cmdOptions?.dryRun) {
48
+ dryRun.enable();
49
+ console.log(chalk.yellow('\n⚠ DRY RUN MODE - No files will be written\n'));
50
+ }
51
+
42
52
  console.log(
43
53
  chalk.blue(`
44
54
  ╔═══════════════════════════════════════════╗
@@ -274,19 +284,23 @@ export const initCommand = new Command('init')
274
284
 
275
285
  spinner.succeed('Project files generated!');
276
286
 
277
- // Install dependencies
278
- const installSpinner = ora('Installing dependencies...').start();
279
-
280
- try {
281
- execSync('npm install', { cwd: projectDir, stdio: 'pipe' });
282
- installSpinner.succeed('Dependencies installed!');
283
- } catch {
284
- installSpinner.warn('Failed to install dependencies automatically');
285
- warn(' Run "npm install" manually in the project directory');
287
+ // Install dependencies (skip in dry-run mode)
288
+ if (!cmdOptions?.dryRun) {
289
+ const installSpinner = ora('Installing dependencies...').start();
290
+
291
+ try {
292
+ execSync('npm install', { cwd: projectDir, stdio: 'pipe' });
293
+ installSpinner.succeed('Dependencies installed!');
294
+ } catch {
295
+ installSpinner.warn('Failed to install dependencies automatically');
296
+ warn(' Run "npm install" manually in the project directory');
297
+ }
286
298
  }
287
299
 
288
300
  // Print success message
289
- console.log('\n' + chalk.green('✨ Project created successfully!'));
301
+ if (!cmdOptions?.dryRun) {
302
+ console.log('\n' + chalk.green('✨ Project created successfully!'));
303
+ }
290
304
  console.log('\n' + chalk.bold('šŸ“ Project structure:'));
291
305
  console.log(`
292
306
  ${options.name}/
@@ -317,6 +331,11 @@ export const initCommand = new Command('init')
317
331
  ${chalk.yellow('servcraft generate service <name>')} Generate a service
318
332
  ${chalk.yellow('servcraft add auth')} Add authentication module
319
333
  `);
334
+
335
+ // Show dry-run summary if enabled
336
+ if (cmdOptions?.dryRun) {
337
+ dryRun.printSummary();
338
+ }
320
339
  } catch (err) {
321
340
  spinner.fail('Failed to create project');
322
341
  error(err instanceof Error ? err.message : String(err));
@@ -0,0 +1,274 @@
1
+ /* eslint-disable no-console */
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs/promises';
5
+ import { getProjectRoot, getModulesDir } from '../utils/helpers.js';
6
+
7
+ // Pre-built modules that can be added
8
+ const AVAILABLE_MODULES: Record<string, { name: string; description: string; category: string }> = {
9
+ // Core
10
+ auth: {
11
+ name: 'Authentication',
12
+ description: 'JWT authentication with access/refresh tokens',
13
+ category: 'Core',
14
+ },
15
+ users: {
16
+ name: 'User Management',
17
+ description: 'User CRUD with RBAC (roles & permissions)',
18
+ category: 'Core',
19
+ },
20
+ email: {
21
+ name: 'Email Service',
22
+ description: 'SMTP email with templates (Handlebars)',
23
+ category: 'Core',
24
+ },
25
+
26
+ // Security
27
+ mfa: {
28
+ name: 'MFA/TOTP',
29
+ description: 'Two-factor authentication with QR codes',
30
+ category: 'Security',
31
+ },
32
+ oauth: {
33
+ name: 'OAuth',
34
+ description: 'Social login (Google, GitHub, Facebook, Twitter, Apple)',
35
+ category: 'Security',
36
+ },
37
+ 'rate-limit': {
38
+ name: 'Rate Limiting',
39
+ description: 'Advanced rate limiting with multiple algorithms',
40
+ category: 'Security',
41
+ },
42
+
43
+ // Data & Storage
44
+ cache: {
45
+ name: 'Redis Cache',
46
+ description: 'Redis caching with TTL & invalidation',
47
+ category: 'Data & Storage',
48
+ },
49
+ upload: {
50
+ name: 'File Upload',
51
+ description: 'File upload with local/S3/Cloudinary storage',
52
+ category: 'Data & Storage',
53
+ },
54
+ search: {
55
+ name: 'Search',
56
+ description: 'Full-text search with Elasticsearch/Meilisearch',
57
+ category: 'Data & Storage',
58
+ },
59
+
60
+ // Communication
61
+ notification: {
62
+ name: 'Notifications',
63
+ description: 'Email, SMS, Push notifications',
64
+ category: 'Communication',
65
+ },
66
+ webhook: {
67
+ name: 'Webhooks',
68
+ description: 'Outgoing webhooks with HMAC signatures & retry',
69
+ category: 'Communication',
70
+ },
71
+ websocket: {
72
+ name: 'WebSockets',
73
+ description: 'Real-time communication with Socket.io',
74
+ category: 'Communication',
75
+ },
76
+
77
+ // Background Processing
78
+ queue: {
79
+ name: 'Queue/Jobs',
80
+ description: 'Background jobs with Bull/BullMQ & cron scheduling',
81
+ category: 'Background Processing',
82
+ },
83
+ 'media-processing': {
84
+ name: 'Media Processing',
85
+ description: 'Image/video processing with FFmpeg',
86
+ category: 'Background Processing',
87
+ },
88
+
89
+ // Monitoring & Analytics
90
+ audit: {
91
+ name: 'Audit Logs',
92
+ description: 'Activity logging and audit trail',
93
+ category: 'Monitoring & Analytics',
94
+ },
95
+ analytics: {
96
+ name: 'Analytics/Metrics',
97
+ description: 'Prometheus metrics & event tracking',
98
+ category: 'Monitoring & Analytics',
99
+ },
100
+
101
+ // Internationalization
102
+ i18n: {
103
+ name: 'i18n/Localization',
104
+ description: 'Multi-language support with 7+ locales',
105
+ category: 'Internationalization',
106
+ },
107
+
108
+ // API Management
109
+ 'feature-flag': {
110
+ name: 'Feature Flags',
111
+ description: 'A/B testing & progressive rollout',
112
+ category: 'API Management',
113
+ },
114
+ 'api-versioning': {
115
+ name: 'API Versioning',
116
+ description: 'Multiple API versions support',
117
+ category: 'API Management',
118
+ },
119
+
120
+ // Payments
121
+ payment: {
122
+ name: 'Payments',
123
+ description: 'Payment processing (Stripe, PayPal, Mobile Money)',
124
+ category: 'Payments',
125
+ },
126
+ };
127
+
128
+ async function getInstalledModules(): Promise<string[]> {
129
+ try {
130
+ const modulesDir = getModulesDir();
131
+ const entries = await fs.readdir(modulesDir, { withFileTypes: true });
132
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ function isServercraftProject(): boolean {
139
+ try {
140
+ getProjectRoot();
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ export const listCommand = new Command('list')
148
+ .alias('ls')
149
+ .description('List available and installed modules')
150
+ .option('-a, --available', 'Show only available modules')
151
+ .option('-i, --installed', 'Show only installed modules')
152
+ .option('-c, --category <category>', 'Filter by category')
153
+ .option('--json', 'Output as JSON')
154
+ .action(
155
+ async (options: {
156
+ available?: boolean;
157
+ installed?: boolean;
158
+ category?: string;
159
+ json?: boolean;
160
+ }) => {
161
+ const installedModules = await getInstalledModules();
162
+ const isProject = isServercraftProject();
163
+
164
+ if (options.json) {
165
+ const output: Record<string, unknown> = {
166
+ available: Object.entries(AVAILABLE_MODULES).map(([key, mod]) => ({
167
+ id: key,
168
+ ...mod,
169
+ installed: installedModules.includes(key),
170
+ })),
171
+ };
172
+
173
+ if (isProject) {
174
+ output.installed = installedModules;
175
+ }
176
+
177
+ console.log(JSON.stringify(output, null, 2));
178
+ return;
179
+ }
180
+
181
+ // Group modules by category
182
+ const byCategory: Record<
183
+ string,
184
+ Array<{ id: string; name: string; description: string; installed: boolean }>
185
+ > = {};
186
+
187
+ for (const [key, mod] of Object.entries(AVAILABLE_MODULES)) {
188
+ if (options.category && mod.category.toLowerCase() !== options.category.toLowerCase()) {
189
+ continue;
190
+ }
191
+
192
+ if (!byCategory[mod.category]) {
193
+ byCategory[mod.category] = [];
194
+ }
195
+
196
+ byCategory[mod.category].push({
197
+ id: key,
198
+ name: mod.name,
199
+ description: mod.description,
200
+ installed: installedModules.includes(key),
201
+ });
202
+ }
203
+
204
+ // Show installed modules only
205
+ if (options.installed) {
206
+ if (!isProject) {
207
+ console.log(chalk.yellow('\n⚠ Not in a Servcraft project directory\n'));
208
+ return;
209
+ }
210
+
211
+ console.log(chalk.bold('\nšŸ“¦ Installed Modules:\n'));
212
+
213
+ if (installedModules.length === 0) {
214
+ console.log(chalk.gray(' No modules installed yet.\n'));
215
+ console.log(` Run ${chalk.cyan('servcraft add <module>')} to add a module.\n`);
216
+ return;
217
+ }
218
+
219
+ for (const modId of installedModules) {
220
+ const mod = AVAILABLE_MODULES[modId];
221
+ if (mod) {
222
+ console.log(` ${chalk.green('āœ“')} ${chalk.cyan(modId.padEnd(18))} ${mod.name}`);
223
+ } else {
224
+ console.log(
225
+ ` ${chalk.green('āœ“')} ${chalk.cyan(modId.padEnd(18))} ${chalk.gray('(custom module)')}`
226
+ );
227
+ }
228
+ }
229
+
230
+ console.log(`\n Total: ${chalk.bold(installedModules.length)} module(s) installed\n`);
231
+ return;
232
+ }
233
+
234
+ // Show available modules (default or --available)
235
+ console.log(chalk.bold('\nšŸ“¦ Available Modules\n'));
236
+
237
+ if (isProject) {
238
+ console.log(
239
+ chalk.gray(` ${chalk.green('āœ“')} = installed ${chalk.dim('ā—‹')} = not installed\n`)
240
+ );
241
+ }
242
+
243
+ for (const [category, modules] of Object.entries(byCategory)) {
244
+ console.log(chalk.bold.blue(` ${category}`));
245
+ console.log(chalk.gray(' ' + '─'.repeat(40)));
246
+
247
+ for (const mod of modules) {
248
+ const status = isProject ? (mod.installed ? chalk.green('āœ“') : chalk.dim('ā—‹')) : ' ';
249
+ const nameColor = mod.installed ? chalk.green : chalk.cyan;
250
+ console.log(` ${status} ${nameColor(mod.id.padEnd(18))} ${mod.name}`);
251
+ console.log(` ${chalk.gray(mod.description)}`);
252
+ }
253
+ console.log();
254
+ }
255
+
256
+ // Summary
257
+ const totalAvailable = Object.keys(AVAILABLE_MODULES).length;
258
+ const totalInstalled = installedModules.filter((m) => AVAILABLE_MODULES[m]).length;
259
+
260
+ console.log(chalk.gray('─'.repeat(50)));
261
+ console.log(
262
+ ` ${chalk.bold(totalAvailable)} modules available` +
263
+ (isProject ? ` | ${chalk.green.bold(totalInstalled)} installed` : '')
264
+ );
265
+ console.log();
266
+
267
+ // Usage hints
268
+ console.log(chalk.bold(' Usage:'));
269
+ console.log(` ${chalk.yellow('servcraft add <module>')} Add a module`);
270
+ console.log(` ${chalk.yellow('servcraft list --installed')} Show installed only`);
271
+ console.log(` ${chalk.yellow('servcraft list --category Security')} Filter by category`);
272
+ console.log();
273
+ }
274
+ );