@rtorcato/repo-tooling 2.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/AGENTS.md +113 -0
  2. package/LICENSE +21 -0
  3. package/README.md +201 -0
  4. package/dist/cli/commands/doctor.js +336 -0
  5. package/dist/cli/commands/fix-targets.js +192 -0
  6. package/dist/cli/commands/fix.js +440 -0
  7. package/dist/cli/commands/setup-presets.js +281 -0
  8. package/dist/cli/commands/setup.js +501 -0
  9. package/dist/cli/generators/agent-rules.js +103 -0
  10. package/dist/cli/generators/badges.js +88 -0
  11. package/dist/cli/generators/build.js +216 -0
  12. package/dist/cli/generators/bun.js +25 -0
  13. package/dist/cli/generators/community-health.js +145 -0
  14. package/dist/cli/generators/docs-site.js +436 -0
  15. package/dist/cli/generators/git.js +164 -0
  16. package/dist/cli/generators/github-actions.js +35 -0
  17. package/dist/cli/generators/github-workflows.js +24 -0
  18. package/dist/cli/generators/gitlab-ci.js +10 -0
  19. package/dist/cli/generators/index.js +123 -0
  20. package/dist/cli/generators/linting.js +54 -0
  21. package/dist/cli/generators/misc.js +298 -0
  22. package/dist/cli/generators/nx.js +16 -0
  23. package/dist/cli/generators/package-json.js +317 -0
  24. package/dist/cli/generators/pnpm-workspace.js +126 -0
  25. package/dist/cli/generators/postcss.js +24 -0
  26. package/dist/cli/generators/readme.js +268 -0
  27. package/dist/cli/generators/security.js +156 -0
  28. package/dist/cli/generators/skills-install.js +70 -0
  29. package/dist/cli/generators/tailwind.js +34 -0
  30. package/dist/cli/generators/testing.js +88 -0
  31. package/dist/cli/generators/treeshake.js +148 -0
  32. package/dist/cli/generators/tsconfig.js +27 -0
  33. package/dist/cli/generators/turborepo.js +35 -0
  34. package/dist/cli/generators/typedoc.js +40 -0
  35. package/dist/cli/index.js +332 -0
  36. package/dist/cli/utils/copy-preset.js +98 -0
  37. package/dist/cli/utils/detect-language.js +22 -0
  38. package/dist/cli/utils/format.js +32 -0
  39. package/dist/cli/utils/install.js +28 -0
  40. package/dist/cli/utils/lockfile.js +84 -0
  41. package/dist/languages/js/checks.js +949 -0
  42. package/dist/languages/js/ci.js +251 -0
  43. package/dist/languages/js/fixers.js +735 -0
  44. package/dist/languages/registry.js +37 -0
  45. package/dist/languages/swift/checks.js +127 -0
  46. package/dist/languages/swift/ci.js +165 -0
  47. package/dist/languages/swift/fixers.js +102 -0
  48. package/dist/languages/swift/git-hooks.js +63 -0
  49. package/dist/languages/swift/gitignore.js +43 -0
  50. package/dist/languages/swift/scaffold.js +244 -0
  51. package/package.json +461 -0
  52. package/tooling/biome/README.md +90 -0
  53. package/tooling/biome/biome.json +63 -0
  54. package/tooling/bun/bunfig.toml +14 -0
  55. package/tooling/changesets/README.md +35 -0
  56. package/tooling/changesets/config.json +11 -0
  57. package/tooling/claude/repo-tooling.md +87 -0
  58. package/tooling/commitlint/commitlint.d.mts +4 -0
  59. package/tooling/commitlint/commitlint.mjs +40 -0
  60. package/tooling/cypress/cypress.config.d.mts +4 -0
  61. package/tooling/cypress/cypress.config.mjs +11 -0
  62. package/tooling/docusaurus/index.d.mts +17 -0
  63. package/tooling/docusaurus/index.mjs +38 -0
  64. package/tooling/docusaurus/sync-changelog.mjs +42 -0
  65. package/tooling/docusaurus/theme-tokens.css +79 -0
  66. package/tooling/docusaurus/theme.css +378 -0
  67. package/tooling/esbuild/index.d.mts +6 -0
  68. package/tooling/esbuild/index.mjs +102 -0
  69. package/tooling/eslint/base.d.mts +6 -0
  70. package/tooling/eslint/base.mjs +122 -0
  71. package/tooling/eslint/nextjs.d.mts +4 -0
  72. package/tooling/eslint/nextjs.mjs +22 -0
  73. package/tooling/eslint/types.d.ts +58 -0
  74. package/tooling/github-actions/workflows/cloudflare-pages.yml +42 -0
  75. package/tooling/github-actions/workflows/docker-publish.yml +44 -0
  76. package/tooling/github-actions/workflows/preview-deployments.yml +54 -0
  77. package/tooling/github-actions/workflows/vercel-deploy.yml +40 -0
  78. package/tooling/jest-presets/browser/jest-preset.d.mts +4 -0
  79. package/tooling/jest-presets/browser/jest-preset.mjs +14 -0
  80. package/tooling/jest-presets/node/jest-preset.d.mts +4 -0
  81. package/tooling/jest-presets/node/jest-preset.mjs +13 -0
  82. package/tooling/mcp/mcp.json.example +27 -0
  83. package/tooling/nx/nx.json +24 -0
  84. package/tooling/oxlint/README.md +25 -0
  85. package/tooling/oxlint/oxlintrc.json +28 -0
  86. package/tooling/playwright/playwright.config.d.mts +4 -0
  87. package/tooling/playwright/playwright.config.mjs +19 -0
  88. package/tooling/prettier/index.d.mts +4 -0
  89. package/tooling/prettier/index.mjs +36 -0
  90. package/tooling/release-please/.release-please-manifest.json +3 -0
  91. package/tooling/release-please/release-please-config.json +9 -0
  92. package/tooling/rolldown/rolldown.config.d.mts +18 -0
  93. package/tooling/rolldown/rolldown.config.mjs +59 -0
  94. package/tooling/rollup/rollup.config.d.mts +20 -0
  95. package/tooling/rollup/rollup.config.mjs +70 -0
  96. package/tooling/semantic-release/docker.d.mts +4 -0
  97. package/tooling/semantic-release/docker.mjs +59 -0
  98. package/tooling/semantic-release/github.d.mts +4 -0
  99. package/tooling/semantic-release/github.mjs +79 -0
  100. package/tooling/semantic-release/index.d.mts +4 -0
  101. package/tooling/semantic-release/index.mjs +80 -0
  102. package/tooling/swift/periphery.yml +4 -0
  103. package/tooling/swift/swiftlint.yml +23 -0
  104. package/tooling/tests/exports-resolution.d.mts +14 -0
  105. package/tooling/tests/exports-resolution.mjs +68 -0
  106. package/tooling/tests/ssr-safety.d.mts +14 -0
  107. package/tooling/tests/ssr-safety.mjs +53 -0
  108. package/tooling/tsup/index.d.mts +8 -0
  109. package/tooling/tsup/index.mjs +33 -0
  110. package/tooling/typedoc/typedoc.json +7 -0
  111. package/tooling/typescript/README.md +49 -0
  112. package/tooling/typescript/reset.d.ts +9 -0
  113. package/tooling/typescript/tsconfig.base.json +84 -0
  114. package/tooling/typescript/tsconfig.build.json +11 -0
  115. package/tooling/typescript/tsconfig.bun.json +9 -0
  116. package/tooling/typescript/tsconfig.express.json +9 -0
  117. package/tooling/typescript/tsconfig.next.json +20 -0
  118. package/tooling/typescript/tsconfig.node.json +9 -0
  119. package/tooling/typescript/tsconfig.react.json +15 -0
  120. package/tooling/typescript/tsconfig.test.json +8 -0
  121. package/tooling/typescript/v1/tsconfig.base.json +81 -0
  122. package/tooling/typescript/v1/tsconfig.express.json +9 -0
  123. package/tooling/typescript/v1/tsconfig.next.json +19 -0
  124. package/tooling/typescript/v1/tsconfig.node.json +9 -0
  125. package/tooling/typescript/v1/tsconfig.react.json +14 -0
  126. package/tooling/typescript/v1/tsconfig.test.json +8 -0
  127. package/tooling/vite/vite.config.d.mts +4 -0
  128. package/tooling/vite/vite.config.mjs +18 -0
  129. package/tooling/vitest/jsdom-shims.d.mts +1 -0
  130. package/tooling/vitest/jsdom-shims.mjs +58 -0
  131. package/tooling/vitest/vitest.config.d.mts +4 -0
  132. package/tooling/vitest/vitest.config.mjs +25 -0
  133. package/tooling/vitest/vitest.config.react.d.mts +4 -0
  134. package/tooling/vitest/vitest.config.react.mjs +27 -0
  135. package/tooling/vitest/vitest.setup.d.mts +1 -0
  136. package/tooling/vitest/vitest.setup.mjs +3 -0
@@ -0,0 +1,192 @@
1
+ export const FIX_TARGETS = {
2
+ 'package.json': 'package-json',
3
+ 'engines.node': 'engines',
4
+ EditorConfig: 'editorconfig',
5
+ 'VS Code extensions': 'vscode-extensions',
6
+ 'Node version pin': 'nvmrc',
7
+ 'Node version consistency': 'node-version',
8
+ TypeScript: 'tsconfig',
9
+ Biome: 'biome',
10
+ ESLint: 'eslint',
11
+ Prettier: 'prettier',
12
+ Vitest: 'vitest',
13
+ Commitlint: 'commitlint',
14
+ 'Git hooks': 'husky',
15
+ 'lint-staged': 'husky',
16
+ 'Pre-push hook': 'husky',
17
+ 'verify script': 'verify',
18
+ 'semantic-release': 'semantic-release',
19
+ knip: 'knip',
20
+ 'size-limit': 'size-limit',
21
+ 'Tree-shake check': 'treeshake-check',
22
+ 'GitHub Actions': 'github-actions',
23
+ 'Coverage upload': 'github-actions',
24
+ Dependabot: 'dependabot',
25
+ CodeQL: 'codeql',
26
+ 'Branch protection': 'github-settings',
27
+ 'Merge settings': 'github-settings',
28
+ 'Workflow permissions': 'github-settings',
29
+ 'Code-scanning gate': 'github-settings',
30
+ CODEOWNERS: 'codeowners',
31
+ 'GitLab CI': 'gitlab-ci',
32
+ Turborepo: 'turborepo',
33
+ 'pnpm settings': 'pnpm-workspace',
34
+ Tailwind: 'tailwind',
35
+ lockfile: 'lockfile',
36
+ '.repo-tooling.json': 'lockfile',
37
+ // Pre-rename lockfile name — still recognized as managed so doctor doesn't
38
+ // flag an un-migrated repo's lockfile as unmanaged (#272).
39
+ '.js-tooling.json': 'lockfile',
40
+ 'are-the-types-wrong': 'attw',
41
+ publint: 'publint',
42
+ 'README badges': 'badges',
43
+ TypeDoc: 'typedoc',
44
+ 'AI setup': 'ai',
45
+ };
46
+ /**
47
+ * Where the Swift module's fixers shadow (or extend) the JS-named defaults
48
+ * above. Without this, `doctor` on a Swift repo suggests `fix husky` and
49
+ * `fix github-actions` — targets its fixer set doesn't contain.
50
+ */
51
+ const SWIFT_FIX_TARGETS = {
52
+ 'Git hooks': 'swift-git-hooks',
53
+ 'Pre-push hook': 'swift-git-hooks',
54
+ 'GitHub Actions': 'swift-ci',
55
+ 'GitLab CI': 'swift-gitlab-ci',
56
+ lockfile: 'swift-lockfile',
57
+ SwiftLint: 'swiftlint',
58
+ Periphery: 'periphery',
59
+ 'Swift .gitignore': 'swift-gitignore',
60
+ };
61
+ export function getFixTargetForCheck(checkName, language) {
62
+ if (language === 'swift' && SWIFT_FIX_TARGETS[checkName]) {
63
+ return SWIFT_FIX_TARGETS[checkName];
64
+ }
65
+ return FIX_TARGETS[checkName] ?? null;
66
+ }
67
+ /**
68
+ * For a given doctor check name, returns true when the lockfile records that
69
+ * the user intentionally opted out of the tool that check covers. Used by
70
+ * doctor to demote `optional-missing` to `ok` and by fix to print a conflict
71
+ * warning before overriding the recorded choice.
72
+ */
73
+ export function declinedInLock(lock, checkName) {
74
+ if (!lock)
75
+ return false;
76
+ const c = lock.config;
77
+ switch (checkName) {
78
+ case 'TypeScript':
79
+ return c.typescript?.enabled === false;
80
+ case 'Biome':
81
+ return c.linting?.tool !== 'biome' && c.linting?.tool !== 'both';
82
+ case 'ESLint':
83
+ return c.linting?.tool !== 'eslint' && c.linting?.tool !== 'both';
84
+ case 'Prettier':
85
+ return c.formatting?.tool !== 'prettier';
86
+ case 'Vitest':
87
+ return c.testing?.framework !== 'vitest';
88
+ case 'Commitlint':
89
+ return c.commitLint === false;
90
+ case 'Git hooks':
91
+ case 'lint-staged':
92
+ case 'Pre-push hook':
93
+ return c.gitHooks === false;
94
+ case 'verify script':
95
+ // Verify is derived from other tools; only "declined" if none of typecheck/lint/test are enabled.
96
+ return (c.typescript?.enabled === false &&
97
+ c.linting?.tool === 'none' &&
98
+ c.testing?.framework === 'none');
99
+ case 'semantic-release':
100
+ return c.semanticRelease === false;
101
+ case 'Dependabot':
102
+ case 'CodeQL':
103
+ // GitHub repo-settings checks (#137) share the securityAutomation opt-out.
104
+ case 'Branch protection':
105
+ case 'Merge settings':
106
+ case 'Workflow permissions':
107
+ case 'Code-scanning gate':
108
+ return c.securityAutomation === false;
109
+ case 'publint':
110
+ return c.publint === false;
111
+ case 'README badges':
112
+ return c.badges === false;
113
+ case 'AI setup':
114
+ return c.aiSetup === false;
115
+ case 'Turborepo':
116
+ return c.turborepo === false;
117
+ case 'Tailwind':
118
+ return c.tailwind === false;
119
+ default:
120
+ return false;
121
+ }
122
+ }
123
+ /**
124
+ * When a fixer is about to scaffold a tool, return the patch to apply to the
125
+ * lockfile's recorded choices so intent stays in sync with reality. Returns
126
+ * null when the target either doesn't change any recorded choice (e.g. the
127
+ * `verify` fixer is derived, or `engines` writes a universal field) or when
128
+ * the lockfile already reflects the change.
129
+ */
130
+ export function lockfilePatchForTarget(target, lock) {
131
+ const c = lock.config;
132
+ switch (target) {
133
+ case 'biome':
134
+ if (c.linting.tool === 'biome' || c.linting.tool === 'both')
135
+ return null;
136
+ return {
137
+ linting: { tool: 'biome' },
138
+ formatting: { tool: 'biome' },
139
+ };
140
+ case 'eslint':
141
+ if (c.linting.tool === 'eslint' || c.linting.tool === 'both')
142
+ return null;
143
+ return {
144
+ linting: { tool: 'eslint', eslintConfig: c.linting.eslintConfig ?? 'base' },
145
+ formatting: { tool: 'prettier' },
146
+ };
147
+ case 'prettier':
148
+ if (c.formatting.tool === 'prettier')
149
+ return null;
150
+ return { formatting: { tool: 'prettier' } };
151
+ case 'vitest':
152
+ if (c.testing.framework === 'vitest')
153
+ return null;
154
+ return {
155
+ testing: { framework: 'vitest', environment: c.testing.environment ?? 'node' },
156
+ };
157
+ case 'commitlint':
158
+ return c.commitLint ? null : { commitLint: true };
159
+ case 'husky':
160
+ case 'swift-git-hooks':
161
+ return c.gitHooks ? null : { gitHooks: true };
162
+ case 'semantic-release':
163
+ return c.semanticRelease ? null : { semanticRelease: true };
164
+ case 'dependabot':
165
+ case 'renovate':
166
+ case 'codeql':
167
+ case 'github-settings':
168
+ return c.securityAutomation ? null : { securityAutomation: true };
169
+ case 'tsconfig':
170
+ return c.typescript.enabled ? null : { typescript: { enabled: true, config: 'base' } };
171
+ case 'treeshake-check':
172
+ return c.treeshakeCheck ? null : { treeshakeCheck: true };
173
+ case 'publint':
174
+ return c.publint ? null : { publint: true };
175
+ case 'badges':
176
+ return c.badges ? null : { badges: true };
177
+ case 'ai':
178
+ return c.aiSetup ? null : { aiSetup: true };
179
+ case 'turborepo':
180
+ return c.turborepo ? null : { turborepo: true };
181
+ case 'nx':
182
+ return c.nx ? null : { nx: true };
183
+ case 'tailwind':
184
+ return c.tailwind ? null : { tailwind: true };
185
+ case 'docs-site':
186
+ return c.docsSite ? null : { docsSite: true };
187
+ case 'bun':
188
+ return c.bun ? null : { bun: true };
189
+ default:
190
+ return null;
191
+ }
192
+ }
@@ -0,0 +1,440 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import chalk from 'chalk';
4
+ import { createPatch } from 'diff';
5
+ import fs from 'fs-extra';
6
+ import inquirer from 'inquirer';
7
+ import { generateConfigs } from '../generators/index.js';
8
+ import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } from '../utils/lockfile.js';
9
+ import { runDoctor } from './doctor.js';
10
+ import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
11
+ import { computeFileList } from './setup-presets.js';
12
+ import { BASE_FIXERS } from '../../base/fixers.js';
13
+ import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
14
+ import { SWIFT_FIXERS } from '../../languages/swift/fixers.js';
15
+ import { detectLanguage } from '../utils/detect-language.js';
16
+ /**
17
+ * The fixers that apply to a repo, by detected language (#286, #303): the
18
+ * language-agnostic base set plus the module's own. Swift repos get the Swift
19
+ * module; everything else (including a bare dir mid-setup) gets JS, the
20
+ * historical default.
21
+ */
22
+ function fixersForLanguage(language) {
23
+ return [...BASE_FIXERS, ...(language === 'swift' ? SWIFT_FIXERS : FIXERS)];
24
+ }
25
+ /** Every fixer across every language — for `--list` and the unknown-target hint. */
26
+ const ALL_FIXERS = [...BASE_FIXERS, ...FIXERS, ...SWIFT_FIXERS];
27
+ export function getFixers() {
28
+ return ALL_FIXERS;
29
+ }
30
+ async function ownOutputsPresent(targetDir, fixer) {
31
+ for (const out of fixer.outputs) {
32
+ // Outputs that reference a package.json field (e.g. "package.json (scripts.verify)")
33
+ // can't be cheaply file-checked here; treat as present so we don't accidentally
34
+ // re-run safe-merge fixers on every targeted invocation.
35
+ if (out.includes('('))
36
+ return true;
37
+ if (await fs.pathExists(path.join(targetDir, out)))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ function findFixer(fixers, target) {
43
+ const normalized = target.toLowerCase();
44
+ return fixers.find((f) => f.target.toLowerCase() === normalized);
45
+ }
46
+ function findFixerForCheck(fixers, checkName) {
47
+ return fixers.find((f) => f.appliesTo.includes(checkName));
48
+ }
49
+ function logTargets(fixers) {
50
+ console.log(chalk.gray('Available fix targets:'));
51
+ for (const f of fixers) {
52
+ console.log(` ${chalk.green('●')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
53
+ }
54
+ }
55
+ export function listFixers() {
56
+ return ALL_FIXERS.map((f) => ({
57
+ target: f.target,
58
+ description: f.description,
59
+ appliesTo: f.appliesTo,
60
+ outputs: f.outputs,
61
+ riskLevel: f.riskLevel ?? 'destructive',
62
+ canFixDrift: f.canFixDrift ?? false,
63
+ }));
64
+ }
65
+ // Fixer outputs sometimes carry annotations like
66
+ // "package.json (lint-staged field)" — strip them to get a usable filesystem path.
67
+ function outputToRelativePath(output) {
68
+ return output.split(' ')[0] ?? output;
69
+ }
70
+ function shouldColorise() {
71
+ // Respect NO_COLOR (https://no-color.org) and chalk's own detection.
72
+ if (process.env.NO_COLOR && process.env.NO_COLOR !== '')
73
+ return false;
74
+ return chalk.level > 0;
75
+ }
76
+ function colorisePatch(patch) {
77
+ if (!shouldColorise())
78
+ return patch;
79
+ return patch
80
+ .split('\n')
81
+ .map((line) => {
82
+ if (line.startsWith('+++') || line.startsWith('---'))
83
+ return chalk.bold(line);
84
+ if (line.startsWith('@@'))
85
+ return chalk.cyan(line);
86
+ if (line.startsWith('+'))
87
+ return chalk.green(line);
88
+ if (line.startsWith('-'))
89
+ return chalk.red(line);
90
+ return line;
91
+ })
92
+ .join('\n');
93
+ }
94
+ /**
95
+ * Shadow-run a fixer in a temp copy of the target directory and return per-output
96
+ * diffs. We copy the real target into tmp so fixers that read existing state
97
+ * (e.g. husky reading package.json) still produce realistic output.
98
+ */
99
+ async function previewFixer(fixer, result, targetDir, pkg, lock) {
100
+ // Pick a tmp root that is NOT inside targetDir. macOS sometimes hands us a
101
+ // $TMPDIR that lives under the working dir (e.g. when the caller is itself
102
+ // running inside a tempdir tree), which would make fs.copy fail with
103
+ // "subdirectory of itself". Fall back to the parent of targetDir if so.
104
+ const resolvedTarget = path.resolve(targetDir);
105
+ let tmpRoot = path.resolve(os.tmpdir());
106
+ if (tmpRoot === resolvedTarget || tmpRoot.startsWith(resolvedTarget + path.sep)) {
107
+ tmpRoot = path.dirname(resolvedTarget);
108
+ }
109
+ const tmpDir = await fs.mkdtemp(path.join(tmpRoot, 'repo-tooling-fix-preview-'));
110
+ try {
111
+ await fs.copy(targetDir, tmpDir, {
112
+ filter: (src) => {
113
+ const rel = path.relative(targetDir, src);
114
+ if (!rel)
115
+ return true;
116
+ const first = rel.split(path.sep)[0];
117
+ // Skip large/derived dirs that fixers never touch — keeps preview fast on
118
+ // big repos.
119
+ return first !== 'node_modules' && first !== 'dist' && first !== 'build' && first !== '.git';
120
+ },
121
+ });
122
+ await fixer.run({ targetDir: tmpDir, pkg, result, lock });
123
+ const previews = [];
124
+ const seen = new Set();
125
+ for (const output of fixer.outputs) {
126
+ const rel = outputToRelativePath(output);
127
+ if (seen.has(rel))
128
+ continue;
129
+ seen.add(rel);
130
+ const tmpPath = path.join(tmpDir, rel);
131
+ const realPath = path.join(targetDir, rel);
132
+ if (!(await fs.pathExists(tmpPath)))
133
+ continue;
134
+ const newContent = await fs.readFile(tmpPath, 'utf-8');
135
+ const existed = await fs.pathExists(realPath);
136
+ const oldContent = existed ? await fs.readFile(realPath, 'utf-8') : '';
137
+ if (newContent === oldContent) {
138
+ previews.push({ path: rel, kind: 'unchanged', patch: null });
139
+ continue;
140
+ }
141
+ const patch = createPatch(rel, oldContent, newContent, undefined, undefined, { context: 3 });
142
+ previews.push({
143
+ path: rel,
144
+ kind: existed ? 'modify' : 'create',
145
+ patch: colorisePatch(patch),
146
+ });
147
+ }
148
+ return previews;
149
+ }
150
+ finally {
151
+ await fs.remove(tmpDir).catch(() => {
152
+ // Best-effort cleanup; tmp dirs get GC'd by the OS eventually.
153
+ });
154
+ }
155
+ }
156
+ function printPreviews(previews) {
157
+ if (previews.length === 0) {
158
+ console.log(chalk.gray(' (no preview available — fixer produced no recognisable outputs)'));
159
+ return;
160
+ }
161
+ for (const p of previews) {
162
+ if (p.kind === 'unchanged') {
163
+ console.log(chalk.gray(` ${p.path} — unchanged`));
164
+ continue;
165
+ }
166
+ const label = p.kind === 'create' ? chalk.green('create') : chalk.yellow('modify');
167
+ console.log(` ${label} ${chalk.bold(p.path)}`);
168
+ if (p.patch) {
169
+ console.log(p.patch
170
+ .split('\n')
171
+ .map((l) => ` ${l}`)
172
+ .join('\n'));
173
+ }
174
+ }
175
+ }
176
+ async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
177
+ if (dryRun) {
178
+ if (!silent) {
179
+ console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
180
+ }
181
+ return { filesWritten: [], dryRun: true };
182
+ }
183
+ const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock });
184
+ if (!silent && filesWritten.length > 0) {
185
+ console.log(chalk.green(` ✅ wrote ${filesWritten.join(', ')}`));
186
+ }
187
+ // Auto-resync the lockfile when a fix changes a recorded choice.
188
+ if (lock && fixer.target !== 'lockfile') {
189
+ const patch = lockfilePatchForTarget(fixer.target, lock);
190
+ if (patch) {
191
+ const ok = await updateLockfileConfig(targetDir, patch);
192
+ if (ok && !silent) {
193
+ console.log(chalk.dim(` ↻ ${LOCKFILE_NAME} updated to reflect the new choice`));
194
+ }
195
+ }
196
+ }
197
+ return { filesWritten, dryRun: false };
198
+ }
199
+ function promptMessageFor(fixer, result) {
200
+ const risk = fixer.riskLevel ?? 'destructive';
201
+ if (risk === 'safe-merge') {
202
+ return { message: `${fixer.description} (existing fields preserved)?`, default: true };
203
+ }
204
+ if (risk === 'safe-add') {
205
+ return { message: `${fixer.description}?`, default: true };
206
+ }
207
+ // destructive
208
+ if (result.status === 'drift') {
209
+ return {
210
+ message: `⚠️ ${fixer.description} — overwrite existing file? user customizations will be lost`,
211
+ default: false,
212
+ };
213
+ }
214
+ return { message: `Apply ${fixer.description}?`, default: true };
215
+ }
216
+ async function confirmApply(fixer, result, assumeYes) {
217
+ if (assumeYes)
218
+ return true;
219
+ const { message, default: defaultValue } = promptMessageFor(fixer, result);
220
+ const { confirm } = await inquirer.prompt([
221
+ { type: 'confirm', name: 'confirm', message, default: defaultValue },
222
+ ]);
223
+ return confirm === true;
224
+ }
225
+ function recordFor(target, check, doctorStatus, status, filesWritten, lockfileConflict = false) {
226
+ const base = { target, check, status, doctorStatus, filesWritten };
227
+ if (lockfileConflict)
228
+ base.lockfileConflict = true;
229
+ return base;
230
+ }
231
+ export async function fixCommand(target, options = {}) {
232
+ const targetDir = path.resolve(options.directory ?? process.cwd());
233
+ const dryRun = options.dryRun === true;
234
+ const json = options.json === true;
235
+ // JSON mode implies --yes so prompts don't corrupt the output stream.
236
+ const assumeYes = options.yes === true || json;
237
+ const silent = json;
238
+ // Diff preview is interactive-only — suppress in JSON mode.
239
+ const showDiff = options.diff === true && !json;
240
+ if (options.list) {
241
+ const summary = listFixers();
242
+ if (json) {
243
+ console.log(JSON.stringify({ targets: summary }, null, 2));
244
+ return;
245
+ }
246
+ console.log(chalk.cyan('\n🔧 Registered fix targets:\n'));
247
+ for (const f of summary) {
248
+ console.log(` ${chalk.green('●')} ${chalk.bold(f.target)}`);
249
+ console.log(` ${chalk.gray(f.description)}`);
250
+ console.log(` ${chalk.dim(`risk=${f.riskLevel}, drift=${f.canFixDrift ? 'yes' : 'no'}, outputs=${f.outputs.join(', ')}`)}`);
251
+ }
252
+ console.log();
253
+ return;
254
+ }
255
+ if (options.resync) {
256
+ if (target) {
257
+ console.error(chalk.red('\n❌ --resync cannot be combined with a [target] argument\n'));
258
+ process.exit(1);
259
+ }
260
+ const resyncLock = await readLockfile(targetDir);
261
+ if (!resyncLock) {
262
+ if (json) {
263
+ console.log(JSON.stringify({ directory: targetDir, error: 'no-lockfile', hint: 'run `fix lockfile` first' }, null, 2));
264
+ }
265
+ else {
266
+ console.error(chalk.red(`\n❌ No ${LOCKFILE_NAME} found — run \`fix lockfile\` first to record choices\n`));
267
+ }
268
+ process.exit(1);
269
+ }
270
+ const files = computeFileList(resyncLock.config);
271
+ if (!silent) {
272
+ console.log(chalk.cyan(`\n🔄 Resync from ${LOCKFILE_NAME} (${files.length} files in scope)\n`));
273
+ }
274
+ if (dryRun) {
275
+ if (json) {
276
+ console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: true, files }, null, 2));
277
+ }
278
+ else {
279
+ for (const f of files)
280
+ console.log(chalk.cyan(` [dry-run] would write: ${f}`));
281
+ console.log();
282
+ }
283
+ return;
284
+ }
285
+ if (!assumeYes) {
286
+ const { confirm } = await inquirer.prompt([
287
+ {
288
+ type: 'confirm',
289
+ name: 'confirm',
290
+ message: `Re-scaffold ${files.length} file(s) from ${LOCKFILE_NAME}? Generators preserve existing customizations where possible, but README.md will be rewritten.`,
291
+ default: false,
292
+ },
293
+ ]);
294
+ if (!confirm) {
295
+ console.log(chalk.gray(' skipped\n'));
296
+ return;
297
+ }
298
+ }
299
+ await generateConfigs(resyncLock.config, targetDir);
300
+ await writeLockfile(targetDir, resyncLock.config);
301
+ if (json) {
302
+ console.log(JSON.stringify({ directory: targetDir, mode: 'resync', dryRun: false, files }, null, 2));
303
+ }
304
+ else {
305
+ console.log(chalk.green(` ✅ resynced ${files.length} file(s)\n`));
306
+ }
307
+ return;
308
+ }
309
+ const pkg = await readPackageJson(targetDir);
310
+ const lock = await readLockfile(targetDir);
311
+ const fixers = fixersForLanguage(await detectLanguage(targetDir));
312
+ const results = await runDoctor(targetDir);
313
+ const actions = [];
314
+ const noteLockConflict = (check) => {
315
+ if (!lock)
316
+ return false;
317
+ const conflict = declinedInLock(lock, check);
318
+ if (conflict && !silent) {
319
+ console.log(chalk.yellow(` ⚠ ${LOCKFILE_NAME} says this tool was declined — applying anyway will update the lockfile to reflect the new choice.`));
320
+ }
321
+ return conflict;
322
+ };
323
+ const emitJson = (resolvedTarget) => {
324
+ const payload = { directory: targetDir, target: resolvedTarget, actions };
325
+ console.log(JSON.stringify(payload, null, 2));
326
+ };
327
+ if (target) {
328
+ const fixer = findFixer(fixers, target);
329
+ if (!fixer) {
330
+ if (json) {
331
+ console.log(JSON.stringify({
332
+ directory: targetDir,
333
+ error: 'unknown-target',
334
+ target,
335
+ available: FIXERS.map((f) => f.target),
336
+ }, null, 2));
337
+ process.exit(1);
338
+ }
339
+ console.error(chalk.red(`\n❌ Unknown fix target: ${target}\n`));
340
+ logTargets(fixers);
341
+ console.log();
342
+ process.exit(1);
343
+ }
344
+ // A fixer can cover several checks (e.g. husky covers Husky + lint-staged +
345
+ // Husky pre-push). Pick the first that still needs work rather than the
346
+ // first that merely matches — otherwise an `ok` check (Husky wired) masks a
347
+ // sibling drift (pre-push not calling verify) and the fixer no-ops.
348
+ const applicable = results.filter((r) => fixer.appliesTo.includes(r.check));
349
+ const result = applicable.find((r) => r.status !== 'ok') ??
350
+ applicable[0] ??
351
+ { check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
352
+ // A check that's `ok` because the lockfile records an opt-out should still be
353
+ // fixable when the user explicitly targets it — treat it as optional-missing
354
+ // so the override + lockfile resync paths run.
355
+ const lockfileDemoted = lock !== null && declinedInLock(lock, result.check);
356
+ // When multiple fixers share a check (e.g. dependabot + renovate both apply to
357
+ // "Dependabot" deps-update coverage), the check can be `ok` from a sibling tool
358
+ // while this fixer's own outputs are still absent. In that case, treat as missing
359
+ // so the targeted scaffold runs.
360
+ const fixerOutputsPresent = await ownOutputsPresent(targetDir, fixer);
361
+ const effectiveResult = result.status === 'ok' && (lockfileDemoted || !fixerOutputsPresent)
362
+ ? { ...result, status: 'optional-missing' }
363
+ : result;
364
+ if (effectiveResult.status === 'ok') {
365
+ actions.push(recordFor(fixer.target, result.check, 'ok', 'already-ok', []));
366
+ if (json)
367
+ return emitJson(fixer.target);
368
+ console.log(chalk.green(`\n✅ ${result.check} is already configured\n`));
369
+ return;
370
+ }
371
+ if (!silent) {
372
+ console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
373
+ }
374
+ const conflict = noteLockConflict(result.check);
375
+ if (showDiff && (fixer.riskLevel ?? 'destructive') !== 'safe-add') {
376
+ const previews = await previewFixer(fixer, effectiveResult, targetDir, pkg, lock);
377
+ printPreviews(previews);
378
+ }
379
+ const ok = await confirmApply(fixer, effectiveResult, assumeYes);
380
+ if (!ok) {
381
+ actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
382
+ if (json)
383
+ return emitJson(fixer.target);
384
+ console.log(chalk.gray(' skipped\n'));
385
+ return;
386
+ }
387
+ const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent);
388
+ actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
389
+ if (json)
390
+ return emitJson(fixer.target);
391
+ console.log();
392
+ return;
393
+ }
394
+ const fixable = results.filter((r) => r.status !== 'ok');
395
+ if (fixable.length === 0) {
396
+ if (json)
397
+ return emitJson(null);
398
+ console.log(chalk.green('\n✅ All checks pass — nothing to fix\n'));
399
+ return;
400
+ }
401
+ if (!silent) {
402
+ console.log(chalk.cyan(`\n🔧 ${fixable.length} item(s) to address\n`));
403
+ }
404
+ let appliedCount = 0;
405
+ let skippedCount = 0;
406
+ let unsupportedCount = 0;
407
+ for (const result of fixable) {
408
+ const fixer = findFixerForCheck(fixers, result.check);
409
+ if (!fixer) {
410
+ actions.push(recordFor(null, result.check, result.status, 'unsupported', []));
411
+ if (!silent)
412
+ console.log(chalk.gray(` — ${result.check}: no fixer registered`));
413
+ unsupportedCount++;
414
+ continue;
415
+ }
416
+ if (!silent) {
417
+ console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
418
+ }
419
+ const conflict = noteLockConflict(result.check);
420
+ if (showDiff && (fixer.riskLevel ?? 'destructive') !== 'safe-add') {
421
+ const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
422
+ printPreviews(previews);
423
+ }
424
+ const ok = await confirmApply(fixer, result, assumeYes);
425
+ if (!ok) {
426
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
427
+ if (!silent)
428
+ console.log(chalk.gray(' skipped'));
429
+ skippedCount++;
430
+ continue;
431
+ }
432
+ const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent);
433
+ actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
434
+ appliedCount++;
435
+ }
436
+ if (json)
437
+ return emitJson(null);
438
+ console.log();
439
+ console.log(` Summary: ${chalk.green(`${appliedCount} applied`)}, ${chalk.gray(`${skippedCount} skipped`)}, ${chalk.yellow(`${unsupportedCount} unsupported`)}\n`);
440
+ }