@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,123 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { generateSwiftProject } from '../../languages/swift/scaffold.js';
5
+ import { installAiSetup } from './agent-rules.js';
6
+ import { bundlerNeedsEsbuild, ensureBuildApprovals, generateBuildConfigs } from './build.js';
7
+ import { ensurePnpmSettings } from './pnpm-workspace.js';
8
+ import { generateGitConfigs } from './git.js';
9
+ import { generateGitHubActions } from './github-actions.js';
10
+ import { generateLintingConfigs } from './linting.js';
11
+ import { generateKnipConfig, generateMiscBaseline, generateVscodeExtensions } from './misc.js';
12
+ import { generatePackageJson } from './package-json.js';
13
+ import { generateReadme } from './readme.js';
14
+ import { generateSecurityConfigs } from './security.js';
15
+ import { generateTestingConfigs } from './testing.js';
16
+ import { generateTreeshakeCheck, inferSubpathsFromExports } from './treeshake.js';
17
+ import { generateTSConfig } from './tsconfig.js';
18
+ import { generateTailwind } from './tailwind.js';
19
+ import { generateTurborepo } from './turborepo.js';
20
+ import { generateNx } from './nx.js';
21
+ import { generateBun } from './bun.js';
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = path.dirname(__filename);
24
+ export async function generateConfigs(config, targetDir) {
25
+ // Swift takes its own path (#288) rather than opting out of each step below.
26
+ // Everything from here down is rooted in package.json — the file a Swift repo
27
+ // is defined by not having.
28
+ if (config.language === 'swift') {
29
+ return generateSwiftProject(config, targetDir);
30
+ }
31
+ // Generate package.json (must run before generateMiscBaseline,
32
+ // which sets engines.node on the resulting file)
33
+ await generatePackageJson(config, targetDir);
34
+ // Universal baseline: .editorconfig, .nvmrc, engines.node, knip.json
35
+ await generateMiscBaseline(targetDir);
36
+ // Recommend the editor extensions matching the enabled tools
37
+ await generateVscodeExtensions(config, targetDir);
38
+ // Generate TypeScript configuration
39
+ if (config.typescript.enabled) {
40
+ await generateTSConfig(config, targetDir);
41
+ }
42
+ // Generate linting configurations
43
+ if (config.linting.tool !== 'none') {
44
+ await generateLintingConfigs(config, targetDir);
45
+ }
46
+ // Generate testing configurations
47
+ if (config.testing.framework !== 'none') {
48
+ await generateTestingConfigs(config, targetDir);
49
+ }
50
+ // Generate git configurations
51
+ if (config.gitHooks || config.commitLint) {
52
+ await generateGitConfigs(config, targetDir);
53
+ }
54
+ // Generate GitHub Actions workflow
55
+ await generateGitHubActions(config, targetDir);
56
+ // Generate security automation (Dependabot + CodeQL)
57
+ if (config.securityAutomation) {
58
+ await generateSecurityConfigs(targetDir);
59
+ }
60
+ // Generate build configurations
61
+ if (config.bundler !== 'none') {
62
+ await generateBuildConfigs(config, targetDir);
63
+ }
64
+ // Tree-shake verification check (libraries only, when opted-in)
65
+ if (config.treeshakeCheck && config.projectType === 'library') {
66
+ const pkgPath = path.join(targetDir, 'package.json');
67
+ const pkg = (await fs.readJson(pkgPath));
68
+ const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
69
+ if (defaultAllowed && allCandidates.length >= 2) {
70
+ await generateTreeshakeCheck(targetDir, {
71
+ workspaceName: config.projectName,
72
+ allowedSubpath: defaultAllowed,
73
+ forbiddenSubpaths: allCandidates.filter((s) => s !== defaultAllowed),
74
+ });
75
+ // treeshake-check writes pnpm-workspace.yaml; regenerate knip so it
76
+ // picks up the workspaces form (the baseline pass above ran before the
77
+ // workspace file existed and emitted the flat single-package form).
78
+ await generateKnipConfig(targetDir);
79
+ }
80
+ }
81
+ // Approve esbuild's build script for pnpm 11 via pnpm-workspace.yaml. Runs
82
+ // after the treeshake-check path so its richer workspace file (if written)
83
+ // is never clobbered.
84
+ await ensureBuildApprovals(config, targetDir);
85
+ // Family-wide pnpm settings (#314). Runs last of the workspace writers so it
86
+ // merges into whatever they wrote rather than racing them for the file.
87
+ await ensurePnpmSettings(targetDir, bundlerNeedsEsbuild(config));
88
+ // Turborepo task pipeline (pnpm-workspace monorepos, when opted-in)
89
+ if (config.turborepo) {
90
+ await generateTurborepo(targetDir);
91
+ }
92
+ // Nx task orchestrator (alternative to Turborepo, when opted-in)
93
+ if (config.nx) {
94
+ await generateNx(targetDir);
95
+ }
96
+ // Tailwind CSS v4 (frontend projects, when opted-in)
97
+ if (config.tailwind) {
98
+ await generateTailwind(targetDir);
99
+ }
100
+ // Bun runtime (#225): bunfig.toml (the Bun-typed tsconfig is already emitted
101
+ // by generateTSConfig above when config.bun, so generateBun only adds bunfig).
102
+ if (config.bun) {
103
+ await generateBun(targetDir);
104
+ }
105
+ // AI agent files (AGENTS.md, CLAUDE.md, Cursor/Copilot, Claude skill, MCP example)
106
+ if (config.aiSetup) {
107
+ await installAiSetup(targetDir);
108
+ }
109
+ // Generate README
110
+ await generateReadme(config, targetDir);
111
+ // Copy ts-reset if TypeScript is enabled
112
+ if (config.typescript.enabled) {
113
+ await copyTSReset(targetDir);
114
+ }
115
+ }
116
+ async function copyTSReset(targetDir) {
117
+ const toolingRoot = path.resolve(__dirname, '../../../tooling');
118
+ const resetPath = path.join(toolingRoot, 'typescript', 'reset.d.ts');
119
+ const targetPath = path.join(targetDir, 'reset.d.ts');
120
+ if (await fs.pathExists(resetPath)) {
121
+ await fs.copy(resetPath, targetPath);
122
+ }
123
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ export async function generateLintingConfigs(config, targetDir) {
4
+ // Generate Biome config
5
+ if (config.linting.tool === 'biome' || config.linting.tool === 'both') {
6
+ await generateBiomeConfig(targetDir);
7
+ }
8
+ // Generate ESLint config
9
+ if (config.linting.tool === 'eslint' || config.linting.tool === 'both') {
10
+ await generateESLintConfig(config, targetDir);
11
+ }
12
+ // Generate Prettier config if using ESLint
13
+ if (config.linting.tool === 'eslint') {
14
+ await generatePrettierConfig(targetDir);
15
+ }
16
+ // Generate Oxlint config (additive — runs alongside Biome/ESLint)
17
+ if (config.oxlint) {
18
+ await generateOxlintConfig(targetDir);
19
+ }
20
+ }
21
+ export async function generateOxlintConfig(targetDir) {
22
+ // Oxlint's `extends` resolution from npm packages isn't reliably supported,
23
+ // so we copy the full preset rather than write a thin pointer file (same
24
+ // pattern as biome.jsonc — the user owns the file once it's in their repo).
25
+ const { copyPreset } = await import('../utils/copy-preset.js');
26
+ await copyPreset('oxlint', targetDir);
27
+ }
28
+ export async function generateBiomeConfig(targetDir) {
29
+ const biomeConfigPath = path.join(targetDir, 'biome.jsonc');
30
+ // Biome 2.x schema + shape. The base preset (extends) already defines the
31
+ // file globs via `files.includes`; emitting the old 1.x `include`/`ignore`
32
+ // keys here forced consumers to run `biome migrate` before `biome check`
33
+ // would run at all.
34
+ const biomeConfig = {
35
+ $schema: 'https://biomejs.dev/schemas/2.5.0/schema.json',
36
+ extends: ['@rtorcato/repo-tooling/biome'],
37
+ };
38
+ await fs.writeJson(biomeConfigPath, biomeConfig, { spaces: 2 });
39
+ }
40
+ export async function generateESLintConfig(config, targetDir) {
41
+ const eslintConfigPath = path.join(targetDir, 'eslint.config.mjs');
42
+ const configType = config.linting.eslintConfig || 'base';
43
+ const eslintConfig = `import { default as config } from '@rtorcato/repo-tooling/eslint/${configType}'
44
+
45
+ export default config
46
+ `;
47
+ await fs.writeFile(eslintConfigPath, eslintConfig);
48
+ }
49
+ export async function generatePrettierConfig(targetDir) {
50
+ const prettierConfigPath = path.join(targetDir, 'prettier.config.mjs');
51
+ const prettierConfig = `export { default } from '@rtorcato/repo-tooling/prettier'
52
+ `;
53
+ await fs.writeFile(prettierConfigPath, prettierConfig);
54
+ }
@@ -0,0 +1,298 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ const EDITORCONFIG_CONTENT = `root = true
4
+
5
+ [*]
6
+ charset = utf-8
7
+ end_of_line = lf
8
+ indent_style = tab
9
+ indent_size = 2
10
+ insert_final_newline = true
11
+ trim_trailing_whitespace = true
12
+
13
+ [*.md]
14
+ trim_trailing_whitespace = false
15
+
16
+ [*.{json,yml,yaml}]
17
+ indent_style = space
18
+ indent_size = 2
19
+ `;
20
+ const NVMRC_CONTENT = '22\n';
21
+ const SIZE_LIMIT_CONFIG = [
22
+ {
23
+ name: 'package (default entry)',
24
+ path: 'dist/index.js',
25
+ limit: '10 kB',
26
+ },
27
+ ];
28
+ // Exports-driven size-limit config for multi-subpath libraries: one budget per
29
+ // exported subpath, computed from package.json at run time so newly added
30
+ // modules are covered automatically. The static-array form silently drifts as
31
+ // modules ship without a budget (#165). The root '.' barrel is skipped — it
32
+ // re-exports everything and grows legitimately. Tighten individual modules via
33
+ // OVERRIDES.
34
+ const SIZE_LIMIT_CJS = `const pkg = require('./package.json')
35
+
36
+ // Per-subpath budget overrides, e.g. { './clipboard': '500 B' }.
37
+ const OVERRIDES = {}
38
+ const DEFAULT_LIMIT = '10 kB'
39
+
40
+ // Resolve the ESM entry file for an exports condition (string, or an object
41
+ // with a string \`import\`, or a nested \`import.default\`).
42
+ function importPath(cond) {
43
+ if (typeof cond === 'string') return cond
44
+ if (cond && typeof cond === 'object') {
45
+ if (typeof cond.import === 'string') return cond.import
46
+ if (cond.import && typeof cond.import === 'object') return cond.import.default
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ module.exports = Object.entries(pkg.exports || {})
52
+ .filter(([sub]) => sub !== '.' && sub !== './package.json')
53
+ .map(([sub, cond]) => [sub, importPath(cond)])
54
+ .filter(([, file]) => typeof file === 'string')
55
+ .map(([sub, file]) => ({
56
+ name: \`\${pkg.name}\${sub.slice(1)}\`,
57
+ path: file.replace(/^\\.\\//, ''),
58
+ limit: OVERRIDES[sub] || DEFAULT_LIMIT,
59
+ }))
60
+ `;
61
+ const CODEOWNERS_CONTENT = `# .github/CODEOWNERS
62
+ # Each line is a file pattern followed by one or more owners.
63
+ # Owners can be GitHub usernames (@user) or team names (@org/team).
64
+ # Order matters — the last matching pattern wins.
65
+ # See https://docs.github.com/articles/about-code-owners/
66
+ #
67
+ # Examples:
68
+ # * @your-username
69
+ # /src/api/ @backend-team
70
+ # /docs/ @docs-team @your-username
71
+ # *.md @docs-team
72
+
73
+ * @TODO-owner
74
+ `;
75
+ export async function generateEditorConfig(targetDir) {
76
+ await fs.writeFile(path.join(targetDir, '.editorconfig'), EDITORCONFIG_CONTENT);
77
+ }
78
+ export async function generateNvmrc(targetDir) {
79
+ await fs.writeFile(path.join(targetDir, '.nvmrc'), NVMRC_CONTENT);
80
+ }
81
+ /**
82
+ * The VS Code extension IDs recommended for the tools this config enables.
83
+ * EditorConfig is always included — the baseline always writes .editorconfig.
84
+ */
85
+ export function recommendedExtensions(config) {
86
+ const ids = new Set(['EditorConfig.EditorConfig']);
87
+ const lint = config.linting.tool;
88
+ if (lint === 'biome' || lint === 'both' || config.formatting.tool === 'biome') {
89
+ ids.add('biomejs.biome');
90
+ }
91
+ if (lint === 'eslint' || lint === 'both')
92
+ ids.add('dbaeumer.vscode-eslint');
93
+ if (config.formatting.tool === 'prettier')
94
+ ids.add('esbenp.prettier-vscode');
95
+ if (config.oxlint)
96
+ ids.add('oxc.oxc-vscode');
97
+ if (config.testing.framework === 'vitest')
98
+ ids.add('vitest.explorer');
99
+ if (config.testing.framework === 'playwright')
100
+ ids.add('ms-playwright.playwright');
101
+ return [...ids];
102
+ }
103
+ /**
104
+ * Emit .vscode/extensions.json recommending the editor extensions that match the
105
+ * enabled tools. Merge-friendly: preserves any recommendations already present
106
+ * and never clobbers a hand-authored file it can't parse as JSON (e.g. JSONC
107
+ * with comments) — returns null in that case. Returns the written path otherwise.
108
+ */
109
+ export async function generateVscodeExtensions(config, targetDir) {
110
+ const filepath = path.join(targetDir, '.vscode', 'extensions.json');
111
+ let existing = [];
112
+ if (await fs.pathExists(filepath)) {
113
+ try {
114
+ const current = (await fs.readJson(filepath));
115
+ if (Array.isArray(current.recommendations)) {
116
+ existing = current.recommendations.filter((r) => typeof r === 'string');
117
+ }
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ const merged = [...new Set([...existing, ...recommendedExtensions(config)])];
124
+ await fs.ensureDir(path.dirname(filepath));
125
+ await fs.writeJson(filepath, { recommendations: merged }, { spaces: 2 });
126
+ return '.vscode/extensions.json';
127
+ }
128
+ const DEFAULT_NODE_MAJOR = 22;
129
+ /**
130
+ * Resolve the authoritative Node major: an existing .nvmrc/.node-version wins,
131
+ * else the `engines.node` floor, else the default. Used to seed .nvmrc when a
132
+ * repo has no pin yet before pointing workflows at it.
133
+ */
134
+ async function resolveNodeMajor(targetDir) {
135
+ for (const candidate of ['.nvmrc', '.node-version']) {
136
+ const p = path.join(targetDir, candidate);
137
+ if (await fs.pathExists(p)) {
138
+ const m = (await fs.readFile(p, 'utf-8')).trim().match(/\d+/);
139
+ if (m)
140
+ return Number.parseInt(m[0], 10);
141
+ }
142
+ }
143
+ const pkgPath = path.join(targetDir, 'package.json');
144
+ if (await fs.pathExists(pkgPath)) {
145
+ const pkg = (await fs.readJson(pkgPath));
146
+ const node = pkg.engines?.node;
147
+ const m = node?.match(/\d+/);
148
+ if (m)
149
+ return Number.parseInt(m[0], 10);
150
+ }
151
+ return DEFAULT_NODE_MAJOR;
152
+ }
153
+ // A hardcoded scalar `node-version: <major>` line (leading digit after an
154
+ // optional quote). Deliberately does NOT match matrix arrays (`[22, 24]`),
155
+ // `${{ }}` expressions, or bare `node-version:` input keys — those are left as-is.
156
+ const NODE_VERSION_LINE = /^([ \t]*)node-version:[ \t]*['"]?\d[\w.-]*['"]?[ \t]*$/gm;
157
+ /**
158
+ * Make .nvmrc the single Node source of truth: ensure it exists (pinned to the
159
+ * resolved major) and rewrite hardcoded scalar `node-version:` lines in CI
160
+ * workflows to `node-version-file: .nvmrc`. Returns the files changed.
161
+ */
162
+ export async function alignNodeVersion(targetDir) {
163
+ const written = [];
164
+ const nvmrcPath = path.join(targetDir, '.nvmrc');
165
+ if (!(await fs.pathExists(nvmrcPath))) {
166
+ const major = await resolveNodeMajor(targetDir);
167
+ await fs.writeFile(nvmrcPath, `${major}\n`);
168
+ written.push('.nvmrc');
169
+ }
170
+ const workflowsDir = path.join(targetDir, '.github', 'workflows');
171
+ if (await fs.pathExists(workflowsDir)) {
172
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
173
+ for (const file of files) {
174
+ const p = path.join(workflowsDir, file);
175
+ const contents = await fs.readFile(p, 'utf-8');
176
+ const next = contents.replace(NODE_VERSION_LINE, '$1node-version-file: .nvmrc');
177
+ if (next !== contents) {
178
+ await fs.writeFile(p, next);
179
+ written.push(`.github/workflows/${file}`);
180
+ }
181
+ }
182
+ }
183
+ return written;
184
+ }
185
+ export async function ensureEnginesNode(targetDir, version = '>=22') {
186
+ const pkgPath = path.join(targetDir, 'package.json');
187
+ if (!(await fs.pathExists(pkgPath)))
188
+ return 'no-package-json';
189
+ const pkg = (await fs.readJson(pkgPath));
190
+ const engines = pkg.engines ?? {};
191
+ if (engines.node)
192
+ return 'already-set';
193
+ pkg.engines = { ...engines, node: version };
194
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
195
+ return 'added';
196
+ }
197
+ /**
198
+ * Build a knip config whose `entry` matches the project's build model, read
199
+ * from package.json `exports`:
200
+ * - >1 public subpath export → per-file/multi-entry lib (e.g. react-common,
201
+ * whose build makes every src module its own entry). Every file is a public
202
+ * entry, so `entry` covers all of src — flagging none as "unused files"
203
+ * while still catching unused exports and dependencies.
204
+ * - single root barrel / no exports → narrow `src/index` entry so knip can
205
+ * trace and flag genuinely unreachable modules.
206
+ * `.tsx` is included so React libraries aren't silently skipped.
207
+ */
208
+ /**
209
+ * Extract package globs from a pnpm-workspace.yaml `packages:` list without a
210
+ * YAML dependency. Matches `- 'apps/*'` / `- "apps/*"` / `- apps/*` entries
211
+ * under the `packages:` key, stopping at the next top-level key.
212
+ */
213
+ function parseWorkspacePackages(yaml) {
214
+ const globs = [];
215
+ let inPackages = false;
216
+ for (const line of yaml.split('\n')) {
217
+ if (/^packages:\s*$/.test(line)) {
218
+ inPackages = true;
219
+ continue;
220
+ }
221
+ if (!inPackages)
222
+ continue;
223
+ const item = line.match(/^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/);
224
+ if (item?.[1]) {
225
+ globs.push(item[1].trim());
226
+ continue;
227
+ }
228
+ if (/^\S/.test(line))
229
+ break; // a new top-level key ends the list
230
+ }
231
+ return globs;
232
+ }
233
+ export async function buildKnipConfig(targetDir) {
234
+ const pkgPath = path.join(targetDir, 'package.json');
235
+ let multiEntry = false;
236
+ if (await fs.pathExists(pkgPath)) {
237
+ const pkg = (await fs.readJson(pkgPath));
238
+ if (pkg.exports && typeof pkg.exports === 'object') {
239
+ const subpaths = Object.keys(pkg.exports).filter((k) => k.startsWith('.') && k !== './package.json');
240
+ multiEntry = subpaths.length > 1;
241
+ }
242
+ }
243
+ const $schema = 'https://unpkg.com/knip@6/schema.json';
244
+ const entry = multiEntry ? ['src/**/*.{ts,tsx}'] : ['src/index.{ts,tsx}'];
245
+ const project = ['src/**/*.{ts,tsx}'];
246
+ // In a pnpm workspace, the flat entry/project form makes `knip` mis-analyze
247
+ // the sub-packages (it treats the whole repo as one package). Emit the
248
+ // `workspaces` form instead: the root keeps the build-model globs, and each
249
+ // declared package glob gets `{}` so knip auto-detects its entries.
250
+ const wsPath = path.join(targetDir, 'pnpm-workspace.yaml');
251
+ if (await fs.pathExists(wsPath)) {
252
+ const workspaces = { '.': { entry, project } };
253
+ for (const glob of parseWorkspacePackages(await fs.readFile(wsPath, 'utf-8'))) {
254
+ workspaces[glob] = {};
255
+ }
256
+ return { $schema, workspaces };
257
+ }
258
+ return { $schema, entry, project };
259
+ }
260
+ export async function generateKnipConfig(targetDir) {
261
+ const config = await buildKnipConfig(targetDir);
262
+ await fs.writeJson(path.join(targetDir, 'knip.json'), config, { spaces: 2 });
263
+ }
264
+ /**
265
+ * Scaffold a size-limit budget. Multi-subpath libraries (≥1 subpath export
266
+ * beyond the root barrel) get an exports-driven `.size-limit.cjs` that emits one
267
+ * budget per subpath from package.json at run time — so new modules are covered
268
+ * automatically. Everything else gets a static `.size-limit.json` for the root
269
+ * bundle. Returns the written filename.
270
+ */
271
+ export async function generateSizeLimitConfig(targetDir) {
272
+ const pkgPath = path.join(targetDir, 'package.json');
273
+ let subpathCount = 0;
274
+ if (await fs.pathExists(pkgPath)) {
275
+ const pkg = (await fs.readJson(pkgPath));
276
+ if (pkg.exports && typeof pkg.exports === 'object') {
277
+ subpathCount = Object.keys(pkg.exports).filter((k) => k.startsWith('./') && k !== './package.json').length;
278
+ }
279
+ }
280
+ if (subpathCount >= 1) {
281
+ await fs.writeFile(path.join(targetDir, '.size-limit.cjs'), SIZE_LIMIT_CJS);
282
+ return '.size-limit.cjs';
283
+ }
284
+ await fs.writeJson(path.join(targetDir, '.size-limit.json'), SIZE_LIMIT_CONFIG, { spaces: 2 });
285
+ return '.size-limit.json';
286
+ }
287
+ export async function generateCodeowners(targetDir) {
288
+ const filepath = path.join(targetDir, '.github', 'CODEOWNERS');
289
+ await fs.ensureDir(path.dirname(filepath));
290
+ await fs.writeFile(filepath, CODEOWNERS_CONTENT);
291
+ return '.github/CODEOWNERS';
292
+ }
293
+ export async function generateMiscBaseline(targetDir) {
294
+ await generateEditorConfig(targetDir);
295
+ await generateNvmrc(targetDir);
296
+ await ensureEnginesNode(targetDir);
297
+ await generateKnipConfig(targetDir);
298
+ }
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ /**
4
+ * Scaffold a starter nx.json for a pnpm-workspace monorepo (the Turborepo
5
+ * alternative). Copies the shipped preset — a minimal `targetDefaults` pipeline;
6
+ * plugin generators are deferred. Never clobbers a hand-tuned nx.json (there is
7
+ * no doctor "ok" short-circuit for the targeted `fix nx` path). Returns the
8
+ * relative path written, or [] when nx.json already exists.
9
+ */
10
+ export async function generateNx(targetDir) {
11
+ if (await fs.pathExists(path.join(targetDir, 'nx.json')))
12
+ return [];
13
+ const { copyPreset } = await import('../utils/copy-preset.js');
14
+ const result = await copyPreset('nx', targetDir);
15
+ return [result.target];
16
+ }