rootless-config 1.0.2 → 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.0.2",
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": {
@@ -2,14 +2,15 @@
2
2
 
3
3
  import path from 'node:path'
4
4
  import { createLogger } from '../../utils/logger.js'
5
- import { fileExists, ensureDir, atomicWrite, writeJsonFile } from '../../utils/fsUtils.js'
5
+ import { fileExists, ensureDir, atomicWrite, writeJsonFile, readJsonFile } from '../../utils/fsUtils.js'
6
6
  import { confirm } from '../../utils/prompt.js'
7
7
  import { getLibraryVersion } from '../../core/versionCheck.js'
8
+ import { addPrepareHook } from '../../core/scriptPatcher.js'
8
9
 
9
10
  const DIRS = ['configs', 'env', 'assets']
10
11
 
11
12
  const DEFAULT_CONFIG = {
12
- mode: 'proxy',
13
+ mode: 'clean',
13
14
  experimental: {
14
15
  virtualFS: false,
15
16
  capsule: false,
@@ -28,7 +29,7 @@ export default {
28
29
  const containerPath = path.join(projectRoot, '.root')
29
30
 
30
31
  if (await fileExists(containerPath)) {
31
- const proceed = await confirm('.root already exists. Reinitialize?')
32
+ const proceed = args.yes || await confirm('.root already exists. Reinitialize?')
32
33
  if (!proceed) {
33
34
  logger.info('Init cancelled')
34
35
  return
@@ -46,7 +47,14 @@ export default {
46
47
  await atomicWrite(path.join(containerPath, 'rootless.version'), getLibraryVersion())
47
48
  logger.success('Created: .root/rootless.version')
48
49
 
50
+ // Add "prepare": "rootless prepare --yes" so npm install auto-triggers generation
51
+ const pkgPath = path.join(projectRoot, 'package.json')
52
+ if (await fileExists(pkgPath)) {
53
+ const hookValue = await addPrepareHook(projectRoot)
54
+ if (hookValue) logger.success(`Added prepare hook to package.json: "${hookValue}"`)
55
+ }
56
+
49
57
  logger.info('Done. Move your config files into .root/configs/, .root/env/, or .root/assets/')
50
- logger.info('Then run: rootless prepare')
58
+ logger.info('Then run: rootless migrate (auto-moves files + patches package.json scripts)')
51
59
  },
52
60
  }
@@ -1,23 +1,39 @@
1
1
  /*-------- rootless migrate — moves existing root configs into .root container --------*/
2
2
 
3
3
  import path from 'node:path'
4
- import { readdir } from 'node:fs/promises'
4
+ import { readdir, unlink, readFile } from 'node:fs/promises'
5
5
  import { createLogger } from '../../utils/logger.js'
6
- import { fileExists, ensureDir, atomicWrite } from '../../utils/fsUtils.js'
7
- import { readFile } from 'node:fs/promises'
6
+ import { fileExists, ensureDir, atomicWrite, readJsonFile } from '../../utils/fsUtils.js'
8
7
  import { confirm } from '../../utils/prompt.js'
8
+ import { patchPackageScripts } from '../../core/scriptPatcher.js'
9
9
 
10
- const CONFIG_PATTERN = /\.(config|rc)\.(js|ts|mjs|cjs|json)$|\.eslintrc$|\.babelrc$/
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)))
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')
17
24
  .map(e => e.name)
18
25
  .sort()
19
26
  }
20
27
 
28
+ async function getProjectMode(containerPath) {
29
+ try {
30
+ const cfg = await readJsonFile(path.join(containerPath, 'rootless.config.json'))
31
+ return cfg.mode ?? 'clean'
32
+ } catch {
33
+ return 'clean'
34
+ }
35
+ }
36
+
21
37
  export default {
22
38
  name: 'migrate',
23
39
  description: 'Move existing config files from project root into .root container',
@@ -26,6 +42,8 @@ export default {
26
42
  const logger = createLogger({ verbose: args.verbose ?? false })
27
43
  const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
28
44
  const containerPath = path.join(projectRoot, '.root')
45
+ const mode = await getProjectMode(containerPath)
46
+ const isCleanMode = mode === 'clean'
29
47
 
30
48
  const candidates = await findMigratableFiles(projectRoot)
31
49
  if (candidates.length === 0) {
@@ -35,6 +53,12 @@ export default {
35
53
 
36
54
  logger.info(`Found ${candidates.length} migratable files:\n ${candidates.join('\n ')}`)
37
55
 
56
+ if (isCleanMode) {
57
+ logger.info('Mode: clean — originals will be DELETED from root, package.json scripts will be patched')
58
+ } else {
59
+ logger.info('Mode: proxy — originals will be replaced with re-export stubs')
60
+ }
61
+
38
62
  const proceed = args.yes || await confirm('Move these files to .root/?')
39
63
  if (!proceed) {
40
64
  logger.info('Migration cancelled')
@@ -50,13 +74,31 @@ export default {
50
74
  const content = await readFile(src, 'utf8')
51
75
  await atomicWrite(path.join(destDir, name), content)
52
76
 
53
- const rel = path.relative(path.dirname(src), path.join(containerPath, isEnv ? 'env' : 'configs', name)).replace(/\\/g, '/')
54
- const relPath = rel.startsWith('.') ? rel : `./${rel}`
55
- await atomicWrite(src, `export { default } from "${relPath}"\n`)
56
-
57
- logger.success(`Migrated: ${name}`)
77
+ if (isCleanMode) {
78
+ // Clean mode: delete original no proxy file in root
79
+ await unlink(src)
80
+ logger.success(`Moved to .root/${isEnv ? 'env' : 'configs'}/${name} (deleted from root)`)
81
+ } else {
82
+ // Proxy mode: replace original with re-export stub
83
+ const rel = path.relative(path.dirname(src), path.join(destDir, name)).replace(/\\/g, '/')
84
+ const relPath = rel.startsWith('.') ? rel : `./${rel}`
85
+ await atomicWrite(src, `export { default } from "${relPath}"\n`)
86
+ logger.success(`Migrated: ${name}`)
87
+ }
58
88
  }
59
89
 
60
- logger.info('Run: rootless prepare to regenerate')
90
+ if (isCleanMode) {
91
+ // Patch package.json scripts to point to .root/configs/
92
+ const configsDir = path.join(containerPath, 'configs')
93
+ const patched = await patchPackageScripts(projectRoot, configsDir, logger)
94
+ if (patched) {
95
+ logger.success('package.json scripts patched — tools will now use configs from .root/configs/')
96
+ } else {
97
+ logger.info('No npm scripts needed patching (or none found)')
98
+ }
99
+ logger.info('Run: rootless prepare (copies .env files to root)')
100
+ } else {
101
+ logger.info('Run: rootless prepare to regenerate')
102
+ }
61
103
  },
62
104
  }
@@ -13,6 +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, isRootRequired } from './scriptPatcher.js'
16
17
  import { ValidationError } from '../types/errors.js'
17
18
 
18
19
  async function loadConfig(containerPath) {
@@ -49,9 +50,64 @@ async function preparePipeline(options = {}) {
49
50
  const containerPath = await resolveContainerPath()
50
51
  const config = ctx.container.manifest ?? {}
51
52
  const graph = await getGraph(config)
53
+ const isCleanMode = (config.mode ?? 'clean') === 'clean'
52
54
 
53
- const entries = await buildGenerationEntries(containerPath, graph, ctx)
54
55
  const projectRoot = await resolveProjectRoot()
56
+
57
+ if (isCleanMode) {
58
+ const envDir = path.join(containerPath, 'env')
59
+ const configsDir = path.join(containerPath, 'configs')
60
+ const assetsDir = path.join(containerPath, 'assets')
61
+
62
+ const copyEntries = []
63
+
64
+ // 1. env/* — all .env files always go to root
65
+ if (await fileExists(envDir)) {
66
+ for (const f of await readdir(envDir, { withFileTypes: true })) {
67
+ if (!f.isFile()) continue
68
+ const absPath = path.join(envDir, f.name)
69
+ const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
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
+ }
95
+ }
96
+ }
97
+
98
+ const results = await generateFiles(copyEntries, options, ctx.logger)
99
+
100
+ // 4. Patch package.json scripts for all patchable tool configs (vite, eslint, jest, pm2, etc.)
101
+ const patched = await patchPackageScripts(projectRoot, configsDir, ctx.logger)
102
+ if (patched) ctx.logger.success('package.json scripts patched with --config flags')
103
+ else ctx.logger.debug('No script patching needed')
104
+
105
+ await ctx.dispose()
106
+ return results
107
+ }
108
+
109
+ // Proxy mode: generate proxy/copy for every file in .root/
110
+ const entries = await buildGenerationEntries(containerPath, graph, ctx)
55
111
  const mapped = entries.map(e => ({
56
112
  ...e,
57
113
  target: path.join(projectRoot, path.basename(e.source)),
@@ -0,0 +1,235 @@
1
+ /*-------- Patches package.json scripts to use --config flags pointing to .root/ --------*/
2
+
3
+ import path from 'node:path'
4
+ import { readdir } from 'node:fs/promises'
5
+ import { readJsonFile, writeJsonFile, fileExists } from '../utils/fsUtils.js'
6
+
7
+ /**
8
+ * Tools that support a --config (or similar) flag.
9
+ * These files can be REMOVED from root — scripts get patched instead.
10
+ * Ordered by specificity (longer command first to avoid partial matches).
11
+ */
12
+ const TOOL_CONFIGS = [
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'] },
37
+ ]
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
+
112
+ /**
113
+ * Build a map of { cmd → relative config path } based on what actually exists in configsDir.
114
+ * configsDir is the absolute path to .root/configs/
115
+ * projectRoot is used to compute relative paths in scripts
116
+ */
117
+ async function buildConfigMap(configsDir, projectRoot) {
118
+ let files = []
119
+ try {
120
+ files = await readdir(configsDir)
121
+ } catch {
122
+ return {}
123
+ }
124
+
125
+ const map = {}
126
+
127
+ // Flag-based tools
128
+ for (const tool of TOOL_CONFIGS) {
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
141
+ const found = tool.files.find(f => files.includes(f))
142
+ if (!found) continue
143
+ const abs = path.join(configsDir, found)
144
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
145
+ map[tool.cmd] = { path: rel.startsWith('.') ? rel : `./${rel}`, flag: null, positional: true, originalFile: found }
146
+ }
147
+
148
+ return map
149
+ }
150
+
151
+ /**
152
+ * Inject --config flag into a single script string where missing.
153
+ * Handles semicolons, &&, || (multi-command scripts).
154
+ */
155
+ function patchScriptString(script, configMap) {
156
+ // Sort by command length descending to avoid partial matches
157
+ const sorted = Object.entries(configMap).sort((a, b) => b[0].length - a[0].length)
158
+
159
+ let result = script
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
+ }
177
+ }
178
+ return result
179
+ }
180
+
181
+ /**
182
+ * Patch all scripts in the project's package.json.
183
+ * Returns true if any scripts were changed.
184
+ */
185
+ async function patchPackageScripts(projectRoot, configsDir, logger) {
186
+ const pkgPath = path.join(projectRoot, 'package.json')
187
+ if (!(await fileExists(pkgPath))) return false
188
+
189
+ const pkg = await readJsonFile(pkgPath)
190
+ if (!pkg.scripts || typeof pkg.scripts !== 'object') return false
191
+
192
+ const configMap = await buildConfigMap(configsDir, projectRoot)
193
+ if (Object.keys(configMap).length === 0) return false
194
+
195
+ let changed = false
196
+ for (const [name, script] of Object.entries(pkg.scripts)) {
197
+ if (typeof script !== 'string') continue
198
+ const patched = patchScriptString(script, configMap)
199
+ if (patched !== script) {
200
+ pkg.scripts[name] = patched
201
+ changed = true
202
+ logger?.debug(`Patched script "${name}": ${patched}`)
203
+ }
204
+ }
205
+
206
+ if (changed) {
207
+ await writeJsonFile(pkgPath, pkg)
208
+ }
209
+ return changed
210
+ }
211
+
212
+ /**
213
+ * Add "prepare": "rootless prepare --yes" to project's package.json.
214
+ * If prepare already exists and doesn't include rootless, prepends to it.
215
+ * Returns the final prepare script value.
216
+ */
217
+ async function addPrepareHook(projectRoot) {
218
+ const pkgPath = path.join(projectRoot, 'package.json')
219
+ if (!(await fileExists(pkgPath))) return null
220
+
221
+ const pkg = await readJsonFile(pkgPath)
222
+ if (!pkg.scripts) pkg.scripts = {}
223
+
224
+ const existing = pkg.scripts.prepare ?? ''
225
+ if (existing.includes('rootless prepare')) return existing
226
+
227
+ pkg.scripts.prepare = existing
228
+ ? `rootless prepare --yes && ${existing}`
229
+ : 'rootless prepare --yes'
230
+
231
+ await writeJsonFile(pkgPath, pkg)
232
+ return pkg.scripts.prepare
233
+ }
234
+
235
+ export { patchPackageScripts, addPrepareHook, buildConfigMap, patchScriptString, isPatchable, isRootRequired }