pnpm-catalog-updates 1.0.2 → 1.1.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.
Files changed (51) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +22031 -10684
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -2
  5. package/src/cli/__tests__/commandRegistrar.test.ts +248 -0
  6. package/src/cli/commandRegistrar.ts +785 -0
  7. package/src/cli/commands/__tests__/aiCommand.test.ts +161 -0
  8. package/src/cli/commands/__tests__/analyzeCommand.test.ts +283 -0
  9. package/src/cli/commands/__tests__/checkCommand.test.ts +435 -0
  10. package/src/cli/commands/__tests__/graphCommand.test.ts +312 -0
  11. package/src/cli/commands/__tests__/initCommand.test.ts +317 -0
  12. package/src/cli/commands/__tests__/rollbackCommand.test.ts +400 -0
  13. package/src/cli/commands/__tests__/securityCommand.test.ts +467 -0
  14. package/src/cli/commands/__tests__/themeCommand.test.ts +166 -0
  15. package/src/cli/commands/__tests__/updateCommand.test.ts +720 -0
  16. package/src/cli/commands/__tests__/workspaceCommand.test.ts +286 -0
  17. package/src/cli/commands/aiCommand.ts +163 -0
  18. package/src/cli/commands/analyzeCommand.ts +219 -0
  19. package/src/cli/commands/checkCommand.ts +91 -98
  20. package/src/cli/commands/graphCommand.ts +475 -0
  21. package/src/cli/commands/initCommand.ts +64 -54
  22. package/src/cli/commands/rollbackCommand.ts +334 -0
  23. package/src/cli/commands/securityCommand.ts +165 -100
  24. package/src/cli/commands/themeCommand.ts +148 -0
  25. package/src/cli/commands/updateCommand.ts +215 -263
  26. package/src/cli/commands/workspaceCommand.ts +73 -0
  27. package/src/cli/constants/cliChoices.ts +93 -0
  28. package/src/cli/formatters/__tests__/__snapshots__/outputFormatter.test.ts.snap +557 -0
  29. package/src/cli/formatters/__tests__/ciFormatter.test.ts +526 -0
  30. package/src/cli/formatters/__tests__/outputFormatter.test.ts +448 -0
  31. package/src/cli/formatters/__tests__/progressBar.test.ts +709 -0
  32. package/src/cli/formatters/ciFormatter.ts +964 -0
  33. package/src/cli/formatters/colorUtils.ts +145 -0
  34. package/src/cli/formatters/outputFormatter.ts +615 -332
  35. package/src/cli/formatters/progressBar.ts +43 -52
  36. package/src/cli/formatters/versionFormatter.ts +132 -0
  37. package/src/cli/handlers/aiAnalysisHandler.ts +205 -0
  38. package/src/cli/handlers/changelogHandler.ts +113 -0
  39. package/src/cli/handlers/index.ts +9 -0
  40. package/src/cli/handlers/installHandler.ts +130 -0
  41. package/src/cli/index.ts +175 -726
  42. package/src/cli/interactive/InteractiveOptionsCollector.ts +387 -0
  43. package/src/cli/interactive/interactivePrompts.ts +189 -83
  44. package/src/cli/interactive/optionUtils.ts +89 -0
  45. package/src/cli/themes/colorTheme.ts +43 -16
  46. package/src/cli/utils/cliOutput.ts +118 -0
  47. package/src/cli/utils/commandHelpers.ts +249 -0
  48. package/src/cli/validators/commandValidator.ts +321 -336
  49. package/src/cli/validators/index.ts +37 -2
  50. package/src/cli/options/globalOptions.ts +0 -437
  51. package/src/cli/options/index.ts +0 -5
package/src/cli/index.ts CHANGED
@@ -10,796 +10,245 @@
10
10
  import { readFileSync } from 'node:fs'
11
11
  import { dirname, join } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
- // Services and Dependencies
14
- import type { AnalysisType } from '@pcu/core'
13
+ import { startCacheInitialization } from '@pcu/core'
15
14
  import {
16
- AIAnalysisService,
17
- AIDetector,
18
- CatalogUpdateService,
19
- FileSystemService,
20
- FileWorkspaceRepository,
21
- NpmRegistryService,
22
- WorkspaceService,
23
- } from '@pcu/core'
24
- // CLI Commands
25
- import { ConfigLoader, VersionChecker } from '@pcu/utils'
15
+ ConfigLoader,
16
+ I18n,
17
+ isCommandExitError,
18
+ Logger,
19
+ logger,
20
+ preloadPackageSuggestions,
21
+ t,
22
+ VersionChecker,
23
+ } from '@pcu/utils'
26
24
  import chalk from 'chalk'
27
25
  import { Command } from 'commander'
28
- import { CheckCommand } from './commands/checkCommand.js'
29
- import { InitCommand } from './commands/initCommand.js'
30
- import { SecurityCommand } from './commands/securityCommand.js'
31
- import { UpdateCommand } from './commands/updateCommand.js'
32
- import { type OutputFormat, OutputFormatter } from './formatters/outputFormatter.js'
33
- import { InteractivePrompts } from './interactive/interactivePrompts.js'
34
- import { StyledText, ThemeManager } from './themes/colorTheme.js'
26
+ import { isExitPromptError, LazyServiceFactory, registerCommands } from './commandRegistrar.js'
27
+ import { cliOutput } from './utils/cliOutput.js'
35
28
 
36
- // Get package.json for version info
29
+ // Only read when version info is actually needed
37
30
  const __filename = fileURLToPath(import.meta.url)
38
31
  const __dirname = dirname(__filename)
39
- const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'))
32
+
33
+ let _packageJson: { version: string; name: string } | null = null
34
+ function getPackageJson(): { version: string; name: string } {
35
+ if (!_packageJson) {
36
+ _packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'))
37
+ }
38
+ return _packageJson!
39
+ }
40
40
 
41
41
  /**
42
- * Create service dependencies with configuration support
42
+ * Exit with proper logger cleanup
43
+ *
44
+ * Ensures all log messages are flushed and file handles are closed
45
+ * before process exit to prevent log data loss.
43
46
  */
44
- function createServices(workspacePath?: string) {
45
- const fileSystemService = new FileSystemService()
46
- const workspaceRepository = new FileWorkspaceRepository(fileSystemService)
47
- // Use factory method to create CatalogUpdateService with configuration
48
- const catalogUpdateService = CatalogUpdateService.createWithConfig(
49
- workspaceRepository,
50
- workspacePath
51
- )
52
- const workspaceService = new WorkspaceService(workspaceRepository)
53
-
54
- return {
55
- fileSystemService,
56
- workspaceRepository,
57
- catalogUpdateService,
58
- workspaceService,
59
- }
47
+ function exitWithCleanup(code: number): never {
48
+ // Synchronously close all loggers to ensure logs are written
49
+ Logger.closeAll()
50
+ process.exit(code)
60
51
  }
61
52
 
62
- function parseBooleanFlag(value: unknown): boolean {
63
- if (value === undefined || value === null) return false
64
- if (typeof value === 'boolean') return value
65
- if (typeof value === 'number') return value !== 0
66
- if (typeof value === 'string') {
67
- const normalized = value.trim().toLowerCase()
68
- if (normalized === '') return false
69
- if (['false', '0', 'no', 'off', 'n'].includes(normalized)) return false
70
- if (['true', '1', 'yes', 'on', 'y'].includes(normalized)) return true
71
- // Commander env() 会把任意非空字符串塞进来;未知字符串按“启用”处理
72
- return true
53
+ /**
54
+ * Check for version updates at startup (non-blocking background check)
55
+ *
56
+ * Runs version check in background to avoid blocking CLI startup.
57
+ * Only notifies user after command execution if an update is available.
58
+ */
59
+ function startBackgroundVersionCheck(
60
+ config: Awaited<ReturnType<typeof ConfigLoader.loadConfig>>
61
+ ): Promise<{ hasUpdate: boolean; message?: string }> | null {
62
+ if (!VersionChecker.shouldCheckForUpdates() || config.advanced?.checkForUpdates === false) {
63
+ return null
73
64
  }
74
- return Boolean(value)
65
+
66
+ // Start check in background, don't await
67
+ return VersionChecker.checkVersion(getPackageJson().version, {
68
+ skipPrompt: true, // Don't prompt during background check
69
+ timeout: 3000,
70
+ })
71
+ .then((result) => {
72
+ if (result.shouldPrompt && result.latestVersion) {
73
+ return {
74
+ hasUpdate: true,
75
+ message: chalk.yellow(
76
+ `\n${t('cli.updateAvailable', { current: getPackageJson().version, latest: result.latestVersion })}`
77
+ ),
78
+ }
79
+ }
80
+ return { hasUpdate: false }
81
+ })
82
+ .catch((error) => {
83
+ logger.debug('Background version check failed', {
84
+ error: error instanceof Error ? error.message : error,
85
+ })
86
+ return { hasUpdate: false }
87
+ })
75
88
  }
76
89
 
77
90
  /**
78
- * Main CLI function
91
+ * Show update notification after command execution if available
79
92
  */
80
- export async function main(): Promise<void> {
81
- const program = new Command()
93
+ async function showUpdateNotificationIfAvailable(
94
+ checkPromise: Promise<{ hasUpdate: boolean; message?: string }> | null
95
+ ): Promise<void> {
96
+ if (!checkPromise) return
82
97
 
83
- // Parse arguments first to get workspace path
84
- let workspacePath: string | undefined
85
-
86
- // Extract workspace path from arguments for service creation
87
- const workspaceIndex = process.argv.findIndex((arg) => arg === '-w' || arg === '--workspace')
88
- if (workspaceIndex !== -1 && workspaceIndex + 1 < process.argv.length) {
89
- workspacePath = process.argv[workspaceIndex + 1]
98
+ try {
99
+ const result = await checkPromise
100
+ if (result.hasUpdate && result.message) {
101
+ cliOutput.print(result.message)
102
+ cliOutput.print(chalk.gray(t('cli.updateHint')))
103
+ }
104
+ } catch (error) {
105
+ // Update check is best-effort, log at debug level for troubleshooting
106
+ logger.debug('Version update check failed', {
107
+ error: error instanceof Error ? error.message : String(error),
108
+ })
90
109
  }
110
+ }
91
111
 
92
- // Load configuration to check if version updates are enabled
93
- const config = ConfigLoader.loadConfig(workspacePath || process.cwd())
112
+ /**
113
+ * Handle custom --version with update checking
114
+ */
115
+ async function handleVersionFlag(
116
+ args: string[],
117
+ config: Awaited<ReturnType<typeof ConfigLoader.loadConfig>>
118
+ ): Promise<void> {
119
+ if (!args.includes('--version')) return
94
120
 
95
- // Check for version updates (skip in CI environments or if disabled)
121
+ cliOutput.print(getPackageJson().version)
122
+
123
+ // Check for updates if not in CI and enabled in config
96
124
  if (VersionChecker.shouldCheckForUpdates() && config.advanced?.checkForUpdates !== false) {
97
125
  try {
98
- const versionResult = await VersionChecker.checkVersion(packageJson.version, {
126
+ cliOutput.print(chalk.gray(t('cli.checkingUpdates')))
127
+ const versionResult = await VersionChecker.checkVersion(getPackageJson().version, {
99
128
  skipPrompt: false,
100
- timeout: 3000, // Short timeout to not delay CLI startup
129
+ timeout: 5000, // Longer timeout for explicit version check
101
130
  })
102
131
 
103
132
  if (versionResult.shouldPrompt) {
104
133
  const didUpdate = await VersionChecker.promptAndUpdate(versionResult)
105
134
  if (didUpdate) {
106
- // Exit after successful update to allow user to restart with new version
107
- console.log(chalk.blue('Please run your command again to use the updated version.'))
108
- process.exit(0)
135
+ cliOutput.print(chalk.blue(t('cli.runAgain')))
136
+ exitWithCleanup(0)
109
137
  }
138
+ } else if (versionResult.isLatest) {
139
+ cliOutput.print(chalk.green(t('cli.latestVersion')))
110
140
  }
111
141
  } catch (error) {
112
- // Silently fail version check to not interrupt CLI usage (only show warning in verbose mode)
113
- if (process.argv.includes('-v') || process.argv.includes('--verbose')) {
114
- console.warn(chalk.yellow('⚠️ Could not check for updates:'), error)
142
+ // Silently fail update check for version command
143
+ logger.debug('Version flag update check failed', {
144
+ error: error instanceof Error ? error.message : error,
145
+ })
146
+ if (args.includes('-v') || args.includes('--verbose')) {
147
+ cliOutput.warn(chalk.yellow(`⚠️ ${t('cli.couldNotCheckUpdates')}`), error)
115
148
  }
116
149
  }
117
150
  }
118
151
 
119
- // Create services with workspace path for configuration loading
120
- const services = createServices(workspacePath)
121
-
122
- // Configure the main command
123
- program
124
- .name('pcu')
125
- .description('A CLI tool to check and update pnpm workspace catalog dependencies')
126
- .option('--version', 'show version information')
127
- .option('-v, --verbose', 'enable verbose logging')
128
- .option('-w, --workspace <path>', 'workspace directory path')
129
- .option('--no-color', 'disable colored output')
130
- .option('-u, --update', 'shorthand for update command')
131
- .option('-c, --check', 'shorthand for check command')
132
- .option('-a, --analyze', 'shorthand for analyze command')
133
- .option('-s, --workspace-info', 'shorthand for workspace command')
134
- .option('-t, --theme', 'shorthand for theme command')
135
- .option('--security-audit', 'shorthand for security command')
136
- .option('--security-fix', 'shorthand for security --fix-vulns command')
137
-
138
- // Check command
139
- program
140
- .command('check')
141
- .alias('chk')
142
- .description('check for outdated catalog dependencies')
143
- .option('--catalog <name>', 'check specific catalog only')
144
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
145
- .option(
146
- '-t, --target <type>',
147
- 'update target: latest, greatest, minor, patch, newest',
148
- 'latest'
149
- )
150
- .option('--prerelease', 'include prerelease versions')
151
- .option('--include <pattern>', 'include packages matching pattern', [])
152
- .option('--exclude <pattern>', 'exclude packages matching pattern', [])
153
- .action(async (options, command) => {
154
- try {
155
- const globalOptions = command.parent.opts()
156
- const checkCommand = new CheckCommand(services.catalogUpdateService)
157
-
158
- await checkCommand.execute({
159
- workspace: globalOptions.workspace,
160
- catalog: options.catalog,
161
- format: options.format,
162
- target: options.target,
163
- prerelease: options.prerelease,
164
- include: Array.isArray(options.include)
165
- ? options.include
166
- : [options.include].filter(Boolean),
167
- exclude: Array.isArray(options.exclude)
168
- ? options.exclude
169
- : [options.exclude].filter(Boolean),
170
- verbose: globalOptions.verbose,
171
- color: !globalOptions.noColor,
172
- })
173
- process.exit(0)
174
- } catch (error) {
175
- console.error(chalk.red('❌ Error:'), error)
176
- process.exit(1)
177
- }
178
- })
179
-
180
- // Update command
181
- program
182
- .command('update')
183
- .alias('u')
184
- .description('update catalog dependencies')
185
- .option('-i, --interactive', 'interactive mode to choose updates')
186
- .option('-d, --dry-run', 'preview changes without writing files')
187
- .option(
188
- '-t, --target <type>',
189
- 'update target: latest, greatest, minor, patch, newest',
190
- 'latest'
191
- )
192
- .option('--catalog <name>', 'update specific catalog only')
193
- .option('--include <pattern>', 'include packages matching pattern', [])
194
- .option('--exclude <pattern>', 'exclude packages matching pattern', [])
195
- .option('--force', 'force updates even if risky')
196
- .option('--prerelease', 'include prerelease versions')
197
- .option('-b, --create-backup', 'create backup files before updating')
198
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
199
- .option('--ai', 'enable AI-powered batch analysis for all updates')
200
- .option('--provider <name>', 'AI provider: auto, claude, gemini, codex', 'auto')
201
- .option(
202
- '--analysis-type <type>',
203
- 'AI analysis type: impact, security, compatibility, recommend',
204
- 'impact'
205
- )
206
- .option('--skip-cache', 'skip AI analysis cache')
207
- .action(async (options, command) => {
208
- try {
209
- const globalOptions = command.parent.opts()
210
- const updateCommand = new UpdateCommand(services.catalogUpdateService)
211
-
212
- await updateCommand.execute({
213
- workspace: globalOptions.workspace,
214
- catalog: options.catalog,
215
- format: options.format,
216
- target: options.target,
217
- interactive: options.interactive,
218
- dryRun: options.dryRun,
219
- force: options.force,
220
- prerelease: options.prerelease,
221
- include: Array.isArray(options.include)
222
- ? options.include
223
- : [options.include].filter(Boolean),
224
- exclude: Array.isArray(options.exclude)
225
- ? options.exclude
226
- : [options.exclude].filter(Boolean),
227
- createBackup: options.createBackup,
228
- verbose: globalOptions.verbose,
229
- color: !globalOptions.noColor,
230
- // AI batch analysis options
231
- ai: parseBooleanFlag(options.ai),
232
- provider: options.provider,
233
- analysisType: options.analysisType as AnalysisType,
234
- skipCache: parseBooleanFlag(options.skipCache),
235
- })
236
- process.exit(0)
237
- } catch (error) {
238
- console.error(chalk.red('❌ Error:'), error)
239
- process.exit(1)
240
- }
241
- })
242
-
243
- // Analyze command
244
- program
245
- .command('analyze')
246
- .alias('a')
247
- .description('analyze the impact of updating a specific dependency')
248
- .argument('<package>', 'package name')
249
- .argument('[version]', 'new version (default: latest)')
250
- .option('--catalog <name>', 'catalog name (auto-detected if not specified)')
251
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
252
- .option('--no-ai', 'disable AI-powered analysis')
253
- .option('--provider <name>', 'AI provider: auto, claude, gemini, codex', 'auto')
254
- .option(
255
- '--analysis-type <type>',
256
- 'AI analysis type: impact, security, compatibility, recommend',
257
- 'impact'
258
- )
259
- .option('--skip-cache', 'skip AI analysis cache')
260
- .action(async (packageName, version, options, command) => {
261
- try {
262
- const globalOptions = command.parent.opts()
263
- const formatter = new OutputFormatter(
264
- options.format as OutputFormat,
265
- !globalOptions.noColor
266
- )
267
-
268
- // Auto-detect catalog if not specified
269
- let catalog = options.catalog
270
- if (!catalog) {
271
- console.log(chalk.gray(`🔍 Auto-detecting catalog for ${packageName}...`))
272
- catalog = await services.catalogUpdateService.findCatalogForPackage(
273
- packageName,
274
- globalOptions.workspace
275
- )
276
- if (!catalog) {
277
- console.error(chalk.red(`❌ Package "${packageName}" not found in any catalog`))
278
- console.log(chalk.gray('Use --catalog <name> to specify the catalog manually'))
279
- process.exit(1)
280
- }
281
- console.log(chalk.gray(` Found in catalog: ${catalog}`))
282
- }
283
-
284
- // Get latest version if not specified
285
- let targetVersion = version
286
- if (!targetVersion) {
287
- const tempRegistryService = new NpmRegistryService()
288
- targetVersion = (await tempRegistryService.getLatestVersion(packageName)).toString()
289
- }
290
-
291
- // Get basic impact analysis first
292
- const analysis = await services.catalogUpdateService.analyzeImpact(
293
- catalog,
294
- packageName,
295
- targetVersion,
296
- globalOptions.workspace
297
- )
298
-
299
- // AI analysis is enabled by default (use --no-ai to disable)
300
- const aiEnabled = options.ai !== false
301
-
302
- if (aiEnabled) {
303
- console.log(chalk.blue('🤖 Running AI-powered analysis...'))
304
-
305
- const aiService = new AIAnalysisService({
306
- config: {
307
- preferredProvider: options.provider === 'auto' ? 'auto' : options.provider,
308
- cache: { enabled: !parseBooleanFlag(options.skipCache), ttl: 3600 },
309
- fallback: { enabled: true, useRuleEngine: true },
310
- },
311
- })
312
-
313
- // Get workspace info
314
- const workspaceInfo = await services.workspaceService.getWorkspaceInfo(
315
- globalOptions.workspace
316
- )
317
-
318
- // Build packages info for AI analysis
319
- const packages = [
320
- {
321
- name: packageName,
322
- currentVersion: analysis.currentVersion,
323
- targetVersion: analysis.proposedVersion,
324
- updateType: analysis.updateType,
325
- },
326
- ]
327
-
328
- // Build workspace info for AI
329
- const wsInfo = {
330
- name: workspaceInfo.name,
331
- path: workspaceInfo.path,
332
- packageCount: workspaceInfo.packageCount,
333
- catalogCount: workspaceInfo.catalogCount,
334
- }
335
-
336
- try {
337
- const aiResult = await aiService.analyzeUpdates(packages, wsInfo, {
338
- analysisType: options.analysisType as AnalysisType,
339
- skipCache: parseBooleanFlag(options.skipCache),
340
- })
341
-
342
- // Format and display AI analysis result
343
- const aiOutput = formatter.formatAIAnalysis(aiResult, analysis)
344
- console.log(aiOutput)
345
- process.exit(0)
346
- } catch (aiError) {
347
- console.warn(chalk.yellow('⚠️ AI analysis failed, showing basic analysis:'))
348
- if (globalOptions.verbose) {
349
- console.warn(chalk.gray(String(aiError)))
350
- }
351
- // Fall back to basic analysis
352
- const formattedOutput = formatter.formatImpactAnalysis(analysis)
353
- console.log(formattedOutput)
354
- process.exit(0)
355
- }
356
- } else {
357
- // Standard analysis without AI
358
- const formattedOutput = formatter.formatImpactAnalysis(analysis)
359
- console.log(formattedOutput)
360
- }
361
- } catch (error) {
362
- console.error(chalk.red('❌ Error:'), error)
363
- process.exit(1)
364
- }
365
- })
366
-
367
- // Workspace command
368
- program
369
- .command('workspace')
370
- .alias('w')
371
- .description('workspace information and validation')
372
- .option('--validate', 'validate workspace configuration')
373
- .option('-s, --stats', 'show workspace statistics')
374
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
375
- .action(async (options, command) => {
376
- try {
377
- const globalOptions = command.parent.opts()
378
- const formatter = new OutputFormatter(
379
- options.format as OutputFormat,
380
- !globalOptions.noColor
381
- )
382
-
383
- if (options.validate) {
384
- const report = await services.workspaceService.validateWorkspace(globalOptions.workspace)
385
- const formattedOutput = formatter.formatValidationReport(report)
386
- console.log(formattedOutput)
387
- process.exit(0)
388
- if (!report.isValid) {
389
- process.exit(1)
390
- }
391
- } else if (options.stats) {
392
- const stats = await services.workspaceService.getWorkspaceStats(globalOptions.workspace)
393
- const formattedOutput = formatter.formatWorkspaceStats(stats)
394
- console.log(formattedOutput)
395
- process.exit(0)
396
- } else {
397
- const info = await services.workspaceService.getWorkspaceInfo(globalOptions.workspace)
398
- console.log(formatter.formatMessage(`Workspace: ${info.name}`, 'info'))
399
- console.log(formatter.formatMessage(`Path: ${info.path}`, 'info'))
400
- console.log(formatter.formatMessage(`Packages: ${info.packageCount}`, 'info'))
401
- console.log(formatter.formatMessage(`Catalogs: ${info.catalogCount}`, 'info'))
402
-
403
- if (info.catalogNames.length > 0) {
404
- console.log(
405
- formatter.formatMessage(`Catalog names: ${info.catalogNames.join(', ')}`, 'info')
406
- )
407
- }
408
- process.exit(0)
409
- }
410
- } catch (error) {
411
- console.error(chalk.red('❌ Error:'), error)
412
- process.exit(1)
413
- }
414
- })
415
-
416
- // Theme command
417
- program
418
- .command('theme')
419
- .alias('t')
420
- .description('configure color theme')
421
- .option('-s, --set <theme>', 'set theme: default, modern, minimal, neon')
422
- .option('-l, --list', 'list available themes')
423
- .option('-i, --interactive', 'interactive theme selection')
424
- .action(async (options, _command) => {
425
- try {
426
- if (options.list) {
427
- const themes = ThemeManager.listThemes()
428
- console.log(StyledText.iconInfo('Available themes:'))
429
- themes.forEach((theme) => {
430
- console.log(` • ${theme}`)
431
- })
432
- return
433
- }
434
-
435
- if (options.set) {
436
- const themes = ThemeManager.listThemes()
437
- if (!themes.includes(options.set)) {
438
- console.error(StyledText.iconError(`Invalid theme: ${options.set}`))
439
- console.log(StyledText.muted(`Available themes: ${themes.join(', ')}`))
440
- process.exit(1)
441
- }
442
-
443
- ThemeManager.setTheme(options.set)
444
- console.log(StyledText.iconSuccess(`Theme set to: ${options.set}`))
445
-
446
- // Show a preview
447
- console.log('\nTheme preview:')
448
- const theme = ThemeManager.getTheme()
449
- console.log(` ${theme.success('✓ Success message')}`)
450
- console.log(` ${theme.warning('⚠ Warning message')}`)
451
- console.log(` ${theme.error('✗ Error message')}`)
452
- console.log(` ${theme.info('ℹ Info message')}`)
453
- console.log(
454
- ` ${theme.major('Major update')} | ${theme.minor('Minor update')} | ${theme.patch('Patch update')}`
455
- )
456
- return
457
- }
458
-
459
- if (options.interactive) {
460
- const interactivePrompts = new InteractivePrompts()
461
- const config = await interactivePrompts.configurationWizard()
462
-
463
- if (config.theme) {
464
- ThemeManager.setTheme(config.theme)
465
- console.log(StyledText.iconSuccess(`Theme configured: ${config.theme}`))
466
- }
467
- return
468
- }
469
-
470
- // Default: show current theme and list
471
- const currentTheme = ThemeManager.getTheme()
472
- console.log(StyledText.iconInfo('Current theme settings:'))
473
- console.log(` Theme: ${currentTheme ? 'custom' : 'default'}`)
474
- console.log('\nAvailable themes:')
475
- ThemeManager.listThemes().forEach((theme) => {
476
- console.log(` • ${theme}`)
477
- })
478
- console.log(
479
- StyledText.muted('\nUse --set <theme> to change theme or --interactive for guided setup')
480
- )
481
- } catch (error) {
482
- console.error(StyledText.iconError('Error configuring theme:'), error)
483
- process.exit(1)
484
- }
485
- })
486
-
487
- // Security command
488
- program
489
- .command('security')
490
- .alias('sec')
491
- .description('security vulnerability scanning and automated fixes')
492
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
493
- .option('--audit', 'perform npm audit scan (default: true)', true)
494
- .option('--fix-vulns', 'automatically fix vulnerabilities')
495
- .option('--severity <level>', 'filter by severity: low, moderate, high, critical')
496
- .option('--include-dev', 'include dev dependencies in scan')
497
- .option('--snyk', 'include Snyk scan (requires snyk CLI)')
498
- .action(async (options, command) => {
499
- try {
500
- const globalOptions = command.parent.opts()
501
- const formatter = new OutputFormatter(
502
- options.format as OutputFormat,
503
- !globalOptions.noColor
504
- )
505
-
506
- const securityCommand = new SecurityCommand(formatter)
507
-
508
- await securityCommand.execute({
509
- workspace: globalOptions.workspace,
510
- format: options.format,
511
- audit: options.audit,
512
- fixVulns: options.fixVulns,
513
- severity: options.severity,
514
- includeDev: options.includeDev,
515
- snyk: options.snyk,
516
- verbose: globalOptions.verbose,
517
- color: !globalOptions.noColor,
518
- })
519
- process.exit(0)
520
- } catch (error) {
521
- console.error(chalk.red('❌ Error:'), error)
522
- process.exit(1)
523
- }
524
- })
525
-
526
- // Init command
527
- program
528
- .command('init')
529
- .alias('i')
530
- .description('initialize PCU configuration and PNPM workspace')
531
- .option('--force', 'overwrite existing configuration file')
532
- .option('--full', 'generate full configuration with all options')
533
- .option(
534
- '--create-workspace',
535
- 'create PNPM workspace structure if missing (default: true)',
536
- true
537
- )
538
- .option('--no-create-workspace', 'skip creating PNPM workspace structure')
539
- .option('-f, --format <type>', 'output format: table, json, yaml, minimal', 'table')
540
- .action(async (options, command) => {
541
- try {
542
- const globalOptions = command.parent.opts()
543
- const initCommand = new InitCommand()
544
-
545
- await initCommand.execute({
546
- workspace: globalOptions.workspace,
547
- force: options.force,
548
- full: options.full,
549
- createWorkspace: options.createWorkspace,
550
- verbose: globalOptions.verbose,
551
- color: !globalOptions.noColor,
552
- })
553
- process.exit(0)
554
- } catch (error) {
555
- console.error(chalk.red('❌ Error:'), error)
556
- process.exit(1)
557
- }
558
- })
559
-
560
- // AI command - check AI provider status and availability
561
- program
562
- .command('ai')
563
- .description('check AI provider status and availability')
564
- .option('--status', 'show status of all AI providers (default)')
565
- .option('--test', 'test AI analysis with a sample request')
566
- .option('--cache-stats', 'show AI analysis cache statistics')
567
- .option('--clear-cache', 'clear AI analysis cache')
568
- .action(async (options) => {
569
- try {
570
- const aiDetector = new AIDetector()
571
-
572
- if (options.clearCache) {
573
- // Import the cache singleton
574
- const { analysisCache } = await import('@pcu/core')
575
- analysisCache.clear()
576
- console.log(chalk.green('✅ AI analysis cache cleared'))
577
- process.exit(0)
578
- return
579
- }
580
-
581
- if (options.cacheStats) {
582
- const { analysisCache } = await import('@pcu/core')
583
- const stats = analysisCache.getStats()
584
- console.log(chalk.blue('📊 AI Analysis Cache Statistics'))
585
- console.log(chalk.gray('─────────────────────────────────'))
586
- console.log(` Total entries: ${chalk.cyan(stats.totalEntries)}`)
587
- console.log(` Cache hits: ${chalk.green(stats.hits)}`)
588
- console.log(` Cache misses: ${chalk.yellow(stats.misses)}`)
589
- console.log(` Hit rate: ${chalk.cyan(`${(stats.hitRate * 100).toFixed(1)}%`)}`)
590
- process.exit(0)
591
- return
592
- }
593
-
594
- if (options.test) {
595
- console.log(chalk.blue('🧪 Testing AI analysis...'))
596
-
597
- const aiService = new AIAnalysisService({
598
- config: {
599
- fallback: { enabled: true, useRuleEngine: true },
600
- },
601
- })
602
-
603
- const testPackages = [
604
- {
605
- name: 'lodash',
606
- currentVersion: '4.17.20',
607
- targetVersion: '4.17.21',
608
- updateType: 'patch' as const,
609
- },
610
- ]
152
+ exitWithCleanup(0)
153
+ }
611
154
 
612
- const testWorkspaceInfo = {
613
- name: 'test-workspace',
614
- path: process.cwd(),
615
- packageCount: 1,
616
- catalogCount: 1,
617
- }
155
+ /**
156
+ * Main CLI function
157
+ */
158
+ export async function main(): Promise<void> {
159
+ const program = new Command()
618
160
 
619
- try {
620
- const result = await aiService.analyzeUpdates(testPackages, testWorkspaceInfo, {
621
- analysisType: 'impact',
622
- })
623
- console.log(chalk.green('✅ AI analysis test successful!'))
624
- console.log(chalk.gray('─────────────────────────────────'))
625
- console.log(` Provider: ${chalk.cyan(result.provider)}`)
626
- console.log(` Confidence: ${chalk.cyan(`${(result.confidence * 100).toFixed(0)}%`)}`)
627
- console.log(` Summary: ${result.summary}`)
628
- } catch (error) {
629
- console.log(chalk.yellow('⚠️ AI analysis test failed:'))
630
- console.log(chalk.gray(String(error)))
631
- }
632
- process.exit(0)
633
- return
634
- }
161
+ // Parse arguments first to get workspace path
162
+ let workspacePath: string | undefined
635
163
 
636
- // Default: show status
637
- console.log(chalk.blue('🤖 AI Provider Status'))
638
- console.log(chalk.gray('─────────────────────────────────'))
164
+ // Extract workspace path from arguments for service creation
165
+ const workspaceIndex = process.argv.findIndex((arg) => arg === '-w' || arg === '--workspace')
166
+ if (workspaceIndex !== -1 && workspaceIndex + 1 < process.argv.length) {
167
+ workspacePath = process.argv[workspaceIndex + 1]
168
+ }
639
169
 
640
- const summary = await aiDetector.getDetectionSummary()
641
- console.log(summary)
170
+ // Load configuration to check if version updates are enabled
171
+ const config = await ConfigLoader.loadConfig(workspacePath || process.cwd())
642
172
 
643
- const providers = await aiDetector.detectAvailableProviders()
644
- console.log('')
645
- console.log(chalk.blue('📋 Provider Details'))
646
- console.log(chalk.gray('─────────────────────────────────'))
173
+ // Initialize i18n with config locale (priority: config > env > system)
174
+ I18n.init(config.locale)
647
175
 
648
- for (const provider of providers) {
649
- const statusIcon = provider.available ? chalk.green('✓') : chalk.red('✗')
650
- const statusText = provider.available ? chalk.green('Available') : chalk.gray('Not found')
651
- const priorityText = chalk.gray(`(priority: ${provider.priority})`)
176
+ // Start cache initialization in background (non-blocking)
177
+ // This allows the cache to load from disk while CLI continues startup
178
+ startCacheInitialization()
652
179
 
653
- console.log(
654
- ` ${statusIcon} ${chalk.cyan(provider.name)} - ${statusText} ${priorityText}`
655
- )
180
+ // PERF-001: Preload package suggestions in background (non-blocking)
181
+ // This loads the suggestions file asynchronously during CLI startup
182
+ preloadPackageSuggestions()
656
183
 
657
- if (provider.available && provider.path) {
658
- console.log(chalk.gray(` Path: ${provider.path}`))
659
- }
660
- if (provider.available && provider.version) {
661
- console.log(chalk.gray(` Version: ${provider.version}`))
662
- }
663
- }
184
+ // Start background version check (non-blocking)
185
+ const versionCheckPromise = startBackgroundVersionCheck(config)
664
186
 
665
- const best = await aiDetector.getBestProvider()
666
- if (best) {
667
- console.log('')
668
- console.log(chalk.green(`✨ Best available provider: ${best.name}`))
669
- }
670
- process.exit(0)
671
- } catch (error) {
672
- console.error(chalk.red('❌ Error:'), error)
673
- process.exit(1)
674
- }
675
- })
187
+ // Create lazy service factory - services will only be instantiated when actually needed
188
+ // This means --help and --version won't trigger service creation
189
+ const serviceFactory = new LazyServiceFactory(workspacePath)
676
190
 
677
- // Add help command
191
+ // Configure the main command
678
192
  program
679
- .command('help')
680
- .alias('h')
681
- .argument('[command]', 'command to get help for')
682
- .description('display help for command')
683
- .action((command) => {
684
- if (command) {
685
- const cmd = program.commands.find((c) => c.name() === command)
686
- if (cmd) {
687
- cmd.help()
688
- } else {
689
- console.log(chalk.red(`Unknown command: ${command}`))
690
- }
691
- } else {
692
- program.help()
693
- }
694
- })
695
-
696
- // Let commander handle help and version normally
697
- // program.exitOverride() removed to fix help/version output
193
+ .name('pcu')
194
+ .description(t('cli.description.main'))
195
+ .helpCommand(t('cli.help.command'), t('cli.help.description'))
196
+ .helpOption('-h, --help', t('cli.help.option'))
197
+ .option('--version', t('cli.option.version'))
198
+ .option('-v, --verbose', t('cli.option.verbose'))
199
+ .option('-w, --workspace <path>', t('cli.option.workspace'))
200
+ .option('--no-color', t('cli.option.noColor'))
698
201
 
699
- // Handle shorthand options and single-letter commands by rewriting arguments
700
- const args = [...process.argv]
701
- // Map single-letter command 'i' -> init (changed from interactive mode)
702
- if (
703
- args.includes('i') &&
704
- !args.some(
705
- (a) =>
706
- a === 'init' ||
707
- a === 'update' ||
708
- a === '-u' ||
709
- a === '--update' ||
710
- a === '-i' ||
711
- a === '--interactive'
712
- )
713
- ) {
714
- const index = args.indexOf('i')
715
- args.splice(index, 1, 'init')
716
- }
717
-
718
- if (args.includes('-u') || args.includes('--update')) {
719
- const index = args.findIndex((arg) => arg === '-u' || arg === '--update')
720
- args.splice(index, 1, 'update')
721
- } else if (
722
- (args.includes('-i') || args.includes('--interactive')) &&
723
- !args.some((a) => a === 'update' || a === '-u' || a === '--update')
724
- ) {
725
- // Map standalone -i to `update -i`
726
- const index = args.findIndex((arg) => arg === '-i' || arg === '--interactive')
727
- // Replace the flag position with 'update' and keep the flag after it
728
- args.splice(index, 1, 'update', '-i')
729
- } else if (args.includes('-c') || args.includes('--check')) {
730
- const index = args.findIndex((arg) => arg === '-c' || arg === '--check')
731
- args.splice(index, 1, 'check')
732
- } else if (args.includes('-a') || args.includes('--analyze')) {
733
- const index = args.findIndex((arg) => arg === '-a' || arg === '--analyze')
734
- args.splice(index, 1, 'analyze')
735
- } else if (args.includes('-s') || args.includes('--workspace-info')) {
736
- const index = args.findIndex((arg) => arg === '-s' || arg === '--workspace-info')
737
- args.splice(index, 1, 'workspace')
738
- } else if (args.includes('-t') || args.includes('--theme')) {
739
- const index = args.findIndex((arg) => arg === '-t' || arg === '--theme')
740
- args.splice(index, 1, 'theme')
741
- } else if (args.includes('--security-audit')) {
742
- const index = args.indexOf('--security-audit')
743
- args.splice(index, 1, 'security')
744
- } else if (args.includes('--security-fix')) {
745
- const index = args.indexOf('--security-fix')
746
- args.splice(index, 1, 'security', '--fix-vulns')
747
- }
202
+ // Register all commands with lazy service factory
203
+ registerCommands(program, serviceFactory, getPackageJson())
748
204
 
749
205
  // Show help if no arguments provided
750
- if (args.length <= 2) {
206
+ if (process.argv.length <= 2) {
751
207
  program.help()
752
208
  }
753
209
 
754
210
  // Handle custom --version with update checking
755
- if (args.includes('--version')) {
756
- console.log(packageJson.version)
757
-
758
- // Check for updates if not in CI and enabled in config
759
- if (VersionChecker.shouldCheckForUpdates() && config.advanced?.checkForUpdates !== false) {
760
- try {
761
- console.log(chalk.gray('Checking for updates...'))
762
- const versionResult = await VersionChecker.checkVersion(packageJson.version, {
763
- skipPrompt: false,
764
- timeout: 5000, // Longer timeout for explicit version check
765
- })
766
-
767
- if (versionResult.shouldPrompt) {
768
- const didUpdate = await VersionChecker.promptAndUpdate(versionResult)
769
- if (didUpdate) {
770
- console.log(chalk.blue('Please run your command again to use the updated version.'))
771
- process.exit(0)
772
- }
773
- } else if (versionResult.isLatest) {
774
- console.log(chalk.green('You are using the latest version!'))
775
- }
776
- } catch (error) {
777
- // Silently fail update check for version command
778
- if (args.includes('-v') || args.includes('--verbose')) {
779
- console.warn(chalk.yellow('⚠️ Could not check for updates:'), error)
780
- }
781
- }
782
- }
783
-
784
- process.exit(0)
785
- }
211
+ await handleVersionFlag(process.argv, config)
786
212
 
787
213
  // Parse command line arguments
788
214
  try {
789
- await program.parseAsync(args)
215
+ await program.parseAsync(process.argv)
216
+
217
+ // Show update notification after successful command execution
218
+ await showUpdateNotificationIfAvailable(versionCheckPromise)
790
219
  } catch (error) {
791
- console.error(chalk.red('❌ Unexpected error:'), error)
220
+ if (isCommandExitError(error)) {
221
+ exitWithCleanup(error.exitCode)
222
+ }
223
+ // Handle user cancellation (Ctrl+C) gracefully
224
+ if (isExitPromptError(error)) {
225
+ cliOutput.print(chalk.gray(`\n${t('cli.cancelled')}`))
226
+ exitWithCleanup(0)
227
+ }
228
+ logger.error('CLI parse error', error instanceof Error ? error : undefined, {
229
+ args: process.argv,
230
+ })
231
+ cliOutput.error(chalk.red(`❌ ${t('cli.unexpectedError')}`), error)
792
232
  if (error instanceof Error && error.stack) {
793
- console.error(chalk.gray(error.stack))
233
+ cliOutput.error(chalk.gray(error.stack))
794
234
  }
795
- process.exit(1)
235
+ exitWithCleanup(1)
796
236
  }
797
237
  }
798
238
 
799
239
  // Run the CLI if this file is executed directly
800
240
  if (import.meta.url === `file://${process.argv[1]}`) {
801
241
  main().catch((error) => {
802
- console.error(chalk.red('❌ Fatal error:'), error)
803
- process.exit(1)
242
+ if (isCommandExitError(error)) {
243
+ exitWithCleanup(error.exitCode)
244
+ }
245
+ // Handle user cancellation (Ctrl+C) gracefully
246
+ if (isExitPromptError(error)) {
247
+ cliOutput.print(chalk.gray(`\n${t('cli.cancelled')}`))
248
+ exitWithCleanup(0)
249
+ }
250
+ logger.error('Fatal CLI error', error instanceof Error ? error : undefined)
251
+ cliOutput.error(chalk.red(`❌ ${t('cli.fatalError')}`), error)
252
+ exitWithCleanup(1)
804
253
  })
805
254
  }