rootless-config 1.0.2 → 1.1.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.1.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,33 @@
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
12
 
13
13
  async function findMigratableFiles(projectRoot) {
14
14
  const entries = await readdir(projectRoot, { withFileTypes: true })
15
15
  return entries
16
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
17
18
  .map(e => e.name)
18
19
  .sort()
19
20
  }
20
21
 
22
+ async function getProjectMode(containerPath) {
23
+ try {
24
+ const cfg = await readJsonFile(path.join(containerPath, 'rootless.config.json'))
25
+ return cfg.mode ?? 'clean'
26
+ } catch {
27
+ return 'clean'
28
+ }
29
+ }
30
+
21
31
  export default {
22
32
  name: 'migrate',
23
33
  description: 'Move existing config files from project root into .root container',
@@ -26,6 +36,8 @@ export default {
26
36
  const logger = createLogger({ verbose: args.verbose ?? false })
27
37
  const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
28
38
  const containerPath = path.join(projectRoot, '.root')
39
+ const mode = await getProjectMode(containerPath)
40
+ const isCleanMode = mode === 'clean'
29
41
 
30
42
  const candidates = await findMigratableFiles(projectRoot)
31
43
  if (candidates.length === 0) {
@@ -35,6 +47,12 @@ export default {
35
47
 
36
48
  logger.info(`Found ${candidates.length} migratable files:\n ${candidates.join('\n ')}`)
37
49
 
50
+ if (isCleanMode) {
51
+ logger.info('Mode: clean — originals will be DELETED from root, package.json scripts will be patched')
52
+ } else {
53
+ logger.info('Mode: proxy — originals will be replaced with re-export stubs')
54
+ }
55
+
38
56
  const proceed = args.yes || await confirm('Move these files to .root/?')
39
57
  if (!proceed) {
40
58
  logger.info('Migration cancelled')
@@ -50,13 +68,31 @@ export default {
50
68
  const content = await readFile(src, 'utf8')
51
69
  await atomicWrite(path.join(destDir, name), content)
52
70
 
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}`)
71
+ if (isCleanMode) {
72
+ // Clean mode: delete original no proxy file in root
73
+ await unlink(src)
74
+ logger.success(`Moved to .root/${isEnv ? 'env' : 'configs'}/${name} (deleted from root)`)
75
+ } else {
76
+ // Proxy mode: replace original with re-export stub
77
+ const rel = path.relative(path.dirname(src), path.join(destDir, name)).replace(/\\/g, '/')
78
+ const relPath = rel.startsWith('.') ? rel : `./${rel}`
79
+ await atomicWrite(src, `export { default } from "${relPath}"\n`)
80
+ logger.success(`Migrated: ${name}`)
81
+ }
58
82
  }
59
83
 
60
- logger.info('Run: rootless prepare to regenerate')
84
+ if (isCleanMode) {
85
+ // Patch package.json scripts to point to .root/configs/
86
+ const configsDir = path.join(containerPath, 'configs')
87
+ const patched = await patchPackageScripts(projectRoot, configsDir, logger)
88
+ if (patched) {
89
+ logger.success('package.json scripts patched — tools will now use configs from .root/configs/')
90
+ } else {
91
+ logger.info('No npm scripts needed patching (or none found)')
92
+ }
93
+ logger.info('Run: rootless prepare (copies .env files to root)')
94
+ } else {
95
+ logger.info('Run: rootless prepare to regenerate')
96
+ }
61
97
  },
62
98
  }
@@ -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 } from './scriptPatcher.js'
16
17
  import { ValidationError } from '../types/errors.js'
17
18
 
18
19
  async function loadConfig(containerPath) {
@@ -49,9 +50,38 @@ 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 ?? 'proxy') === 'clean'
52
54
 
53
- const entries = await buildGenerationEntries(containerPath, graph, ctx)
54
55
  const projectRoot = await resolveProjectRoot()
56
+
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')
60
+ const configsDir = path.join(containerPath, 'configs')
61
+
62
+ const envEntries = []
63
+ if (await fileExists(envDir)) {
64
+ const envFiles = await readdir(envDir, { withFileTypes: true })
65
+ for (const f of envFiles) {
66
+ if (!f.isFile()) continue
67
+ const absPath = path.join(envDir, f.name)
68
+ const plugin = ctx.pluginRegistry.resolve(absPath)[0] ?? null
69
+ envEntries.push({ source: absPath, target: path.join(projectRoot, f.name), mode: 'copy', plugin })
70
+ }
71
+ }
72
+
73
+ const results = await generateFiles(envEntries, options, ctx.logger)
74
+
75
+ const patched = await patchPackageScripts(projectRoot, configsDir, ctx.logger)
76
+ if (patched) ctx.logger.success('package.json scripts patched with --config flags')
77
+ else ctx.logger.debug('No script patching needed')
78
+
79
+ await ctx.dispose()
80
+ return results
81
+ }
82
+
83
+ // Proxy mode (default): generate proxy/copy for every file in .root/
84
+ const entries = await buildGenerationEntries(containerPath, graph, ctx)
55
85
  const mapped = entries.map(e => ({
56
86
  ...e,
57
87
  target: path.join(projectRoot, path.basename(e.source)),
@@ -0,0 +1,127 @@
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
+ * 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
+ */
11
+ 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'] },
23
+ ]
24
+
25
+ /**
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
29
+ */
30
+ async function buildConfigMap(configsDir, projectRoot) {
31
+ let files = []
32
+ try {
33
+ files = await readdir(configsDir)
34
+ } catch {
35
+ return {}
36
+ }
37
+
38
+ const map = {}
39
+ for (const tool of TOOL_CONFIGS) {
40
+ if (map[tool.cmd]) continue // already mapped (longer cmd took priority)
41
+ const found = tool.files.find(f => files.includes(f))
42
+ if (!found) continue
43
+ const abs = path.join(configsDir, found)
44
+ const rel = path.relative(projectRoot, abs).replace(/\\/g, '/')
45
+ map[tool.cmd] = rel.startsWith('.') ? rel : `./${rel}`
46
+ }
47
+ return map
48
+ }
49
+
50
+ /**
51
+ * Inject --config flag into a single script string where missing.
52
+ * Handles semicolons, &&, || (multi-command scripts).
53
+ */
54
+ function patchScriptString(script, configMap) {
55
+ // Process longer commands first to avoid partial matches (vite build before vite)
56
+ const sorted = Object.entries(configMap).sort((a, b) => b[0].length - a[0].length)
57
+
58
+ 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}`)
69
+ }
70
+ return result
71
+ }
72
+
73
+ /**
74
+ * Patch all scripts in the project's package.json.
75
+ * Returns true if any scripts were changed.
76
+ */
77
+ async function patchPackageScripts(projectRoot, configsDir, logger) {
78
+ const pkgPath = path.join(projectRoot, 'package.json')
79
+ if (!(await fileExists(pkgPath))) return false
80
+
81
+ const pkg = await readJsonFile(pkgPath)
82
+ if (!pkg.scripts || typeof pkg.scripts !== 'object') return false
83
+
84
+ const configMap = await buildConfigMap(configsDir, projectRoot)
85
+ if (Object.keys(configMap).length === 0) return false
86
+
87
+ let changed = false
88
+ for (const [name, script] of Object.entries(pkg.scripts)) {
89
+ if (typeof script !== 'string') continue
90
+ const patched = patchScriptString(script, configMap)
91
+ if (patched !== script) {
92
+ pkg.scripts[name] = patched
93
+ changed = true
94
+ logger?.debug(`Patched script "${name}": ${patched}`)
95
+ }
96
+ }
97
+
98
+ if (changed) {
99
+ await writeJsonFile(pkgPath, pkg)
100
+ }
101
+ return changed
102
+ }
103
+
104
+ /**
105
+ * 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
+ */
109
+ async function addPrepareHook(projectRoot) {
110
+ const pkgPath = path.join(projectRoot, 'package.json')
111
+ if (!(await fileExists(pkgPath))) return null
112
+
113
+ const pkg = await readJsonFile(pkgPath)
114
+ if (!pkg.scripts) pkg.scripts = {}
115
+
116
+ const existing = pkg.scripts.prepare ?? ''
117
+ if (existing.includes('rootless prepare')) return existing // already set
118
+
119
+ pkg.scripts.prepare = existing
120
+ ? `rootless prepare --yes && ${existing}`
121
+ : 'rootless prepare --yes'
122
+
123
+ await writeJsonFile(pkgPath, pkg)
124
+ return pkg.scripts.prepare
125
+ }
126
+
127
+ export { patchPackageScripts, addPrepareHook, buildConfigMap, patchScriptString }