viberails 0.6.5 → 0.6.7

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/dist/index.js CHANGED
@@ -1,15 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- assertNotCancelled,
4
- confirm,
5
- confirmDangerous,
6
- getRootPackage,
7
- promptExistingConfigAction,
8
- promptInitDecision,
9
- promptIntegrations,
10
- promptRuleMenu,
11
- spawnAsync
12
- } from "./chunk-XQKOK3FU.js";
13
2
 
14
3
  // src/index.ts
15
4
  import chalk16 from "chalk";
@@ -38,6 +27,656 @@ function findProjectRoot(startDir) {
38
27
  }
39
28
  }
40
29
 
30
+ // src/utils/prompt.ts
31
+ import * as clack5 from "@clack/prompts";
32
+
33
+ // src/utils/prompt-rules.ts
34
+ import * as clack4 from "@clack/prompts";
35
+
36
+ // src/utils/get-root-package.ts
37
+ function getRootPackage(packages) {
38
+ return packages.find((pkg) => pkg.path === ".") ?? packages[0];
39
+ }
40
+
41
+ // src/utils/prompt-menu-handlers.ts
42
+ import * as clack3 from "@clack/prompts";
43
+
44
+ // src/utils/prompt-package-overrides.ts
45
+ import * as clack2 from "@clack/prompts";
46
+
47
+ // src/utils/prompt-constants.ts
48
+ var SENTINEL_DONE = "__done__";
49
+ var SENTINEL_CLEAR = "__clear__";
50
+ var SENTINEL_CUSTOM = "__custom__";
51
+ var SENTINEL_NONE = "__none__";
52
+ var SENTINEL_INHERIT = "__inherit__";
53
+ var SENTINEL_SKIP = "__skip__";
54
+ var HINT_NOT_SET = "not set";
55
+ var HINT_NO_OVERRIDES = "no overrides";
56
+ var HINT_AUTO_DETECT = "auto-detect";
57
+
58
+ // src/utils/prompt-submenus.ts
59
+ import * as clack from "@clack/prompts";
60
+ var FILE_NAMING_OPTIONS = [
61
+ { value: "kebab-case", label: "kebab-case" },
62
+ { value: "camelCase", label: "camelCase" },
63
+ { value: "PascalCase", label: "PascalCase" },
64
+ { value: "snake_case", label: "snake_case" }
65
+ ];
66
+ var COMPONENT_NAMING_OPTIONS = [
67
+ { value: "PascalCase", label: "PascalCase", hint: "MyComponent.tsx" },
68
+ { value: "camelCase", label: "camelCase", hint: "myComponent.tsx" }
69
+ ];
70
+ var HOOK_NAMING_OPTIONS = [
71
+ { value: "useXxx", label: "useXxx", hint: "useAuth, useFormData" },
72
+ { value: "use-*", label: "use-*", hint: "use-auth, use-form-data" }
73
+ ];
74
+ async function promptFileLimitsMenu(state) {
75
+ while (true) {
76
+ const choice = await clack.select({
77
+ message: "File limits",
78
+ options: [
79
+ { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
80
+ {
81
+ value: "maxTestFileLines",
82
+ label: "Max test file lines",
83
+ hint: state.maxTestFileLines > 0 ? String(state.maxTestFileLines) : "0 (unlimited)"
84
+ },
85
+ { value: "back", label: "Back" }
86
+ ]
87
+ });
88
+ if (isCancelled(choice) || choice === "back") return;
89
+ if (choice === "maxFileLines") {
90
+ const result = await clack.text({
91
+ message: "Maximum lines per source file?",
92
+ initialValue: String(state.maxFileLines),
93
+ validate: (v) => {
94
+ if (typeof v !== "string") return "Enter a positive number";
95
+ const n = Number.parseInt(v, 10);
96
+ if (Number.isNaN(n) || n < 1) return "Enter a positive number";
97
+ }
98
+ });
99
+ if (isCancelled(result)) continue;
100
+ state.maxFileLines = Number.parseInt(result, 10);
101
+ }
102
+ if (choice === "maxTestFileLines") {
103
+ const result = await clack.text({
104
+ message: "Maximum lines per test file (0 to disable)?",
105
+ initialValue: String(state.maxTestFileLines),
106
+ validate: (v) => {
107
+ if (typeof v !== "string") return "Enter a number (0 or positive)";
108
+ const n = Number.parseInt(v, 10);
109
+ if (Number.isNaN(n) || n < 0) return "Enter a number (0 or positive)";
110
+ }
111
+ });
112
+ if (isCancelled(result)) continue;
113
+ state.maxTestFileLines = Number.parseInt(result, 10);
114
+ }
115
+ }
116
+ }
117
+ async function promptNamingMenu(state) {
118
+ while (true) {
119
+ const options = [
120
+ {
121
+ value: "enforceNaming",
122
+ label: "Enforce file naming",
123
+ hint: state.enforceNaming ? "yes" : "no"
124
+ }
125
+ ];
126
+ if (state.enforceNaming) {
127
+ options.push({
128
+ value: "fileNaming",
129
+ label: "File naming convention",
130
+ hint: state.fileNamingValue ?? HINT_NOT_SET
131
+ });
132
+ }
133
+ options.push(
134
+ {
135
+ value: "componentNaming",
136
+ label: "Component naming",
137
+ hint: state.componentNaming ?? HINT_NOT_SET
138
+ },
139
+ {
140
+ value: "hookNaming",
141
+ label: "Hook naming",
142
+ hint: state.hookNaming ?? HINT_NOT_SET
143
+ },
144
+ {
145
+ value: "importAlias",
146
+ label: "Import alias",
147
+ hint: state.importAlias ?? HINT_NOT_SET
148
+ },
149
+ { value: "back", label: "Back" }
150
+ );
151
+ const choice = await clack.select({ message: "Naming & conventions", options });
152
+ if (isCancelled(choice) || choice === "back") return;
153
+ if (choice === "enforceNaming") {
154
+ const result = await clack.confirm({
155
+ message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
156
+ initialValue: state.enforceNaming
157
+ });
158
+ if (isCancelled(result)) continue;
159
+ if (result && !state.fileNamingValue) {
160
+ const selected = await clack.select({
161
+ message: "Which file naming convention should be enforced?",
162
+ options: [...FILE_NAMING_OPTIONS]
163
+ });
164
+ if (isCancelled(selected)) continue;
165
+ state.fileNamingValue = selected;
166
+ }
167
+ state.enforceNaming = result;
168
+ }
169
+ if (choice === "fileNaming") {
170
+ const selected = await clack.select({
171
+ message: "Which file naming convention should be enforced?",
172
+ options: [...FILE_NAMING_OPTIONS],
173
+ initialValue: state.fileNamingValue
174
+ });
175
+ if (isCancelled(selected)) continue;
176
+ state.fileNamingValue = selected;
177
+ }
178
+ if (choice === "componentNaming") {
179
+ const selected = await clack.select({
180
+ message: "Component naming convention",
181
+ options: [
182
+ ...COMPONENT_NAMING_OPTIONS,
183
+ { value: SENTINEL_CLEAR, label: "Clear (no convention)" }
184
+ ],
185
+ initialValue: state.componentNaming ?? SENTINEL_CLEAR
186
+ });
187
+ if (isCancelled(selected)) continue;
188
+ state.componentNaming = selected === SENTINEL_CLEAR ? void 0 : selected;
189
+ }
190
+ if (choice === "hookNaming") {
191
+ const selected = await clack.select({
192
+ message: "Hook naming convention",
193
+ options: [
194
+ ...HOOK_NAMING_OPTIONS,
195
+ { value: SENTINEL_CLEAR, label: "Clear (no convention)" }
196
+ ],
197
+ initialValue: state.hookNaming ?? SENTINEL_CLEAR
198
+ });
199
+ if (isCancelled(selected)) continue;
200
+ state.hookNaming = selected === SENTINEL_CLEAR ? void 0 : selected;
201
+ }
202
+ if (choice === "importAlias") {
203
+ const selected = await clack.select({
204
+ message: "Import alias pattern",
205
+ options: [
206
+ { value: "@/*", label: "@/*", hint: "import { x } from '@/utils'" },
207
+ { value: "~/*", label: "~/*", hint: "import { x } from '~/utils'" },
208
+ { value: SENTINEL_CUSTOM, label: "Custom..." },
209
+ { value: SENTINEL_CLEAR, label: "Clear (no alias)" }
210
+ ],
211
+ initialValue: state.importAlias ?? SENTINEL_CLEAR
212
+ });
213
+ if (isCancelled(selected)) continue;
214
+ if (selected === SENTINEL_CLEAR) {
215
+ state.importAlias = void 0;
216
+ } else if (selected === SENTINEL_CUSTOM) {
217
+ const result = await clack.text({
218
+ message: "Custom import alias (e.g. #/*)?",
219
+ initialValue: state.importAlias ?? "",
220
+ placeholder: "e.g. #/*",
221
+ validate: (v) => {
222
+ if (typeof v !== "string" || !v.trim()) return "Alias cannot be empty";
223
+ if (!/^[a-zA-Z@~#$][a-zA-Z0-9@~#$_-]*\/\*$/.test(v.trim()))
224
+ return "Must match pattern like @/*, ~/*, or #src/*";
225
+ }
226
+ });
227
+ if (isCancelled(result)) continue;
228
+ state.importAlias = result.trim();
229
+ } else {
230
+ state.importAlias = selected;
231
+ }
232
+ }
233
+ }
234
+ }
235
+ async function promptTestingMenu(state) {
236
+ while (true) {
237
+ const options = [
238
+ {
239
+ value: "enforceMissingTests",
240
+ label: "Enforce missing tests",
241
+ hint: state.enforceMissingTests ? "yes" : "no"
242
+ },
243
+ {
244
+ value: "testCoverage",
245
+ label: "Test coverage target",
246
+ hint: state.testCoverage === 0 ? "0 (disabled)" : `${state.testCoverage}%`
247
+ }
248
+ ];
249
+ if (state.testCoverage > 0) {
250
+ options.push(
251
+ {
252
+ value: "coverageSummaryPath",
253
+ label: "Coverage summary path",
254
+ hint: state.coverageSummaryPath
255
+ },
256
+ {
257
+ value: "coverageCommand",
258
+ label: "Coverage command",
259
+ hint: state.coverageCommand ?? "auto-detect from package.json test runner"
260
+ }
261
+ );
262
+ }
263
+ options.push({ value: "back", label: "Back" });
264
+ const choice = await clack.select({ message: "Testing & coverage", options });
265
+ if (isCancelled(choice) || choice === "back") return;
266
+ if (choice === "enforceMissingTests") {
267
+ const result = await clack.confirm({
268
+ message: "Require every source file to have a corresponding test file?",
269
+ initialValue: state.enforceMissingTests
270
+ });
271
+ if (isCancelled(result)) continue;
272
+ state.enforceMissingTests = result;
273
+ }
274
+ if (choice === "testCoverage") {
275
+ const result = await clack.text({
276
+ message: "Test coverage target (0 disables coverage checks)?",
277
+ initialValue: String(state.testCoverage),
278
+ validate: (v) => {
279
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
280
+ const n = Number.parseInt(v, 10);
281
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
282
+ }
283
+ });
284
+ if (isCancelled(result)) continue;
285
+ state.testCoverage = Number.parseInt(result, 10);
286
+ }
287
+ if (choice === "coverageSummaryPath") {
288
+ const result = await clack.text({
289
+ message: "Coverage summary path (relative to package root)?",
290
+ initialValue: state.coverageSummaryPath,
291
+ validate: (v) => {
292
+ if (typeof v !== "string" || v.trim().length === 0) return "Path cannot be empty";
293
+ }
294
+ });
295
+ if (isCancelled(result)) continue;
296
+ state.coverageSummaryPath = result.trim();
297
+ }
298
+ if (choice === "coverageCommand") {
299
+ const result = await clack.text({
300
+ message: "Coverage command (blank to auto-detect from package.json)?",
301
+ initialValue: state.coverageCommand ?? "",
302
+ placeholder: "(auto-detect from package.json test runner)"
303
+ });
304
+ if (isCancelled(result)) continue;
305
+ const trimmed = result.trim();
306
+ state.coverageCommand = trimmed.length > 0 ? trimmed : void 0;
307
+ }
308
+ }
309
+ }
310
+
311
+ // src/utils/prompt-package-overrides.ts
312
+ function normalizePackageOverrides(packages) {
313
+ for (const pkg of packages) {
314
+ if (pkg.rules && Object.keys(pkg.rules).length === 0) {
315
+ delete pkg.rules;
316
+ }
317
+ if (pkg.coverage && Object.keys(pkg.coverage).length === 0) {
318
+ delete pkg.coverage;
319
+ }
320
+ if (pkg.conventions && Object.keys(pkg.conventions).length === 0) {
321
+ delete pkg.conventions;
322
+ }
323
+ }
324
+ return packages;
325
+ }
326
+ function packageOverrideHint(pkg, defaults) {
327
+ const tags = [];
328
+ if (pkg.conventions?.fileNaming && pkg.conventions.fileNaming !== defaults.fileNamingValue) {
329
+ tags.push(pkg.conventions.fileNaming);
330
+ }
331
+ if (pkg.rules?.maxFileLines !== void 0 && pkg.rules.maxFileLines !== defaults.maxFileLines && pkg.rules.maxFileLines > 0) {
332
+ tags.push(`${pkg.rules.maxFileLines} lines`);
333
+ }
334
+ const coverage = pkg.rules?.testCoverage ?? defaults.testCoverage;
335
+ const isExempt = coverage === 0;
336
+ const nameSegments = pkg.name.replace(/^@[^/]+\//, "").split(/[-/]/);
337
+ const isTypesOnly = isExempt && nameSegments.some((s) => s === "types");
338
+ if (isExempt) {
339
+ tags.push(isTypesOnly ? "exempt (types-only)" : "exempt");
340
+ } else if (pkg.rules?.testCoverage !== void 0 && pkg.rules.testCoverage !== defaults.testCoverage) {
341
+ tags.push(`${coverage}%`);
342
+ }
343
+ const hasSummaryOverride = pkg.coverage?.summaryPath !== void 0 && pkg.coverage.summaryPath !== defaults.coverageSummaryPath;
344
+ const defaultCommand = defaults.coverageCommand ?? "";
345
+ const hasCommandOverride = pkg.coverage?.command !== void 0 && pkg.coverage.command !== defaultCommand;
346
+ if (hasSummaryOverride) tags.push("summary override");
347
+ if (hasCommandOverride) tags.push("command override");
348
+ return tags.length > 0 ? tags.join(", ") : HINT_NO_OVERRIDES;
349
+ }
350
+ async function promptPackageOverrides(packages, defaults) {
351
+ const editablePackages = packages.filter((pkg) => pkg.path !== ".");
352
+ if (editablePackages.length === 0) return packages;
353
+ while (true) {
354
+ const selectedPath = await clack2.select({
355
+ message: "Select package to edit overrides",
356
+ options: [
357
+ ...editablePackages.map((pkg) => ({
358
+ value: pkg.path,
359
+ label: `${pkg.path} (${pkg.name})`,
360
+ hint: packageOverrideHint(pkg, defaults)
361
+ })),
362
+ { value: SENTINEL_DONE, label: "Done" }
363
+ ]
364
+ });
365
+ if (isCancelled(selectedPath) || selectedPath === SENTINEL_DONE) break;
366
+ const target = editablePackages.find((pkg) => pkg.path === selectedPath);
367
+ if (!target) continue;
368
+ await promptSinglePackageOverrides(target, defaults);
369
+ normalizePackageOverrides(editablePackages);
370
+ }
371
+ return normalizePackageOverrides(packages);
372
+ }
373
+ async function promptSinglePackageOverrides(target, defaults) {
374
+ while (true) {
375
+ const effectiveNaming = target.conventions?.fileNaming ?? defaults.fileNamingValue;
376
+ const effectiveMaxLines = target.rules?.maxFileLines ?? defaults.maxFileLines;
377
+ const effectiveCoverage = target.rules?.testCoverage ?? defaults.testCoverage;
378
+ const effectiveSummary = target.coverage?.summaryPath ?? defaults.coverageSummaryPath;
379
+ const effectiveCommand = target.coverage?.command ?? defaults.coverageCommand ?? HINT_AUTO_DETECT;
380
+ const hasNamingOverride = target.conventions?.fileNaming !== void 0 && target.conventions.fileNaming !== defaults.fileNamingValue;
381
+ const hasMaxLinesOverride = target.rules?.maxFileLines !== void 0 && target.rules.maxFileLines !== defaults.maxFileLines;
382
+ const namingHint = hasNamingOverride ? String(effectiveNaming) : `inherits: ${effectiveNaming ?? "not set"}`;
383
+ const maxLinesHint = hasMaxLinesOverride ? String(effectiveMaxLines) : `inherits: ${effectiveMaxLines}`;
384
+ const choice = await clack2.select({
385
+ message: `Edit overrides for ${target.path}`,
386
+ options: [
387
+ { value: "fileNaming", label: "File naming", hint: namingHint },
388
+ { value: "maxFileLines", label: "Max file lines", hint: maxLinesHint },
389
+ { value: "testCoverage", label: "Test coverage", hint: String(effectiveCoverage) },
390
+ { value: "summaryPath", label: "Coverage summary path", hint: effectiveSummary },
391
+ { value: "command", label: "Coverage command", hint: effectiveCommand },
392
+ { value: "reset", label: "Reset all overrides for this package" },
393
+ { value: "back", label: "Back to package list" }
394
+ ]
395
+ });
396
+ if (isCancelled(choice) || choice === "back") break;
397
+ if (choice === "fileNaming") {
398
+ const selected = await clack2.select({
399
+ message: `File naming for ${target.path}`,
400
+ options: [
401
+ ...FILE_NAMING_OPTIONS,
402
+ { value: SENTINEL_NONE, label: "(none \u2014 exempt from checks)" },
403
+ {
404
+ value: SENTINEL_INHERIT,
405
+ label: `Inherit default${defaults.fileNamingValue ? ` (${defaults.fileNamingValue})` : ""}`
406
+ }
407
+ ],
408
+ initialValue: target.conventions?.fileNaming ?? SENTINEL_INHERIT
409
+ });
410
+ if (isCancelled(selected)) continue;
411
+ if (selected === SENTINEL_INHERIT) {
412
+ if (target.conventions) delete target.conventions.fileNaming;
413
+ } else if (selected === SENTINEL_NONE) {
414
+ target.conventions = { ...target.conventions ?? {}, fileNaming: "" };
415
+ } else {
416
+ target.conventions = { ...target.conventions ?? {}, fileNaming: selected };
417
+ }
418
+ }
419
+ if (choice === "maxFileLines") {
420
+ const result = await clack2.text({
421
+ message: `Max file lines for ${target.path} (blank to inherit default)?`,
422
+ initialValue: target.rules?.maxFileLines !== void 0 ? String(target.rules.maxFileLines) : "",
423
+ placeholder: String(defaults.maxFileLines)
424
+ });
425
+ if (isCancelled(result)) continue;
426
+ const value = result.trim();
427
+ if (value.length === 0 || Number.parseInt(value, 10) === defaults.maxFileLines) {
428
+ if (target.rules) delete target.rules.maxFileLines;
429
+ } else {
430
+ target.rules = { ...target.rules ?? {}, maxFileLines: Number.parseInt(value, 10) };
431
+ }
432
+ }
433
+ if (choice === "testCoverage") {
434
+ const result = await clack2.text({
435
+ message: "Package testCoverage (0 to exempt package)?",
436
+ initialValue: String(effectiveCoverage),
437
+ validate: (v) => {
438
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
439
+ const n = Number.parseInt(v, 10);
440
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
441
+ }
442
+ });
443
+ if (isCancelled(result)) continue;
444
+ const nextCoverage = Number.parseInt(result, 10);
445
+ if (nextCoverage === defaults.testCoverage) {
446
+ if (target.rules) delete target.rules.testCoverage;
447
+ } else {
448
+ target.rules = { ...target.rules ?? {}, testCoverage: nextCoverage };
449
+ }
450
+ }
451
+ if (choice === "summaryPath") {
452
+ const result = await clack2.text({
453
+ message: "Path to coverage summary file (blank to inherit default)?",
454
+ initialValue: target.coverage?.summaryPath !== void 0 ? target.coverage.summaryPath : "",
455
+ placeholder: defaults.coverageSummaryPath
456
+ });
457
+ if (isCancelled(result)) continue;
458
+ const value = result.trim();
459
+ if (value.length === 0 || value === defaults.coverageSummaryPath) {
460
+ if (target.coverage) delete target.coverage.summaryPath;
461
+ } else {
462
+ target.coverage = { ...target.coverage ?? {}, summaryPath: value };
463
+ }
464
+ }
465
+ if (choice === "command") {
466
+ const result = await clack2.text({
467
+ message: "Coverage command (blank to auto-detect)?",
468
+ initialValue: target.coverage?.command !== void 0 ? target.coverage.command : "",
469
+ placeholder: defaults.coverageCommand ?? "(auto-detect from package.json test runner)"
470
+ });
471
+ if (isCancelled(result)) continue;
472
+ const value = result.trim();
473
+ const defaultCommand = defaults.coverageCommand ?? "";
474
+ if (value.length === 0 || value === defaultCommand) {
475
+ if (target.coverage) delete target.coverage.command;
476
+ } else {
477
+ target.coverage = { ...target.coverage ?? {}, command: value };
478
+ }
479
+ }
480
+ if (choice === "reset") {
481
+ if (target.rules) {
482
+ delete target.rules.testCoverage;
483
+ delete target.rules.maxFileLines;
484
+ }
485
+ delete target.coverage;
486
+ delete target.conventions;
487
+ }
488
+ }
489
+ }
490
+
491
+ // src/utils/prompt-menu-handlers.ts
492
+ function getPackageDiffs(pkg, root) {
493
+ const diffs = [];
494
+ const convKeys = ["fileNaming", "componentNaming", "hookNaming", "importAlias"];
495
+ for (const key of convKeys) {
496
+ if (pkg.conventions?.[key] && pkg.conventions[key] !== root.conventions?.[key]) {
497
+ diffs.push(`${key}: ${pkg.conventions[key]}`);
498
+ }
499
+ }
500
+ const stackKeys = [
501
+ "framework",
502
+ "language",
503
+ "styling",
504
+ "backend",
505
+ "orm",
506
+ "linter",
507
+ "formatter",
508
+ "testRunner",
509
+ "packageManager"
510
+ ];
511
+ for (const key of stackKeys) {
512
+ if (pkg.stack?.[key] && pkg.stack[key] !== root.stack?.[key]) {
513
+ diffs.push(`${key}: ${pkg.stack[key]}`);
514
+ }
515
+ }
516
+ if (pkg.rules?.maxFileLines !== void 0 && pkg.rules.maxFileLines !== root.rules?.maxFileLines && pkg.rules.maxFileLines > 0) {
517
+ diffs.push(`maxFileLines: ${pkg.rules.maxFileLines}`);
518
+ }
519
+ if (pkg.rules?.testCoverage !== void 0 && pkg.rules.testCoverage !== root.rules?.testCoverage && pkg.rules.testCoverage >= 0) {
520
+ diffs.push(`testCoverage: ${pkg.rules.testCoverage}`);
521
+ }
522
+ if (pkg.coverage?.summaryPath && pkg.coverage.summaryPath !== root.coverage?.summaryPath) {
523
+ diffs.push(`coverage.summaryPath: ${pkg.coverage.summaryPath}`);
524
+ }
525
+ if (pkg.coverage?.command && pkg.coverage.command !== root.coverage?.command) {
526
+ diffs.push("coverage.command: (override)");
527
+ }
528
+ return diffs;
529
+ }
530
+ function buildMenuOptions(state, packageCount) {
531
+ const fileLimitsHint2 = state.maxTestFileLines > 0 ? `max ${state.maxFileLines} lines, tests ${state.maxTestFileLines}` : `max ${state.maxFileLines} lines, test files unlimited`;
532
+ const namingHint = state.enforceNaming ? `${state.fileNamingValue ?? "not set"} (enforced)` : "not enforced";
533
+ const testingHint = state.testCoverage > 0 ? `${state.testCoverage}% coverage, missing tests ${state.enforceMissingTests ? "enforced" : "not enforced"}` : `coverage disabled, missing tests ${state.enforceMissingTests ? "enforced" : "not enforced"}`;
534
+ const options = [
535
+ { value: "fileLimits", label: "File limits", hint: fileLimitsHint2 },
536
+ { value: "naming", label: "Naming & conventions", hint: namingHint },
537
+ { value: "testing", label: "Testing & coverage", hint: testingHint }
538
+ ];
539
+ if (packageCount > 0) {
540
+ options.push({
541
+ value: "packageOverrides",
542
+ label: "Per-package overrides",
543
+ hint: `${packageCount} package${packageCount > 1 ? "s" : ""} configurable`
544
+ });
545
+ }
546
+ options.push(
547
+ { value: "reset", label: "Reset all to detected defaults" },
548
+ { value: "done", label: "Done" }
549
+ );
550
+ return options;
551
+ }
552
+ function clonePackages(packages) {
553
+ return packages ? structuredClone(packages) : void 0;
554
+ }
555
+ async function handleMenuChoice(choice, state, defaults, root) {
556
+ if (choice === "reset") {
557
+ state.maxFileLines = defaults.maxFileLines;
558
+ state.maxTestFileLines = defaults.maxTestFileLines;
559
+ state.testCoverage = defaults.testCoverage;
560
+ state.enforceMissingTests = defaults.enforceMissingTests;
561
+ state.enforceNaming = defaults.enforceNaming;
562
+ state.fileNamingValue = defaults.fileNamingValue;
563
+ state.componentNaming = defaults.componentNaming;
564
+ state.hookNaming = defaults.hookNaming;
565
+ state.importAlias = defaults.importAlias;
566
+ state.coverageSummaryPath = defaults.coverageSummaryPath;
567
+ state.coverageCommand = defaults.coverageCommand;
568
+ state.packageOverrides = clonePackages(defaults.packageOverrides);
569
+ clack3.log.info("Reset all rules to detected defaults.");
570
+ return;
571
+ }
572
+ if (choice === "fileLimits") {
573
+ await promptFileLimitsMenu(state);
574
+ return;
575
+ }
576
+ if (choice === "naming") {
577
+ await promptNamingMenu(state);
578
+ return;
579
+ }
580
+ if (choice === "testing") {
581
+ await promptTestingMenu(state);
582
+ return;
583
+ }
584
+ if (choice === "packageOverrides") {
585
+ if (state.packageOverrides) {
586
+ const packageDiffs = root ? state.packageOverrides.filter((pkg) => pkg.path !== root.path).map((pkg) => ({ pkg, diffs: getPackageDiffs(pkg, root) })).filter((entry) => entry.diffs.length > 0) : [];
587
+ state.packageOverrides = await promptPackageOverrides(state.packageOverrides, {
588
+ fileNamingValue: state.fileNamingValue,
589
+ maxFileLines: state.maxFileLines,
590
+ testCoverage: state.testCoverage,
591
+ coverageSummaryPath: state.coverageSummaryPath,
592
+ coverageCommand: state.coverageCommand
593
+ });
594
+ const lines = packageDiffs.map((entry) => `${entry.pkg.path}
595
+ ${entry.diffs.join(", ")}`);
596
+ if (lines.length > 0) {
597
+ clack3.note(lines.join("\n\n"), "Existing package differences");
598
+ }
599
+ }
600
+ return;
601
+ }
602
+ }
603
+
604
+ // src/utils/prompt-rules.ts
605
+ async function promptRuleMenu(defaults) {
606
+ const state = {
607
+ ...defaults,
608
+ packageOverrides: clonePackages(defaults.packageOverrides)
609
+ };
610
+ const root = state.packageOverrides && state.packageOverrides.length > 0 ? getRootPackage(state.packageOverrides) : void 0;
611
+ const packageCount = state.packageOverrides?.filter((pkg) => pkg.path !== ".").length ?? 0;
612
+ while (true) {
613
+ const options = buildMenuOptions(state, packageCount);
614
+ const choice = await clack4.select({ message: "Customize rules", options });
615
+ assertNotCancelled(choice);
616
+ if (choice === "done") break;
617
+ await handleMenuChoice(choice, state, defaults, root);
618
+ }
619
+ return {
620
+ maxFileLines: state.maxFileLines,
621
+ maxTestFileLines: state.maxTestFileLines,
622
+ testCoverage: state.testCoverage,
623
+ enforceMissingTests: state.enforceMissingTests,
624
+ enforceNaming: state.enforceNaming,
625
+ fileNamingValue: state.fileNamingValue,
626
+ componentNaming: state.componentNaming,
627
+ hookNaming: state.hookNaming,
628
+ importAlias: state.importAlias,
629
+ coverageSummaryPath: state.coverageSummaryPath,
630
+ coverageCommand: state.coverageCommand,
631
+ packageOverrides: state.packageOverrides
632
+ };
633
+ }
634
+
635
+ // src/utils/prompt.ts
636
+ function assertNotCancelled(value) {
637
+ if (clack5.isCancel(value)) {
638
+ clack5.cancel("Setup cancelled.");
639
+ process.exit(0);
640
+ }
641
+ }
642
+ function isCancelled(value) {
643
+ return clack5.isCancel(value);
644
+ }
645
+ async function confirm3(message) {
646
+ const result = await clack5.confirm({ message, initialValue: true });
647
+ assertNotCancelled(result);
648
+ return result;
649
+ }
650
+ async function confirmDangerous(message) {
651
+ const result = await clack5.confirm({ message, initialValue: false });
652
+ assertNotCancelled(result);
653
+ return result;
654
+ }
655
+ async function promptExistingConfigAction(configFile) {
656
+ const result = await clack5.select({
657
+ message: `${configFile} already exists. What do you want to do?`,
658
+ options: [
659
+ {
660
+ value: "edit",
661
+ label: "Edit existing config",
662
+ hint: "open the current rules and save updates in place"
663
+ },
664
+ {
665
+ value: "replace",
666
+ label: "Replace with a fresh scan",
667
+ hint: "re-scan the project and overwrite the current config"
668
+ },
669
+ {
670
+ value: "cancel",
671
+ label: "Cancel",
672
+ hint: "leave the current setup unchanged"
673
+ }
674
+ ]
675
+ });
676
+ assertNotCancelled(result);
677
+ return result;
678
+ }
679
+
41
680
  // src/utils/resolve-workspace-packages.ts
42
681
  import * as fs2 from "fs";
43
682
  import * as path2 from "path";
@@ -142,7 +781,7 @@ ${chalk.bold("Inferred boundary rules:")}
142
781
  console.log(`
143
782
  ${totalRules} denied`);
144
783
  console.log("");
145
- const shouldSave = await confirm("Save to viberails.config.json?");
784
+ const shouldSave = await confirm3("Save to viberails.config.json?");
146
785
  if (shouldSave) {
147
786
  config.boundaries = inferred;
148
787
  config.rules.enforceBoundaries = true;
@@ -741,9 +1380,9 @@ async function checkCommand(options, cwd) {
741
1380
  }
742
1381
  const violations = [];
743
1382
  const severity = options.enforce ? "error" : "warn";
744
- const log5 = options.format !== "json" && !options.hook && !options.quiet ? (msg) => process.stderr.write(chalk3.dim(msg)) : () => {
1383
+ const log9 = options.format !== "json" && !options.hook && !options.quiet ? (msg) => process.stderr.write(chalk3.dim(msg)) : () => {
745
1384
  };
746
- log5(" Checking files...");
1385
+ log9(" Checking files...");
747
1386
  for (const file of filesToCheck) {
748
1387
  const absPath = path7.isAbsolute(file) ? file : path7.join(projectRoot, file);
749
1388
  const relPath = path7.relative(projectRoot, absPath);
@@ -776,9 +1415,9 @@ async function checkCommand(options, cwd) {
776
1415
  }
777
1416
  }
778
1417
  }
779
- log5(" done\n");
1418
+ log9(" done\n");
780
1419
  if (!options.files) {
781
- log5(" Checking missing tests...");
1420
+ log9(" Checking missing tests...");
782
1421
  const testViolations = checkMissingTests(projectRoot, config, severity);
783
1422
  if (options.staged) {
784
1423
  const stagedSet = new Set(filesToCheck);
@@ -791,14 +1430,14 @@ async function checkCommand(options, cwd) {
791
1430
  } else {
792
1431
  violations.push(...testViolations);
793
1432
  }
794
- log5(" done\n");
1433
+ log9(" done\n");
795
1434
  }
796
1435
  if (!options.files && !options.staged && !options.diffBase) {
797
- log5(" Running test coverage...\n");
1436
+ log9(" Running test coverage...\n");
798
1437
  const coverageViolations = checkCoverage(projectRoot, config, filesToCheck, {
799
1438
  staged: options.staged,
800
1439
  enforce: options.enforce,
801
- onProgress: (pkg) => log5(` Coverage: ${pkg}...
1440
+ onProgress: (pkg) => log9(` Coverage: ${pkg}...
802
1441
  `)
803
1442
  });
804
1443
  violations.push(...coverageViolations);
@@ -823,7 +1462,7 @@ async function checkCommand(options, cwd) {
823
1462
  severity
824
1463
  });
825
1464
  }
826
- log5(` Boundary check: ${graph.nodes.length} files in ${Date.now() - startTime}ms
1465
+ log9(` Boundary check: ${graph.nodes.length} files in ${Date.now() - startTime}ms
827
1466
  `);
828
1467
  }
829
1468
  if (options.format === "json") {
@@ -899,7 +1538,7 @@ async function hookCheckCommand(cwd) {
899
1538
  // src/commands/config.ts
900
1539
  import * as fs10 from "fs";
901
1540
  import * as path9 from "path";
902
- import * as clack from "@clack/prompts";
1541
+ import * as clack6 from "@clack/prompts";
903
1542
  import { compactConfig as compactConfig2, loadConfig as loadConfig3, mergeConfig } from "@viberails/config";
904
1543
  import { scan } from "@viberails/scanner";
905
1544
  import chalk6 from "chalk";
@@ -1544,11 +2183,11 @@ async function configCommand(options, cwd) {
1544
2183
  return;
1545
2184
  }
1546
2185
  if (!options.suppressIntro) {
1547
- clack.intro("viberails config");
2186
+ clack6.intro("viberails config");
1548
2187
  }
1549
2188
  const config = await loadConfig3(configPath);
1550
2189
  let scanResult = options.rescan ? await rescanAndMerge(projectRoot, config) : void 0;
1551
- clack.note(formatRulesText(config).join("\n"), "Current rules");
2190
+ clack6.note(formatRulesText(config).join("\n"), "Current rules");
1552
2191
  const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
1553
2192
  const overrides = await promptRuleMenu({
1554
2193
  maxFileLines: config.rules.maxFileLines,
@@ -1566,9 +2205,9 @@ async function configCommand(options, cwd) {
1566
2205
  });
1567
2206
  applyRuleOverrides(config, overrides);
1568
2207
  if (options.rescan && config.packages.length > 1) {
1569
- const shouldInfer = await confirm("Re-infer boundary rules from import patterns?");
2208
+ const shouldInfer = await confirm3("Re-infer boundary rules from import patterns?");
1570
2209
  if (shouldInfer) {
1571
- const bs = clack.spinner();
2210
+ const bs = clack6.spinner();
1572
2211
  bs.start("Building import graph...");
1573
2212
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
1574
2213
  const packages = resolveWorkspacePackages(projectRoot, config.packages);
@@ -1584,31 +2223,31 @@ async function configCommand(options, cwd) {
1584
2223
  }
1585
2224
  }
1586
2225
  }
1587
- const shouldWrite = await confirm("Save updated configuration?");
2226
+ const shouldWrite = await confirm3("Save updated configuration?");
1588
2227
  if (!shouldWrite) {
1589
- clack.outro("No changes written.");
2228
+ clack6.outro("No changes written.");
1590
2229
  return;
1591
2230
  }
1592
2231
  const compacted = compactConfig2(config);
1593
2232
  fs10.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
1594
2233
  `);
1595
2234
  if (!scanResult) {
1596
- const s = clack.spinner();
2235
+ const s = clack6.spinner();
1597
2236
  s.start("Scanning for context generation...");
1598
2237
  scanResult = await scan(projectRoot);
1599
2238
  s.stop("Scan complete");
1600
2239
  }
1601
2240
  writeGeneratedFiles(projectRoot, config, scanResult);
1602
- clack.log.success(
2241
+ clack6.log.success(
1603
2242
  `Updated:
1604
2243
  ${CONFIG_FILE3}
1605
2244
  .viberails/context.md
1606
2245
  .viberails/scan-result.json`
1607
2246
  );
1608
- clack.outro("Done! Run viberails check to verify.");
2247
+ clack6.outro("Done! Run viberails check to verify.");
1609
2248
  }
1610
2249
  async function rescanAndMerge(projectRoot, config) {
1611
- const s = clack.spinner();
2250
+ const s = clack6.spinner();
1612
2251
  s.start("Re-scanning project...");
1613
2252
  const scanResult = await scan(projectRoot);
1614
2253
  const merged = mergeConfig(config, scanResult);
@@ -1619,9 +2258,9 @@ async function rescanAndMerge(projectRoot, config) {
1619
2258
  const icon = c.type === "removed" ? "-" : "+";
1620
2259
  return `${icon} ${c.description}`;
1621
2260
  }).join("\n");
1622
- clack.note(changeLines, "Changes detected");
2261
+ clack6.note(changeLines, "Changes detected");
1623
2262
  } else {
1624
- clack.log.info("No new changes detected from scan.");
2263
+ clack6.log.info("No new changes detected from scan.");
1625
2264
  }
1626
2265
  Object.assign(config, merged);
1627
2266
  return scanResult;
@@ -2115,159 +2754,42 @@ ${chalk8.yellow("!")} No safe fixes to apply. Resolve aliased imports first.`);
2115
2754
  }
2116
2755
 
2117
2756
  // src/commands/init.ts
2118
- import * as fs20 from "fs";
2119
- import * as path20 from "path";
2120
- import * as clack4 from "@clack/prompts";
2757
+ import * as fs21 from "fs";
2758
+ import * as path21 from "path";
2759
+ import * as clack13 from "@clack/prompts";
2121
2760
  import { compactConfig as compactConfig4, generateConfig as generateConfig2 } from "@viberails/config";
2122
2761
  import { scan as scan3 } from "@viberails/scanner";
2123
2762
  import chalk14 from "chalk";
2124
2763
 
2125
- // src/display-init.ts
2126
- import { FRAMEWORK_NAMES as FRAMEWORK_NAMES5, STYLING_NAMES as STYLING_NAMES5 } from "@viberails/types";
2764
+ // src/utils/check-prerequisites.ts
2765
+ import * as fs14 from "fs";
2766
+ import * as path14 from "path";
2767
+ import * as clack7 from "@clack/prompts";
2127
2768
  import chalk9 from "chalk";
2128
- var INIT_OVERVIEW_NAMES = {
2129
- typescript: "TypeScript",
2130
- javascript: "JavaScript",
2131
- eslint: "ESLint",
2132
- prettier: "Prettier",
2133
- jest: "Jest",
2134
- vitest: "Vitest",
2135
- biome: "Biome"
2136
- };
2137
- function formatDetectedOverview(scanResult) {
2138
- const { stack } = scanResult;
2139
- const primaryParts = [];
2140
- const secondaryParts = [];
2141
- const formatOverviewItem = (item, nameMap) => formatItem(item, { ...INIT_OVERVIEW_NAMES, ...nameMap });
2142
- if (scanResult.packages.length > 1) {
2143
- primaryParts.push("monorepo");
2144
- primaryParts.push(`${scanResult.packages.length} packages`);
2145
- } else if (stack.framework) {
2146
- primaryParts.push(formatItem(stack.framework, FRAMEWORK_NAMES5));
2147
- } else {
2148
- primaryParts.push("single package");
2149
- }
2150
- primaryParts.push(formatOverviewItem(stack.language));
2151
- if (stack.styling) {
2152
- primaryParts.push(formatOverviewItem(stack.styling, STYLING_NAMES5));
2153
- }
2154
- if (stack.packageManager) secondaryParts.push(formatOverviewItem(stack.packageManager));
2155
- if (stack.linter) secondaryParts.push(formatOverviewItem(stack.linter));
2156
- if (stack.formatter) secondaryParts.push(formatOverviewItem(stack.formatter));
2157
- if (stack.testRunner) secondaryParts.push(formatOverviewItem(stack.testRunner));
2158
- const primary = primaryParts.map((part) => chalk9.cyan(part)).join(chalk9.dim(" \xB7 "));
2159
- const secondary = secondaryParts.join(chalk9.dim(" \xB7 "));
2160
- return secondary ? `${primary}
2161
- ${chalk9.dim(secondary)}` : primary;
2162
- }
2163
- function displayInitOverview(scanResult, config, exemptedPackages) {
2164
- const root = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2165
- const isMonorepo = config.packages.length > 1;
2166
- const ok = chalk9.green("\u2713");
2167
- const info = chalk9.yellow("~");
2168
- console.log("");
2169
- console.log(` ${chalk9.bold("Ready to initialize:")}`);
2170
- console.log(` ${formatDetectedOverview(scanResult)}`);
2171
- console.log("");
2172
- console.log(` ${chalk9.bold("Rules to apply:")}`);
2173
- console.log(` ${ok} Max file size: ${chalk9.cyan(`${config.rules.maxFileLines} lines`)}`);
2174
- const fileNaming = root?.conventions?.fileNaming ?? config.packages.find((p) => p.conventions?.fileNaming)?.conventions?.fileNaming;
2175
- if (config.rules.enforceNaming && fileNaming) {
2176
- console.log(` ${ok} File naming: ${chalk9.cyan(fileNaming)}`);
2177
- } else {
2178
- console.log(` ${info} File naming: ${chalk9.dim("not enforced")}`);
2179
- }
2180
- const testPattern = root?.structure?.testPattern ?? config.packages.find((p) => p.structure?.testPattern)?.structure?.testPattern;
2181
- if (config.rules.enforceMissingTests && testPattern) {
2182
- console.log(` ${ok} Missing tests: ${chalk9.cyan(`enforced (${testPattern})`)}`);
2183
- } else if (config.rules.enforceMissingTests) {
2184
- console.log(` ${ok} Missing tests: ${chalk9.cyan("enforced")}`);
2185
- } else {
2186
- console.log(` ${info} Missing tests: ${chalk9.dim("not enforced")}`);
2187
- }
2188
- if (config.rules.testCoverage > 0) {
2189
- if (isMonorepo) {
2190
- const withCoverage = config.packages.filter(
2191
- (p) => (p.rules?.testCoverage ?? config.rules.testCoverage) > 0
2192
- );
2193
- console.log(
2194
- ` ${ok} Coverage: ${chalk9.cyan(`${config.rules.testCoverage}%`)} default ${chalk9.dim(`(${withCoverage.length}/${config.packages.length} packages)`)}`
2195
- );
2196
- } else {
2197
- console.log(` ${ok} Coverage: ${chalk9.cyan(`${config.rules.testCoverage}%`)}`);
2198
- }
2199
- } else {
2200
- console.log(` ${info} Coverage: ${chalk9.dim("disabled")}`);
2201
- }
2202
- if (exemptedPackages.length > 0) {
2203
- console.log(
2204
- ` ${chalk9.dim(" exempted:")} ${chalk9.dim(exemptedPackages.join(", "))} ${chalk9.dim("(types-only)")}`
2205
- );
2206
- }
2207
- console.log("");
2208
- console.log(` ${chalk9.bold("Also available:")}`);
2209
- if (isMonorepo) {
2210
- console.log(` ${info} Infer boundaries from current imports`);
2211
- }
2212
- console.log(` ${info} Set up hooks, Claude integration, and CI checks`);
2213
- console.log(
2214
- `
2215
- ${chalk9.dim("Defaults warn locally. Use --enforce in CI when you want failures to block.")}`
2216
- );
2217
- console.log("");
2218
- }
2219
- function summarizeSelectedIntegrations(integrations, opts) {
2220
- const lines = [];
2221
- if (opts.hasBoundaries) {
2222
- lines.push("\u2713 Boundary rules: inferred from current imports");
2223
- } else {
2224
- lines.push("~ Boundary rules: not enabled");
2225
- }
2226
- if (opts.hasCoverage) {
2227
- lines.push("\u2713 Coverage checks: enabled");
2228
- } else {
2229
- lines.push("~ Coverage checks: disabled");
2230
- }
2231
- const selectedIntegrations = [
2232
- integrations.preCommitHook ? "pre-commit hook" : void 0,
2233
- integrations.typecheckHook ? "typecheck" : void 0,
2234
- integrations.lintHook ? "lint check" : void 0,
2235
- integrations.claudeCodeHook ? "Claude Code hook" : void 0,
2236
- integrations.claudeMdRef ? "CLAUDE.md reference" : void 0,
2237
- integrations.githubAction ? "GitHub Actions workflow" : void 0
2238
- ].filter(Boolean);
2239
- if (selectedIntegrations.length > 0) {
2240
- lines.push(`\u2713 Integrations: ${selectedIntegrations.join(" \xB7 ")}`);
2241
- } else {
2242
- lines.push("~ Integrations: none selected");
2243
- }
2244
- return lines;
2245
- }
2246
- function displaySetupPlan(config, integrations, opts = {}) {
2247
- const configFile = opts.configFile ?? "viberails.config.json";
2248
- const lines = summarizeSelectedIntegrations(integrations, {
2249
- hasBoundaries: config.rules.enforceBoundaries,
2250
- hasCoverage: config.rules.testCoverage > 0
2769
+
2770
+ // src/utils/spawn-async.ts
2771
+ import { spawn } from "child_process";
2772
+ function spawnAsync(command, cwd) {
2773
+ return new Promise((resolve4) => {
2774
+ const child = spawn(command, { cwd, shell: true, stdio: "pipe" });
2775
+ let stdout = "";
2776
+ let stderr = "";
2777
+ child.stdout.on("data", (d) => {
2778
+ stdout += d.toString();
2779
+ });
2780
+ child.stderr.on("data", (d) => {
2781
+ stderr += d.toString();
2782
+ });
2783
+ child.on("close", (status) => {
2784
+ resolve4({ status, stdout, stderr });
2785
+ });
2786
+ child.on("error", () => {
2787
+ resolve4({ status: 1, stdout, stderr });
2788
+ });
2251
2789
  });
2252
- console.log("");
2253
- console.log(` ${chalk9.bold("Ready to write:")}`);
2254
- console.log(
2255
- ` ${opts.replacingExistingConfig ? chalk9.yellow("!") : chalk9.green("\u2713")} ${configFile}${opts.replacingExistingConfig ? chalk9.dim(" (replacing existing config)") : ""}`
2256
- );
2257
- console.log(` ${chalk9.green("\u2713")} .viberails/context.md`);
2258
- console.log(` ${chalk9.green("\u2713")} .viberails/scan-result.json`);
2259
- for (const line of lines) {
2260
- const icon = line.startsWith("\u2713") ? chalk9.green("\u2713") : chalk9.yellow("~");
2261
- console.log(` ${icon} ${line.slice(2)}`);
2262
- }
2263
- console.log("");
2264
2790
  }
2265
2791
 
2266
2792
  // src/utils/check-prerequisites.ts
2267
- import * as fs14 from "fs";
2268
- import * as path14 from "path";
2269
- import * as clack2 from "@clack/prompts";
2270
- import chalk10 from "chalk";
2271
2793
  function checkCoveragePrereqs(projectRoot, scanResult) {
2272
2794
  const pm = scanResult.stack.packageManager.name;
2273
2795
  const vitestPackages = scanResult.packages.filter((pkg) => pkg.stack.testRunner?.name === "vitest").map((pkg) => pkg.relativePath);
@@ -2298,113 +2820,572 @@ function displayMissingPrereqs(prereqs) {
2298
2820
  const missing = prereqs.filter((p) => !p.installed);
2299
2821
  for (const m of missing) {
2300
2822
  const suffix = m.affectedPackages ? ` \u2014 needed for coverage in: ${m.affectedPackages.join(", ")}` : ` \u2014 ${m.reason}`;
2301
- console.log(` ${chalk10.yellow("!")} ${m.label} not installed${suffix}`);
2823
+ console.log(` ${chalk9.yellow("!")} ${m.label} not installed${suffix}`);
2302
2824
  if (m.installCommand) {
2303
- console.log(` Install: ${chalk10.cyan(m.installCommand)}`);
2825
+ console.log(` Install: ${chalk9.cyan(m.installCommand)}`);
2304
2826
  }
2305
2827
  }
2306
2828
  }
2307
- async function promptMissingPrereqs(projectRoot, prereqs) {
2308
- const missing = prereqs.filter((p) => !p.installed);
2309
- if (missing.length === 0) return { disableCoverage: false };
2310
- const prereqLines = prereqs.map((p) => {
2311
- if (p.installed) return `\u2713 ${p.label}`;
2312
- const detail = p.affectedPackages ? `needed by: ${p.affectedPackages.join(", ")}` : p.reason;
2313
- return `\u2717 ${p.label} \u2014 ${detail}`;
2314
- }).join("\n");
2315
- clack2.note(prereqLines, "Coverage support");
2316
- let disableCoverage = false;
2317
- for (const m of missing) {
2318
- if (!m.installCommand) continue;
2319
- const pkgCount = m.affectedPackages?.length;
2320
- const message = pkgCount ? `${m.label} is not installed. Required for coverage in ${pkgCount} packages using vitest.` : `${m.label} is not installed. It is required for coverage percentage checks.`;
2321
- const choice = await clack2.select({
2322
- message,
2829
+ function planCoverageInstall(prereqs) {
2830
+ const missing = prereqs.find((p) => !p.installed && p.installCommand);
2831
+ if (!missing?.installCommand) return void 0;
2832
+ return {
2833
+ label: missing.label,
2834
+ command: missing.installCommand
2835
+ };
2836
+ }
2837
+ function hasDependency(projectRoot, name) {
2838
+ try {
2839
+ const pkgPath = path14.join(projectRoot, "package.json");
2840
+ const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf-8"));
2841
+ return !!(pkg.devDependencies?.[name] || pkg.dependencies?.[name]);
2842
+ } catch {
2843
+ return false;
2844
+ }
2845
+ }
2846
+
2847
+ // src/utils/deferred-install.ts
2848
+ import * as clack8 from "@clack/prompts";
2849
+ async function executeDeferredInstalls(projectRoot, installs) {
2850
+ if (installs.length === 0) return 0;
2851
+ let successCount = 0;
2852
+ for (const install of installs) {
2853
+ const s = clack8.spinner();
2854
+ s.start(`Installing ${install.label}...`);
2855
+ const result = await spawnAsync(install.command, projectRoot);
2856
+ if (result.status === 0) {
2857
+ s.stop(`Installed ${install.label}`);
2858
+ install.onSuccess?.();
2859
+ successCount++;
2860
+ } else {
2861
+ s.stop(`Failed to install ${install.label}`);
2862
+ clack8.log.warn(`Install manually: ${install.command}`);
2863
+ install.onFailure?.();
2864
+ }
2865
+ }
2866
+ return successCount;
2867
+ }
2868
+
2869
+ // src/utils/prompt-main-menu.ts
2870
+ import * as clack11 from "@clack/prompts";
2871
+
2872
+ // src/utils/prompt-main-menu-handlers.ts
2873
+ import * as clack10 from "@clack/prompts";
2874
+
2875
+ // src/utils/prompt-integrations.ts
2876
+ import * as fs15 from "fs";
2877
+ import * as path15 from "path";
2878
+ import * as clack9 from "@clack/prompts";
2879
+ function buildLefthookInstallCommand(pm, isWorkspace) {
2880
+ if (pm === "yarn") return "yarn add -D lefthook";
2881
+ if (pm === "pnpm") return `pnpm add -D${isWorkspace ? " -w" : ""} lefthook`;
2882
+ if (pm === "npm") return "npm install -D lefthook";
2883
+ return `${pm} add -D lefthook`;
2884
+ }
2885
+ async function promptIntegrationsDeferred(hookManager, tools, packageManager, isWorkspace, projectRoot) {
2886
+ const options = [];
2887
+ const needsLefthook = !hookManager;
2888
+ if (needsLefthook) {
2889
+ const pm = packageManager ?? "npm";
2890
+ options.push({
2891
+ value: "installLefthook",
2892
+ label: "Install Lefthook",
2893
+ hint: `after final confirmation \u2014 ${buildLefthookInstallCommand(pm, isWorkspace)}`
2894
+ });
2895
+ }
2896
+ const hookLabel = hookManager ? `Pre-commit hook (${hookManager})` : "Pre-commit hook";
2897
+ const hookHint = needsLefthook ? "uses Lefthook if installed above, otherwise local git hook" : "runs viberails checks when you commit";
2898
+ options.push({ value: "preCommit", label: hookLabel, hint: hookHint });
2899
+ if (tools?.isTypeScript) {
2900
+ options.push({
2901
+ value: "typecheck",
2902
+ label: "Typecheck (tsc --noEmit)",
2903
+ hint: "pre-commit hook + CI check"
2904
+ });
2905
+ }
2906
+ if (tools?.linter) {
2907
+ const linterName = tools.linter === "biome" ? "Biome" : "ESLint";
2908
+ options.push({
2909
+ value: "lint",
2910
+ label: `Lint check (${linterName})`,
2911
+ hint: "pre-commit hook + CI check"
2912
+ });
2913
+ }
2914
+ options.push(
2915
+ {
2916
+ value: "claude",
2917
+ label: "Claude Code hook",
2918
+ hint: "checks files when Claude edits them"
2919
+ },
2920
+ {
2921
+ value: "claudeMd",
2922
+ label: "CLAUDE.md reference",
2923
+ hint: "appends @.viberails/context.md so Claude loads rules automatically"
2924
+ },
2925
+ {
2926
+ value: "githubAction",
2927
+ label: "GitHub Actions workflow",
2928
+ hint: "blocks PRs that fail viberails check"
2929
+ }
2930
+ );
2931
+ const initialValues = options.map((o) => o.value);
2932
+ const result = await clack9.multiselect({
2933
+ message: "Integrations",
2934
+ options,
2935
+ initialValues,
2936
+ required: false
2937
+ });
2938
+ assertNotCancelled(result);
2939
+ let lefthookInstall;
2940
+ if (needsLefthook && result.includes("installLefthook")) {
2941
+ const pm = packageManager ?? "npm";
2942
+ lefthookInstall = {
2943
+ label: "Lefthook",
2944
+ command: buildLefthookInstallCommand(pm, isWorkspace),
2945
+ onSuccess: projectRoot ? () => {
2946
+ const ymlPath = path15.join(projectRoot, "lefthook.yml");
2947
+ if (!fs15.existsSync(ymlPath)) {
2948
+ fs15.writeFileSync(ymlPath, "# Generated by viberails\n");
2949
+ }
2950
+ } : void 0
2951
+ };
2952
+ }
2953
+ return {
2954
+ choice: {
2955
+ preCommitHook: result.includes("preCommit"),
2956
+ claudeCodeHook: result.includes("claude"),
2957
+ claudeMdRef: result.includes("claudeMd"),
2958
+ githubAction: result.includes("githubAction"),
2959
+ typecheckHook: result.includes("typecheck"),
2960
+ lintHook: result.includes("lint")
2961
+ },
2962
+ lefthookInstall
2963
+ };
2964
+ }
2965
+
2966
+ // src/utils/prompt-main-menu-handlers.ts
2967
+ async function handleAdvancedNaming(config) {
2968
+ const rootPkg = getRootPackage(config.packages);
2969
+ const state = {
2970
+ maxFileLines: config.rules.maxFileLines,
2971
+ maxTestFileLines: config.rules.maxTestFileLines,
2972
+ testCoverage: config.rules.testCoverage,
2973
+ enforceMissingTests: config.rules.enforceMissingTests,
2974
+ enforceNaming: config.rules.enforceNaming,
2975
+ fileNamingValue: rootPkg.conventions?.fileNaming,
2976
+ componentNaming: rootPkg.conventions?.componentNaming,
2977
+ hookNaming: rootPkg.conventions?.hookNaming,
2978
+ importAlias: rootPkg.conventions?.importAlias,
2979
+ coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
2980
+ coverageCommand: config.defaults?.coverage?.command
2981
+ };
2982
+ await promptNamingMenu(state);
2983
+ rootPkg.conventions = rootPkg.conventions ?? {};
2984
+ config.rules.enforceNaming = state.enforceNaming;
2985
+ if (state.fileNamingValue) {
2986
+ rootPkg.conventions.fileNaming = state.fileNamingValue;
2987
+ } else {
2988
+ delete rootPkg.conventions.fileNaming;
2989
+ }
2990
+ rootPkg.conventions.componentNaming = state.componentNaming || void 0;
2991
+ rootPkg.conventions.hookNaming = state.hookNaming || void 0;
2992
+ rootPkg.conventions.importAlias = state.importAlias || void 0;
2993
+ }
2994
+ async function handleFileNaming(config, scanResult) {
2995
+ const isMonorepo = config.packages.length > 1;
2996
+ if (isMonorepo) {
2997
+ const pkgData = scanResult.packages.filter((p) => p.conventions.fileNaming && p.conventions.fileNaming.confidence !== "low").map((p) => ({
2998
+ path: p.relativePath,
2999
+ naming: p.conventions.fileNaming
3000
+ }));
3001
+ if (pkgData.length > 0) {
3002
+ const lines = pkgData.map(
3003
+ (p) => `${p.path}: ${p.naming.value} (${Math.round(p.naming.consistency)}%)`
3004
+ );
3005
+ clack10.note(lines.join("\n"), "Per-package file naming detected");
3006
+ }
3007
+ }
3008
+ const namingOptions = FILE_NAMING_OPTIONS.map((opt) => {
3009
+ if (isMonorepo) {
3010
+ const pkgs = scanResult.packages.filter((p) => p.conventions.fileNaming?.value === opt.value);
3011
+ const hint = pkgs.length > 0 ? `${pkgs.length} package${pkgs.length > 1 ? "s" : ""}` : void 0;
3012
+ return { value: opt.value, label: opt.label, hint };
3013
+ }
3014
+ return { value: opt.value, label: opt.label };
3015
+ });
3016
+ const rootPkg = getRootPackage(config.packages);
3017
+ const selected = await clack10.select({
3018
+ message: isMonorepo ? "Default file naming convention" : "File naming convention",
3019
+ options: [...namingOptions, { value: SENTINEL_SKIP, label: "Don't enforce" }],
3020
+ initialValue: rootPkg.conventions?.fileNaming ?? SENTINEL_SKIP
3021
+ });
3022
+ if (isCancelled(selected)) return;
3023
+ if (selected === SENTINEL_SKIP) {
3024
+ config.rules.enforceNaming = false;
3025
+ if (rootPkg.conventions) delete rootPkg.conventions.fileNaming;
3026
+ } else {
3027
+ config.rules.enforceNaming = true;
3028
+ rootPkg.conventions = rootPkg.conventions ?? {};
3029
+ rootPkg.conventions.fileNaming = selected;
3030
+ }
3031
+ }
3032
+ async function handleMissingTests(config) {
3033
+ const result = await clack10.confirm({
3034
+ message: "Require every source file to have a test file?",
3035
+ initialValue: config.rules.enforceMissingTests
3036
+ });
3037
+ if (isCancelled(result)) return;
3038
+ config.rules.enforceMissingTests = result;
3039
+ }
3040
+ async function handleCoverage(config, state, opts) {
3041
+ if (!opts.hasTestRunner) {
3042
+ clack10.note(
3043
+ "No test runner (vitest, jest, etc.) was detected.\nInstall one, then re-run viberails init to configure coverage.",
3044
+ "Coverage inactive"
3045
+ );
3046
+ return;
3047
+ }
3048
+ const planned = planCoverageInstall(opts.coveragePrereqs);
3049
+ if (planned) {
3050
+ const choice = await clack10.select({
3051
+ message: `${planned.label} is not installed. Needed for coverage checks.`,
2323
3052
  options: [
2324
3053
  {
2325
3054
  value: "install",
2326
- label: "Install now",
2327
- hint: m.installCommand
2328
- },
2329
- {
2330
- value: "disable",
2331
- label: "Disable coverage checks",
2332
- hint: "missing-test checks still stay active"
3055
+ label: "Install (after final confirmation)",
3056
+ hint: planned.command
2333
3057
  },
3058
+ { value: "disable", label: "Disable coverage checks" },
2334
3059
  {
2335
3060
  value: "skip",
2336
3061
  label: "Skip for now",
2337
- hint: `install later: ${m.installCommand}`
3062
+ hint: `install later: ${planned.command}`
2338
3063
  }
2339
3064
  ]
2340
3065
  });
2341
- assertNotCancelled(choice);
3066
+ if (isCancelled(choice)) return;
3067
+ state.deferredInstalls = state.deferredInstalls.filter((d) => d.command !== planned.command);
2342
3068
  if (choice === "install") {
2343
- const is = clack2.spinner();
2344
- is.start(`Installing ${m.label}...`);
2345
- const result = await spawnAsync(m.installCommand, projectRoot);
2346
- if (result.status === 0) {
2347
- is.stop(`Installed ${m.label}`);
2348
- } else {
2349
- is.stop(`Failed to install ${m.label}`);
2350
- clack2.log.warn(
2351
- `Install manually: ${m.installCommand}
2352
- Coverage percentage checks will not work until the dependency is installed.`
2353
- );
2354
- }
3069
+ planned.onFailure = () => {
3070
+ config.rules.testCoverage = 0;
3071
+ };
3072
+ state.deferredInstalls.push(planned);
2355
3073
  } else if (choice === "disable") {
2356
- disableCoverage = true;
2357
- clack2.log.info("Coverage percentage checks disabled. Missing-test checks remain active.");
2358
- } else {
2359
- clack2.log.info(
2360
- `Coverage percentage checks will fail until ${m.label} is installed.
2361
- Install later: ${m.installCommand}`
2362
- );
3074
+ config.rules.testCoverage = 0;
3075
+ return;
2363
3076
  }
2364
3077
  }
2365
- return { disableCoverage };
3078
+ const result = await clack10.text({
3079
+ message: "Test coverage target (0 = disable)?",
3080
+ initialValue: String(config.rules.testCoverage),
3081
+ validate: (v) => {
3082
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
3083
+ const n = Number.parseInt(v, 10);
3084
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
3085
+ }
3086
+ });
3087
+ if (isCancelled(result)) return;
3088
+ config.rules.testCoverage = Number.parseInt(result, 10);
2366
3089
  }
2367
- function hasDependency(projectRoot, name) {
3090
+ async function handlePackageOverrides(config) {
3091
+ const rootPkg = getRootPackage(config.packages);
3092
+ config.packages = await promptPackageOverrides(config.packages, {
3093
+ fileNamingValue: rootPkg.conventions?.fileNaming,
3094
+ maxFileLines: config.rules.maxFileLines,
3095
+ testCoverage: config.rules.testCoverage,
3096
+ coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
3097
+ coverageCommand: config.defaults?.coverage?.command
3098
+ });
3099
+ normalizePackageOverrides(config.packages);
3100
+ }
3101
+ async function handleBoundaries(config, state, opts) {
3102
+ const shouldInfer = await clack10.confirm({
3103
+ message: "Infer boundary rules from current import patterns?",
3104
+ initialValue: false
3105
+ });
3106
+ if (isCancelled(shouldInfer)) return;
3107
+ state.visited.boundaries = true;
3108
+ if (!shouldInfer) {
3109
+ config.rules.enforceBoundaries = false;
3110
+ return;
3111
+ }
3112
+ const bs = clack10.spinner();
3113
+ bs.start("Building import graph...");
2368
3114
  try {
2369
- const pkgPath = path14.join(projectRoot, "package.json");
2370
- const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf-8"));
2371
- return !!(pkg.devDependencies?.[name] || pkg.dependencies?.[name]);
2372
- } catch {
2373
- return false;
3115
+ const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
3116
+ const packages = resolveWorkspacePackages(opts.projectRoot, config.packages);
3117
+ const graph = await buildImportGraph(opts.projectRoot, { packages, ignore: config.ignore });
3118
+ const inferred = inferBoundaries(graph);
3119
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
3120
+ if (denyCount > 0) {
3121
+ config.boundaries = inferred;
3122
+ config.rules.enforceBoundaries = true;
3123
+ const pkgCount = Object.keys(inferred.deny).length;
3124
+ bs.stop(`Inferred ${denyCount} boundary rules across ${pkgCount} packages`);
3125
+ } else {
3126
+ bs.stop("No boundary rules inferred");
3127
+ }
3128
+ } catch (err) {
3129
+ bs.stop("Failed to build import graph");
3130
+ clack10.log.warn(`Boundary inference failed: ${err instanceof Error ? err.message : err}`);
3131
+ }
3132
+ }
3133
+ async function handleIntegrations(state, opts) {
3134
+ const result = await promptIntegrationsDeferred(
3135
+ state.hookManager,
3136
+ opts.tools,
3137
+ opts.tools.packageManager,
3138
+ opts.tools.isWorkspace,
3139
+ opts.projectRoot
3140
+ );
3141
+ state.visited.integrations = true;
3142
+ state.integrations = result.choice;
3143
+ state.deferredInstalls = state.deferredInstalls.filter((d) => !d.command.includes("lefthook"));
3144
+ if (result.lefthookInstall) {
3145
+ state.deferredInstalls.push(result.lefthookInstall);
2374
3146
  }
2375
3147
  }
2376
3148
 
3149
+ // src/utils/prompt-main-menu-hints.ts
3150
+ import chalk10 from "chalk";
3151
+ function fileLimitsHint(config) {
3152
+ const max = config.rules.maxFileLines;
3153
+ const test = config.rules.maxTestFileLines;
3154
+ return test > 0 ? `${max} lines, tests ${test}` : `${max} lines`;
3155
+ }
3156
+ function fileNamingHint(config, scanResult) {
3157
+ const rootPkg = getRootPackage(config.packages);
3158
+ const naming = rootPkg.conventions?.fileNaming;
3159
+ if (!config.rules.enforceNaming) return "not enforced";
3160
+ if (naming) {
3161
+ const detected = scanResult.packages.some(
3162
+ (p) => p.conventions.fileNaming?.value === naming && p.conventions.fileNaming.confidence === "high"
3163
+ );
3164
+ return detected ? `${naming} (detected)` : naming;
3165
+ }
3166
+ return "mixed \u2014 will not enforce if skipped";
3167
+ }
3168
+ function fileNamingStatus(config) {
3169
+ if (!config.rules.enforceNaming) return "disabled";
3170
+ const rootPkg = getRootPackage(config.packages);
3171
+ return rootPkg.conventions?.fileNaming ? "ok" : "needs-input";
3172
+ }
3173
+ function missingTestsHint(config) {
3174
+ if (!config.rules.enforceMissingTests) return "not enforced";
3175
+ const rootPkg = getRootPackage(config.packages);
3176
+ const pattern = rootPkg.structure?.testPattern;
3177
+ return pattern ? `enforced (${pattern})` : "enforced";
3178
+ }
3179
+ function coverageHint(config, hasTestRunner) {
3180
+ if (config.rules.testCoverage === 0) return "disabled";
3181
+ if (!hasTestRunner)
3182
+ return `${config.rules.testCoverage}% target (inactive \u2014 no test runner)`;
3183
+ const isMonorepo = config.packages.length > 1;
3184
+ if (isMonorepo) {
3185
+ const withCov = config.packages.filter(
3186
+ (p) => (p.rules?.testCoverage ?? config.rules.testCoverage) > 0
3187
+ );
3188
+ const exempt = config.packages.length - withCov.length;
3189
+ return exempt > 0 ? `${config.rules.testCoverage}% (${withCov.length}/${config.packages.length} packages, ${exempt} exempt)` : `${config.rules.testCoverage}%`;
3190
+ }
3191
+ return `${config.rules.testCoverage}%`;
3192
+ }
3193
+ function advancedNamingHint(config) {
3194
+ const rootPkg = getRootPackage(config.packages);
3195
+ const parts = [];
3196
+ if (rootPkg.conventions?.componentNaming)
3197
+ parts.push(`${rootPkg.conventions.componentNaming} components`);
3198
+ if (rootPkg.conventions?.hookNaming) parts.push(`${rootPkg.conventions.hookNaming} hooks`);
3199
+ if (rootPkg.conventions?.importAlias) parts.push(rootPkg.conventions.importAlias);
3200
+ return parts.length > 0 ? parts.join(", ") : "component, hook, and alias conventions";
3201
+ }
3202
+ function integrationsHint(state) {
3203
+ if (!state.visited.integrations || !state.integrations)
3204
+ return "not configured \u2014 select to set up";
3205
+ const items = [];
3206
+ if (state.integrations.preCommitHook) items.push("pre-commit");
3207
+ if (state.integrations.typecheckHook) items.push("typecheck");
3208
+ if (state.integrations.lintHook) items.push("lint");
3209
+ if (state.integrations.claudeCodeHook) items.push("Claude");
3210
+ if (state.integrations.claudeMdRef) items.push("CLAUDE.md");
3211
+ if (state.integrations.githubAction) items.push("CI");
3212
+ return items.length > 0 ? items.join(" \xB7 ") : "none selected";
3213
+ }
3214
+ function packageOverridesHint(config) {
3215
+ const rootNaming = getRootPackage(config.packages).conventions?.fileNaming;
3216
+ const editable = config.packages.filter((p) => p.path !== ".");
3217
+ const customized = editable.filter(
3218
+ (p) => p.rules || p.coverage || p.conventions?.fileNaming !== void 0 && p.conventions.fileNaming !== rootNaming
3219
+ ).length;
3220
+ return customized > 0 ? `${editable.length} packages (${customized} customized)` : `${editable.length} packages`;
3221
+ }
3222
+ function boundariesHint(config, state) {
3223
+ if (!state.visited.boundaries || !config.rules.enforceBoundaries) return "not enabled";
3224
+ const deny = config.boundaries?.deny;
3225
+ if (!deny) return "enabled";
3226
+ const ruleCount = Object.values(deny).reduce((s, a) => s + a.length, 0);
3227
+ const pkgCount = Object.keys(deny).length;
3228
+ return `${ruleCount} rules across ${pkgCount} packages`;
3229
+ }
3230
+ function advancedNamingStatus(config) {
3231
+ const rootPkg = getRootPackage(config.packages);
3232
+ const hasAny = !!rootPkg.conventions?.componentNaming || !!rootPkg.conventions?.hookNaming || !!rootPkg.conventions?.importAlias;
3233
+ return hasAny ? "ok" : "unconfigured";
3234
+ }
3235
+ function packageOverridesStatus(config) {
3236
+ const rootNaming = getRootPackage(config.packages).conventions?.fileNaming;
3237
+ const editable = config.packages.filter((p) => p.path !== ".");
3238
+ const customized = editable.some(
3239
+ (p) => p.rules || p.coverage || p.conventions?.fileNaming !== void 0 && p.conventions.fileNaming !== rootNaming
3240
+ );
3241
+ return customized ? "ok" : "unconfigured";
3242
+ }
3243
+ function statusIcon(status) {
3244
+ if (status === "ok") return chalk10.green("\u2713");
3245
+ if (status === "needs-input") return chalk10.yellow("?");
3246
+ if (status === "unconfigured") return chalk10.dim("-");
3247
+ return chalk10.yellow("~");
3248
+ }
3249
+ function buildMainMenuOptions(config, scanResult, state) {
3250
+ const namingStatus = fileNamingStatus(config);
3251
+ const coverageStatus = config.rules.testCoverage === 0 ? "disabled" : !state.hasTestRunner ? "disabled" : "ok";
3252
+ const missingTestsStatus = config.rules.enforceMissingTests ? "ok" : "disabled";
3253
+ const options = [
3254
+ {
3255
+ value: "fileLimits",
3256
+ label: `${statusIcon("ok")} Max file size`,
3257
+ hint: fileLimitsHint(config)
3258
+ },
3259
+ {
3260
+ value: "fileNaming",
3261
+ label: `${statusIcon(namingStatus)} Default file naming`,
3262
+ hint: fileNamingHint(config, scanResult)
3263
+ },
3264
+ {
3265
+ value: "missingTests",
3266
+ label: `${statusIcon(missingTestsStatus)} Missing tests`,
3267
+ hint: missingTestsHint(config)
3268
+ },
3269
+ {
3270
+ value: "coverage",
3271
+ label: `${statusIcon(coverageStatus)} Coverage`,
3272
+ hint: coverageHint(config, state.hasTestRunner)
3273
+ },
3274
+ {
3275
+ value: "advancedNaming",
3276
+ label: `${statusIcon(advancedNamingStatus(config))} Advanced naming`,
3277
+ hint: advancedNamingHint(config)
3278
+ }
3279
+ ];
3280
+ if (config.packages.length > 1) {
3281
+ const bIcon = statusIcon(
3282
+ state.visited.boundaries && config.rules.enforceBoundaries ? "ok" : "unconfigured"
3283
+ );
3284
+ const poIcon = statusIcon(packageOverridesStatus(config));
3285
+ options.push(
3286
+ {
3287
+ value: "packageOverrides",
3288
+ label: `${poIcon} Per-package overrides`,
3289
+ hint: packageOverridesHint(config)
3290
+ },
3291
+ { value: "boundaries", label: `${bIcon} Boundaries`, hint: boundariesHint(config, state) }
3292
+ );
3293
+ }
3294
+ const iIcon = state.visited.integrations ? statusIcon("ok") : statusIcon("unconfigured");
3295
+ options.push(
3296
+ { value: "integrations", label: `${iIcon} Integrations`, hint: integrationsHint(state) },
3297
+ { value: "reset", label: " Reset all to defaults" },
3298
+ { value: "review", label: " Review scan details" },
3299
+ { value: "done", label: " Done \u2014 write config" }
3300
+ );
3301
+ return options;
3302
+ }
3303
+
3304
+ // src/utils/prompt-main-menu.ts
3305
+ async function promptMainMenu(config, scanResult, opts) {
3306
+ const originalConfig = structuredClone(config);
3307
+ const state = {
3308
+ visited: { integrations: false, boundaries: false },
3309
+ deferredInstalls: [],
3310
+ hasTestRunner: opts.hasTestRunner,
3311
+ hookManager: opts.hookManager
3312
+ };
3313
+ while (true) {
3314
+ const options = buildMainMenuOptions(config, scanResult, state);
3315
+ const choice = await clack11.select({ message: "Configure viberails", options });
3316
+ assertNotCancelled(choice);
3317
+ if (choice === "done") {
3318
+ if (config.rules.enforceNaming && !getRootPackage(config.packages).conventions?.fileNaming) {
3319
+ config.rules.enforceNaming = false;
3320
+ }
3321
+ break;
3322
+ }
3323
+ if (choice === "fileLimits") {
3324
+ const s = {
3325
+ maxFileLines: config.rules.maxFileLines,
3326
+ maxTestFileLines: config.rules.maxTestFileLines
3327
+ };
3328
+ await promptFileLimitsMenu(s);
3329
+ config.rules.maxFileLines = s.maxFileLines;
3330
+ config.rules.maxTestFileLines = s.maxTestFileLines;
3331
+ }
3332
+ if (choice === "fileNaming") await handleFileNaming(config, scanResult);
3333
+ if (choice === "missingTests") await handleMissingTests(config);
3334
+ if (choice === "coverage") await handleCoverage(config, state, opts);
3335
+ if (choice === "advancedNaming") await handleAdvancedNaming(config);
3336
+ if (choice === "packageOverrides") await handlePackageOverrides(config);
3337
+ if (choice === "boundaries") await handleBoundaries(config, state, opts);
3338
+ if (choice === "integrations") await handleIntegrations(state, opts);
3339
+ if (choice === "review") clack11.note(formatScanResultsText(scanResult), "Scan details");
3340
+ if (choice === "reset") {
3341
+ const confirmed = await clack11.confirm({
3342
+ message: "Reset all settings to scan-detected defaults?",
3343
+ initialValue: false
3344
+ });
3345
+ assertNotCancelled(confirmed);
3346
+ if (confirmed) {
3347
+ Object.assign(config, structuredClone(originalConfig));
3348
+ state.deferredInstalls = [];
3349
+ state.visited = { integrations: false, boundaries: false };
3350
+ state.integrations = void 0;
3351
+ clack11.log.info("Reset all settings to scan-detected defaults.");
3352
+ }
3353
+ }
3354
+ }
3355
+ return state;
3356
+ }
3357
+
2377
3358
  // src/utils/update-gitignore.ts
2378
- import * as fs15 from "fs";
2379
- import * as path15 from "path";
3359
+ import * as fs16 from "fs";
3360
+ import * as path16 from "path";
2380
3361
  function updateGitignore(projectRoot) {
2381
- const gitignorePath = path15.join(projectRoot, ".gitignore");
3362
+ const gitignorePath = path16.join(projectRoot, ".gitignore");
2382
3363
  let content = "";
2383
- if (fs15.existsSync(gitignorePath)) {
2384
- content = fs15.readFileSync(gitignorePath, "utf-8");
3364
+ if (fs16.existsSync(gitignorePath)) {
3365
+ content = fs16.readFileSync(gitignorePath, "utf-8");
2385
3366
  }
2386
3367
  if (!content.includes(".viberails/scan-result.json")) {
2387
3368
  const block = "\n# viberails\n.viberails/scan-result.json\n";
2388
3369
  const prefix = content.length === 0 ? "" : `${content.trimEnd()}
2389
3370
  `;
2390
- fs15.writeFileSync(gitignorePath, `${prefix}${block}`);
3371
+ fs16.writeFileSync(gitignorePath, `${prefix}${block}`);
2391
3372
  }
2392
3373
  }
2393
3374
 
2394
3375
  // src/commands/init-hooks.ts
2395
- import * as fs17 from "fs";
2396
- import * as path17 from "path";
3376
+ import * as fs18 from "fs";
3377
+ import * as path18 from "path";
2397
3378
  import chalk11 from "chalk";
2398
3379
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
2399
3380
 
2400
3381
  // src/commands/resolve-typecheck.ts
2401
- import * as fs16 from "fs";
2402
- import * as path16 from "path";
3382
+ import * as fs17 from "fs";
3383
+ import * as path17 from "path";
2403
3384
  function hasTurboTask(projectRoot, taskName) {
2404
- const turboPath = path16.join(projectRoot, "turbo.json");
2405
- if (!fs16.existsSync(turboPath)) return false;
3385
+ const turboPath = path17.join(projectRoot, "turbo.json");
3386
+ if (!fs17.existsSync(turboPath)) return false;
2406
3387
  try {
2407
- const turbo = JSON.parse(fs16.readFileSync(turboPath, "utf-8"));
3388
+ const turbo = JSON.parse(fs17.readFileSync(turboPath, "utf-8"));
2408
3389
  const tasks = turbo.tasks ?? turbo.pipeline ?? {};
2409
3390
  return taskName in tasks;
2410
3391
  } catch {
@@ -2415,10 +3396,10 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2415
3396
  if (hasTurboTask(projectRoot, "typecheck")) {
2416
3397
  return { command: "npx turbo typecheck", label: "turbo typecheck" };
2417
3398
  }
2418
- const pkgJsonPath = path16.join(projectRoot, "package.json");
2419
- if (fs16.existsSync(pkgJsonPath)) {
3399
+ const pkgJsonPath = path17.join(projectRoot, "package.json");
3400
+ if (fs17.existsSync(pkgJsonPath)) {
2420
3401
  try {
2421
- const pkg = JSON.parse(fs16.readFileSync(pkgJsonPath, "utf-8"));
3402
+ const pkg = JSON.parse(fs17.readFileSync(pkgJsonPath, "utf-8"));
2422
3403
  if (pkg.scripts?.typecheck) {
2423
3404
  const pm = packageManager ?? "npm";
2424
3405
  return { command: `${pm} run typecheck`, label: `${pm} run typecheck` };
@@ -2426,7 +3407,7 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2426
3407
  } catch {
2427
3408
  }
2428
3409
  }
2429
- if (fs16.existsSync(path16.join(projectRoot, "tsconfig.json"))) {
3410
+ if (fs17.existsSync(path17.join(projectRoot, "tsconfig.json"))) {
2430
3411
  return { command: "npx tsc --noEmit", label: "tsc --noEmit" };
2431
3412
  }
2432
3413
  return {
@@ -2436,23 +3417,23 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2436
3417
 
2437
3418
  // src/commands/init-hooks.ts
2438
3419
  function setupPreCommitHook(projectRoot) {
2439
- const lefthookPath = path17.join(projectRoot, "lefthook.yml");
2440
- if (fs17.existsSync(lefthookPath)) {
3420
+ const lefthookPath = path18.join(projectRoot, "lefthook.yml");
3421
+ if (fs18.existsSync(lefthookPath)) {
2441
3422
  addLefthookPreCommit(lefthookPath);
2442
3423
  console.log(` ${chalk11.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
2443
3424
  return "lefthook.yml";
2444
3425
  }
2445
- const huskyDir = path17.join(projectRoot, ".husky");
2446
- if (fs17.existsSync(huskyDir)) {
3426
+ const huskyDir = path18.join(projectRoot, ".husky");
3427
+ if (fs18.existsSync(huskyDir)) {
2447
3428
  writeHuskyPreCommit(huskyDir);
2448
3429
  console.log(` ${chalk11.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
2449
3430
  return ".husky/pre-commit";
2450
3431
  }
2451
- const gitDir = path17.join(projectRoot, ".git");
2452
- if (fs17.existsSync(gitDir)) {
2453
- const hooksDir = path17.join(gitDir, "hooks");
2454
- if (!fs17.existsSync(hooksDir)) {
2455
- fs17.mkdirSync(hooksDir, { recursive: true });
3432
+ const gitDir = path18.join(projectRoot, ".git");
3433
+ if (fs18.existsSync(gitDir)) {
3434
+ const hooksDir = path18.join(gitDir, "hooks");
3435
+ if (!fs18.existsSync(hooksDir)) {
3436
+ fs18.mkdirSync(hooksDir, { recursive: true });
2456
3437
  }
2457
3438
  writeGitHookPreCommit(hooksDir);
2458
3439
  console.log(` ${chalk11.green("\u2713")} .git/hooks/pre-commit`);
@@ -2461,11 +3442,11 @@ function setupPreCommitHook(projectRoot) {
2461
3442
  return void 0;
2462
3443
  }
2463
3444
  function writeGitHookPreCommit(hooksDir) {
2464
- const hookPath = path17.join(hooksDir, "pre-commit");
2465
- if (fs17.existsSync(hookPath)) {
2466
- const existing = fs17.readFileSync(hookPath, "utf-8");
3445
+ const hookPath = path18.join(hooksDir, "pre-commit");
3446
+ if (fs18.existsSync(hookPath)) {
3447
+ const existing = fs18.readFileSync(hookPath, "utf-8");
2467
3448
  if (existing.includes("viberails")) return;
2468
- fs17.writeFileSync(
3449
+ fs18.writeFileSync(
2469
3450
  hookPath,
2470
3451
  `${existing.trimEnd()}
2471
3452
 
@@ -2482,10 +3463,10 @@ if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails chec
2482
3463
  "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi",
2483
3464
  ""
2484
3465
  ].join("\n");
2485
- fs17.writeFileSync(hookPath, script, { mode: 493 });
3466
+ fs18.writeFileSync(hookPath, script, { mode: 493 });
2486
3467
  }
2487
3468
  function addLefthookPreCommit(lefthookPath) {
2488
- const content = fs17.readFileSync(lefthookPath, "utf-8");
3469
+ const content = fs18.readFileSync(lefthookPath, "utf-8");
2489
3470
  if (content.includes("viberails")) return;
2490
3471
  const doc = parseYaml(content) ?? {};
2491
3472
  if (!doc["pre-commit"]) {
@@ -2497,23 +3478,23 @@ function addLefthookPreCommit(lefthookPath) {
2497
3478
  doc["pre-commit"].commands.viberails = {
2498
3479
  run: "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi"
2499
3480
  };
2500
- fs17.writeFileSync(lefthookPath, stringifyYaml(doc));
3481
+ fs18.writeFileSync(lefthookPath, stringifyYaml(doc));
2501
3482
  }
2502
3483
  function detectHookManager(projectRoot) {
2503
- if (fs17.existsSync(path17.join(projectRoot, "lefthook.yml"))) return "Lefthook";
2504
- if (fs17.existsSync(path17.join(projectRoot, ".husky"))) return "Husky";
3484
+ if (fs18.existsSync(path18.join(projectRoot, "lefthook.yml"))) return "Lefthook";
3485
+ if (fs18.existsSync(path18.join(projectRoot, ".husky"))) return "Husky";
2505
3486
  return void 0;
2506
3487
  }
2507
3488
  function setupClaudeCodeHook(projectRoot) {
2508
- const claudeDir = path17.join(projectRoot, ".claude");
2509
- if (!fs17.existsSync(claudeDir)) {
2510
- fs17.mkdirSync(claudeDir, { recursive: true });
3489
+ const claudeDir = path18.join(projectRoot, ".claude");
3490
+ if (!fs18.existsSync(claudeDir)) {
3491
+ fs18.mkdirSync(claudeDir, { recursive: true });
2511
3492
  }
2512
- const settingsPath = path17.join(claudeDir, "settings.json");
3493
+ const settingsPath = path18.join(claudeDir, "settings.json");
2513
3494
  let settings = {};
2514
- if (fs17.existsSync(settingsPath)) {
3495
+ if (fs18.existsSync(settingsPath)) {
2515
3496
  try {
2516
- settings = JSON.parse(fs17.readFileSync(settingsPath, "utf-8"));
3497
+ settings = JSON.parse(fs18.readFileSync(settingsPath, "utf-8"));
2517
3498
  } catch {
2518
3499
  console.warn(
2519
3500
  ` ${chalk11.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
@@ -2539,30 +3520,30 @@ function setupClaudeCodeHook(projectRoot) {
2539
3520
  }
2540
3521
  ];
2541
3522
  settings.hooks = hooks;
2542
- fs17.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
3523
+ fs18.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
2543
3524
  `);
2544
3525
  console.log(` ${chalk11.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
2545
3526
  }
2546
3527
  function setupClaudeMdReference(projectRoot) {
2547
- const claudeMdPath = path17.join(projectRoot, "CLAUDE.md");
3528
+ const claudeMdPath = path18.join(projectRoot, "CLAUDE.md");
2548
3529
  let content = "";
2549
- if (fs17.existsSync(claudeMdPath)) {
2550
- content = fs17.readFileSync(claudeMdPath, "utf-8");
3530
+ if (fs18.existsSync(claudeMdPath)) {
3531
+ content = fs18.readFileSync(claudeMdPath, "utf-8");
2551
3532
  }
2552
3533
  if (content.includes("@.viberails/context.md")) return;
2553
3534
  const ref = "\n@.viberails/context.md\n";
2554
3535
  const prefix = content.length === 0 ? "" : content.trimEnd();
2555
- fs17.writeFileSync(claudeMdPath, prefix + ref);
3536
+ fs18.writeFileSync(claudeMdPath, prefix + ref);
2556
3537
  console.log(` ${chalk11.green("\u2713")} CLAUDE.md \u2014 added @.viberails/context.md reference`);
2557
3538
  }
2558
3539
  function setupGithubAction(projectRoot, packageManager, options) {
2559
- const workflowDir = path17.join(projectRoot, ".github", "workflows");
2560
- const workflowPath = path17.join(workflowDir, "viberails.yml");
2561
- if (fs17.existsSync(workflowPath)) {
2562
- const existing = fs17.readFileSync(workflowPath, "utf-8");
3540
+ const workflowDir = path18.join(projectRoot, ".github", "workflows");
3541
+ const workflowPath = path18.join(workflowDir, "viberails.yml");
3542
+ if (fs18.existsSync(workflowPath)) {
3543
+ const existing = fs18.readFileSync(workflowPath, "utf-8");
2563
3544
  if (existing.includes("viberails")) return void 0;
2564
3545
  }
2565
- fs17.mkdirSync(workflowDir, { recursive: true });
3546
+ fs18.mkdirSync(workflowDir, { recursive: true });
2566
3547
  const pm = packageManager || "npm";
2567
3548
  const installCmd = pm === "yarn" ? "yarn install --frozen-lockfile" : pm === "pnpm" ? "pnpm install --frozen-lockfile" : "npm ci";
2568
3549
  const runPrefix = pm === "npm" ? "npx" : `${pm} exec`;
@@ -2616,74 +3597,74 @@ function setupGithubAction(projectRoot, packageManager, options) {
2616
3597
  ""
2617
3598
  );
2618
3599
  const content = lines.filter((l) => l !== void 0).join("\n");
2619
- fs17.writeFileSync(workflowPath, content);
3600
+ fs18.writeFileSync(workflowPath, content);
2620
3601
  return ".github/workflows/viberails.yml";
2621
3602
  }
2622
3603
  function writeHuskyPreCommit(huskyDir) {
2623
- const hookPath = path17.join(huskyDir, "pre-commit");
3604
+ const hookPath = path18.join(huskyDir, "pre-commit");
2624
3605
  const cmd = "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi";
2625
- if (fs17.existsSync(hookPath)) {
2626
- const existing = fs17.readFileSync(hookPath, "utf-8");
3606
+ if (fs18.existsSync(hookPath)) {
3607
+ const existing = fs18.readFileSync(hookPath, "utf-8");
2627
3608
  if (!existing.includes("viberails")) {
2628
- fs17.writeFileSync(hookPath, `${existing.trimEnd()}
3609
+ fs18.writeFileSync(hookPath, `${existing.trimEnd()}
2629
3610
  ${cmd}
2630
3611
  `);
2631
3612
  }
2632
3613
  return;
2633
3614
  }
2634
- fs17.writeFileSync(hookPath, `#!/bin/sh
3615
+ fs18.writeFileSync(hookPath, `#!/bin/sh
2635
3616
  ${cmd}
2636
3617
  `, { mode: 493 });
2637
3618
  }
2638
3619
 
2639
3620
  // src/commands/init-hooks-extra.ts
2640
- import * as fs18 from "fs";
2641
- import * as path18 from "path";
3621
+ import * as fs19 from "fs";
3622
+ import * as path19 from "path";
2642
3623
  import chalk12 from "chalk";
2643
3624
  import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
2644
3625
  function addPreCommitStep(projectRoot, name, command, marker, lefthookExtra) {
2645
- const lefthookPath = path18.join(projectRoot, "lefthook.yml");
2646
- if (fs18.existsSync(lefthookPath)) {
2647
- const content = fs18.readFileSync(lefthookPath, "utf-8");
3626
+ const lefthookPath = path19.join(projectRoot, "lefthook.yml");
3627
+ if (fs19.existsSync(lefthookPath)) {
3628
+ const content = fs19.readFileSync(lefthookPath, "utf-8");
2648
3629
  if (content.includes(marker)) return void 0;
2649
3630
  const doc = parseYaml2(content) ?? {};
2650
3631
  if (!doc["pre-commit"]) doc["pre-commit"] = { commands: {} };
2651
3632
  if (!doc["pre-commit"].commands) doc["pre-commit"].commands = {};
2652
3633
  doc["pre-commit"].commands[name] = { run: command, ...lefthookExtra };
2653
- fs18.writeFileSync(lefthookPath, stringifyYaml2(doc));
3634
+ fs19.writeFileSync(lefthookPath, stringifyYaml2(doc));
2654
3635
  return "lefthook.yml";
2655
3636
  }
2656
- const huskyDir = path18.join(projectRoot, ".husky");
2657
- if (fs18.existsSync(huskyDir)) {
2658
- const hookPath = path18.join(huskyDir, "pre-commit");
2659
- if (fs18.existsSync(hookPath)) {
2660
- const existing = fs18.readFileSync(hookPath, "utf-8");
3637
+ const huskyDir = path19.join(projectRoot, ".husky");
3638
+ if (fs19.existsSync(huskyDir)) {
3639
+ const hookPath = path19.join(huskyDir, "pre-commit");
3640
+ if (fs19.existsSync(hookPath)) {
3641
+ const existing = fs19.readFileSync(hookPath, "utf-8");
2661
3642
  if (existing.includes(marker)) return void 0;
2662
- fs18.writeFileSync(hookPath, `${existing.trimEnd()}
3643
+ fs19.writeFileSync(hookPath, `${existing.trimEnd()}
2663
3644
  ${command}
2664
3645
  `);
2665
3646
  } else {
2666
- fs18.writeFileSync(hookPath, `#!/bin/sh
3647
+ fs19.writeFileSync(hookPath, `#!/bin/sh
2667
3648
  ${command}
2668
3649
  `, { mode: 493 });
2669
3650
  }
2670
3651
  return ".husky/pre-commit";
2671
3652
  }
2672
- const gitDir = path18.join(projectRoot, ".git");
2673
- if (fs18.existsSync(gitDir)) {
2674
- const hooksDir = path18.join(gitDir, "hooks");
2675
- if (!fs18.existsSync(hooksDir)) fs18.mkdirSync(hooksDir, { recursive: true });
2676
- const hookPath = path18.join(hooksDir, "pre-commit");
2677
- if (fs18.existsSync(hookPath)) {
2678
- const existing = fs18.readFileSync(hookPath, "utf-8");
3653
+ const gitDir = path19.join(projectRoot, ".git");
3654
+ if (fs19.existsSync(gitDir)) {
3655
+ const hooksDir = path19.join(gitDir, "hooks");
3656
+ if (!fs19.existsSync(hooksDir)) fs19.mkdirSync(hooksDir, { recursive: true });
3657
+ const hookPath = path19.join(hooksDir, "pre-commit");
3658
+ if (fs19.existsSync(hookPath)) {
3659
+ const existing = fs19.readFileSync(hookPath, "utf-8");
2679
3660
  if (existing.includes(marker)) return void 0;
2680
- fs18.writeFileSync(hookPath, `${existing.trimEnd()}
3661
+ fs19.writeFileSync(hookPath, `${existing.trimEnd()}
2681
3662
 
2682
3663
  # ${name}
2683
3664
  ${command}
2684
3665
  `);
2685
3666
  } else {
2686
- fs18.writeFileSync(hookPath, `#!/bin/sh
3667
+ fs19.writeFileSync(hookPath, `#!/bin/sh
2687
3668
  # Generated by viberails
2688
3669
 
2689
3670
  # ${name}
@@ -2709,7 +3690,7 @@ function setupTypecheckHook(projectRoot, packageManager) {
2709
3690
  return target;
2710
3691
  }
2711
3692
  function setupLintHook(projectRoot, linter) {
2712
- const isLefthook = fs18.existsSync(path18.join(projectRoot, "lefthook.yml"));
3693
+ const isLefthook = fs19.existsSync(path19.join(projectRoot, "lefthook.yml"));
2713
3694
  const linterName = linter === "biome" ? "Biome" : "ESLint";
2714
3695
  let command;
2715
3696
  let lefthookExtra;
@@ -2733,6 +3714,9 @@ function setupSelectedIntegrations(projectRoot, integrations, opts) {
2733
3714
  const created = [];
2734
3715
  if (integrations.preCommitHook) {
2735
3716
  const t = setupPreCommitHook(projectRoot);
3717
+ if (t && opts.lefthookExpected && !t.includes("lefthook")) {
3718
+ console.log(` ${chalk12.yellow("!")} Lefthook install failed \u2014 fell back to ${t}`);
3719
+ }
2736
3720
  created.push(t ? `${t} \u2014 added viberails pre-commit` : "pre-commit hook skipped");
2737
3721
  }
2738
3722
  if (integrations.typecheckHook) {
@@ -2762,9 +3746,9 @@ function setupSelectedIntegrations(projectRoot, integrations, opts) {
2762
3746
  }
2763
3747
 
2764
3748
  // src/commands/init-non-interactive.ts
2765
- import * as fs19 from "fs";
2766
- import * as path19 from "path";
2767
- import * as clack3 from "@clack/prompts";
3749
+ import * as fs20 from "fs";
3750
+ import * as path20 from "path";
3751
+ import * as clack12 from "@clack/prompts";
2768
3752
  import { compactConfig as compactConfig3, generateConfig } from "@viberails/config";
2769
3753
  import { scan as scan2 } from "@viberails/scanner";
2770
3754
  import chalk13 from "chalk";
@@ -2788,7 +3772,7 @@ function getExemptedPackages(config) {
2788
3772
  return config.packages.filter((pkg) => pkg.rules?.testCoverage === 0 && pkg.path !== ".").map((pkg) => pkg.path);
2789
3773
  }
2790
3774
  async function initNonInteractive(projectRoot, configPath) {
2791
- const s = clack3.spinner();
3775
+ const s = clack12.spinner();
2792
3776
  s.start("Scanning project...");
2793
3777
  const scanResult = await scan2(projectRoot);
2794
3778
  const config = generateConfig(scanResult);
@@ -2807,7 +3791,7 @@ async function initNonInteractive(projectRoot, configPath) {
2807
3791
  );
2808
3792
  }
2809
3793
  if (config.packages.length > 1) {
2810
- const bs = clack3.spinner();
3794
+ const bs = clack12.spinner();
2811
3795
  bs.start("Building import graph...");
2812
3796
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
2813
3797
  const packages = resolveWorkspacePackages(projectRoot, config.packages);
@@ -2823,7 +3807,7 @@ async function initNonInteractive(projectRoot, configPath) {
2823
3807
  }
2824
3808
  }
2825
3809
  const compacted = compactConfig3(config);
2826
- fs19.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3810
+ fs20.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
2827
3811
  `);
2828
3812
  writeGeneratedFiles(projectRoot, config, scanResult);
2829
3813
  updateGitignore(projectRoot);
@@ -2842,7 +3826,7 @@ async function initNonInteractive(projectRoot, configPath) {
2842
3826
  const preCommitTarget = hasHookManager ? setupPreCommitHook(projectRoot) : void 0;
2843
3827
  const ok = chalk13.green("\u2713");
2844
3828
  const created = [
2845
- `${ok} ${path19.basename(configPath)}`,
3829
+ `${ok} ${path20.basename(configPath)}`,
2846
3830
  `${ok} .viberails/context.md`,
2847
3831
  `${ok} .viberails/scan-result.json`,
2848
3832
  `${ok} .claude/settings.json \u2014 added viberails hook`,
@@ -2859,9 +3843,6 @@ ${created.map((f) => ` ${f}`).join("\n")}`);
2859
3843
 
2860
3844
  // src/commands/init.ts
2861
3845
  var CONFIG_FILE5 = "viberails.config.json";
2862
- function getExemptedPackages2(config) {
2863
- return config.packages.filter((pkg) => pkg.rules?.testCoverage === 0 && pkg.path !== ".").map((pkg) => pkg.path);
2864
- }
2865
3846
  async function initCommand(options, cwd) {
2866
3847
  const projectRoot = findProjectRoot(cwd ?? process.cwd());
2867
3848
  if (!projectRoot) {
@@ -2869,8 +3850,8 @@ async function initCommand(options, cwd) {
2869
3850
  "No package.json found. Make sure you are inside a JS/TS project, then run:\n npx viberails"
2870
3851
  );
2871
3852
  }
2872
- const configPath = path20.join(projectRoot, CONFIG_FILE5);
2873
- if (fs20.existsSync(configPath) && !options.force) {
3853
+ const configPath = path21.join(projectRoot, CONFIG_FILE5);
3854
+ if (fs21.existsSync(configPath) && !options.force) {
2874
3855
  if (!options.yes) {
2875
3856
  return initInteractive(projectRoot, configPath, options);
2876
3857
  }
@@ -2884,12 +3865,11 @@ async function initCommand(options, cwd) {
2884
3865
  await initInteractive(projectRoot, configPath, options);
2885
3866
  }
2886
3867
  async function initInteractive(projectRoot, configPath, options) {
2887
- clack4.intro("viberails");
2888
- const replacingExistingConfig = fs20.existsSync(configPath);
2889
- if (fs20.existsSync(configPath) && !options.force) {
2890
- const action = await promptExistingConfigAction(path20.basename(configPath));
3868
+ clack13.intro("viberails");
3869
+ if (fs21.existsSync(configPath) && !options.force) {
3870
+ const action = await promptExistingConfigAction(path21.basename(configPath));
2891
3871
  if (action === "cancel") {
2892
- clack4.outro("Aborted. No files were written.");
3872
+ clack13.outro("Aborted. No files were written.");
2893
3873
  return;
2894
3874
  }
2895
3875
  if (action === "edit") {
@@ -2898,142 +3878,93 @@ async function initInteractive(projectRoot, configPath, options) {
2898
3878
  }
2899
3879
  options.force = true;
2900
3880
  }
2901
- if (fs20.existsSync(configPath) && options.force) {
3881
+ if (fs21.existsSync(configPath) && options.force) {
2902
3882
  const replace = await confirmDangerous(
2903
- `${path20.basename(configPath)} already exists and will be replaced. Continue?`
3883
+ `${path21.basename(configPath)} already exists and will be replaced. Continue?`
2904
3884
  );
2905
3885
  if (!replace) {
2906
- clack4.outro("Aborted. No files were written.");
3886
+ clack13.outro("Aborted. No files were written.");
2907
3887
  return;
2908
3888
  }
2909
3889
  }
2910
- const s = clack4.spinner();
3890
+ const s = clack13.spinner();
2911
3891
  s.start("Scanning project...");
2912
3892
  const scanResult = await scan3(projectRoot);
2913
3893
  const config = generateConfig2(scanResult);
2914
3894
  s.stop("Scan complete");
2915
3895
  if (scanResult.statistics.totalFiles === 0) {
2916
- clack4.log.warn(
3896
+ clack13.log.warn(
2917
3897
  "No source files detected. Try running from the project root,\nor check that source files exist. Run viberails sync after adding files."
2918
3898
  );
2919
3899
  }
2920
- const exemptedPkgs = getExemptedPackages2(config);
2921
- let decision;
2922
- while (true) {
2923
- displayInitOverview(scanResult, config, exemptedPkgs);
2924
- const nextDecision = await promptInitDecision();
2925
- if (nextDecision === "review") {
2926
- clack4.note(formatScanResultsText(scanResult), "Detected details");
2927
- continue;
2928
- }
2929
- decision = nextDecision;
2930
- break;
2931
- }
2932
- if (decision === "customize") {
2933
- const { resolveNamingDefault } = await import("./prompt-naming-default-AH54HEBC.js");
2934
- await resolveNamingDefault(config, scanResult);
2935
- const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2936
- const overrides = await promptRuleMenu({
2937
- maxFileLines: config.rules.maxFileLines,
2938
- maxTestFileLines: config.rules.maxTestFileLines,
2939
- testCoverage: config.rules.testCoverage,
2940
- enforceMissingTests: config.rules.enforceMissingTests,
2941
- enforceNaming: config.rules.enforceNaming,
2942
- fileNamingValue: rootPkg.conventions?.fileNaming,
2943
- componentNaming: rootPkg.conventions?.componentNaming,
2944
- hookNaming: rootPkg.conventions?.hookNaming,
2945
- importAlias: rootPkg.conventions?.importAlias,
2946
- coverageSummaryPath: "coverage/coverage-summary.json",
2947
- coverageCommand: config.defaults?.coverage?.command,
2948
- packageOverrides: config.packages
2949
- });
2950
- applyRuleOverrides(config, overrides);
2951
- }
2952
- if (config.packages.length > 1) {
2953
- clack4.note(
2954
- "Optional for monorepos. viberails can infer package boundaries\nfrom imports that already work today, so you start with rules\nthat match the current codebase.",
2955
- "Boundaries"
3900
+ const hasTestRunner = !!scanResult.stack.testRunner;
3901
+ if (!hasTestRunner) {
3902
+ clack13.log.info(
3903
+ "No test runner detected. Coverage checks are inactive until a test runner is installed.\nInstall a test runner (e.g. vitest) and re-run viberails init."
2956
3904
  );
2957
- const shouldInfer = await confirm("Infer boundary rules from current import patterns?");
2958
- if (shouldInfer) {
2959
- const bs = clack4.spinner();
2960
- bs.start("Building import graph...");
2961
- const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
2962
- const packages = resolveWorkspacePackages(projectRoot, config.packages);
2963
- const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
2964
- const inferred = inferBoundaries(graph);
2965
- const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
2966
- if (denyCount > 0) {
2967
- config.boundaries = inferred;
2968
- config.rules.enforceBoundaries = true;
2969
- const pkgCount = Object.keys(inferred.deny).length;
2970
- bs.stop(`Inferred ${denyCount} boundary rules across ${pkgCount} packages`);
2971
- } else {
2972
- bs.stop("No boundary rules inferred");
2973
- }
2974
- }
2975
3905
  }
2976
3906
  const hookManager = detectHookManager(projectRoot);
2977
3907
  const coveragePrereqs = checkCoveragePrereqs(projectRoot, scanResult);
2978
- const hasMissingPrereqs = coveragePrereqs.some((p) => !p.installed) || !hookManager;
2979
- if (hasMissingPrereqs) {
2980
- clack4.log.info("Some dependencies are needed for full functionality.");
2981
- }
2982
- const prereqResult = await promptMissingPrereqs(projectRoot, coveragePrereqs);
2983
- if (prereqResult.disableCoverage) {
2984
- config.rules.testCoverage = 0;
2985
- }
2986
3908
  const rootPkgStack = (config.packages.find((p) => p.path === ".") ?? config.packages[0])?.stack;
2987
- const integrations = await promptIntegrations(projectRoot, hookManager, {
2988
- isTypeScript: rootPkgStack?.language?.split("@")[0] === "typescript",
2989
- linter: rootPkgStack?.linter?.split("@")[0],
2990
- packageManager: rootPkgStack?.packageManager?.split("@")[0],
2991
- isWorkspace: config.packages.length > 1
2992
- });
2993
- displaySetupPlan(config, integrations, {
2994
- replacingExistingConfig,
2995
- configFile: path20.basename(configPath)
3909
+ const state = await promptMainMenu(config, scanResult, {
3910
+ hasTestRunner,
3911
+ hookManager,
3912
+ coveragePrereqs,
3913
+ projectRoot,
3914
+ tools: {
3915
+ isTypeScript: rootPkgStack?.language?.split("@")[0] === "typescript",
3916
+ linter: rootPkgStack?.linter?.split("@")[0],
3917
+ packageManager: rootPkgStack?.packageManager?.split("@")[0],
3918
+ isWorkspace: config.packages.length > 1
3919
+ }
2996
3920
  });
2997
- const shouldWrite = await confirm("Apply this setup?");
3921
+ const shouldWrite = await confirm3("Apply this setup?");
2998
3922
  if (!shouldWrite) {
2999
- clack4.outro("Aborted. No files were written.");
3923
+ clack13.outro("Aborted. No files were written.");
3000
3924
  return;
3001
3925
  }
3002
- const ws = clack4.spinner();
3926
+ if (state.deferredInstalls.length > 0) {
3927
+ await executeDeferredInstalls(projectRoot, state.deferredInstalls);
3928
+ }
3929
+ const ws = clack13.spinner();
3003
3930
  ws.start("Writing configuration...");
3004
3931
  const compacted = compactConfig4(config);
3005
- fs20.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3932
+ fs21.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3006
3933
  `);
3007
3934
  writeGeneratedFiles(projectRoot, config, scanResult);
3008
3935
  updateGitignore(projectRoot);
3009
3936
  ws.stop("Configuration written");
3010
3937
  const ok = chalk14.green("\u2713");
3011
- clack4.log.step(`${ok} ${path20.basename(configPath)}`);
3012
- clack4.log.step(`${ok} .viberails/context.md`);
3013
- clack4.log.step(`${ok} .viberails/scan-result.json`);
3014
- setupSelectedIntegrations(projectRoot, integrations, {
3015
- linter: rootPkgStack?.linter?.split("@")[0],
3016
- packageManager: rootPkgStack?.packageManager?.split("@")[0]
3017
- });
3018
- clack4.outro(
3938
+ clack13.log.step(`${ok} ${path21.basename(configPath)}`);
3939
+ clack13.log.step(`${ok} .viberails/context.md`);
3940
+ clack13.log.step(`${ok} .viberails/scan-result.json`);
3941
+ if (state.visited.integrations && state.integrations) {
3942
+ const lefthookExpected = state.deferredInstalls.some((d) => d.command.includes("lefthook"));
3943
+ setupSelectedIntegrations(projectRoot, state.integrations, {
3944
+ linter: rootPkgStack?.linter?.split("@")[0],
3945
+ packageManager: rootPkgStack?.packageManager?.split("@")[0],
3946
+ lefthookExpected
3947
+ });
3948
+ }
3949
+ clack13.outro(
3019
3950
  `Done! Next: review viberails.config.json, then run viberails check
3020
3951
  ${chalk14.dim("Tip: use")} ${chalk14.cyan("viberails check --enforce")} ${chalk14.dim("in CI to block PRs on violations.")}`
3021
3952
  );
3022
3953
  }
3023
3954
 
3024
3955
  // src/commands/sync.ts
3025
- import * as fs21 from "fs";
3026
- import * as path21 from "path";
3027
- import * as clack5 from "@clack/prompts";
3956
+ import * as fs22 from "fs";
3957
+ import * as path22 from "path";
3958
+ import * as clack14 from "@clack/prompts";
3028
3959
  import { compactConfig as compactConfig5, loadConfig as loadConfig5, mergeConfig as mergeConfig2 } from "@viberails/config";
3029
3960
  import { scan as scan4 } from "@viberails/scanner";
3030
3961
  import chalk15 from "chalk";
3031
3962
  var CONFIG_FILE6 = "viberails.config.json";
3032
3963
  var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
3033
3964
  function loadPreviousStats(projectRoot) {
3034
- const scanResultPath = path21.join(projectRoot, SCAN_RESULT_FILE2);
3965
+ const scanResultPath = path22.join(projectRoot, SCAN_RESULT_FILE2);
3035
3966
  try {
3036
- const raw = fs21.readFileSync(scanResultPath, "utf-8");
3967
+ const raw = fs22.readFileSync(scanResultPath, "utf-8");
3037
3968
  const parsed = JSON.parse(raw);
3038
3969
  if (parsed?.statistics?.totalFiles !== void 0) {
3039
3970
  return parsed.statistics;
@@ -3050,17 +3981,17 @@ async function syncCommand(options, cwd) {
3050
3981
  "No package.json found in this directory or any parent.\n\nMake sure you are inside a JavaScript or TypeScript project, then run:\n npx viberails"
3051
3982
  );
3052
3983
  }
3053
- const configPath = path21.join(projectRoot, CONFIG_FILE6);
3984
+ const configPath = path22.join(projectRoot, CONFIG_FILE6);
3054
3985
  const existing = await loadConfig5(configPath);
3055
3986
  const previousStats = loadPreviousStats(projectRoot);
3056
- const s = clack5.spinner();
3987
+ const s = clack14.spinner();
3057
3988
  s.start("Scanning project...");
3058
3989
  const scanResult = await scan4(projectRoot);
3059
3990
  s.stop("Scan complete");
3060
3991
  const merged = mergeConfig2(existing, scanResult);
3061
3992
  const compacted = compactConfig5(merged);
3062
3993
  const compactedJson = JSON.stringify(compacted, null, 2);
3063
- const rawDisk = fs21.readFileSync(configPath, "utf-8").trim();
3994
+ const rawDisk = fs22.readFileSync(configPath, "utf-8").trim();
3064
3995
  const diskWithoutSync = rawDisk.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
3065
3996
  const mergedWithoutSync = compactedJson.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
3066
3997
  const configChanged = diskWithoutSync !== mergedWithoutSync;
@@ -3078,9 +4009,9 @@ ${chalk15.bold("Changes:")}`);
3078
4009
  }
3079
4010
  }
3080
4011
  if (options?.interactive) {
3081
- clack5.intro("viberails sync (interactive)");
3082
- clack5.note(formatRulesText(merged).join("\n"), "Rules after sync");
3083
- const decision = await clack5.select({
4012
+ clack14.intro("viberails sync (interactive)");
4013
+ clack14.note(formatRulesText(merged).join("\n"), "Rules after sync");
4014
+ const decision = await clack14.select({
3084
4015
  message: "How would you like to proceed?",
3085
4016
  options: [
3086
4017
  { value: "accept", label: "Accept changes" },
@@ -3090,7 +4021,7 @@ ${chalk15.bold("Changes:")}`);
3090
4021
  });
3091
4022
  assertNotCancelled(decision);
3092
4023
  if (decision === "cancel") {
3093
- clack5.outro("Sync cancelled. No files were written.");
4024
+ clack14.outro("Sync cancelled. No files were written.");
3094
4025
  return;
3095
4026
  }
3096
4027
  if (decision === "customize") {
@@ -3111,15 +4042,15 @@ ${chalk15.bold("Changes:")}`);
3111
4042
  });
3112
4043
  applyRuleOverrides(merged, overrides);
3113
4044
  const recompacted = compactConfig5(merged);
3114
- fs21.writeFileSync(configPath, `${JSON.stringify(recompacted, null, 2)}
4045
+ fs22.writeFileSync(configPath, `${JSON.stringify(recompacted, null, 2)}
3115
4046
  `);
3116
4047
  writeGeneratedFiles(projectRoot, merged, scanResult);
3117
- clack5.log.success("Updated config with your customizations.");
3118
- clack5.outro("Done! Run viberails check to verify.");
4048
+ clack14.log.success("Updated config with your customizations.");
4049
+ clack14.outro("Done! Run viberails check to verify.");
3119
4050
  return;
3120
4051
  }
3121
4052
  }
3122
- fs21.writeFileSync(configPath, `${compactedJson}
4053
+ fs22.writeFileSync(configPath, `${compactedJson}
3123
4054
  `);
3124
4055
  writeGeneratedFiles(projectRoot, merged, scanResult);
3125
4056
  console.log(`
@@ -3134,7 +4065,7 @@ ${chalk15.bold("Synced:")}`);
3134
4065
  }
3135
4066
 
3136
4067
  // src/index.ts
3137
- var VERSION = "0.6.5";
4068
+ var VERSION = "0.6.7";
3138
4069
  var program = new Command();
3139
4070
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
3140
4071
  program.command("init", { isDefault: true }).description("Scan your project and set up enforcement guardrails").option("-y, --yes", "Non-interactive mode (use defaults, high-confidence only)").option("-f, --force", "Re-initialize, replacing existing config").action(async (options) => {