@eslint-config-snapshot/cli 0.1.4 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @eslint-config-snapshot/cli
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add effective config inspection command and recommended grouped init workflow with numeric workspace assignments.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @eslint-config-snapshot/api@0.2.0
13
+
14
+ ## 0.1.5
15
+
16
+ ### Patch Changes
17
+
18
+ - Add package-level README files for npm pages with cross-links between CLI and API docs.
19
+ - Updated dependencies
20
+ - @eslint-config-snapshot/api@0.1.5
21
+
3
22
  ## 0.1.4
4
23
 
5
24
  ### Patch Changes
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @eslint-config-snapshot/cli
2
+
3
+ Deterministic ESLint config drift checker for workspaces.
4
+
5
+ `@eslint-config-snapshot/cli` snapshots effective ESLint rule state and reports drift after dependency or config changes.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -D @eslint-config-snapshot/cli
11
+ ```
12
+
13
+ Or run without install:
14
+
15
+ ```bash
16
+ npx @eslint-config-snapshot/cli@latest --update
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ Create baseline:
22
+
23
+ ```bash
24
+ eslint-config-snapshot --update
25
+ ```
26
+
27
+ Check drift:
28
+
29
+ ```bash
30
+ eslint-config-snapshot
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ - `check`
36
+ - `update`
37
+ - `print`
38
+ - `config`
39
+ - `init`
40
+
41
+ Compatibility aliases:
42
+
43
+ - `snapshot` => `update`
44
+ - `compare` => `check --format diff`
45
+ - `status` => `check --format status`
46
+ - `what-changed` => `check --format summary`
47
+
48
+ ## Notes
49
+
50
+ - Node.js `>=20` required.
51
+ - If no config is found, built-in defaults are used.
52
+ - Snapshots are stored under `.eslint-config-snapshot/`.
53
+
54
+ ## Related Packages
55
+
56
+ - API engine: [`@eslint-config-snapshot/api`](https://www.npmjs.com/package/@eslint-config-snapshot/api)
57
+
58
+ ## More Docs
59
+
60
+ - Project overview and full guides: [root README](https://github.com/gabrielmoreira/eslint-config-snapshot#readme)
package/dist/index.cjs CHANGED
@@ -114,17 +114,23 @@ function createProgram(cwd, onActionExit) {
114
114
  await executePrint(cwd, format);
115
115
  onActionExit(0);
116
116
  });
117
- program.command("init").description("Initialize config (file or package.json)").option("--target <target>", "Config target: file|package-json", parseInitTarget).option("--preset <preset>", "Config preset: minimal|full", parseInitPreset).option("-f, --force", "Allow init even when an existing config is detected").option("-y, --yes", "Skip prompts and use defaults/options").addHelpText(
117
+ program.command("config").description("Print effective evaluated config").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").action(async (opts) => {
118
+ const format = opts.short ? "short" : opts.format;
119
+ await executeConfig(cwd, format);
120
+ onActionExit(0);
121
+ });
122
+ program.command("init").description("Initialize config (file or package.json)").option("--target <target>", "Config target: file|package-json", parseInitTarget).option("--preset <preset>", "Config preset: recommended|minimal|full", parseInitPreset).option("--show-effective", "Print the evaluated config that will be written").option("-f, --force", "Allow init even when an existing config is detected").option("-y, --yes", "Skip prompts and use defaults/options").addHelpText(
118
123
  "after",
119
124
  `
120
125
  Examples:
121
126
  $ eslint-config-snapshot init
122
127
  Runs interactive numbered prompts:
123
128
  target: 1) package-json, 2) file
124
- preset: 1) minimal, 2) full
129
+ preset: 1) recommended, 2) minimal, 3) full
130
+ recommended preset supports per-workspace group number assignment.
125
131
 
126
- $ eslint-config-snapshot init --yes --target package-json --preset minimal
127
- Non-interactive minimal setup in package.json.
132
+ $ eslint-config-snapshot init --yes --target package-json --preset recommended --show-effective
133
+ Non-interactive recommended setup in package.json, with effective preview.
128
134
 
129
135
  $ eslint-config-snapshot init --yes --force --target file --preset full
130
136
  Overwrite-safe bypass when a config is already detected.
@@ -167,10 +173,10 @@ function parseInitTarget(value) {
167
173
  }
168
174
  function parseInitPreset(value) {
169
175
  const normalized = value.trim().toLowerCase();
170
- if (normalized === "minimal" || normalized === "full") {
176
+ if (normalized === "recommended" || normalized === "minimal" || normalized === "full") {
171
177
  return normalized;
172
178
  }
173
- throw new import_commander.InvalidArgumentError("Expected one of: minimal, full");
179
+ throw new import_commander.InvalidArgumentError("Expected one of: recommended, minimal, full");
174
180
  }
175
181
  async function executeCheck(cwd, format, defaultInvocation = false) {
176
182
  const foundConfig = await (0, import_api.findConfigPath)(cwd);
@@ -281,17 +287,31 @@ async function executePrint(cwd, format) {
281
287
  process.stdout.write(`${JSON.stringify(output, null, 2)}
282
288
  `);
283
289
  }
284
- async function computeCurrentSnapshots(cwd) {
290
+ async function executeConfig(cwd, format) {
291
+ const foundConfig = await (0, import_api.findConfigPath)(cwd);
285
292
  const config = await (0, import_api.loadConfig)(cwd);
286
- const discovery = await (0, import_api.discoverWorkspaces)({ cwd, workspaceInput: config.workspaceInput });
287
- const assignments = config.grouping.mode === "standalone" ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] })) : (0, import_api.assignGroupsByMatch)(discovery.workspacesRel, config.grouping.groups ?? [{ name: "default", match: ["**/*"] }]);
288
- const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false;
289
- if (!allowEmptyGroups) {
290
- const empty = assignments.filter((group) => group.workspaces.length === 0);
291
- if (empty.length > 0) {
292
- throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(", ")}`);
293
- }
293
+ const resolved = await resolveWorkspaceAssignments(cwd, config);
294
+ const payload = {
295
+ source: foundConfig?.path ?? "built-in-defaults",
296
+ workspaceInput: config.workspaceInput,
297
+ workspaces: resolved.discovery.workspacesRel,
298
+ grouping: {
299
+ mode: config.grouping.mode,
300
+ allowEmptyGroups: config.grouping.allowEmptyGroups ?? false,
301
+ groups: resolved.assignments.map((entry) => ({ name: entry.name, workspaces: entry.workspaces }))
302
+ },
303
+ sampling: config.sampling
304
+ };
305
+ if (format === "short") {
306
+ process.stdout.write(formatShortConfig(payload));
307
+ return;
294
308
  }
309
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
310
+ `);
311
+ }
312
+ async function computeCurrentSnapshots(cwd) {
313
+ const config = await (0, import_api.loadConfig)(cwd);
314
+ const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config);
295
315
  const snapshots = /* @__PURE__ */ new Map();
296
316
  for (const group of assignments) {
297
317
  const extractedForGroup = [];
@@ -326,6 +346,18 @@ async function computeCurrentSnapshots(cwd) {
326
346
  }
327
347
  return snapshots;
328
348
  }
349
+ async function resolveWorkspaceAssignments(cwd, config) {
350
+ const discovery = await (0, import_api.discoverWorkspaces)({ cwd, workspaceInput: config.workspaceInput });
351
+ const assignments = config.grouping.mode === "standalone" ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] })) : (0, import_api.assignGroupsByMatch)(discovery.workspacesRel, config.grouping.groups ?? [{ name: "default", match: ["**/*"] }]);
352
+ const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false;
353
+ if (!allowEmptyGroups) {
354
+ const empty = assignments.filter((group) => group.workspaces.length === 0);
355
+ if (empty.length > 0) {
356
+ throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(", ")}`);
357
+ }
358
+ }
359
+ return { discovery, assignments };
360
+ }
329
361
  async function loadStoredSnapshots(cwd) {
330
362
  const dir = import_node_path.default.join(cwd, SNAPSHOT_DIR);
331
363
  const files = await (0, import_fast_glob.default)("**/*.json", { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true });
@@ -407,6 +439,7 @@ function getDisplayOptionChanges(diff) {
407
439
  }
408
440
  async function runInit(cwd, opts = {}) {
409
441
  const force = opts.force ?? false;
442
+ const showEffective = opts.showEffective ?? false;
410
443
  const existing = await (0, import_api.findConfigPath)(cwd);
411
444
  if (existing && !force) {
412
445
  process.stderr.write(
@@ -423,11 +456,17 @@ async function runInit(cwd, opts = {}) {
423
456
  preset = interactive.preset;
424
457
  }
425
458
  const finalTarget = target ?? "file";
426
- const finalPreset = preset ?? "minimal";
459
+ const finalPreset = preset ?? "recommended";
460
+ const configObject = await resolveInitConfigObject(cwd, finalPreset, Boolean(opts.yes));
461
+ if (showEffective) {
462
+ process.stdout.write(`Effective config preview:
463
+ ${JSON.stringify(configObject, null, 2)}
464
+ `);
465
+ }
427
466
  if (finalTarget === "package-json") {
428
- return runInitInPackageJson(cwd, finalPreset, force);
467
+ return runInitInPackageJson(cwd, configObject, force);
429
468
  }
430
- return runInitInFile(cwd, finalPreset, force);
469
+ return runInitInFile(cwd, configObject, force);
431
470
  }
432
471
  async function askInitPreferences() {
433
472
  const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
@@ -454,12 +493,15 @@ async function askInitTarget(rl) {
454
493
  }
455
494
  async function askInitPreset(rl) {
456
495
  while (true) {
457
- const answer = await askQuestion(rl, "Select preset:\n 1) minimal (recommended)\n 2) full\nChoose [1]: ");
496
+ const answer = await askQuestion(
497
+ rl,
498
+ "Select preset:\n 1) recommended (group by workspace numbers)\n 2) minimal\n 3) full\nChoose [1]: "
499
+ );
458
500
  const parsed = parseInitPresetChoice(answer);
459
501
  if (parsed) {
460
502
  return parsed;
461
503
  }
462
- process.stdout.write("Please choose 1 (minimal) or 2 (full).\n");
504
+ process.stdout.write("Please choose 1 (recommended), 2 (minimal), or 3 (full).\n");
463
505
  }
464
506
  }
465
507
  function parseInitTargetChoice(value) {
@@ -478,12 +520,15 @@ function parseInitTargetChoice(value) {
478
520
  function parseInitPresetChoice(value) {
479
521
  const normalized = value.trim().toLowerCase();
480
522
  if (normalized === "") {
481
- return "minimal";
523
+ return "recommended";
524
+ }
525
+ if (normalized === "1" || normalized === "recommended" || normalized === "rec" || normalized === "grouped") {
526
+ return "recommended";
482
527
  }
483
- if (normalized === "1" || normalized === "minimal" || normalized === "min") {
528
+ if (normalized === "2" || normalized === "minimal" || normalized === "min") {
484
529
  return "minimal";
485
530
  }
486
- if (normalized === "2" || normalized === "full") {
531
+ if (normalized === "3" || normalized === "full") {
487
532
  return "full";
488
533
  }
489
534
  return void 0;
@@ -508,7 +553,7 @@ async function askYesNo(prompt, defaultYes) {
508
553
  rl.close();
509
554
  }
510
555
  }
511
- async function runInitInFile(cwd, preset, force) {
556
+ async function runInitInFile(cwd, configObject, force) {
512
557
  const candidates = [
513
558
  ".eslint-config-snapshot.js",
514
559
  ".eslint-config-snapshot.cjs",
@@ -529,12 +574,12 @@ async function runInitInFile(cwd, preset, force) {
529
574
  }
530
575
  }
531
576
  const target = import_node_path.default.join(cwd, "eslint-config-snapshot.config.mjs");
532
- await (0, import_promises.writeFile)(target, (0, import_api.getConfigScaffold)(preset), "utf8");
577
+ await (0, import_promises.writeFile)(target, toConfigScaffold(configObject), "utf8");
533
578
  process.stdout.write(`Created ${import_node_path.default.basename(target)}
534
579
  `);
535
580
  return 0;
536
581
  }
537
- async function runInitInPackageJson(cwd, preset, force) {
582
+ async function runInitInPackageJson(cwd, configObject, force) {
538
583
  const packageJsonPath = import_node_path.default.join(cwd, "package.json");
539
584
  let packageJsonRaw;
540
585
  try {
@@ -554,12 +599,117 @@ async function runInitInPackageJson(cwd, preset, force) {
554
599
  process.stderr.write("Config already exists in package.json: eslint-config-snapshot\n");
555
600
  return 1;
556
601
  }
557
- parsed["eslint-config-snapshot"] = preset === "full" ? getFullPresetObject() : {};
602
+ parsed["eslint-config-snapshot"] = configObject;
558
603
  await (0, import_promises.writeFile)(packageJsonPath, `${JSON.stringify(parsed, null, 2)}
559
604
  `, "utf8");
560
605
  process.stdout.write('Created config in package.json under "eslint-config-snapshot"\n');
561
606
  return 0;
562
607
  }
608
+ async function resolveInitConfigObject(cwd, preset, nonInteractive) {
609
+ if (preset === "minimal") {
610
+ return {};
611
+ }
612
+ if (preset === "full") {
613
+ return getFullPresetObject();
614
+ }
615
+ return buildRecommendedPresetObject(cwd, nonInteractive);
616
+ }
617
+ async function buildRecommendedPresetObject(cwd, nonInteractive) {
618
+ const workspaces = await discoverInitWorkspaces(cwd);
619
+ const assignments = new Map(workspaces.map((workspace) => [workspace, 1]));
620
+ if (!nonInteractive && process.stdin.isTTY && process.stdout.isTTY) {
621
+ process.stdout.write("Recommended setup: assign a group number for each workspace (default: 1).\n");
622
+ for (const workspace of workspaces) {
623
+ const answer = await askGroupNumber(workspace);
624
+ assignments.set(workspace, answer);
625
+ }
626
+ }
627
+ const groupNumbers = [...new Set(assignments.values())].sort((a, b) => a - b);
628
+ const groups = groupNumbers.map((number) => ({
629
+ name: `group-${number}`,
630
+ match: workspaces.filter((workspace) => assignments.get(workspace) === number)
631
+ }));
632
+ return {
633
+ workspaceInput: { mode: "manual", workspaces },
634
+ grouping: {
635
+ mode: "match",
636
+ groups
637
+ },
638
+ sampling: {
639
+ maxFilesPerWorkspace: 8,
640
+ includeGlobs: ["**/*.{js,jsx,ts,tsx,cjs,mjs}"],
641
+ excludeGlobs: ["**/node_modules/**", "**/dist/**"],
642
+ hintGlobs: []
643
+ }
644
+ };
645
+ }
646
+ async function discoverInitWorkspaces(cwd) {
647
+ const discovered = await (0, import_api.discoverWorkspaces)({ cwd, workspaceInput: { mode: "discover" } });
648
+ if (!(discovered.workspacesRel.length === 1 && discovered.workspacesRel[0] === ".")) {
649
+ return discovered.workspacesRel;
650
+ }
651
+ const packageJsonPath = import_node_path.default.join(cwd, "package.json");
652
+ try {
653
+ const raw = await (0, import_promises.readFile)(packageJsonPath, "utf8");
654
+ const parsed = JSON.parse(raw);
655
+ let workspacePatterns = [];
656
+ if (Array.isArray(parsed.workspaces)) {
657
+ workspacePatterns = parsed.workspaces;
658
+ } else if (parsed.workspaces && typeof parsed.workspaces === "object" && Array.isArray(parsed.workspaces.packages)) {
659
+ workspacePatterns = parsed.workspaces.packages;
660
+ }
661
+ if (workspacePatterns.length === 0) {
662
+ return discovered.workspacesRel;
663
+ }
664
+ const workspacePackageFiles = await (0, import_fast_glob.default)(
665
+ workspacePatterns.map((pattern) => `${trimTrailingSlashes(pattern)}/package.json`),
666
+ { cwd, onlyFiles: true, dot: true }
667
+ );
668
+ const workspaceDirs = [...new Set(workspacePackageFiles.map((entry) => (0, import_api.normalizePath)(import_node_path.default.dirname(entry))))].sort(
669
+ (a, b) => a.localeCompare(b)
670
+ );
671
+ if (workspaceDirs.length > 0) {
672
+ return workspaceDirs;
673
+ }
674
+ } catch {
675
+ }
676
+ return discovered.workspacesRel;
677
+ }
678
+ function trimTrailingSlashes(value) {
679
+ let normalized = value;
680
+ while (normalized.endsWith("/")) {
681
+ normalized = normalized.slice(0, -1);
682
+ }
683
+ return normalized;
684
+ }
685
+ async function askGroupNumber(workspace) {
686
+ const rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stdout });
687
+ try {
688
+ while (true) {
689
+ const answer = await askQuestion(rl, `Group number for ${workspace} [1]: `);
690
+ const normalized = answer.trim();
691
+ if (normalized === "") {
692
+ return 1;
693
+ }
694
+ if (/^\d+$/.test(normalized)) {
695
+ const parsed = Number.parseInt(normalized, 10);
696
+ if (parsed >= 1) {
697
+ return parsed;
698
+ }
699
+ }
700
+ process.stdout.write("Please provide a positive integer (1, 2, 3, ...).\n");
701
+ }
702
+ } finally {
703
+ rl.close();
704
+ }
705
+ }
706
+ function toConfigScaffold(configObject) {
707
+ if (Object.keys(configObject).length === 0) {
708
+ return (0, import_api.getConfigScaffold)("minimal");
709
+ }
710
+ return `export default ${JSON.stringify(configObject, null, 2)}
711
+ `;
712
+ }
563
713
  function getFullPresetObject() {
564
714
  return {
565
715
  workspaceInput: { mode: "discover" },
@@ -711,6 +861,19 @@ function formatShortPrint(snapshots) {
711
861
  return `${lines.join("\n")}
712
862
  `;
713
863
  }
864
+ function formatShortConfig(payload) {
865
+ const lines = [
866
+ `source: ${payload.source}`,
867
+ `workspaces (${payload.workspaces.length}): ${payload.workspaces.join(", ") || "(none)"}`,
868
+ `grouping mode: ${payload.grouping.mode} (allow empty: ${payload.grouping.allowEmptyGroups})`
869
+ ];
870
+ for (const group of payload.grouping.groups) {
871
+ lines.push(`group ${group.name} (${group.workspaces.length}): ${group.workspaces.join(", ") || "(none)"}`);
872
+ }
873
+ lines.push(`workspaceInput: ${JSON.stringify(payload.workspaceInput)}`, `sampling: ${JSON.stringify(payload.sampling)}`);
874
+ return `${lines.join("\n")}
875
+ `;
876
+ }
714
877
  // Annotate the CommonJS export names for ESM import in node:
715
878
  0 && (module.exports = {
716
879
  main,
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getConfigScaffold,
13
13
  hasDiff,
14
14
  loadConfig,
15
+ normalizePath,
15
16
  readSnapshotFile,
16
17
  sampleWorkspaceFiles,
17
18
  writeSnapshotFile
@@ -92,17 +93,23 @@ function createProgram(cwd, onActionExit) {
92
93
  await executePrint(cwd, format);
93
94
  onActionExit(0);
94
95
  });
95
- program.command("init").description("Initialize config (file or package.json)").option("--target <target>", "Config target: file|package-json", parseInitTarget).option("--preset <preset>", "Config preset: minimal|full", parseInitPreset).option("-f, --force", "Allow init even when an existing config is detected").option("-y, --yes", "Skip prompts and use defaults/options").addHelpText(
96
+ program.command("config").description("Print effective evaluated config").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").action(async (opts) => {
97
+ const format = opts.short ? "short" : opts.format;
98
+ await executeConfig(cwd, format);
99
+ onActionExit(0);
100
+ });
101
+ program.command("init").description("Initialize config (file or package.json)").option("--target <target>", "Config target: file|package-json", parseInitTarget).option("--preset <preset>", "Config preset: recommended|minimal|full", parseInitPreset).option("--show-effective", "Print the evaluated config that will be written").option("-f, --force", "Allow init even when an existing config is detected").option("-y, --yes", "Skip prompts and use defaults/options").addHelpText(
96
102
  "after",
97
103
  `
98
104
  Examples:
99
105
  $ eslint-config-snapshot init
100
106
  Runs interactive numbered prompts:
101
107
  target: 1) package-json, 2) file
102
- preset: 1) minimal, 2) full
108
+ preset: 1) recommended, 2) minimal, 3) full
109
+ recommended preset supports per-workspace group number assignment.
103
110
 
104
- $ eslint-config-snapshot init --yes --target package-json --preset minimal
105
- Non-interactive minimal setup in package.json.
111
+ $ eslint-config-snapshot init --yes --target package-json --preset recommended --show-effective
112
+ Non-interactive recommended setup in package.json, with effective preview.
106
113
 
107
114
  $ eslint-config-snapshot init --yes --force --target file --preset full
108
115
  Overwrite-safe bypass when a config is already detected.
@@ -145,10 +152,10 @@ function parseInitTarget(value) {
145
152
  }
146
153
  function parseInitPreset(value) {
147
154
  const normalized = value.trim().toLowerCase();
148
- if (normalized === "minimal" || normalized === "full") {
155
+ if (normalized === "recommended" || normalized === "minimal" || normalized === "full") {
149
156
  return normalized;
150
157
  }
151
- throw new InvalidArgumentError("Expected one of: minimal, full");
158
+ throw new InvalidArgumentError("Expected one of: recommended, minimal, full");
152
159
  }
153
160
  async function executeCheck(cwd, format, defaultInvocation = false) {
154
161
  const foundConfig = await findConfigPath(cwd);
@@ -259,17 +266,31 @@ async function executePrint(cwd, format) {
259
266
  process.stdout.write(`${JSON.stringify(output, null, 2)}
260
267
  `);
261
268
  }
262
- async function computeCurrentSnapshots(cwd) {
269
+ async function executeConfig(cwd, format) {
270
+ const foundConfig = await findConfigPath(cwd);
263
271
  const config = await loadConfig(cwd);
264
- const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput });
265
- const assignments = config.grouping.mode === "standalone" ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] })) : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: "default", match: ["**/*"] }]);
266
- const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false;
267
- if (!allowEmptyGroups) {
268
- const empty = assignments.filter((group) => group.workspaces.length === 0);
269
- if (empty.length > 0) {
270
- throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(", ")}`);
271
- }
272
+ const resolved = await resolveWorkspaceAssignments(cwd, config);
273
+ const payload = {
274
+ source: foundConfig?.path ?? "built-in-defaults",
275
+ workspaceInput: config.workspaceInput,
276
+ workspaces: resolved.discovery.workspacesRel,
277
+ grouping: {
278
+ mode: config.grouping.mode,
279
+ allowEmptyGroups: config.grouping.allowEmptyGroups ?? false,
280
+ groups: resolved.assignments.map((entry) => ({ name: entry.name, workspaces: entry.workspaces }))
281
+ },
282
+ sampling: config.sampling
283
+ };
284
+ if (format === "short") {
285
+ process.stdout.write(formatShortConfig(payload));
286
+ return;
272
287
  }
288
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
289
+ `);
290
+ }
291
+ async function computeCurrentSnapshots(cwd) {
292
+ const config = await loadConfig(cwd);
293
+ const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config);
273
294
  const snapshots = /* @__PURE__ */ new Map();
274
295
  for (const group of assignments) {
275
296
  const extractedForGroup = [];
@@ -304,6 +325,18 @@ async function computeCurrentSnapshots(cwd) {
304
325
  }
305
326
  return snapshots;
306
327
  }
328
+ async function resolveWorkspaceAssignments(cwd, config) {
329
+ const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput });
330
+ const assignments = config.grouping.mode === "standalone" ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] })) : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: "default", match: ["**/*"] }]);
331
+ const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false;
332
+ if (!allowEmptyGroups) {
333
+ const empty = assignments.filter((group) => group.workspaces.length === 0);
334
+ if (empty.length > 0) {
335
+ throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(", ")}`);
336
+ }
337
+ }
338
+ return { discovery, assignments };
339
+ }
307
340
  async function loadStoredSnapshots(cwd) {
308
341
  const dir = path.join(cwd, SNAPSHOT_DIR);
309
342
  const files = await fg("**/*.json", { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true });
@@ -385,6 +418,7 @@ function getDisplayOptionChanges(diff) {
385
418
  }
386
419
  async function runInit(cwd, opts = {}) {
387
420
  const force = opts.force ?? false;
421
+ const showEffective = opts.showEffective ?? false;
388
422
  const existing = await findConfigPath(cwd);
389
423
  if (existing && !force) {
390
424
  process.stderr.write(
@@ -401,11 +435,17 @@ async function runInit(cwd, opts = {}) {
401
435
  preset = interactive.preset;
402
436
  }
403
437
  const finalTarget = target ?? "file";
404
- const finalPreset = preset ?? "minimal";
438
+ const finalPreset = preset ?? "recommended";
439
+ const configObject = await resolveInitConfigObject(cwd, finalPreset, Boolean(opts.yes));
440
+ if (showEffective) {
441
+ process.stdout.write(`Effective config preview:
442
+ ${JSON.stringify(configObject, null, 2)}
443
+ `);
444
+ }
405
445
  if (finalTarget === "package-json") {
406
- return runInitInPackageJson(cwd, finalPreset, force);
446
+ return runInitInPackageJson(cwd, configObject, force);
407
447
  }
408
- return runInitInFile(cwd, finalPreset, force);
448
+ return runInitInFile(cwd, configObject, force);
409
449
  }
410
450
  async function askInitPreferences() {
411
451
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -432,12 +472,15 @@ async function askInitTarget(rl) {
432
472
  }
433
473
  async function askInitPreset(rl) {
434
474
  while (true) {
435
- const answer = await askQuestion(rl, "Select preset:\n 1) minimal (recommended)\n 2) full\nChoose [1]: ");
475
+ const answer = await askQuestion(
476
+ rl,
477
+ "Select preset:\n 1) recommended (group by workspace numbers)\n 2) minimal\n 3) full\nChoose [1]: "
478
+ );
436
479
  const parsed = parseInitPresetChoice(answer);
437
480
  if (parsed) {
438
481
  return parsed;
439
482
  }
440
- process.stdout.write("Please choose 1 (minimal) or 2 (full).\n");
483
+ process.stdout.write("Please choose 1 (recommended), 2 (minimal), or 3 (full).\n");
441
484
  }
442
485
  }
443
486
  function parseInitTargetChoice(value) {
@@ -456,12 +499,15 @@ function parseInitTargetChoice(value) {
456
499
  function parseInitPresetChoice(value) {
457
500
  const normalized = value.trim().toLowerCase();
458
501
  if (normalized === "") {
459
- return "minimal";
502
+ return "recommended";
503
+ }
504
+ if (normalized === "1" || normalized === "recommended" || normalized === "rec" || normalized === "grouped") {
505
+ return "recommended";
460
506
  }
461
- if (normalized === "1" || normalized === "minimal" || normalized === "min") {
507
+ if (normalized === "2" || normalized === "minimal" || normalized === "min") {
462
508
  return "minimal";
463
509
  }
464
- if (normalized === "2" || normalized === "full") {
510
+ if (normalized === "3" || normalized === "full") {
465
511
  return "full";
466
512
  }
467
513
  return void 0;
@@ -486,7 +532,7 @@ async function askYesNo(prompt, defaultYes) {
486
532
  rl.close();
487
533
  }
488
534
  }
489
- async function runInitInFile(cwd, preset, force) {
535
+ async function runInitInFile(cwd, configObject, force) {
490
536
  const candidates = [
491
537
  ".eslint-config-snapshot.js",
492
538
  ".eslint-config-snapshot.cjs",
@@ -507,12 +553,12 @@ async function runInitInFile(cwd, preset, force) {
507
553
  }
508
554
  }
509
555
  const target = path.join(cwd, "eslint-config-snapshot.config.mjs");
510
- await writeFile(target, getConfigScaffold(preset), "utf8");
556
+ await writeFile(target, toConfigScaffold(configObject), "utf8");
511
557
  process.stdout.write(`Created ${path.basename(target)}
512
558
  `);
513
559
  return 0;
514
560
  }
515
- async function runInitInPackageJson(cwd, preset, force) {
561
+ async function runInitInPackageJson(cwd, configObject, force) {
516
562
  const packageJsonPath = path.join(cwd, "package.json");
517
563
  let packageJsonRaw;
518
564
  try {
@@ -532,12 +578,117 @@ async function runInitInPackageJson(cwd, preset, force) {
532
578
  process.stderr.write("Config already exists in package.json: eslint-config-snapshot\n");
533
579
  return 1;
534
580
  }
535
- parsed["eslint-config-snapshot"] = preset === "full" ? getFullPresetObject() : {};
581
+ parsed["eslint-config-snapshot"] = configObject;
536
582
  await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}
537
583
  `, "utf8");
538
584
  process.stdout.write('Created config in package.json under "eslint-config-snapshot"\n');
539
585
  return 0;
540
586
  }
587
+ async function resolveInitConfigObject(cwd, preset, nonInteractive) {
588
+ if (preset === "minimal") {
589
+ return {};
590
+ }
591
+ if (preset === "full") {
592
+ return getFullPresetObject();
593
+ }
594
+ return buildRecommendedPresetObject(cwd, nonInteractive);
595
+ }
596
+ async function buildRecommendedPresetObject(cwd, nonInteractive) {
597
+ const workspaces = await discoverInitWorkspaces(cwd);
598
+ const assignments = new Map(workspaces.map((workspace) => [workspace, 1]));
599
+ if (!nonInteractive && process.stdin.isTTY && process.stdout.isTTY) {
600
+ process.stdout.write("Recommended setup: assign a group number for each workspace (default: 1).\n");
601
+ for (const workspace of workspaces) {
602
+ const answer = await askGroupNumber(workspace);
603
+ assignments.set(workspace, answer);
604
+ }
605
+ }
606
+ const groupNumbers = [...new Set(assignments.values())].sort((a, b) => a - b);
607
+ const groups = groupNumbers.map((number) => ({
608
+ name: `group-${number}`,
609
+ match: workspaces.filter((workspace) => assignments.get(workspace) === number)
610
+ }));
611
+ return {
612
+ workspaceInput: { mode: "manual", workspaces },
613
+ grouping: {
614
+ mode: "match",
615
+ groups
616
+ },
617
+ sampling: {
618
+ maxFilesPerWorkspace: 8,
619
+ includeGlobs: ["**/*.{js,jsx,ts,tsx,cjs,mjs}"],
620
+ excludeGlobs: ["**/node_modules/**", "**/dist/**"],
621
+ hintGlobs: []
622
+ }
623
+ };
624
+ }
625
+ async function discoverInitWorkspaces(cwd) {
626
+ const discovered = await discoverWorkspaces({ cwd, workspaceInput: { mode: "discover" } });
627
+ if (!(discovered.workspacesRel.length === 1 && discovered.workspacesRel[0] === ".")) {
628
+ return discovered.workspacesRel;
629
+ }
630
+ const packageJsonPath = path.join(cwd, "package.json");
631
+ try {
632
+ const raw = await readFile(packageJsonPath, "utf8");
633
+ const parsed = JSON.parse(raw);
634
+ let workspacePatterns = [];
635
+ if (Array.isArray(parsed.workspaces)) {
636
+ workspacePatterns = parsed.workspaces;
637
+ } else if (parsed.workspaces && typeof parsed.workspaces === "object" && Array.isArray(parsed.workspaces.packages)) {
638
+ workspacePatterns = parsed.workspaces.packages;
639
+ }
640
+ if (workspacePatterns.length === 0) {
641
+ return discovered.workspacesRel;
642
+ }
643
+ const workspacePackageFiles = await fg(
644
+ workspacePatterns.map((pattern) => `${trimTrailingSlashes(pattern)}/package.json`),
645
+ { cwd, onlyFiles: true, dot: true }
646
+ );
647
+ const workspaceDirs = [...new Set(workspacePackageFiles.map((entry) => normalizePath(path.dirname(entry))))].sort(
648
+ (a, b) => a.localeCompare(b)
649
+ );
650
+ if (workspaceDirs.length > 0) {
651
+ return workspaceDirs;
652
+ }
653
+ } catch {
654
+ }
655
+ return discovered.workspacesRel;
656
+ }
657
+ function trimTrailingSlashes(value) {
658
+ let normalized = value;
659
+ while (normalized.endsWith("/")) {
660
+ normalized = normalized.slice(0, -1);
661
+ }
662
+ return normalized;
663
+ }
664
+ async function askGroupNumber(workspace) {
665
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
666
+ try {
667
+ while (true) {
668
+ const answer = await askQuestion(rl, `Group number for ${workspace} [1]: `);
669
+ const normalized = answer.trim();
670
+ if (normalized === "") {
671
+ return 1;
672
+ }
673
+ if (/^\d+$/.test(normalized)) {
674
+ const parsed = Number.parseInt(normalized, 10);
675
+ if (parsed >= 1) {
676
+ return parsed;
677
+ }
678
+ }
679
+ process.stdout.write("Please provide a positive integer (1, 2, 3, ...).\n");
680
+ }
681
+ } finally {
682
+ rl.close();
683
+ }
684
+ }
685
+ function toConfigScaffold(configObject) {
686
+ if (Object.keys(configObject).length === 0) {
687
+ return getConfigScaffold("minimal");
688
+ }
689
+ return `export default ${JSON.stringify(configObject, null, 2)}
690
+ `;
691
+ }
541
692
  function getFullPresetObject() {
542
693
  return {
543
694
  workspaceInput: { mode: "discover" },
@@ -689,6 +840,19 @@ function formatShortPrint(snapshots) {
689
840
  return `${lines.join("\n")}
690
841
  `;
691
842
  }
843
+ function formatShortConfig(payload) {
844
+ const lines = [
845
+ `source: ${payload.source}`,
846
+ `workspaces (${payload.workspaces.length}): ${payload.workspaces.join(", ") || "(none)"}`,
847
+ `grouping mode: ${payload.grouping.mode} (allow empty: ${payload.grouping.allowEmptyGroups})`
848
+ ];
849
+ for (const group of payload.grouping.groups) {
850
+ lines.push(`group ${group.name} (${group.workspaces.length}): ${group.workspaces.join(", ") || "(none)"}`);
851
+ }
852
+ lines.push(`workspaceInput: ${JSON.stringify(payload.workspaceInput)}`, `sampling: ${JSON.stringify(payload.sampling)}`);
853
+ return `${lines.join("\n")}
854
+ `;
855
+ }
692
856
  export {
693
857
  main,
694
858
  parseInitPresetChoice,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eslint-config-snapshot/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,6 @@
29
29
  "dependencies": {
30
30
  "commander": "^14.0.3",
31
31
  "fast-glob": "^3.3.3",
32
- "@eslint-config-snapshot/api": "0.1.4"
32
+ "@eslint-config-snapshot/api": "0.2.0"
33
33
  }
34
34
  }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getConfigScaffold,
11
11
  hasDiff,
12
12
  loadConfig,
13
+ normalizePath,
13
14
  readSnapshotFile,
14
15
  sampleWorkspaceFiles,
15
16
  writeSnapshotFile
@@ -30,7 +31,7 @@ type SnapshotDiff = ReturnType<typeof diffSnapshots>
30
31
  type CheckFormat = 'summary' | 'status' | 'diff'
31
32
  type PrintFormat = 'json' | 'short'
32
33
  type InitTarget = 'file' | 'package-json'
33
- type InitPreset = 'minimal' | 'full'
34
+ type InitPreset = 'recommended' | 'minimal' | 'full'
34
35
 
35
36
  type RootOptions = {
36
37
  update?: boolean
@@ -138,11 +139,23 @@ function createProgram(cwd: string, onActionExit: (code: number) => void): Comma
138
139
  onActionExit(0)
139
140
  })
140
141
 
142
+ program
143
+ .command('config')
144
+ .description('Print effective evaluated config')
145
+ .option('--format <format>', 'Output format: json|short', parsePrintFormat, 'json')
146
+ .option('--short', 'Alias for --format short')
147
+ .action(async (opts: { format: PrintFormat; short?: boolean }) => {
148
+ const format: PrintFormat = opts.short ? 'short' : opts.format
149
+ await executeConfig(cwd, format)
150
+ onActionExit(0)
151
+ })
152
+
141
153
  program
142
154
  .command('init')
143
155
  .description('Initialize config (file or package.json)')
144
156
  .option('--target <target>', 'Config target: file|package-json', parseInitTarget)
145
- .option('--preset <preset>', 'Config preset: minimal|full', parseInitPreset)
157
+ .option('--preset <preset>', 'Config preset: recommended|minimal|full', parseInitPreset)
158
+ .option('--show-effective', 'Print the evaluated config that will be written')
146
159
  .option('-f, --force', 'Allow init even when an existing config is detected')
147
160
  .option('-y, --yes', 'Skip prompts and use defaults/options')
148
161
  .addHelpText(
@@ -152,16 +165,17 @@ Examples:
152
165
  $ eslint-config-snapshot init
153
166
  Runs interactive numbered prompts:
154
167
  target: 1) package-json, 2) file
155
- preset: 1) minimal, 2) full
168
+ preset: 1) recommended, 2) minimal, 3) full
169
+ recommended preset supports per-workspace group number assignment.
156
170
 
157
- $ eslint-config-snapshot init --yes --target package-json --preset minimal
158
- Non-interactive minimal setup in package.json.
171
+ $ eslint-config-snapshot init --yes --target package-json --preset recommended --show-effective
172
+ Non-interactive recommended setup in package.json, with effective preview.
159
173
 
160
174
  $ eslint-config-snapshot init --yes --force --target file --preset full
161
175
  Overwrite-safe bypass when a config is already detected.
162
176
  `
163
177
  )
164
- .action(async (opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean }) => {
178
+ .action(async (opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean; showEffective?: boolean }) => {
165
179
  onActionExit(await runInit(cwd, opts))
166
180
  })
167
181
 
@@ -217,11 +231,11 @@ function parseInitTarget(value: string): InitTarget {
217
231
 
218
232
  function parseInitPreset(value: string): InitPreset {
219
233
  const normalized = value.trim().toLowerCase()
220
- if (normalized === 'minimal' || normalized === 'full') {
234
+ if (normalized === 'recommended' || normalized === 'minimal' || normalized === 'full') {
221
235
  return normalized
222
236
  }
223
237
 
224
- throw new InvalidArgumentError('Expected one of: minimal, full')
238
+ throw new InvalidArgumentError('Expected one of: recommended, minimal, full')
225
239
  }
226
240
 
227
241
  async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation = false): Promise<number> {
@@ -349,23 +363,34 @@ async function executePrint(cwd: string, format: PrintFormat): Promise<void> {
349
363
  process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
350
364
  }
351
365
 
352
- async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
366
+ async function executeConfig(cwd: string, format: PrintFormat): Promise<void> {
367
+ const foundConfig = await findConfigPath(cwd)
353
368
  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: ['**/*'] }])
369
+ const resolved = await resolveWorkspaceAssignments(cwd, config)
370
+ const payload = {
371
+ source: foundConfig?.path ?? 'built-in-defaults',
372
+ workspaceInput: config.workspaceInput,
373
+ workspaces: resolved.discovery.workspacesRel,
374
+ grouping: {
375
+ mode: config.grouping.mode,
376
+ allowEmptyGroups: config.grouping.allowEmptyGroups ?? false,
377
+ groups: resolved.assignments.map((entry) => ({ name: entry.name, workspaces: entry.workspaces }))
378
+ },
379
+ sampling: config.sampling
380
+ }
360
381
 
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
- }
382
+ if (format === 'short') {
383
+ process.stdout.write(formatShortConfig(payload))
384
+ return
367
385
  }
368
386
 
387
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
388
+ }
389
+
390
+ async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
391
+ const config = await loadConfig(cwd)
392
+ const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config)
393
+
369
394
  const snapshots = new Map<string, BuiltSnapshot>()
370
395
 
371
396
  for (const group of assignments) {
@@ -411,6 +436,25 @@ async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSn
411
436
  return snapshots
412
437
  }
413
438
 
439
+ async function resolveWorkspaceAssignments(cwd: string, config: Awaited<ReturnType<typeof loadConfig>>) {
440
+ const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput })
441
+
442
+ const assignments =
443
+ config.grouping.mode === 'standalone'
444
+ ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] }))
445
+ : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: 'default', match: ['**/*'] }])
446
+
447
+ const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false
448
+ if (!allowEmptyGroups) {
449
+ const empty = assignments.filter((group) => group.workspaces.length === 0)
450
+ if (empty.length > 0) {
451
+ throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(', ')}`)
452
+ }
453
+ }
454
+
455
+ return { discovery, assignments }
456
+ }
457
+
414
458
  async function loadStoredSnapshots(cwd: string): Promise<Map<string, StoredSnapshot>> {
415
459
  const dir = path.join(cwd, SNAPSHOT_DIR)
416
460
  const files = await fg('**/*.json', { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true })
@@ -515,9 +559,10 @@ function getDisplayOptionChanges(diff: SnapshotDiff): SnapshotDiff['optionChange
515
559
 
516
560
  async function runInit(
517
561
  cwd: string,
518
- opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean } = {}
562
+ opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean; showEffective?: boolean } = {}
519
563
  ): Promise<number> {
520
564
  const force = opts.force ?? false
565
+ const showEffective = opts.showEffective ?? false
521
566
  const existing = await findConfigPath(cwd)
522
567
  if (existing && !force) {
523
568
  process.stderr.write(
@@ -535,13 +580,18 @@ async function runInit(
535
580
  }
536
581
 
537
582
  const finalTarget = target ?? 'file'
538
- const finalPreset = preset ?? 'minimal'
583
+ const finalPreset = preset ?? 'recommended'
584
+ const configObject = await resolveInitConfigObject(cwd, finalPreset, Boolean(opts.yes))
585
+
586
+ if (showEffective) {
587
+ process.stdout.write(`Effective config preview:\n${JSON.stringify(configObject, null, 2)}\n`)
588
+ }
539
589
 
540
590
  if (finalTarget === 'package-json') {
541
- return runInitInPackageJson(cwd, finalPreset, force)
591
+ return runInitInPackageJson(cwd, configObject, force)
542
592
  }
543
593
 
544
- return runInitInFile(cwd, finalPreset, force)
594
+ return runInitInFile(cwd, configObject, force)
545
595
  }
546
596
 
547
597
  async function askInitPreferences(): Promise<{ target: InitTarget; preset: InitPreset }> {
@@ -571,12 +621,15 @@ async function askInitTarget(rl: ReturnType<typeof createInterface>): Promise<In
571
621
 
572
622
  async function askInitPreset(rl: ReturnType<typeof createInterface>): Promise<InitPreset> {
573
623
  while (true) {
574
- const answer = await askQuestion(rl, 'Select preset:\n 1) minimal (recommended)\n 2) full\nChoose [1]: ')
624
+ const answer = await askQuestion(
625
+ rl,
626
+ 'Select preset:\n 1) recommended (group by workspace numbers)\n 2) minimal\n 3) full\nChoose [1]: '
627
+ )
575
628
  const parsed = parseInitPresetChoice(answer)
576
629
  if (parsed) {
577
630
  return parsed
578
631
  }
579
- process.stdout.write('Please choose 1 (minimal) or 2 (full).\n')
632
+ process.stdout.write('Please choose 1 (recommended), 2 (minimal), or 3 (full).\n')
580
633
  }
581
634
  }
582
635
 
@@ -597,12 +650,15 @@ export function parseInitTargetChoice(value: string): InitTarget | undefined {
597
650
  export function parseInitPresetChoice(value: string): InitPreset | undefined {
598
651
  const normalized = value.trim().toLowerCase()
599
652
  if (normalized === '') {
600
- return 'minimal'
653
+ return 'recommended'
601
654
  }
602
- if (normalized === '1' || normalized === 'minimal' || normalized === 'min') {
655
+ if (normalized === '1' || normalized === 'recommended' || normalized === 'rec' || normalized === 'grouped') {
656
+ return 'recommended'
657
+ }
658
+ if (normalized === '2' || normalized === 'minimal' || normalized === 'min') {
603
659
  return 'minimal'
604
660
  }
605
- if (normalized === '2' || normalized === 'full') {
661
+ if (normalized === '3' || normalized === 'full') {
606
662
  return 'full'
607
663
  }
608
664
  return undefined
@@ -631,7 +687,7 @@ async function askYesNo(prompt: string, defaultYes: boolean): Promise<boolean> {
631
687
  }
632
688
  }
633
689
 
634
- async function runInitInFile(cwd: string, preset: InitPreset, force: boolean): Promise<number> {
690
+ async function runInitInFile(cwd: string, configObject: Record<string, unknown>, force: boolean): Promise<number> {
635
691
  const candidates = [
636
692
  '.eslint-config-snapshot.js',
637
693
  '.eslint-config-snapshot.cjs',
@@ -654,12 +710,12 @@ async function runInitInFile(cwd: string, preset: InitPreset, force: boolean): P
654
710
  }
655
711
 
656
712
  const target = path.join(cwd, 'eslint-config-snapshot.config.mjs')
657
- await writeFile(target, getConfigScaffold(preset), 'utf8')
713
+ await writeFile(target, toConfigScaffold(configObject), 'utf8')
658
714
  process.stdout.write(`Created ${path.basename(target)}\n`)
659
715
  return 0
660
716
  }
661
717
 
662
- async function runInitInPackageJson(cwd: string, preset: InitPreset, force: boolean): Promise<number> {
718
+ async function runInitInPackageJson(cwd: string, configObject: Record<string, unknown>, force: boolean): Promise<number> {
663
719
  const packageJsonPath = path.join(cwd, 'package.json')
664
720
 
665
721
  let packageJsonRaw: string
@@ -683,12 +739,139 @@ async function runInitInPackageJson(cwd: string, preset: InitPreset, force: bool
683
739
  return 1
684
740
  }
685
741
 
686
- parsed['eslint-config-snapshot'] = preset === 'full' ? getFullPresetObject() : {}
742
+ parsed['eslint-config-snapshot'] = configObject
687
743
  await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8')
688
744
  process.stdout.write('Created config in package.json under "eslint-config-snapshot"\n')
689
745
  return 0
690
746
  }
691
747
 
748
+ async function resolveInitConfigObject(
749
+ cwd: string,
750
+ preset: InitPreset,
751
+ nonInteractive: boolean
752
+ ): Promise<Record<string, unknown>> {
753
+ if (preset === 'minimal') {
754
+ return {}
755
+ }
756
+
757
+ if (preset === 'full') {
758
+ return getFullPresetObject()
759
+ }
760
+
761
+ return buildRecommendedPresetObject(cwd, nonInteractive)
762
+ }
763
+
764
+ async function buildRecommendedPresetObject(cwd: string, nonInteractive: boolean): Promise<Record<string, unknown>> {
765
+ const workspaces = await discoverInitWorkspaces(cwd)
766
+ const assignments = new Map<string, number>(workspaces.map((workspace) => [workspace, 1]))
767
+
768
+ if (!nonInteractive && process.stdin.isTTY && process.stdout.isTTY) {
769
+ process.stdout.write('Recommended setup: assign a group number for each workspace (default: 1).\n')
770
+ for (const workspace of workspaces) {
771
+ const answer = await askGroupNumber(workspace)
772
+ assignments.set(workspace, answer)
773
+ }
774
+ }
775
+
776
+ const groupNumbers = [...new Set(assignments.values())].sort((a, b) => a - b)
777
+ const groups = groupNumbers.map((number) => ({
778
+ name: `group-${number}`,
779
+ match: workspaces.filter((workspace) => assignments.get(workspace) === number)
780
+ }))
781
+
782
+ return {
783
+ workspaceInput: { mode: 'manual', workspaces },
784
+ grouping: {
785
+ mode: 'match',
786
+ groups
787
+ },
788
+ sampling: {
789
+ maxFilesPerWorkspace: 8,
790
+ includeGlobs: ['**/*.{js,jsx,ts,tsx,cjs,mjs}'],
791
+ excludeGlobs: ['**/node_modules/**', '**/dist/**'],
792
+ hintGlobs: []
793
+ }
794
+ }
795
+ }
796
+
797
+ async function discoverInitWorkspaces(cwd: string): Promise<string[]> {
798
+ const discovered = await discoverWorkspaces({ cwd, workspaceInput: { mode: 'discover' } })
799
+ if (!(discovered.workspacesRel.length === 1 && discovered.workspacesRel[0] === '.')) {
800
+ return discovered.workspacesRel
801
+ }
802
+
803
+ const packageJsonPath = path.join(cwd, 'package.json')
804
+ try {
805
+ const raw = await readFile(packageJsonPath, 'utf8')
806
+ const parsed = JSON.parse(raw) as { workspaces?: string[] | { packages?: string[] } }
807
+ let workspacePatterns: string[] = []
808
+ if (Array.isArray(parsed.workspaces)) {
809
+ workspacePatterns = parsed.workspaces
810
+ } else if (parsed.workspaces && typeof parsed.workspaces === 'object' && Array.isArray(parsed.workspaces.packages)) {
811
+ workspacePatterns = parsed.workspaces.packages
812
+ }
813
+
814
+ if (workspacePatterns.length === 0) {
815
+ return discovered.workspacesRel
816
+ }
817
+
818
+ const workspacePackageFiles = await fg(
819
+ workspacePatterns.map((pattern) => `${trimTrailingSlashes(pattern)}/package.json`),
820
+ { cwd, onlyFiles: true, dot: true }
821
+ )
822
+ const workspaceDirs = [...new Set(workspacePackageFiles.map((entry) => normalizePath(path.dirname(entry))))].sort((a, b) =>
823
+ a.localeCompare(b)
824
+ )
825
+ if (workspaceDirs.length > 0) {
826
+ return workspaceDirs
827
+ }
828
+ } catch {
829
+ // fallback to discovered output
830
+ }
831
+
832
+ return discovered.workspacesRel
833
+ }
834
+
835
+ function trimTrailingSlashes(value: string): string {
836
+ let normalized = value
837
+ while (normalized.endsWith('/')) {
838
+ normalized = normalized.slice(0, -1)
839
+ }
840
+ return normalized
841
+ }
842
+
843
+ async function askGroupNumber(workspace: string): Promise<number> {
844
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
845
+ try {
846
+ while (true) {
847
+ const answer = await askQuestion(rl, `Group number for ${workspace} [1]: `)
848
+ const normalized = answer.trim()
849
+ if (normalized === '') {
850
+ return 1
851
+ }
852
+
853
+ if (/^\d+$/.test(normalized)) {
854
+ const parsed = Number.parseInt(normalized, 10)
855
+ if (parsed >= 1) {
856
+ return parsed
857
+ }
858
+ }
859
+
860
+ process.stdout.write('Please provide a positive integer (1, 2, 3, ...).\n')
861
+ }
862
+ } finally {
863
+ rl.close()
864
+ }
865
+ }
866
+
867
+ function toConfigScaffold(configObject: Record<string, unknown>): string {
868
+ if (Object.keys(configObject).length === 0) {
869
+ return getConfigScaffold('minimal')
870
+ }
871
+
872
+ return `export default ${JSON.stringify(configObject, null, 2)}\n`
873
+ }
874
+
692
875
  function getFullPresetObject() {
693
876
  return {
694
877
  workspaceInput: { mode: 'discover' },
@@ -859,3 +1042,22 @@ function formatShortPrint(
859
1042
 
860
1043
  return `${lines.join('\n')}\n`
861
1044
  }
1045
+
1046
+ function formatShortConfig(payload: {
1047
+ source: string
1048
+ workspaceInput: unknown
1049
+ workspaces: string[]
1050
+ grouping: { mode: string; allowEmptyGroups: boolean; groups: Array<{ name: string; workspaces: string[] }> }
1051
+ sampling: unknown
1052
+ }): string {
1053
+ const lines: string[] = [
1054
+ `source: ${payload.source}`,
1055
+ `workspaces (${payload.workspaces.length}): ${payload.workspaces.join(', ') || '(none)'}`,
1056
+ `grouping mode: ${payload.grouping.mode} (allow empty: ${payload.grouping.allowEmptyGroups})`
1057
+ ]
1058
+ for (const group of payload.grouping.groups) {
1059
+ lines.push(`group ${group.name} (${group.workspaces.length}): ${group.workspaces.join(', ') || '(none)'}`)
1060
+ }
1061
+ lines.push(`workspaceInput: ${JSON.stringify(payload.workspaceInput)}`, `sampling: ${JSON.stringify(payload.sampling)}`)
1062
+ return `${lines.join('\n')}\n`
1063
+ }
@@ -47,10 +47,12 @@ describe('cli integration', () => {
47
47
  })
48
48
 
49
49
  it('parses init interactive preset choices from numeric and aliases', () => {
50
- expect(parseInitPresetChoice('')).toBe('minimal')
51
- expect(parseInitPresetChoice('1')).toBe('minimal')
50
+ expect(parseInitPresetChoice('')).toBe('recommended')
51
+ expect(parseInitPresetChoice('1')).toBe('recommended')
52
+ expect(parseInitPresetChoice('rec')).toBe('recommended')
53
+ expect(parseInitPresetChoice('2')).toBe('minimal')
52
54
  expect(parseInitPresetChoice('min')).toBe('minimal')
53
- expect(parseInitPresetChoice('2')).toBe('full')
55
+ expect(parseInitPresetChoice('3')).toBe('full')
54
56
  expect(parseInitPresetChoice('full')).toBe('full')
55
57
  expect(parseInitPresetChoice('invalid')).toBeUndefined()
56
58
  })
@@ -126,11 +128,22 @@ no-debugger: off
126
128
  expect(code).toBe(0)
127
129
 
128
130
  const content = await readFile(path.join(tmp, 'eslint-config-snapshot.config.mjs'), 'utf8')
129
- expect(content).toContain("workspaceInput: { mode: 'discover' }")
131
+ expect(content).toContain('"workspaceInput"')
132
+ expect(content).toContain('"grouping"')
133
+ expect(content).toContain('"sampling"')
130
134
 
131
135
  await rm(tmp, { recursive: true, force: true })
132
136
  })
133
137
 
138
+ it('config prints effective evaluated config and exits 0', async () => {
139
+ const writeSpy = vi.spyOn(process.stdout, 'write')
140
+ const code = await runCli('config', fixtureRoot)
141
+ expect(code).toBe(0)
142
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"workspaceInput"'))
143
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"workspaces"'))
144
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"groups"'))
145
+ })
146
+
134
147
  it('init writes minimal config to package.json when target=package-json', async () => {
135
148
  const tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-init-pkg-'))
136
149
  await writeFile(path.join(tmp, 'package.json'), JSON.stringify({ name: 'fixture', private: true }, null, 2))
@@ -66,6 +66,7 @@ describe('cli terminal invocation', () => {
66
66
  expect(result.stdout).toContain('check [options]')
67
67
  expect(result.stdout).toContain('update|snapshot')
68
68
  expect(result.stdout).toContain('print [options]')
69
+ expect(result.stdout).toContain('config [options]')
69
70
  expect(result.stdout).toContain('init')
70
71
  expect(result.stderr).toBe('')
71
72
  })
@@ -232,6 +233,15 @@ no-debugger: off
232
233
  expect(existing.stderr).toContain('rerun with --force')
233
234
  })
234
235
 
236
+ it('config prints effective evaluated config output', () => {
237
+ const result = run(['config'])
238
+ expect(result.status).toBe(0)
239
+ expect(result.stdout).toContain('"workspaceInput"')
240
+ expect(result.stdout).toContain('"workspaces"')
241
+ expect(result.stdout).toContain('"groups"')
242
+ expect(result.stderr).toBe('')
243
+ })
244
+
235
245
  it('init can write config to package.json', async () => {
236
246
  const initRoot = path.join(tmpDir, 'init-package-json-case')
237
247
  await rm(initRoot, { recursive: true, force: true })
@@ -250,6 +260,37 @@ no-debugger: off
250
260
  expect(parsed['eslint-config-snapshot']).toEqual({})
251
261
  })
252
262
 
263
+ it('init recommended writes grouped workspace config in package.json', async () => {
264
+ const initRoot = path.join(tmpDir, 'init-recommended-package-json-case')
265
+ await rm(initRoot, { recursive: true, force: true })
266
+ await cp(fixtureRoot, initRoot, { recursive: true })
267
+ repoRoot = initRoot
268
+
269
+ await rm(path.join(repoRoot, 'eslint-config-snapshot.config.mjs'), { force: true })
270
+
271
+ const created = run(['init', '--yes', '--target', 'package-json', '--preset', 'recommended'])
272
+ expect(created.status).toBe(0)
273
+ expect(created.stdout).toBe('Created config in package.json under "eslint-config-snapshot"\n')
274
+ expect(created.stderr).toBe('')
275
+
276
+ const packageJsonRaw = await readFile(path.join(repoRoot, 'package.json'), 'utf8')
277
+ const parsed = JSON.parse(packageJsonRaw) as {
278
+ 'eslint-config-snapshot'?: {
279
+ workspaceInput?: { mode?: string; workspaces?: string[] }
280
+ grouping?: { mode?: string; groups?: Array<{ name: string; match: string[] }> }
281
+ }
282
+ }
283
+
284
+ expect(parsed['eslint-config-snapshot']?.workspaceInput).toEqual({
285
+ mode: 'manual',
286
+ workspaces: ['packages/ws-a', 'packages/ws-b']
287
+ })
288
+ expect(parsed['eslint-config-snapshot']?.grouping).toEqual({
289
+ mode: 'match',
290
+ groups: [{ name: 'group-1', match: ['packages/ws-a', 'packages/ws-b'] }]
291
+ })
292
+ })
293
+
253
294
  it('init fails early on existing config unless --force is provided', async () => {
254
295
  const initRoot = path.join(tmpDir, 'init-force-case')
255
296
  await rm(initRoot, { recursive: true, force: true })
@@ -342,7 +383,8 @@ no-debugger: off
342
383
  expect(result.stdout).toContain('-f, --force')
343
384
  expect(result.stdout).toContain('Runs interactive numbered prompts:')
344
385
  expect(result.stdout).toContain('target: 1) package-json, 2) file')
345
- expect(result.stdout).toContain('preset: 1) minimal, 2) full')
386
+ expect(result.stdout).toContain('preset: 1) recommended, 2) minimal, 3) full')
387
+ expect(result.stdout).toContain('--show-effective')
346
388
  expect(result.stdout).toContain('--yes --force --target file --preset full')
347
389
  expect(result.stderr).toBe('')
348
390
  })