@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.
- package/AGENTS.md +113 -0
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/cli/commands/doctor.js +336 -0
- package/dist/cli/commands/fix-targets.js +192 -0
- package/dist/cli/commands/fix.js +440 -0
- package/dist/cli/commands/setup-presets.js +281 -0
- package/dist/cli/commands/setup.js +501 -0
- package/dist/cli/generators/agent-rules.js +103 -0
- package/dist/cli/generators/badges.js +88 -0
- package/dist/cli/generators/build.js +216 -0
- package/dist/cli/generators/bun.js +25 -0
- package/dist/cli/generators/community-health.js +145 -0
- package/dist/cli/generators/docs-site.js +436 -0
- package/dist/cli/generators/git.js +164 -0
- package/dist/cli/generators/github-actions.js +35 -0
- package/dist/cli/generators/github-workflows.js +24 -0
- package/dist/cli/generators/gitlab-ci.js +10 -0
- package/dist/cli/generators/index.js +123 -0
- package/dist/cli/generators/linting.js +54 -0
- package/dist/cli/generators/misc.js +298 -0
- package/dist/cli/generators/nx.js +16 -0
- package/dist/cli/generators/package-json.js +317 -0
- package/dist/cli/generators/pnpm-workspace.js +126 -0
- package/dist/cli/generators/postcss.js +24 -0
- package/dist/cli/generators/readme.js +268 -0
- package/dist/cli/generators/security.js +156 -0
- package/dist/cli/generators/skills-install.js +70 -0
- package/dist/cli/generators/tailwind.js +34 -0
- package/dist/cli/generators/testing.js +88 -0
- package/dist/cli/generators/treeshake.js +148 -0
- package/dist/cli/generators/tsconfig.js +27 -0
- package/dist/cli/generators/turborepo.js +35 -0
- package/dist/cli/generators/typedoc.js +40 -0
- package/dist/cli/index.js +332 -0
- package/dist/cli/utils/copy-preset.js +98 -0
- package/dist/cli/utils/detect-language.js +22 -0
- package/dist/cli/utils/format.js +32 -0
- package/dist/cli/utils/install.js +28 -0
- package/dist/cli/utils/lockfile.js +84 -0
- package/dist/languages/js/checks.js +949 -0
- package/dist/languages/js/ci.js +251 -0
- package/dist/languages/js/fixers.js +735 -0
- package/dist/languages/registry.js +37 -0
- package/dist/languages/swift/checks.js +127 -0
- package/dist/languages/swift/ci.js +165 -0
- package/dist/languages/swift/fixers.js +102 -0
- package/dist/languages/swift/git-hooks.js +63 -0
- package/dist/languages/swift/gitignore.js +43 -0
- package/dist/languages/swift/scaffold.js +244 -0
- package/package.json +461 -0
- package/tooling/biome/README.md +90 -0
- package/tooling/biome/biome.json +63 -0
- package/tooling/bun/bunfig.toml +14 -0
- package/tooling/changesets/README.md +35 -0
- package/tooling/changesets/config.json +11 -0
- package/tooling/claude/repo-tooling.md +87 -0
- package/tooling/commitlint/commitlint.d.mts +4 -0
- package/tooling/commitlint/commitlint.mjs +40 -0
- package/tooling/cypress/cypress.config.d.mts +4 -0
- package/tooling/cypress/cypress.config.mjs +11 -0
- package/tooling/docusaurus/index.d.mts +17 -0
- package/tooling/docusaurus/index.mjs +38 -0
- package/tooling/docusaurus/sync-changelog.mjs +42 -0
- package/tooling/docusaurus/theme-tokens.css +79 -0
- package/tooling/docusaurus/theme.css +378 -0
- package/tooling/esbuild/index.d.mts +6 -0
- package/tooling/esbuild/index.mjs +102 -0
- package/tooling/eslint/base.d.mts +6 -0
- package/tooling/eslint/base.mjs +122 -0
- package/tooling/eslint/nextjs.d.mts +4 -0
- package/tooling/eslint/nextjs.mjs +22 -0
- package/tooling/eslint/types.d.ts +58 -0
- package/tooling/github-actions/workflows/cloudflare-pages.yml +42 -0
- package/tooling/github-actions/workflows/docker-publish.yml +44 -0
- package/tooling/github-actions/workflows/preview-deployments.yml +54 -0
- package/tooling/github-actions/workflows/vercel-deploy.yml +40 -0
- package/tooling/jest-presets/browser/jest-preset.d.mts +4 -0
- package/tooling/jest-presets/browser/jest-preset.mjs +14 -0
- package/tooling/jest-presets/node/jest-preset.d.mts +4 -0
- package/tooling/jest-presets/node/jest-preset.mjs +13 -0
- package/tooling/mcp/mcp.json.example +27 -0
- package/tooling/nx/nx.json +24 -0
- package/tooling/oxlint/README.md +25 -0
- package/tooling/oxlint/oxlintrc.json +28 -0
- package/tooling/playwright/playwright.config.d.mts +4 -0
- package/tooling/playwright/playwright.config.mjs +19 -0
- package/tooling/prettier/index.d.mts +4 -0
- package/tooling/prettier/index.mjs +36 -0
- package/tooling/release-please/.release-please-manifest.json +3 -0
- package/tooling/release-please/release-please-config.json +9 -0
- package/tooling/rolldown/rolldown.config.d.mts +18 -0
- package/tooling/rolldown/rolldown.config.mjs +59 -0
- package/tooling/rollup/rollup.config.d.mts +20 -0
- package/tooling/rollup/rollup.config.mjs +70 -0
- package/tooling/semantic-release/docker.d.mts +4 -0
- package/tooling/semantic-release/docker.mjs +59 -0
- package/tooling/semantic-release/github.d.mts +4 -0
- package/tooling/semantic-release/github.mjs +79 -0
- package/tooling/semantic-release/index.d.mts +4 -0
- package/tooling/semantic-release/index.mjs +80 -0
- package/tooling/swift/periphery.yml +4 -0
- package/tooling/swift/swiftlint.yml +23 -0
- package/tooling/tests/exports-resolution.d.mts +14 -0
- package/tooling/tests/exports-resolution.mjs +68 -0
- package/tooling/tests/ssr-safety.d.mts +14 -0
- package/tooling/tests/ssr-safety.mjs +53 -0
- package/tooling/tsup/index.d.mts +8 -0
- package/tooling/tsup/index.mjs +33 -0
- package/tooling/typedoc/typedoc.json +7 -0
- package/tooling/typescript/README.md +49 -0
- package/tooling/typescript/reset.d.ts +9 -0
- package/tooling/typescript/tsconfig.base.json +84 -0
- package/tooling/typescript/tsconfig.build.json +11 -0
- package/tooling/typescript/tsconfig.bun.json +9 -0
- package/tooling/typescript/tsconfig.express.json +9 -0
- package/tooling/typescript/tsconfig.next.json +20 -0
- package/tooling/typescript/tsconfig.node.json +9 -0
- package/tooling/typescript/tsconfig.react.json +15 -0
- package/tooling/typescript/tsconfig.test.json +8 -0
- package/tooling/typescript/v1/tsconfig.base.json +81 -0
- package/tooling/typescript/v1/tsconfig.express.json +9 -0
- package/tooling/typescript/v1/tsconfig.next.json +19 -0
- package/tooling/typescript/v1/tsconfig.node.json +9 -0
- package/tooling/typescript/v1/tsconfig.react.json +14 -0
- package/tooling/typescript/v1/tsconfig.test.json +8 -0
- package/tooling/vite/vite.config.d.mts +4 -0
- package/tooling/vite/vite.config.mjs +18 -0
- package/tooling/vitest/jsdom-shims.d.mts +1 -0
- package/tooling/vitest/jsdom-shims.mjs +58 -0
- package/tooling/vitest/vitest.config.d.mts +4 -0
- package/tooling/vitest/vitest.config.mjs +25 -0
- package/tooling/vitest/vitest.config.react.d.mts +4 -0
- package/tooling/vitest/vitest.config.react.mjs +27 -0
- package/tooling/vitest/vitest.setup.d.mts +1 -0
- 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
|
+
}
|