@eslint-config-snapshot/cli 0.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 (28) hide show
  1. package/dist/index.cjs +720 -0
  2. package/dist/index.js +697 -0
  3. package/package.json +26 -0
  4. package/project.json +35 -0
  5. package/src/index.ts +861 -0
  6. package/test/cli.integration.test.ts +247 -0
  7. package/test/cli.npm-isolated.integration.test.ts +109 -0
  8. package/test/cli.pnpm-isolated.integration.test.ts +140 -0
  9. package/test/cli.terminal.integration.test.ts +370 -0
  10. package/test/fixtures/npm-isolated-template/eslint-config-snapshot.config.mjs +16 -0
  11. package/test/fixtures/npm-isolated-template/package.json +7 -0
  12. package/test/fixtures/npm-isolated-template/packages/ws-a/.eslintrc.cjs +7 -0
  13. package/test/fixtures/npm-isolated-template/packages/ws-a/package.json +7 -0
  14. package/test/fixtures/npm-isolated-template/packages/ws-a/src/index.ts +1 -0
  15. package/test/fixtures/npm-isolated-template/packages/ws-b/.eslintrc.cjs +7 -0
  16. package/test/fixtures/npm-isolated-template/packages/ws-b/package.json +7 -0
  17. package/test/fixtures/npm-isolated-template/packages/ws-b/src/index.ts +1 -0
  18. package/test/fixtures/repo/eslint-config-snapshot.config.mjs +16 -0
  19. package/test/fixtures/repo/package.json +7 -0
  20. package/test/fixtures/repo/packages/ws-a/node_modules/eslint/bin/eslint.js +1 -0
  21. package/test/fixtures/repo/packages/ws-a/node_modules/eslint/package.json +4 -0
  22. package/test/fixtures/repo/packages/ws-a/package.json +4 -0
  23. package/test/fixtures/repo/packages/ws-a/src/index.ts +1 -0
  24. package/test/fixtures/repo/packages/ws-b/node_modules/eslint/bin/eslint.js +1 -0
  25. package/test/fixtures/repo/packages/ws-b/node_modules/eslint/package.json +4 -0
  26. package/test/fixtures/repo/packages/ws-b/package.json +4 -0
  27. package/test/fixtures/repo/packages/ws-b/src/index.ts +1 -0
  28. package/tsconfig.json +12 -0
package/src/index.ts ADDED
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ aggregateRules,
4
+ assignGroupsByMatch,
5
+ buildSnapshot,
6
+ diffSnapshots,
7
+ discoverWorkspaces,
8
+ extractRulesFromPrintConfig,
9
+ findConfigPath,
10
+ getConfigScaffold,
11
+ hasDiff,
12
+ loadConfig,
13
+ readSnapshotFile,
14
+ sampleWorkspaceFiles,
15
+ writeSnapshotFile
16
+ } from '@eslint-config-snapshot/api'
17
+ import { Command, CommanderError, InvalidArgumentError } from 'commander'
18
+ import fg from 'fast-glob'
19
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
20
+ import path from 'node:path'
21
+ import { createInterface } from 'node:readline'
22
+
23
+
24
+ const SNAPSHOT_DIR = '.eslint-config-snapshot'
25
+ const UPDATE_HINT = 'Tip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n'
26
+
27
+ type BuiltSnapshot = Awaited<ReturnType<typeof buildSnapshot>>
28
+ type StoredSnapshot = Awaited<ReturnType<typeof readSnapshotFile>>
29
+ type SnapshotDiff = ReturnType<typeof diffSnapshots>
30
+ type CheckFormat = 'summary' | 'status' | 'diff'
31
+ type PrintFormat = 'json' | 'short'
32
+ type InitTarget = 'file' | 'package-json'
33
+ type InitPreset = 'minimal' | 'full'
34
+
35
+ type RootOptions = {
36
+ update?: boolean
37
+ }
38
+
39
+ export async function runCli(command: string | undefined, cwd: string, flags: string[] = []): Promise<number> {
40
+ const argv = command ? [command, ...flags] : [...flags]
41
+ return runArgv(argv, cwd)
42
+ }
43
+
44
+ async function runArgv(argv: string[], cwd: string): Promise<number> {
45
+ const hasCommandToken = argv.some((token) => !token.startsWith('-'))
46
+ if (!hasCommandToken) {
47
+ return runDefaultInvocation(argv, cwd)
48
+ }
49
+
50
+ let actionCode: number | undefined
51
+
52
+ const program = createProgram(cwd, (code) => {
53
+ actionCode = code
54
+ })
55
+
56
+ try {
57
+ await program.parseAsync(argv, { from: 'user' })
58
+ } catch (error: unknown) {
59
+ if (error instanceof CommanderError) {
60
+ if (error.code === 'commander.helpDisplayed') {
61
+ return 0
62
+ }
63
+ return error.exitCode
64
+ }
65
+
66
+ const message = error instanceof Error ? error.message : String(error)
67
+ process.stderr.write(`${message}\n`)
68
+ return 1
69
+ }
70
+
71
+ return actionCode ?? 0
72
+ }
73
+
74
+ async function runDefaultInvocation(argv: string[], cwd: string): Promise<number> {
75
+ const known = new Set(['-u', '--update', '-h', '--help'])
76
+ for (const token of argv) {
77
+ if (!known.has(token)) {
78
+ process.stderr.write(`error: unknown option '${token}'\n`)
79
+ return 1
80
+ }
81
+ }
82
+
83
+ if (argv.includes('-h') || argv.includes('--help')) {
84
+ const program = createProgram(cwd, () => {
85
+ // no-op
86
+ })
87
+ program.outputHelp()
88
+ return 0
89
+ }
90
+
91
+ if (argv.includes('-u') || argv.includes('--update')) {
92
+ return executeUpdate(cwd, true)
93
+ }
94
+
95
+ return executeCheck(cwd, 'summary', true)
96
+ }
97
+
98
+ function createProgram(cwd: string, onActionExit: (code: number) => void): Command {
99
+ const program = new Command()
100
+
101
+ program
102
+ .name('eslint-config-snapshot')
103
+ .description('Deterministic ESLint config snapshot drift checker for workspaces')
104
+ .showHelpAfterError('(add --help for usage)')
105
+ .option('-u, --update', 'Update snapshots (default mode only)')
106
+
107
+ program.hook('preAction', (thisCommand) => {
108
+ const opts = thisCommand.opts<RootOptions>()
109
+ if (opts.update) {
110
+ throw new Error('--update can only be used without a command')
111
+ }
112
+ })
113
+
114
+ program
115
+ .command('check')
116
+ .description('Compare current state against stored snapshots')
117
+ .option('--format <format>', 'Output format: summary|status|diff', parseCheckFormat, 'summary')
118
+ .action(async (opts: { format: CheckFormat }) => {
119
+ onActionExit(await executeCheck(cwd, opts.format))
120
+ })
121
+
122
+ program
123
+ .command('update')
124
+ .alias('snapshot')
125
+ .description('Compute and write snapshots to .eslint-config-snapshot/')
126
+ .action(async () => {
127
+ onActionExit(await executeUpdate(cwd, true))
128
+ })
129
+
130
+ program
131
+ .command('print')
132
+ .description('Print aggregated rules')
133
+ .option('--format <format>', 'Output format: json|short', parsePrintFormat, 'json')
134
+ .option('--short', 'Alias for --format short')
135
+ .action(async (opts: { format: PrintFormat; short?: boolean }) => {
136
+ const format: PrintFormat = opts.short ? 'short' : opts.format
137
+ await executePrint(cwd, format)
138
+ onActionExit(0)
139
+ })
140
+
141
+ program
142
+ .command('init')
143
+ .description('Initialize config (file or package.json)')
144
+ .option('--target <target>', 'Config target: file|package-json', parseInitTarget)
145
+ .option('--preset <preset>', 'Config preset: minimal|full', parseInitPreset)
146
+ .option('-f, --force', 'Allow init even when an existing config is detected')
147
+ .option('-y, --yes', 'Skip prompts and use defaults/options')
148
+ .addHelpText(
149
+ 'after',
150
+ `
151
+ Examples:
152
+ $ eslint-config-snapshot init
153
+ Runs interactive numbered prompts:
154
+ target: 1) package-json, 2) file
155
+ preset: 1) minimal, 2) full
156
+
157
+ $ eslint-config-snapshot init --yes --target package-json --preset minimal
158
+ Non-interactive minimal setup in package.json.
159
+
160
+ $ eslint-config-snapshot init --yes --force --target file --preset full
161
+ Overwrite-safe bypass when a config is already detected.
162
+ `
163
+ )
164
+ .action(async (opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean }) => {
165
+ onActionExit(await runInit(cwd, opts))
166
+ })
167
+
168
+ // Backward-compatible aliases kept out of help.
169
+ program
170
+ .command('compare', { hidden: true })
171
+ .action(async () => {
172
+ onActionExit(await executeCheck(cwd, 'diff'))
173
+ })
174
+
175
+ program
176
+ .command('status', { hidden: true })
177
+ .action(async () => {
178
+ onActionExit(await executeCheck(cwd, 'status'))
179
+ })
180
+
181
+ program
182
+ .command('what-changed', { hidden: true })
183
+ .action(async () => {
184
+ onActionExit(await executeCheck(cwd, 'summary'))
185
+ })
186
+
187
+ program.exitOverride()
188
+ return program
189
+ }
190
+
191
+ function parseCheckFormat(value: string): CheckFormat {
192
+ const normalized = value.trim().toLowerCase()
193
+ if (normalized === 'summary' || normalized === 'status' || normalized === 'diff') {
194
+ return normalized
195
+ }
196
+
197
+ throw new InvalidArgumentError('Expected one of: summary, status, diff')
198
+ }
199
+
200
+ function parsePrintFormat(value: string): PrintFormat {
201
+ const normalized = value.trim().toLowerCase()
202
+ if (normalized === 'json' || normalized === 'short') {
203
+ return normalized
204
+ }
205
+
206
+ throw new InvalidArgumentError('Expected one of: json, short')
207
+ }
208
+
209
+ function parseInitTarget(value: string): InitTarget {
210
+ const normalized = value.trim().toLowerCase()
211
+ if (normalized === 'file' || normalized === 'package-json') {
212
+ return normalized
213
+ }
214
+
215
+ throw new InvalidArgumentError('Expected one of: file, package-json')
216
+ }
217
+
218
+ function parseInitPreset(value: string): InitPreset {
219
+ const normalized = value.trim().toLowerCase()
220
+ if (normalized === 'minimal' || normalized === 'full') {
221
+ return normalized
222
+ }
223
+
224
+ throw new InvalidArgumentError('Expected one of: minimal, full')
225
+ }
226
+
227
+ async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation = false): Promise<number> {
228
+ const foundConfig = await findConfigPath(cwd)
229
+ if (!foundConfig) {
230
+ writeSubtleInfo(
231
+ 'Tip: no explicit config found. Using safe built-in defaults. Run `eslint-config-snapshot init` to customize when needed.\n'
232
+ )
233
+ }
234
+
235
+ let currentSnapshots: Map<string, BuiltSnapshot>
236
+ try {
237
+ currentSnapshots = await computeCurrentSnapshots(cwd)
238
+ } catch (error: unknown) {
239
+ if (!foundConfig) {
240
+ process.stdout.write(
241
+ 'Automatic workspace discovery could not complete with defaults.\nRun `eslint-config-snapshot init` to configure workspaces, then run `eslint-config-snapshot --update`.\n'
242
+ )
243
+ return 1
244
+ }
245
+
246
+ throw error
247
+ }
248
+ const storedSnapshots = await loadStoredSnapshots(cwd)
249
+
250
+ if (storedSnapshots.size === 0) {
251
+ const summary = summarizeSnapshots(currentSnapshots)
252
+ process.stdout.write(
253
+ `Current rule state: ${summary.groups} groups, ${summary.rules} rules (severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off).\n`
254
+ )
255
+
256
+ const canPromptBaseline = defaultInvocation || format === 'summary'
257
+ if (canPromptBaseline && process.stdin.isTTY && process.stdout.isTTY) {
258
+ const shouldCreateBaseline = await askYesNo(
259
+ 'No baseline yet. Use current rule state as your baseline now? [Y/n] ',
260
+ true
261
+ )
262
+ if (shouldCreateBaseline) {
263
+ await writeSnapshots(cwd, currentSnapshots)
264
+ const summary = summarizeSnapshots(currentSnapshots)
265
+ process.stdout.write(`Great start: baseline created with ${summary.groups} groups and ${summary.rules} rules.\n`)
266
+ writeSubtleInfo(UPDATE_HINT)
267
+ return 0
268
+ }
269
+ }
270
+
271
+ process.stdout.write('You are almost set: no baseline snapshot found yet.\n')
272
+ process.stdout.write('Run `eslint-config-snapshot --update` to create your first baseline.\n')
273
+ return 1
274
+ }
275
+
276
+ const changes = compareSnapshotMaps(storedSnapshots, currentSnapshots)
277
+
278
+ if (format === 'status') {
279
+ if (changes.length === 0) {
280
+ process.stdout.write('clean\n')
281
+ return 0
282
+ }
283
+
284
+ process.stdout.write('changes\n')
285
+ writeSubtleInfo(UPDATE_HINT)
286
+ return 1
287
+ }
288
+
289
+ if (format === 'diff') {
290
+ if (changes.length === 0) {
291
+ process.stdout.write('Great news: no snapshot changes detected.\n')
292
+ return 0
293
+ }
294
+
295
+ for (const change of changes) {
296
+ process.stdout.write(`${formatDiff(change.groupId, change.diff)}\n`)
297
+ }
298
+ writeSubtleInfo(UPDATE_HINT)
299
+
300
+ return 1
301
+ }
302
+
303
+ return printWhatChanged(changes, currentSnapshots)
304
+ }
305
+
306
+ async function executeUpdate(cwd: string, printSummary: boolean): Promise<number> {
307
+ const foundConfig = await findConfigPath(cwd)
308
+ if (!foundConfig) {
309
+ writeSubtleInfo(
310
+ 'Tip: no explicit config found. Using safe built-in defaults. Run `eslint-config-snapshot init` to customize when needed.\n'
311
+ )
312
+ }
313
+
314
+ let currentSnapshots: Map<string, BuiltSnapshot>
315
+ try {
316
+ currentSnapshots = await computeCurrentSnapshots(cwd)
317
+ } catch (error: unknown) {
318
+ if (!foundConfig) {
319
+ process.stdout.write(
320
+ 'Automatic workspace discovery could not complete with defaults.\nRun `eslint-config-snapshot init` to configure workspaces, then run `eslint-config-snapshot --update`.\n'
321
+ )
322
+ return 1
323
+ }
324
+
325
+ throw error
326
+ }
327
+ await writeSnapshots(cwd, currentSnapshots)
328
+
329
+ if (printSummary) {
330
+ const summary = summarizeSnapshots(currentSnapshots)
331
+ process.stdout.write(`Baseline updated: ${summary.groups} groups, ${summary.rules} rules.\n`)
332
+ }
333
+
334
+ return 0
335
+ }
336
+
337
+ async function executePrint(cwd: string, format: PrintFormat): Promise<void> {
338
+ const currentSnapshots = await computeCurrentSnapshots(cwd)
339
+
340
+ if (format === 'short') {
341
+ process.stdout.write(formatShortPrint([...currentSnapshots.values()]))
342
+ return
343
+ }
344
+
345
+ const output = [...currentSnapshots.values()].map((snapshot) => ({
346
+ groupId: snapshot.groupId,
347
+ rules: snapshot.rules
348
+ }))
349
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
350
+ }
351
+
352
+ async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
353
+ const config = await loadConfig(cwd)
354
+ const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput })
355
+
356
+ const assignments =
357
+ config.grouping.mode === 'standalone'
358
+ ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] }))
359
+ : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: 'default', match: ['**/*'] }])
360
+
361
+ const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false
362
+ if (!allowEmptyGroups) {
363
+ const empty = assignments.filter((group) => group.workspaces.length === 0)
364
+ if (empty.length > 0) {
365
+ throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(', ')}`)
366
+ }
367
+ }
368
+
369
+ const snapshots = new Map<string, BuiltSnapshot>()
370
+
371
+ for (const group of assignments) {
372
+ const extractedForGroup = []
373
+
374
+ for (const workspaceRel of group.workspaces) {
375
+ const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
376
+ const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
377
+ let extractedCount = 0
378
+ let lastExtractionError: string | undefined
379
+
380
+ for (const sampledRel of sampled) {
381
+ const sampledAbs = path.resolve(workspaceAbs, sampledRel)
382
+ try {
383
+ extractedForGroup.push(extractRulesFromPrintConfig(workspaceAbs, sampledAbs))
384
+ extractedCount += 1
385
+ } catch (error: unknown) {
386
+ const message = error instanceof Error ? error.message : String(error)
387
+ if (
388
+ message.startsWith('Invalid JSON from eslint --print-config') ||
389
+ message.startsWith('Empty ESLint print-config output')
390
+ ) {
391
+ lastExtractionError = message
392
+ continue
393
+ }
394
+
395
+ throw error
396
+ }
397
+ }
398
+
399
+ if (extractedCount === 0) {
400
+ const context = lastExtractionError ? ` Last error: ${lastExtractionError}` : ''
401
+ throw new Error(
402
+ `Unable to extract ESLint config for workspace ${workspaceRel}. All sampled files were ignored or produced non-JSON output.${context}`
403
+ )
404
+ }
405
+ }
406
+
407
+ const aggregated = aggregateRules(extractedForGroup)
408
+ snapshots.set(group.name, buildSnapshot(group.name, group.workspaces, aggregated))
409
+ }
410
+
411
+ return snapshots
412
+ }
413
+
414
+ async function loadStoredSnapshots(cwd: string): Promise<Map<string, StoredSnapshot>> {
415
+ const dir = path.join(cwd, SNAPSHOT_DIR)
416
+ const files = await fg('**/*.json', { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true })
417
+ const snapshots = new Map<string, StoredSnapshot>()
418
+ const sortedFiles = [...files].sort((a, b) => a.localeCompare(b))
419
+
420
+ for (const file of sortedFiles) {
421
+ const snapshot = await readSnapshotFile(file)
422
+ snapshots.set(snapshot.groupId, snapshot)
423
+ }
424
+
425
+ return snapshots
426
+ }
427
+
428
+ async function writeSnapshots(cwd: string, snapshots: Map<string, BuiltSnapshot>): Promise<void> {
429
+ await mkdir(path.join(cwd, SNAPSHOT_DIR), { recursive: true })
430
+ for (const snapshot of snapshots.values()) {
431
+ await writeSnapshotFile(path.join(cwd, SNAPSHOT_DIR), snapshot)
432
+ }
433
+ }
434
+
435
+ function compareSnapshotMaps(before: Map<string, StoredSnapshot>, after: Map<string, BuiltSnapshot>) {
436
+ const ids = [...new Set([...before.keys(), ...after.keys()])].sort()
437
+ const changes: Array<{ groupId: string; diff: SnapshotDiff }> = []
438
+
439
+ for (const id of ids) {
440
+ const prev =
441
+ before.get(id) ??
442
+ ({
443
+ formatVersion: 1,
444
+ groupId: id,
445
+ workspaces: [],
446
+ rules: {}
447
+ } as const)
448
+
449
+ const next =
450
+ after.get(id) ??
451
+ ({
452
+ formatVersion: 1,
453
+ groupId: id,
454
+ workspaces: [],
455
+ rules: {}
456
+ } as const)
457
+
458
+ const diff = diffSnapshots(prev, next)
459
+ if (hasDiff(diff)) {
460
+ changes.push({ groupId: id, diff })
461
+ }
462
+ }
463
+
464
+ return changes
465
+ }
466
+
467
+ function formatDiff(groupId: string, diff: SnapshotDiff): string {
468
+ const lines = [`group: ${groupId}`]
469
+
470
+ addListSection(lines, 'introduced rules', diff.introducedRules)
471
+ addListSection(lines, 'removed rules', diff.removedRules)
472
+
473
+ if (diff.severityChanges.length > 0) {
474
+ lines.push('severity changed:')
475
+ for (const change of diff.severityChanges) {
476
+ lines.push(` - ${change.rule}: ${change.before} -> ${change.after}`)
477
+ }
478
+ }
479
+
480
+ const optionChanges = getDisplayOptionChanges(diff)
481
+ if (optionChanges.length > 0) {
482
+ lines.push('options changed:')
483
+ for (const change of optionChanges) {
484
+ lines.push(` - ${change.rule}: ${formatValue(change.before)} -> ${formatValue(change.after)}`)
485
+ }
486
+ }
487
+
488
+ addListSection(lines, 'workspaces added', diff.workspaceMembershipChanges.added)
489
+ addListSection(lines, 'workspaces removed', diff.workspaceMembershipChanges.removed)
490
+
491
+ return lines.join('\n')
492
+ }
493
+
494
+ function addListSection(lines: string[], title: string, values: string[]): void {
495
+ if (values.length === 0) {
496
+ return
497
+ }
498
+
499
+ lines.push(`${title}:`)
500
+ for (const value of values) {
501
+ lines.push(` - ${value}`)
502
+ }
503
+ }
504
+
505
+ function formatValue(value: unknown): string {
506
+ const serialized = JSON.stringify(value)
507
+ return serialized === undefined ? 'undefined' : serialized
508
+ }
509
+
510
+ function getDisplayOptionChanges(diff: SnapshotDiff): SnapshotDiff['optionChanges'] {
511
+ const removedRules = new Set(diff.removedRules)
512
+ const severityChangedRules = new Set(diff.severityChanges.map((change) => change.rule))
513
+ return diff.optionChanges.filter((change) => !removedRules.has(change.rule) && !severityChangedRules.has(change.rule))
514
+ }
515
+
516
+ async function runInit(
517
+ cwd: string,
518
+ opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean } = {}
519
+ ): Promise<number> {
520
+ const force = opts.force ?? false
521
+ const existing = await findConfigPath(cwd)
522
+ if (existing && !force) {
523
+ process.stderr.write(
524
+ `Existing config detected at ${existing.path}. Creating another config can cause conflicts. Remove the existing config or rerun with --force.\n`
525
+ )
526
+ return 1
527
+ }
528
+
529
+ let target = opts.target
530
+ let preset = opts.preset
531
+ if (!opts.yes && !target && !preset && process.stdin.isTTY && process.stdout.isTTY) {
532
+ const interactive = await askInitPreferences()
533
+ target = interactive.target
534
+ preset = interactive.preset
535
+ }
536
+
537
+ const finalTarget = target ?? 'file'
538
+ const finalPreset = preset ?? 'minimal'
539
+
540
+ if (finalTarget === 'package-json') {
541
+ return runInitInPackageJson(cwd, finalPreset, force)
542
+ }
543
+
544
+ return runInitInFile(cwd, finalPreset, force)
545
+ }
546
+
547
+ async function askInitPreferences(): Promise<{ target: InitTarget; preset: InitPreset }> {
548
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
549
+ try {
550
+ const target = await askInitTarget(rl)
551
+ const preset = await askInitPreset(rl)
552
+ return { target, preset }
553
+ } finally {
554
+ rl.close()
555
+ }
556
+ }
557
+
558
+ async function askInitTarget(rl: ReturnType<typeof createInterface>): Promise<InitTarget> {
559
+ while (true) {
560
+ const answer = await askQuestion(
561
+ rl,
562
+ 'Select config target:\n 1) package-json (recommended)\n 2) file\nChoose [1]: '
563
+ )
564
+ const parsed = parseInitTargetChoice(answer)
565
+ if (parsed) {
566
+ return parsed
567
+ }
568
+ process.stdout.write('Please choose 1 (package-json) or 2 (file).\n')
569
+ }
570
+ }
571
+
572
+ async function askInitPreset(rl: ReturnType<typeof createInterface>): Promise<InitPreset> {
573
+ while (true) {
574
+ const answer = await askQuestion(rl, 'Select preset:\n 1) minimal (recommended)\n 2) full\nChoose [1]: ')
575
+ const parsed = parseInitPresetChoice(answer)
576
+ if (parsed) {
577
+ return parsed
578
+ }
579
+ process.stdout.write('Please choose 1 (minimal) or 2 (full).\n')
580
+ }
581
+ }
582
+
583
+ export function parseInitTargetChoice(value: string): InitTarget | undefined {
584
+ const normalized = value.trim().toLowerCase()
585
+ if (normalized === '') {
586
+ return 'package-json'
587
+ }
588
+ if (normalized === '1' || normalized === 'package-json' || normalized === 'packagejson' || normalized === 'package' || normalized === 'pkg') {
589
+ return 'package-json'
590
+ }
591
+ if (normalized === '2' || normalized === 'file') {
592
+ return 'file'
593
+ }
594
+ return undefined
595
+ }
596
+
597
+ export function parseInitPresetChoice(value: string): InitPreset | undefined {
598
+ const normalized = value.trim().toLowerCase()
599
+ if (normalized === '') {
600
+ return 'minimal'
601
+ }
602
+ if (normalized === '1' || normalized === 'minimal' || normalized === 'min') {
603
+ return 'minimal'
604
+ }
605
+ if (normalized === '2' || normalized === 'full') {
606
+ return 'full'
607
+ }
608
+ return undefined
609
+ }
610
+
611
+ function askQuestion(rl: ReturnType<typeof createInterface>, prompt: string): Promise<string> {
612
+ return new Promise((resolve) => {
613
+ rl.question(prompt, (answer) => {
614
+ resolve(answer)
615
+ })
616
+ })
617
+ }
618
+
619
+ async function askYesNo(prompt: string, defaultYes: boolean): Promise<boolean> {
620
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
621
+ try {
622
+ const answerRaw = await askQuestion(rl, prompt)
623
+ const answer = answerRaw.trim().toLowerCase()
624
+ if (answer.length === 0) {
625
+ return defaultYes
626
+ }
627
+
628
+ return answer === 'y' || answer === 'yes'
629
+ } finally {
630
+ rl.close()
631
+ }
632
+ }
633
+
634
+ async function runInitInFile(cwd: string, preset: InitPreset, force: boolean): Promise<number> {
635
+ const candidates = [
636
+ '.eslint-config-snapshot.js',
637
+ '.eslint-config-snapshot.cjs',
638
+ '.eslint-config-snapshot.mjs',
639
+ 'eslint-config-snapshot.config.js',
640
+ 'eslint-config-snapshot.config.cjs',
641
+ 'eslint-config-snapshot.config.mjs'
642
+ ]
643
+
644
+ for (const candidate of candidates) {
645
+ try {
646
+ await access(path.join(cwd, candidate))
647
+ if (!force) {
648
+ process.stderr.write(`Config already exists: ${candidate}\n`)
649
+ return 1
650
+ }
651
+ } catch {
652
+ // continue
653
+ }
654
+ }
655
+
656
+ const target = path.join(cwd, 'eslint-config-snapshot.config.mjs')
657
+ await writeFile(target, getConfigScaffold(preset), 'utf8')
658
+ process.stdout.write(`Created ${path.basename(target)}\n`)
659
+ return 0
660
+ }
661
+
662
+ async function runInitInPackageJson(cwd: string, preset: InitPreset, force: boolean): Promise<number> {
663
+ const packageJsonPath = path.join(cwd, 'package.json')
664
+
665
+ let packageJsonRaw: string
666
+ try {
667
+ packageJsonRaw = await readFile(packageJsonPath, 'utf8')
668
+ } catch {
669
+ process.stderr.write('package.json not found in current directory.\n')
670
+ return 1
671
+ }
672
+
673
+ let parsed: Record<string, unknown>
674
+ try {
675
+ parsed = JSON.parse(packageJsonRaw) as Record<string, unknown>
676
+ } catch {
677
+ process.stderr.write('Invalid package.json (must be valid JSON).\n')
678
+ return 1
679
+ }
680
+
681
+ if (parsed['eslint-config-snapshot'] !== undefined && !force) {
682
+ process.stderr.write('Config already exists in package.json: eslint-config-snapshot\n')
683
+ return 1
684
+ }
685
+
686
+ parsed['eslint-config-snapshot'] = preset === 'full' ? getFullPresetObject() : {}
687
+ await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8')
688
+ process.stdout.write('Created config in package.json under "eslint-config-snapshot"\n')
689
+ return 0
690
+ }
691
+
692
+ function getFullPresetObject() {
693
+ return {
694
+ workspaceInput: { mode: 'discover' },
695
+ grouping: {
696
+ mode: 'match',
697
+ groups: [{ name: 'default', match: ['**/*'] }]
698
+ },
699
+ sampling: {
700
+ maxFilesPerWorkspace: 8,
701
+ includeGlobs: ['**/*.{js,jsx,ts,tsx,cjs,mjs}'],
702
+ excludeGlobs: ['**/node_modules/**', '**/dist/**'],
703
+ hintGlobs: []
704
+ }
705
+ }
706
+ }
707
+
708
+ export async function main(): Promise<void> {
709
+ const code = await runArgv(process.argv.slice(2), process.cwd())
710
+ process.exit(code)
711
+ }
712
+
713
+ function isDirectCliExecution(): boolean {
714
+ const entry = process.argv[1]
715
+ if (!entry) {
716
+ return false
717
+ }
718
+
719
+ const normalized = path.basename(entry).toLowerCase()
720
+ return normalized === 'index.js' || normalized === 'index.cjs' || normalized === 'index.ts' || normalized === 'eslint-config-snapshot'
721
+ }
722
+
723
+ if (isDirectCliExecution()) {
724
+ void main()
725
+ }
726
+
727
+ function printWhatChanged(changes: Array<{ groupId: string; diff: SnapshotDiff }>, currentSnapshots: Map<string, BuiltSnapshot>): number {
728
+ const color = createColorizer()
729
+ const currentSummary = summarizeSnapshots(currentSnapshots)
730
+ const changeSummary = summarizeChanges(changes)
731
+
732
+ if (changes.length === 0) {
733
+ process.stdout.write(color.green('Great news: no snapshot drift detected.\n'))
734
+ process.stdout.write(
735
+ `Baseline status: ${currentSummary.groups} groups, ${currentSummary.rules} rules (severity mix: ${currentSummary.error} errors, ${currentSummary.warn} warnings, ${currentSummary.off} off).\n`
736
+ )
737
+ return 0
738
+ }
739
+
740
+ process.stdout.write(color.red('Heads up: snapshot drift detected.\n'))
741
+ process.stdout.write(
742
+ `Changed groups: ${changes.length} | introduced: ${changeSummary.introduced} | removed: ${changeSummary.removed} | severity: ${changeSummary.severity} | options: ${changeSummary.options} | workspace membership: ${changeSummary.workspace}\n`
743
+ )
744
+ process.stdout.write(
745
+ `Current rules: ${currentSummary.rules} (severity mix: ${currentSummary.error} errors, ${currentSummary.warn} warnings, ${currentSummary.off} off)\n\n`
746
+ )
747
+
748
+ for (const change of changes) {
749
+ process.stdout.write(color.bold(`group ${change.groupId}\n`))
750
+ const lines = formatDiff(change.groupId, change.diff).split('\n').slice(1)
751
+ for (const line of lines) {
752
+ const decorated = decorateDiffLine(line, color)
753
+ process.stdout.write(`${decorated}\n`)
754
+ }
755
+ process.stdout.write('\n')
756
+ }
757
+ writeSubtleInfo(UPDATE_HINT)
758
+
759
+ return 1
760
+ }
761
+
762
+ function summarizeChanges(changes: Array<{ groupId: string; diff: SnapshotDiff }>) {
763
+ let introduced = 0
764
+ let removed = 0
765
+ let severity = 0
766
+ let options = 0
767
+ let workspace = 0
768
+ for (const change of changes) {
769
+ introduced += change.diff.introducedRules.length
770
+ removed += change.diff.removedRules.length
771
+ severity += change.diff.severityChanges.length
772
+ options += getDisplayOptionChanges(change.diff).length
773
+ workspace += change.diff.workspaceMembershipChanges.added.length + change.diff.workspaceMembershipChanges.removed.length
774
+ }
775
+ return { introduced, removed, severity, options, workspace }
776
+ }
777
+
778
+ function summarizeSnapshots(snapshots: Map<string, BuiltSnapshot>) {
779
+ let rules = 0
780
+ let error = 0
781
+ let warn = 0
782
+ let off = 0
783
+ for (const snapshot of snapshots.values()) {
784
+ for (const entry of Object.values(snapshot.rules)) {
785
+ rules += 1
786
+ if (entry[0] === 'error') {
787
+ error += 1
788
+ } else if (entry[0] === 'warn') {
789
+ warn += 1
790
+ } else {
791
+ off += 1
792
+ }
793
+ }
794
+ }
795
+ return { groups: snapshots.size, rules, error, warn, off }
796
+ }
797
+
798
+ function decorateDiffLine(line: string, color: ReturnType<typeof createColorizer>): string {
799
+ if (line.startsWith('introduced rules:') || line.startsWith('workspaces added:')) {
800
+ return color.green(`+ ${line}`)
801
+ }
802
+ if (line.startsWith('removed rules:') || line.startsWith('workspaces removed:')) {
803
+ return color.red(`- ${line}`)
804
+ }
805
+ if (line.startsWith('severity changed:') || line.startsWith('options changed:')) {
806
+ return color.yellow(`~ ${line}`)
807
+ }
808
+ return line
809
+ }
810
+
811
+ function createColorizer() {
812
+ const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
813
+ const wrap = (code: string, text: string) => (enabled ? `\u001B[${code}m${text}\u001B[0m` : text)
814
+ return {
815
+ green: (text: string) => wrap('32', text),
816
+ yellow: (text: string) => wrap('33', text),
817
+ red: (text: string) => wrap('31', text),
818
+ bold: (text: string) => wrap('1', text),
819
+ dim: (text: string) => wrap('2', text)
820
+ }
821
+ }
822
+
823
+ function writeSubtleInfo(text: string): void {
824
+ const color = createColorizer()
825
+ process.stdout.write(color.dim(text))
826
+ }
827
+
828
+ function formatShortPrint(
829
+ snapshots: Array<{
830
+ groupId: string
831
+ workspaces: string[]
832
+ rules: Record<string, [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]>
833
+ }>
834
+ ): string {
835
+ const lines: string[] = []
836
+ const sorted = [...snapshots].sort((a, b) => a.groupId.localeCompare(b.groupId))
837
+
838
+ for (const snapshot of sorted) {
839
+ const ruleNames = Object.keys(snapshot.rules).sort()
840
+ const severityCounts = { error: 0, warn: 0, off: 0 }
841
+
842
+ for (const name of ruleNames) {
843
+ const severity = snapshot.rules[name][0]
844
+ severityCounts[severity] += 1
845
+ }
846
+
847
+ lines.push(
848
+ `group: ${snapshot.groupId}`,
849
+ `workspaces (${snapshot.workspaces.length}): ${snapshot.workspaces.length > 0 ? snapshot.workspaces.join(', ') : '(none)'}`,
850
+ `rules (${ruleNames.length}): error ${severityCounts.error}, warn ${severityCounts.warn}, off ${severityCounts.off}`
851
+ )
852
+
853
+ for (const ruleName of ruleNames) {
854
+ const entry = snapshot.rules[ruleName]
855
+ const suffix = entry.length > 1 ? ` ${JSON.stringify(entry[1])}` : ''
856
+ lines.push(`${ruleName}: ${entry[0]}${suffix}`)
857
+ }
858
+ }
859
+
860
+ return `${lines.join('\n')}\n`
861
+ }