rootless-config 1.4.5 → 1.5.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.4.5",
3
+ "version": "1.5.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": {
@@ -0,0 +1,176 @@
1
+ /*-------- rootless serve — virtual static server --------*/
2
+ // Serves files from projectRoot first, then falls back to .root/assets/
3
+ // Files never need to be copied to root for local development.
4
+
5
+ import http from 'node:http'
6
+ import path from 'node:path'
7
+ import { readFile, stat } from 'node:fs/promises'
8
+ import { createLogger } from '../../utils/logger.js'
9
+
10
+ const DEFAULT_PORT = 3000
11
+
12
+ const MIME = {
13
+ '.html': 'text/html; charset=utf-8',
14
+ '.htm': 'text/html; charset=utf-8',
15
+ '.md': 'text/markdown; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.js': 'application/javascript; charset=utf-8',
18
+ '.mjs': 'application/javascript; charset=utf-8',
19
+ '.cjs': 'application/javascript; charset=utf-8',
20
+ '.ts': 'application/typescript; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
23
+ '.xml': 'application/xml; charset=utf-8',
24
+ '.txt': 'text/plain; charset=utf-8',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.gif': 'image/gif',
29
+ '.svg': 'image/svg+xml',
30
+ '.ico': 'image/x-icon',
31
+ '.webp': 'image/webp',
32
+ '.avif': 'image/avif',
33
+ '.woff': 'font/woff',
34
+ '.woff2': 'font/woff2',
35
+ '.ttf': 'font/ttf',
36
+ '.otf': 'font/otf',
37
+ '.eot': 'application/vnd.ms-fontobject',
38
+ '.pdf': 'application/pdf',
39
+ '.zip': 'application/zip',
40
+ '.map': 'application/json',
41
+ }
42
+
43
+ function getMime(filePath) {
44
+ const ext = path.extname(filePath).toLowerCase()
45
+ return MIME[ext] ?? 'application/octet-stream'
46
+ }
47
+
48
+ async function isFile(p) {
49
+ try {
50
+ return (await stat(p)).isFile()
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Try to resolve a URL path to a real file across multiple search directories.
58
+ * For directory requests also tries index.html / index.htm inside that dir.
59
+ */
60
+ async function resolveFile(requestPath, searchDirs) {
61
+ // Normalize: remove leading slash, decode
62
+ const rel = requestPath.replace(/^\//, '') || ''
63
+
64
+ for (const dir of searchDirs) {
65
+ // Skip .root itself to avoid serving internal rootless files
66
+ const candidates = rel === ''
67
+ ? [
68
+ path.join(dir, 'index.html'),
69
+ path.join(dir, 'index.htm'),
70
+ ]
71
+ : [
72
+ path.join(dir, rel),
73
+ path.join(dir, rel, 'index.html'),
74
+ path.join(dir, rel, 'index.htm'),
75
+ ]
76
+
77
+ for (const candidate of candidates) {
78
+ if (await isFile(candidate)) return candidate
79
+ }
80
+ }
81
+
82
+ return null
83
+ }
84
+
85
+ export default {
86
+ name: 'serve',
87
+ description: 'Virtual static server — serves from root + .root/assets/ without copying files',
88
+
89
+ async handler(args) {
90
+ const logger = createLogger({ verbose: args.verbose ?? false })
91
+ const port = parseInt(args.port ?? DEFAULT_PORT, 10)
92
+ const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
93
+ const containerPath = path.join(projectRoot, '.root')
94
+ const assetsDir = path.join(containerPath, 'assets')
95
+ const envDir = path.join(containerPath, 'env')
96
+
97
+ // Lookup order: real root first → .root/assets/ → .root/env/
98
+ // This means files physically in root always win.
99
+ const searchDirs = [projectRoot, assetsDir, envDir]
100
+
101
+ const server = http.createServer(async (req, res) => {
102
+ try {
103
+ const url = new URL(req.url, `http://localhost:${port}`)
104
+ const requestPath = decodeURIComponent(url.pathname)
105
+
106
+ // Block access to .root internals
107
+ if (requestPath.startsWith('/.root')) {
108
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
109
+ res.end('Forbidden')
110
+ return
111
+ }
112
+
113
+ const filePath = await resolveFile(requestPath, searchDirs)
114
+
115
+ if (filePath) {
116
+ const content = await readFile(filePath)
117
+ res.writeHead(200, {
118
+ 'Content-Type': getMime(filePath),
119
+ 'Cache-Control': 'no-cache',
120
+ })
121
+ res.end(content)
122
+
123
+ const source = filePath.startsWith(assetsDir)
124
+ ? '.root/assets'
125
+ : filePath.startsWith(envDir)
126
+ ? '.root/env'
127
+ : 'root'
128
+ logger.info(`200 GET ${requestPath} [${source}]`)
129
+ return
130
+ }
131
+
132
+ // 404 — try to serve custom error page from .root or root
133
+ const notFoundPage = await resolveFile('/404.html', searchDirs)
134
+ ?? await resolveFile('/404.htm', searchDirs)
135
+ ?? await resolveFile('/404.md', searchDirs)
136
+
137
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
138
+ if (notFoundPage) {
139
+ res.end(await readFile(notFoundPage))
140
+ } else {
141
+ res.end('<h1>404 Not Found</h1>')
142
+ }
143
+ logger.info(`404 ${requestPath}`)
144
+
145
+ } catch (err) {
146
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
147
+ res.end('Internal Server Error')
148
+ logger.fail(`500 ${req.url}: ${err.message}`)
149
+ }
150
+ })
151
+
152
+ server.listen(port, () => {
153
+ logger.success(`Virtual server running at http://localhost:${port}`)
154
+ logger.info('')
155
+ logger.info('Serving from (in order):')
156
+ logger.info(` [1] ${projectRoot}`)
157
+ logger.info(` [2] ${assetsDir}`)
158
+ logger.info(` [3] ${envDir}`)
159
+ logger.info('')
160
+ logger.info('Files in .root/ are served AS IF they are in root.')
161
+ logger.info('No need to run rootless prepare for local development.')
162
+ logger.info('')
163
+ logger.info('Press Ctrl+C to stop.')
164
+ })
165
+
166
+ // Keep process alive until Ctrl+C
167
+ await new Promise((_resolve, reject) => {
168
+ server.on('error', reject)
169
+ process.on('SIGINT', () => {
170
+ logger.info('\nServer stopped.')
171
+ server.close()
172
+ process.exit(0)
173
+ })
174
+ })
175
+ },
176
+ }
@@ -1,21 +1,62 @@
1
- /*-------- rootless setup — one-command full root cleanup: init + migrate + prepare --------*/
1
+ /*-------- rootless setup — one-command full root cleanup: init + normalize + migrate --------*/
2
2
 
3
3
  import path from 'node:path'
4
+ import { readdir, rename } from 'node:fs/promises'
4
5
  import { createLogger } from '../../utils/logger.js'
6
+ import { fileExists, ensureDir } from '../../utils/fsUtils.js'
7
+ import { isPatchable } from '../../core/scriptPatcher.js'
5
8
  import initCmd from './init.js'
6
9
  import migrateCmd from './migrate.js'
7
- import prepareCmd from './prepare.js'
10
+
11
+ /**
12
+ * Fix files that ended up in the wrong subfolder from a previous run.
13
+ * Non-patchable files (web assets, docs, scripts) belong in assets/, not configs/.
14
+ */
15
+ async function normalizeContainer(containerPath, logger) {
16
+ const configsDir = path.join(containerPath, 'configs')
17
+ const assetsDir = path.join(containerPath, 'assets')
18
+ if (!(await fileExists(configsDir))) return
19
+
20
+ const entries = await readdir(configsDir, { withFileTypes: true })
21
+ for (const e of entries) {
22
+ if (!e.isFile()) continue
23
+ if (!isPatchable(e.name)) {
24
+ // Not a tool config — belongs in assets/
25
+ await ensureDir(assetsDir)
26
+ await rename(
27
+ path.join(configsDir, e.name),
28
+ path.join(assetsDir, e.name)
29
+ )
30
+ logger.debug(`Normalized: .root/configs/${e.name} → .root/assets/${e.name}`)
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Count total files managed across all .root subdirectories.
37
+ */
38
+ async function countRootFiles(containerPath) {
39
+ let total = 0
40
+ for (const sub of ['env', 'assets', 'configs']) {
41
+ const dir = path.join(containerPath, sub)
42
+ if (!(await fileExists(dir))) continue
43
+ const entries = await readdir(dir, { withFileTypes: true })
44
+ total += entries.filter(e => e.isFile()).length
45
+ }
46
+ return total
47
+ }
8
48
 
9
49
  export default {
10
50
  name: 'setup',
11
- description: 'Full automated root cleanup: init .root, migrate all configs, prepare (copy + patch scripts)',
51
+ description: 'Full automated root cleanup: move ALL files into .root container (run prepare to deploy)',
12
52
 
13
53
  async handler(args) {
14
54
  const logger = createLogger({ verbose: args.verbose ?? false })
15
55
  const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
56
+ const containerPath = path.join(projectRoot, '.root')
16
57
 
17
58
  logger.info('─────────────────────────────────────────')
18
- logger.info(' rootless setup — cleaning your project root')
59
+ logger.info(' rootless setup — moving everything to .root/')
19
60
  logger.info('─────────────────────────────────────────')
20
61
  logger.info('')
21
62
 
@@ -24,31 +65,36 @@ export default {
24
65
  await initCmd.handler({ ...args, yes: true, cwd: projectRoot })
25
66
  logger.info('')
26
67
 
27
- // ── Step 2: migrate ───────────────────────────────────────────────────────
28
- logger.info('[2/3] Migrating config files from root…')
29
- await migrateCmd.handler({ ...args, yes: true, cwd: projectRoot })
68
+ // ── Step 2: normalize existing .root structure (fix misrouted files) ──────
69
+ logger.info('[2/3] Normalizing .root structure…')
70
+ await normalizeContainer(containerPath, logger)
71
+ logger.success('Structure normalized')
30
72
  logger.info('')
31
73
 
32
- // ── Step 3: prepare ───────────────────────────────────────────────────────
33
- logger.info('[3/3] Preparing root (copying mandatory files + patching scripts)…')
34
- await prepareCmd.handler({ ...args, yes: true })
74
+ // ── Step 3: migrate ALL files from root → .root/ ──────────────────────────
75
+ logger.info('[3/3] Migrating files from root to .root/…')
76
+ await migrateCmd.handler({ ...args, yes: true, cwd: projectRoot })
35
77
  logger.info('')
36
78
 
79
+ const total = await countRootFiles(containerPath)
80
+
37
81
  logger.info('─────────────────────────────────────────')
38
- logger.success('Setup complete! Your project root is clean.')
39
- logger.info('')
40
- logger.info('What happened:')
41
- logger.info(' • .root/ container created')
42
- logger.info(' • Config files moved to .root/configs/')
43
- logger.info(' .env* files moved to .root/env/')
44
- logger.info(' Web assets moved to .root/assets/')
45
- logger.info(' • package.json scripts patched with --config flags')
46
- logger.info(' Mandatory files copied back to root (prepare)')
47
- logger.info('')
48
- logger.info('Next steps:')
49
- logger.info(' rootless statussee what\'s managed')
50
- logger.info(' rootless doctor — verify everything is wired correctly')
51
- logger.info(' rootless watch — auto-sync on .root/ changes')
82
+ logger.success(`Setup complete! ${total} files now managed in .root/`)
83
+ logger.info('')
84
+ logger.info('Your project root is now clean.')
85
+ logger.info('')
86
+ logger.info(' .root/env/ — .env files')
87
+ logger.info(' .root/assets/ — web assets, HTML, images, scripts')
88
+ logger.info(' .root/configs/ tool configs (vite, eslint, jest…)')
89
+ logger.info('')
90
+ logger.info(' Files were REMOVED from root.')
91
+ logger.info(' Run the following before deploying or starting the project:')
92
+ logger.info('')
93
+ logger.info(' rootless preparecopies files back to root')
94
+ logger.info('')
95
+ logger.info('Other commands:')
96
+ logger.info(' rootless status — show what is managed')
97
+ logger.info(' rootless watch — auto-sync .root/ → root on changes')
52
98
  logger.info('─────────────────────────────────────────')
53
99
  },
54
100
  }
package/src/cli/index.js CHANGED
@@ -25,6 +25,10 @@ async function run(argv) {
25
25
  sub.option('--no-yes', 'Auto-decline all file override prompts')
26
26
  }
27
27
 
28
+ if (cmd.name === 'serve') {
29
+ sub.option('--port <number>', 'Port to listen on (default: 3000)')
30
+ }
31
+
28
32
  sub.option('--verbose', 'Enable verbose output')
29
33
  sub.option('--silent', 'Suppress all output')
30
34