rootless-config 1.1.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootless-config",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Store project config files outside the project root, auto-deploy them where tools expect them.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,16 +5,29 @@ import { readdir, unlink, readFile } from 'node:fs/promises'
5
5
  import { createLogger } from '../../utils/logger.js'
6
6
  import { fileExists, ensureDir, atomicWrite, readJsonFile } from '../../utils/fsUtils.js'
7
7
  import { confirm } from '../../utils/prompt.js'
8
- import { patchPackageScripts } from '../../core/scriptPatcher.js'
8
+ import { patchPackageScripts, getAllKnownFiles, NEVER_MIGRATE } from '../../core/scriptPatcher.js'
9
9
 
10
- const CONFIG_PATTERN = /\.(config|rc)\.(js|ts|mjs|cjs|json)$|\.eslintrc$|\.babelrc$|tsconfig.*\.json$/
11
- const ENV_PATTERN = /^\.env/
10
+ // Pattern-based detection for file families not covered by the explicit known-files set
11
+ const ENV_PATTERN = /^\.env/ // .env, .env.local, .env.production, etc.
12
+ const TSCONFIG_PATTERN = /^tsconfig.*\.json$/ // tsconfig.custom.json etc.
13
+ const DOCKERFILE_PATTERN = /^Dockerfile/ // Dockerfile.dev, Dockerfile.prod etc.
14
+ const DOCKER_COMPOSE_PATTERN = /^docker-compose/ // docker-compose.override.yml etc.
12
15
 
13
16
  async function findMigratableFiles(projectRoot) {
17
+ const knownFiles = getAllKnownFiles()
14
18
  const entries = await readdir(projectRoot, { withFileTypes: true })
15
19
  return entries
16
- .filter(e => e.isFile() && (CONFIG_PATTERN.test(e.name) || ENV_PATTERN.test(e.name)))
17
- .filter(e => e.name !== 'package.json') // never migrate package.json
20
+ .filter(e => {
21
+ if (!e.isFile()) return false
22
+ if (NEVER_MIGRATE.has(e.name)) return false
23
+ return (
24
+ knownFiles.has(e.name) ||
25
+ ENV_PATTERN.test(e.name) ||
26
+ TSCONFIG_PATTERN.test(e.name) ||
27
+ DOCKERFILE_PATTERN.test(e.name) ||
28
+ DOCKER_COMPOSE_PATTERN.test(e.name)
29
+ )
30
+ })
18
31
  .map(e => e.name)
19
32
  .sort()
20
33
  }
@@ -62,18 +75,19 @@ export default {
62
75
  for (const name of candidates) {
63
76
  const src = path.join(projectRoot, name)
64
77
  const isEnv = ENV_PATTERN.test(name)
65
- const destDir = path.join(containerPath, isEnv ? 'env' : 'configs')
78
+ const isAsset = /\.(ico|png|jpg|jpeg|svg|txt|xml|webmanifest|json)$/.test(name) &&
79
+ ['favicon.ico', 'robots.txt', 'sitemap.xml', 'manifest.json', 'site.webmanifest'].includes(name)
80
+ const destSubdir = isEnv ? 'env' : (isAsset ? 'assets' : 'configs')
81
+ const destDir = path.join(containerPath, destSubdir)
66
82
 
67
83
  await ensureDir(destDir)
68
84
  const content = await readFile(src, 'utf8')
69
85
  await atomicWrite(path.join(destDir, name), content)
70
86
 
71
87
  if (isCleanMode) {
72
- // Clean mode: delete original — no proxy file in root
73
88
  await unlink(src)
74
- logger.success(`Moved to .root/${isEnv ? 'env' : 'configs'}/${name} (deleted from root)`)
89
+ logger.success(`Moved to .root/${destSubdir}/${name} (deleted from root)`)
75
90
  } else {
76
- // Proxy mode: replace original with re-export stub
77
91
  const rel = path.relative(path.dirname(src), path.join(destDir, name)).replace(/\\/g, '/')
78
92
  const relPath = rel.startsWith('.') ? rel : `./${rel}`
79
93
  await atomicWrite(src, `export { default } from "${relPath}"\n`)
@@ -13,7 +13,7 @@ import { readManifest, removeFromManifest } from './generatedManifest.js'
13
13
  import { removeHash } from './fileHashCache.js'
14
14
  import { resolveProjectRoot, resolveContainerPath, setContainerOverride } from './pathResolver.js'
15
15
  import { fileExists, removeFile } from '../utils/fsUtils.js'
16
- import { patchPackageScripts } from './scriptPatcher.js'
16
+ import { patchPackageScripts, isRootRequired } from './scriptPatcher.js'
17
17
  import { ValidationError } from '../types/errors.js'
18
18
 
19
19
  async function loadConfig(containerPath) {
@@ -50,28 +50,54 @@ async function preparePipeline(options = {}) {
50
50
  const containerPath = await resolveContainerPath()
51
51
  const config = ctx.container.manifest ?? {}
52
52
  const graph = await getGraph(config)
53
- const isCleanMode = (config.mode ?? 'proxy') === 'clean'
53
+ const isCleanMode = (config.mode ?? 'clean') === 'clean'
54
54
 
55
55
  const projectRoot = await resolveProjectRoot()
56
56
 
57
57
  if (isCleanMode) {
58
- // Clean mode: only copy .env files to root, patch package.json scripts for everything else
59
- const envDir = path.join(containerPath, 'env')
58
+ const envDir = path.join(containerPath, 'env')
60
59
  const configsDir = path.join(containerPath, 'configs')
60
+ const assetsDir = path.join(containerPath, 'assets')
61
61
 
62
- const envEntries = []
62
+ const copyEntries = []
63
+
64
+ // 1. env/* — all .env files always go to root
63
65
  if (await fileExists(envDir)) {
64
- const envFiles = await readdir(envDir, { withFileTypes: true })
65
- for (const f of envFiles) {
66
+ for (const f of await readdir(envDir, { withFileTypes: true })) {
66
67
  if (!f.isFile()) continue
67
68
  const absPath = path.join(envDir, f.name)
68
69
  const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
69
- envEntries.push({ source: absPath, target: path.join(projectRoot, f.name), mode: 'copy', plugin })
70
+ copyEntries.push({ source: absPath, target: path.join(projectRoot, f.name), mode: 'copy', plugin })
71
+ }
72
+ }
73
+
74
+ // 2. assets/* — favicon, robots.txt, manifests etc. always go to root
75
+ if (await fileExists(assetsDir)) {
76
+ for (const f of await readdir(assetsDir, { withFileTypes: true })) {
77
+ if (!f.isFile()) continue
78
+ const absPath = path.join(assetsDir, f.name)
79
+ const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
80
+ copyEntries.push({ source: absPath, target: path.join(projectRoot, f.name), mode: 'copy', plugin })
81
+ }
82
+ }
83
+
84
+ // 3. configs/* — split by whether the file can be redirected via CLI flags
85
+ // isRootRequired(name) = true → copy to root (tool can't be redirected: .nvmrc, .editorconfig, etc.)
86
+ // isRootRequired(name) = false → skip copy, only patch scripts below
87
+ if (await fileExists(configsDir)) {
88
+ for (const f of await readdir(configsDir, { withFileTypes: true })) {
89
+ if (!f.isFile()) continue
90
+ if (isRootRequired(f.name)) {
91
+ const absPath = path.join(configsDir, f.name)
92
+ const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
93
+ copyEntries.push({ source: absPath, target: path.join(projectRoot, f.name), mode: 'copy', plugin })
94
+ }
70
95
  }
71
96
  }
72
97
 
73
- const results = await generateFiles(envEntries, options, ctx.logger)
98
+ const results = await generateFiles(copyEntries, options, ctx.logger)
74
99
 
100
+ // 4. Patch package.json scripts for all patchable tool configs (vite, eslint, jest, pm2, etc.)
75
101
  const patched = await patchPackageScripts(projectRoot, configsDir, ctx.logger)
76
102
  if (patched) ctx.logger.success('package.json scripts patched with --config flags')
77
103
  else ctx.logger.debug('No script patching needed')
@@ -80,7 +106,7 @@ async function preparePipeline(options = {}) {
80
106
  return results
81
107
  }
82
108
 
83
- // Proxy mode (default): generate proxy/copy for every file in .root/
109
+ // Proxy mode: generate proxy/copy for every file in .root/
84
110
  const entries = await buildGenerationEntries(containerPath, graph, ctx)
85
111
  const mapped = entries.map(e => ({
86
112
  ...e,
@@ -1,31 +1,216 @@
1
- /*-------- Patches package.json scripts to use --config flags pointing to .root/ --------*/
1
+ /*-------- Patches package.json scripts to use --config flags pointing to .root/ --------*/
2
2
 
3
3
  import path from 'node:path'
4
4
  import { readdir } from 'node:fs/promises'
5
5
  import { readJsonFile, writeJsonFile, fileExists } from '../utils/fsUtils.js'
6
6
 
7
+ // ─── Tool definitions ────────────────────────────────────────────────────────
8
+
7
9
  /**
8
- * Known tools: what config files they use and which flag points to a config.
9
- * Ordered by specificity (longer command first to avoid partial matches).
10
+ * Tools that support a --config (or similar) flag.
11
+ * Matching files will NOT be copied to root scripts get patched instead.
12
+ * Ordered by command specificity (longer commands first to avoid partial matches).
10
13
  */
11
14
  const TOOL_CONFIGS = [
12
- { cmd: 'vite build', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'] },
13
- { cmd: 'vite', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'] },
14
- { cmd: 'vitest', flag: '--config', files: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'] },
15
- { cmd: 'eslint', flag: '--config', files: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'] },
16
- { cmd: 'prettier', flag: '--config', files: ['prettier.config.js', 'prettier.config.cjs', '.prettierrc.js'] },
17
- { cmd: 'jest', flag: '--config', files: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'] },
18
- { cmd: 'webpack', flag: '--config', files: ['webpack.config.js', 'webpack.config.ts', 'webpack.config.mjs'] },
19
- { cmd: 'rollup', flag: '--config', files: ['rollup.config.js', 'rollup.config.ts', 'rollup.config.mjs'] },
20
- { cmd: 'tsup', flag: '--config', files: ['tsup.config.js', 'tsup.config.ts'] },
21
- { cmd: 'tsc', flag: '--project', files: ['tsconfig.json', 'tsconfig.build.json'] },
22
- { cmd: 'nodemon', flag: '--config', files: ['nodemon.json', '.nodemonrc', 'nodemon.config.js'] },
15
+ // Vite
16
+ { cmd: 'vite build', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'] },
17
+ { cmd: 'vite preview', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'] },
18
+ { cmd: 'vite', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'] },
19
+ // Vitest
20
+ { cmd: 'vitest run', flag: '--config', files: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'] },
21
+ { cmd: 'vitest', flag: '--config', files: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'] },
22
+ // ESLint (v8 flat config + legacy .eslintrc variants)
23
+ { cmd: 'eslint', flag: '--config', files: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.mjs', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml'] },
24
+ // Prettier
25
+ { cmd: 'prettier', flag: '--config', files: ['prettier.config.js', 'prettier.config.cjs', 'prettier.config.mjs', '.prettierrc', '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.json', '.prettierrc.yaml', '.prettierrc.yml'] },
26
+ // Stylelint
27
+ { cmd: 'stylelint', flag: '--config', files: ['stylelint.config.js', 'stylelint.config.cjs', 'stylelint.config.mjs', '.stylelintrc', '.stylelintrc.js', '.stylelintrc.json', '.stylelintrc.yaml', '.stylelintrc.yml'] },
28
+ // Jest
29
+ { cmd: 'jest', flag: '--config', files: ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'] },
30
+ // Webpack
31
+ { cmd: 'webpack serve', flag: '--config', files: ['webpack.config.js', 'webpack.config.cjs', 'webpack.config.mjs', 'webpack.config.ts', 'webpack.common.js', 'webpack.dev.js', 'webpack.prod.js', 'webpack.base.js', 'webpack.config.babel.js'] },
32
+ { cmd: 'webpack', flag: '--config', files: ['webpack.config.js', 'webpack.config.cjs', 'webpack.config.mjs', 'webpack.config.ts', 'webpack.common.js', 'webpack.dev.js', 'webpack.prod.js', 'webpack.base.js', 'webpack.config.babel.js'] },
33
+ // Rollup
34
+ { cmd: 'rollup', flag: '--config', files: ['rollup.config.js', 'rollup.config.mjs', 'rollup.config.cjs', 'rollup.config.ts'] },
35
+ // tsup
36
+ { cmd: 'tsup', flag: '--config', files: ['tsup.config.js', 'tsup.config.ts', 'tsup.config.mjs'] },
37
+ // TypeScript compiler
38
+ { cmd: 'tsc', flag: '--project', files: ['tsconfig.json', 'tsconfig.base.json', 'tsconfig.app.json', 'tsconfig.build.json', 'tsconfig.node.json', 'tsconfig.spec.json', 'tsconfig.test.json', 'tsconfig.worker.json'] },
39
+ // Nodemon
40
+ { cmd: 'nodemon', flag: '--config', files: ['nodemon.json', '.nodemonrc', 'nodemon.config.js'] },
41
+ // Babel
42
+ { cmd: 'babel', flag: '--config-file', files: ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json', '.babelrc', '.babelrc.js', '.babelrc.cjs', '.babelrc.json'] },
43
+ // PostCSS
44
+ { cmd: 'postcss', flag: '--config', files: ['postcss.config.js', 'postcss.config.cjs', 'postcss.config.mjs', 'postcss.config.ts'] },
45
+ // Tailwind CSS
46
+ { cmd: 'tailwindcss', flag: '--config', files: ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts'] },
47
+ // Mocha
48
+ { cmd: 'mocha', flag: '--config', files: ['.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.yaml', '.mocharc.yml'] },
49
+ // nyc (Istanbul coverage)
50
+ { cmd: 'nyc', flag: '--nycrc', files: ['.nycrc', '.nycrc.json', 'nyc.config.js', 'nyc.config.cjs'] },
51
+ // Cypress
52
+ { cmd: 'cypress run', flag: '--config-file', files: ['cypress.config.js', 'cypress.config.ts'] },
53
+ { cmd: 'cypress open', flag: '--config-file', files: ['cypress.config.js', 'cypress.config.ts'] },
54
+ // Playwright
55
+ { cmd: 'playwright test', flag: '--config', files: ['playwright.config.js', 'playwright.config.ts'] },
56
+ // Astro
57
+ { cmd: 'astro dev', flag: '--config', files: ['astro.config.js', 'astro.config.mjs', 'astro.config.ts'] },
58
+ { cmd: 'astro build', flag: '--config', files: ['astro.config.js', 'astro.config.mjs', 'astro.config.ts'] },
59
+ { cmd: 'astro preview', flag: '--config', files: ['astro.config.js', 'astro.config.mjs', 'astro.config.ts'] },
60
+ { cmd: 'astro', flag: '--config', files: ['astro.config.js', 'astro.config.mjs', 'astro.config.ts'] },
61
+ // Biome
62
+ { cmd: 'biome check', flag: '--config-path', files: ['biome.json'] },
63
+ { cmd: 'biome lint', flag: '--config-path', files: ['biome.json'] },
64
+ { cmd: 'biome format', flag: '--config-path', files: ['biome.json'] },
65
+ { cmd: 'biome ci', flag: '--config-path', files: ['biome.json'] },
66
+ { cmd: 'biome', flag: '--config-path', files: ['biome.json'] },
67
+ ]
68
+
69
+ /**
70
+ * Tools that use the config file as a POSITIONAL argument (not a --flag).
71
+ */
72
+ const POSITIONAL_TOOLS = [
73
+ { cmd: 'pm2 start', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs', 'pm2.config.js'] },
74
+ { cmd: 'pm2 restart', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs', 'pm2.config.js'] },
75
+ { cmd: 'pm2 reload', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs', 'pm2.config.js'] },
76
+ { cmd: 'karma start', files: ['karma.conf.js', 'karma.conf.ts'] },
77
+ { cmd: 'karma', files: ['karma.conf.js', 'karma.conf.ts'] },
23
78
  ]
24
79
 
25
80
  /**
26
- * Build a map of { cmd relative config path } based on what actually exists in configsDir.
27
- * configsDir is the absolute path to .root/configs/
28
- * projectRoot is used to compute relative paths in scripts
81
+ * Files that MUST be physically present in the project root.
82
+ * Tools discover them by convention no CLI redirect is possible.
83
+ * These are COPIED to root by `rootless prepare`.
84
+ */
85
+ const ROOT_REQUIRED_FILES = new Set([
86
+ // Node / npm
87
+ '.npmrc',
88
+ // Node version managers
89
+ '.nvmrc', '.node-version',
90
+ // Package managers
91
+ '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', 'pnpm-workspace.yaml',
92
+ // TypeScript (auto-discovered by ts-node, ts-jest, esbuild, IDEs, etc.)
93
+ 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.app.json', 'tsconfig.build.json',
94
+ 'tsconfig.node.json', 'tsconfig.spec.json', 'tsconfig.test.json', 'tsconfig.worker.json',
95
+ 'jsconfig.json',
96
+ // Editor
97
+ '.editorconfig',
98
+ // Git
99
+ '.gitignore', '.gitattributes', '.gitmodules', '.mailmap',
100
+ // Linting ignore files (auto-discovered, no --flag support)
101
+ '.eslintignore', '.prettierignore', '.stylelintignore',
102
+ // Parcel (auto-discovers .parcelrc from root)
103
+ '.parcelrc', 'parcel.config.js',
104
+ // Next.js (no --config flag support)
105
+ 'next.config.js', 'next.config.mjs', 'next.config.ts',
106
+ 'middleware.ts', 'middleware.js', 'instrumentation.ts', 'instrumentation.js',
107
+ // Nuxt (auto-discovered)
108
+ 'nuxt.config.js', 'nuxt.config.ts',
109
+ // Vue CLI (auto-discovered)
110
+ 'vue.config.js',
111
+ // Angular
112
+ 'angular.json', 'proxy.conf.json',
113
+ // SvelteKit / Svelte (auto-discovered by Vite plugin from root)
114
+ 'svelte.config.js', 'svelte.config.cjs', 'svelte.config.mjs',
115
+ // Remix (auto-discovered)
116
+ 'remix.config.js', 'remix.config.mjs', 'remix.config.ts',
117
+ // Monorepo tools
118
+ 'turbo.json', 'nx.json', 'workspace.json', 'project.json', 'lerna.json', 'rush.json',
119
+ // Build tools (auto-discovered from root)
120
+ '.swcrc', 'swc.config.js', 'rome.json',
121
+ // CSS tooling (auto-discovered by framework plugins)
122
+ 'windicss.config.js', 'windicss.config.ts', 'unocss.config.js', 'unocss.config.ts',
123
+ // Browser targets
124
+ '.browserslistrc', 'browserslist',
125
+ // Commit / git hooks
126
+ '.huskyrc', '.huskyrc.json', '.huskyrc.js', '.huskyrc.yaml', '.huskyrc.yml',
127
+ 'commitlint.config.js', 'commitlint.config.cjs', 'commitlint.config.mjs',
128
+ '.commitlintrc', '.commitlintrc.js', '.commitlintrc.json', '.commitlintrc.yaml', '.commitlintrc.yml',
129
+ 'lint-staged.config.js', 'lint-staged.config.cjs', 'lint-staged.config.mjs',
130
+ '.lintstagedrc', '.lintstagedrc.js', '.lintstagedrc.json', '.lintstagedrc.cjs',
131
+ // Release automation
132
+ 'release.config.js', 'release.config.cjs', 'semantic-release.config.js', '.semantic-release.json',
133
+ '.releaserc', '.releaserc.js', '.releaserc.json', '.releaserc.yml', '.releaserc.yaml',
134
+ // Semantic commit
135
+ '.czrc', '.cz.json',
136
+ // Docker
137
+ 'Dockerfile', '.dockerignore', 'docker-compose.yml', 'docker-compose.yaml',
138
+ // CI
139
+ '.gitlab-ci.yml', 'azure-pipelines.yml',
140
+ // Web assets (served directly from root by web servers)
141
+ 'robots.txt', 'sitemap.xml', 'favicon.ico', 'manifest.json', 'site.webmanifest',
142
+ // Documentation
143
+ 'README.md', 'LICENSE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md',
144
+ 'SECURITY.md', 'SUPPORT.md',
145
+ // Dependency management / update bots
146
+ 'renovate.json', '.renovaterc', '.renovaterc.json',
147
+ // Misc
148
+ '.htaccess', 'Procfile',
149
+ ])
150
+
151
+ /**
152
+ * Lock files and core manifests that should NEVER be migrated.
153
+ * They must always live in the project root and are managed by package managers.
154
+ */
155
+ const NEVER_MIGRATE = new Set([
156
+ 'package.json',
157
+ 'package-lock.json',
158
+ 'yarn.lock',
159
+ 'pnpm-lock.yaml',
160
+ 'npm-shrinkwrap.json',
161
+ ])
162
+
163
+ // ─── Helper functions ────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Returns all config file names known to rootless across all categories.
167
+ * Excludes NEVER_MIGRATE entries. Used by the migrate command.
168
+ */
169
+ function getAllKnownFiles() {
170
+ const all = new Set()
171
+ for (const tool of TOOL_CONFIGS) {
172
+ for (const f of tool.files) all.add(f)
173
+ }
174
+ for (const tool of POSITIONAL_TOOLS) {
175
+ for (const f of tool.files) all.add(f)
176
+ }
177
+ for (const f of ROOT_REQUIRED_FILES) {
178
+ all.add(f)
179
+ }
180
+ for (const f of NEVER_MIGRATE) {
181
+ all.delete(f)
182
+ }
183
+ return all
184
+ }
185
+
186
+ /**
187
+ * Returns true if the file can be redirected via CLI flags (not copied to root).
188
+ */
189
+ function isPatchable(filename) {
190
+ return (
191
+ TOOL_CONFIGS.some(t => t.files.includes(filename)) ||
192
+ POSITIONAL_TOOLS.some(t => t.files.includes(filename))
193
+ )
194
+ }
195
+
196
+ /**
197
+ * Returns true if the file must be physically present in the project root.
198
+ */
199
+ function isRootRequired(filename) {
200
+ if (NEVER_MIGRATE.has(filename)) return false
201
+ if (ROOT_REQUIRED_FILES.has(filename)) return true
202
+ if (/^\.env/.test(filename)) return true // .env.local, .env.production, etc.
203
+ if (/^Dockerfile/.test(filename)) return true // Dockerfile.dev, Dockerfile.prod etc.
204
+ if (/^tsconfig.*\.json$/.test(filename)) return true // tsconfig.custom.json variants
205
+ if (/^docker-compose/.test(filename)) return true // docker-compose.override.yml etc.
206
+ if (!isPatchable(filename)) return true // unknown file → copy to root to be safe
207
+ return false
208
+ }
209
+
210
+ // ─── Config map building ─────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Build a map of { cmd → { path, flag, positional } } based on files in configsDir.
29
214
  */
30
215
  async function buildConfigMap(configsDir, projectRoot) {
31
216
  let files = []
@@ -36,36 +221,52 @@ async function buildConfigMap(configsDir, projectRoot) {
36
221
  }
37
222
 
38
223
  const map = {}
224
+
39
225
  for (const tool of TOOL_CONFIGS) {
40
- if (map[tool.cmd]) continue // already mapped (longer cmd took priority)
226
+ if (!tool.files.length || map[tool.cmd]) continue
41
227
  const found = tool.files.find(f => files.includes(f))
42
228
  if (!found) continue
43
229
  const abs = path.join(configsDir, found)
44
230
  const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
45
- map[tool.cmd] = rel.startsWith('.') ? rel : `./${rel}`
231
+ map[tool.cmd] = { path: rel.startsWith('.') ? rel : `./${rel}`, flag: tool.flag, positional: false }
46
232
  }
233
+
234
+ for (const tool of POSITIONAL_TOOLS) {
235
+ if (map[tool.cmd]) continue
236
+ const found = tool.files.find(f => files.includes(f))
237
+ if (!found) continue
238
+ const abs = path.join(configsDir, found)
239
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
240
+ map[tool.cmd] = { path: rel.startsWith('.') ? rel : `./${rel}`, flag: null, positional: true, originalFiles: tool.files }
241
+ }
242
+
47
243
  return map
48
244
  }
49
245
 
246
+ // ─── Script patching ─────────────────────────────────────────────────────────
247
+
50
248
  /**
51
- * Inject --config flag into a single script string where missing.
52
- * Handles semicolons, &&, || (multi-command scripts).
249
+ * Inject --config flag (or replace positional arg) in a single script string.
53
250
  */
54
251
  function patchScriptString(script, configMap) {
55
- // Process longer commands first to avoid partial matches (vite build before vite)
56
252
  const sorted = Object.entries(configMap).sort((a, b) => b[0].length - a[0].length)
57
253
 
58
254
  let result = script
59
- for (const [cmd, configPath] of sorted) {
60
- const tool = TOOL_CONFIGS.find(t => t.cmd === cmd)
61
- if (!tool) continue
62
- const flag = tool.flag
63
-
64
- // Match the command not already followed by its flag
65
- // Word boundary on left, ensure flag not already present after the cmd
66
- const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
67
- const regex = new RegExp(`((?:^|[;&|\\s]))(${escapedCmd})(?!\\s+${flag.replace(/-/g, '\\-')})`, 'g')
68
- result = result.replace(regex, (_, pre, match) => `${pre}${match} ${flag} ${configPath}`)
255
+ for (const [cmd, info] of sorted) {
256
+ if (info.positional) {
257
+ for (const file of (info.originalFiles ?? [])) {
258
+ const escapedFile = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
259
+ const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
260
+ const regex = new RegExp(`(${escapedCmd})\\s+${escapedFile}`, 'g')
261
+ result = result.replace(regex, `$1 ${info.path}`)
262
+ }
263
+ } else {
264
+ const flag = info.flag
265
+ const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
266
+ const escapedFlag = flag.replace(/-/g, '\\-')
267
+ const regex = new RegExp(`((?:^|[;&|\\s]))(${escapedCmd})(?!\\s+${escapedFlag})`, 'g')
268
+ result = result.replace(regex, (_, pre, match) => `${pre}${match} ${flag} ${info.path}`)
269
+ }
69
270
  }
70
271
  return result
71
272
  }
@@ -95,16 +296,12 @@ async function patchPackageScripts(projectRoot, configsDir, logger) {
95
296
  }
96
297
  }
97
298
 
98
- if (changed) {
99
- await writeJsonFile(pkgPath, pkg)
100
- }
299
+ if (changed) await writeJsonFile(pkgPath, pkg)
101
300
  return changed
102
301
  }
103
302
 
104
303
  /**
105
304
  * Add "prepare": "rootless prepare --yes" to project's package.json.
106
- * If prepare already exists and doesn't include rootless, prepends to it.
107
- * Returns the final prepare script value.
108
305
  */
109
306
  async function addPrepareHook(projectRoot) {
110
307
  const pkgPath = path.join(projectRoot, 'package.json')
@@ -114,7 +311,7 @@ async function addPrepareHook(projectRoot) {
114
311
  if (!pkg.scripts) pkg.scripts = {}
115
312
 
116
313
  const existing = pkg.scripts.prepare ?? ''
117
- if (existing.includes('rootless prepare')) return existing // already set
314
+ if (existing.includes('rootless prepare')) return existing
118
315
 
119
316
  pkg.scripts.prepare = existing
120
317
  ? `rootless prepare --yes && ${existing}`
@@ -124,4 +321,14 @@ async function addPrepareHook(projectRoot) {
124
321
  return pkg.scripts.prepare
125
322
  }
126
323
 
127
- export { patchPackageScripts, addPrepareHook, buildConfigMap, patchScriptString }
324
+ export {
325
+ patchPackageScripts,
326
+ addPrepareHook,
327
+ buildConfigMap,
328
+ patchScriptString,
329
+ isPatchable,
330
+ isRootRequired,
331
+ getAllKnownFiles,
332
+ NEVER_MIGRATE,
333
+ }
334
+