rootless-config 1.1.0 → 1.2.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.2.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": {
@@ -9,12 +9,18 @@ import { patchPackageScripts } from '../../core/scriptPatcher.js'
9
9
 
10
10
  const CONFIG_PATTERN = /\.(config|rc)\.(js|ts|mjs|cjs|json)$|\.eslintrc$|\.babelrc$|tsconfig.*\.json$/
11
11
  const ENV_PATTERN = /^\.env/
12
+ // Dot-files and special files that must be in root — auto-detected
13
+ const DOTFILE_PATTERN = /^\.(nvmrc|node-version|npmrc|yarnrc|yarnrc\.yml|pnpmfile\.cjs|editorconfig|gitignore|gitattributes|dockerignore|browserslistrc|prettierignore|eslintignore|stylelintignore|czrc|cz\.json|commitlintrc.*|lintstagedrc.*|releaserc.*)$|^(Dockerfile.*|Procfile|browserslist)$/
12
14
 
13
15
  async function findMigratableFiles(projectRoot) {
14
16
  const entries = await readdir(projectRoot, { withFileTypes: true })
15
17
  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
18
+ .filter(e => e.isFile() && (
19
+ CONFIG_PATTERN.test(e.name) ||
20
+ ENV_PATTERN.test(e.name) ||
21
+ DOTFILE_PATTERN.test(e.name)
22
+ ))
23
+ .filter(e => e.name !== 'package.json')
18
24
  .map(e => e.name)
19
25
  .sort()
20
26
  }
@@ -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,
@@ -5,23 +5,110 @@ import { readdir } from 'node:fs/promises'
5
5
  import { readJsonFile, writeJsonFile, fileExists } from '../utils/fsUtils.js'
6
6
 
7
7
  /**
8
- * Known tools: what config files they use and which flag points to a config.
8
+ * Tools that support a --config (or similar) flag.
9
+ * These files can be REMOVED from root — scripts get patched instead.
9
10
  * Ordered by specificity (longer command first to avoid partial matches).
10
11
  */
11
12
  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'] },
13
+ { cmd: 'vite build', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'] },
14
+ { cmd: 'vite preview', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'] },
15
+ { cmd: 'vite', flag: '--config', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'] },
16
+ { cmd: 'vitest run', flag: '--config', files: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'] },
17
+ { cmd: 'vitest', flag: '--config', files: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'] },
18
+ { cmd: 'eslint', flag: '--config', files: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'] },
19
+ { cmd: 'prettier', flag: '--config', files: ['prettier.config.js', 'prettier.config.cjs', '.prettierrc.js', 'prettier.config.mjs'] },
20
+ { cmd: 'jest', flag: '--config', files: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'] },
21
+ { cmd: 'webpack serve', flag: '--config', files: ['webpack.config.js', 'webpack.config.ts', 'webpack.config.mjs'] },
22
+ { cmd: 'webpack', flag: '--config', files: ['webpack.config.js', 'webpack.config.ts', 'webpack.config.mjs'] },
23
+ { cmd: 'rollup', flag: '--config', files: ['rollup.config.js', 'rollup.config.ts', 'rollup.config.mjs'] },
24
+ { cmd: 'tsup', flag: '--config', files: ['tsup.config.js', 'tsup.config.ts', 'tsup.config.mjs'] },
25
+ { cmd: 'tsc', flag: '--project', files: ['tsconfig.json', 'tsconfig.build.json', 'tsconfig.prod.json'] },
26
+ { cmd: 'nodemon', flag: '--config', files: ['nodemon.json', '.nodemonrc', 'nodemon.config.js'] },
27
+ { cmd: 'stylelint', flag: '--config', files: ['stylelint.config.js', 'stylelint.config.mjs', 'stylelint.config.cjs', '.stylelintrc.js', '.stylelintrc.json'] },
28
+ { cmd: 'next build', flag: '--', files: [] }, // next doesn't support --config; handled separately
29
+ { cmd: 'tailwindcss', flag: '--config', files: ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs'] },
30
+ { cmd: 'postcss', flag: '--config', files: ['postcss.config.js', 'postcss.config.mjs', 'postcss.config.cjs'] },
31
+ { cmd: 'babel', flag: '--config-file', files: ['babel.config.js', 'babel.config.mjs', 'babel.config.cjs', 'babel.config.json'] },
32
+ { cmd: 'mocha', flag: '--config', files: ['.mocharc.js', '.mocharc.cjs', '.mocharc.yml', '.mocharc.json', 'mocha.config.js'] },
33
+ { cmd: 'nyc', flag: '--config', files: ['.nycrc', '.nycrc.json', 'nyc.config.js', 'nyc.config.cjs'] },
34
+ { cmd: 'cypress run', flag: '--config-file', files: ['cypress.config.js', 'cypress.config.ts', 'cypress.config.mjs'] },
35
+ { cmd: 'cypress open', flag: '--config-file', files: ['cypress.config.js', 'cypress.config.ts', 'cypress.config.mjs'] },
36
+ { cmd: 'playwright test', flag: '--config', files: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs'] },
23
37
  ]
24
38
 
39
+ /**
40
+ * Tools that use the config file as a POSITIONAL argument (not a --flag).
41
+ * These files can also be removed from root — the argument in the script is patched.
42
+ */
43
+ const POSITIONAL_TOOLS = [
44
+ { cmd: 'pm2 start', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs'] },
45
+ { cmd: 'pm2 restart', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs'] },
46
+ { cmd: 'pm2 reload', files: ['ecosystem.config.js', 'ecosystem.config.cjs', 'ecosystem.config.mjs'] },
47
+ ]
48
+
49
+ /**
50
+ * Files that MUST be physically present in the project root.
51
+ * These cannot be redirected via CLI flags — tools discover them by convention.
52
+ * Everything in .root/configs/ NOT matching TOOL_CONFIGS or POSITIONAL_TOOLS falls into this category,
53
+ * but we keep an explicit list for clarity and migrate detection.
54
+ */
55
+ const ROOT_REQUIRED_FILES = new Set([
56
+ // Node.js version managers
57
+ '.nvmrc', '.node-version',
58
+ // Package managers
59
+ '.npmrc', '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs',
60
+ // Editor
61
+ '.editorconfig',
62
+ // Git (root discovery via git, these are auto-found)
63
+ '.gitignore', '.gitattributes', '.gitmodules',
64
+ // Docker
65
+ 'Dockerfile', '.dockerignore',
66
+ // Process / deployment
67
+ 'Procfile',
68
+ // Browser targets
69
+ '.browserslistrc', 'browserslist',
70
+ // Lint ignore files (eslint/prettier look for these automatically)
71
+ '.eslintignore', '.prettierignore', '.stylelintignore',
72
+ // Commitlint / lint-staged / release (auto-discovery only)
73
+ 'commitlint.config.js', 'commitlint.config.cjs', 'commitlint.config.mjs',
74
+ '.commitlintrc', '.commitlintrc.js', '.commitlintrc.json',
75
+ '.lintstagedrc', '.lintstagedrc.js', '.lintstagedrc.json', '.lintstagedrc.cjs',
76
+ 'lint-staged.config.js', 'lint-staged.config.mjs', 'lint-staged.config.cjs',
77
+ '.releaserc', '.releaserc.js', '.releaserc.json', '.releaserc.yml',
78
+ 'release.config.js', 'release.config.cjs',
79
+ // Semantic tooling
80
+ '.czrc', '.cz.json',
81
+ // HTTPS / certs
82
+ '.htaccess',
83
+ // Web assets
84
+ 'robots.txt', 'sitemap.xml', 'favicon.ico', 'manifest.json', 'site.webmanifest',
85
+ ])
86
+
87
+ /**
88
+ * Returns true if the file can be redirected away from root via CLI flags.
89
+ * Files where this returns false MUST be copied to root.
90
+ */
91
+ function isPatchable(filename) {
92
+ return (
93
+ TOOL_CONFIGS.some(t => t.files.includes(filename)) ||
94
+ POSITIONAL_TOOLS.some(t => t.files.includes(filename))
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Returns true if the file must always be physically present in root.
100
+ */
101
+ function isRootRequired(filename) {
102
+ if (ROOT_REQUIRED_FILES.has(filename)) return true
103
+ // .env* patterns
104
+ if (/^\.env/.test(filename)) return true
105
+ // Dockerfile variants: Dockerfile.dev, Dockerfile.prod etc.
106
+ if (/^Dockerfile/.test(filename)) return true
107
+ // Not patchable and not in ROOT_REQUIRED_FILES → still copy to be safe
108
+ if (!isPatchable(filename)) return true
109
+ return false
110
+ }
111
+
25
112
  /**
26
113
  * Build a map of { cmd → relative config path } based on what actually exists in configsDir.
27
114
  * configsDir is the absolute path to .root/configs/
@@ -36,14 +123,28 @@ async function buildConfigMap(configsDir, projectRoot) {
36
123
  }
37
124
 
38
125
  const map = {}
126
+
127
+ // Flag-based tools
39
128
  for (const tool of TOOL_CONFIGS) {
40
- if (map[tool.cmd]) continue // already mapped (longer cmd took priority)
129
+ if (!tool.files.length) continue
130
+ if (map[tool.cmd]) continue
131
+ const found = tool.files.find(f => files.includes(f))
132
+ if (!found) continue
133
+ const abs = path.join(configsDir, found)
134
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
135
+ map[tool.cmd] = { path: rel.startsWith('.') ? rel : `./${rel}`, flag: tool.flag, positional: false }
136
+ }
137
+
138
+ // Positional tools (pm2 etc.)
139
+ for (const tool of POSITIONAL_TOOLS) {
140
+ if (map[tool.cmd]) continue
41
141
  const found = tool.files.find(f => files.includes(f))
42
142
  if (!found) continue
43
143
  const abs = path.join(configsDir, found)
44
144
  const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
45
- map[tool.cmd] = rel.startsWith('.') ? rel : `./${rel}`
145
+ map[tool.cmd] = { path: rel.startsWith('.') ? rel : `./${rel}`, flag: null, positional: true, originalFile: found }
46
146
  }
147
+
47
148
  return map
48
149
  }
49
150
 
@@ -52,20 +153,27 @@ async function buildConfigMap(configsDir, projectRoot) {
52
153
  * Handles semicolons, &&, || (multi-command scripts).
53
154
  */
54
155
  function patchScriptString(script, configMap) {
55
- // Process longer commands first to avoid partial matches (vite build before vite)
156
+ // Sort by command length descending to avoid partial matches
56
157
  const sorted = Object.entries(configMap).sort((a, b) => b[0].length - a[0].length)
57
158
 
58
159
  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}`)
160
+ for (const [cmd, info] of sorted) {
161
+ if (info.positional) {
162
+ // Replace filename argument directly: pm2 start ecosystem.config.cjs → pm2 start .root/...
163
+ for (const file of (POSITIONAL_TOOLS.find(t => t.cmd === cmd)?.files ?? [])) {
164
+ const escapedFile = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
165
+ const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
166
+ const regex = new RegExp(`(${escapedCmd})\\s+${escapedFile}`, 'g')
167
+ result = result.replace(regex, `$1 ${info.path}`)
168
+ }
169
+ } else {
170
+ // Flag-based: insert --config <path> after command if not already present
171
+ const flag = info.flag
172
+ const escapedCmd = cmd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
173
+ const escapedFlag = flag.replace(/-/g, '\\-')
174
+ const regex = new RegExp(`((?:^|[;&|\\s]))(${escapedCmd})(?!\\s+${escapedFlag})`, 'g')
175
+ result = result.replace(regex, (_, pre, match) => `${pre}${match} ${flag} ${info.path}`)
176
+ }
69
177
  }
70
178
  return result
71
179
  }
@@ -114,7 +222,7 @@ async function addPrepareHook(projectRoot) {
114
222
  if (!pkg.scripts) pkg.scripts = {}
115
223
 
116
224
  const existing = pkg.scripts.prepare ?? ''
117
- if (existing.includes('rootless prepare')) return existing // already set
225
+ if (existing.includes('rootless prepare')) return existing
118
226
 
119
227
  pkg.scripts.prepare = existing
120
228
  ? `rootless prepare --yes && ${existing}`
@@ -124,4 +232,4 @@ async function addPrepareHook(projectRoot) {
124
232
  return pkg.scripts.prepare
125
233
  }
126
234
 
127
- export { patchPackageScripts, addPrepareHook, buildConfigMap, patchScriptString }
235
+ export { patchPackageScripts, addPrepareHook, buildConfigMap, patchScriptString, isPatchable, isRootRequired }