rootless-config 1.4.6 → 1.6.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.6",
3
+ "version": "1.6.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,95 @@
1
+ /*-------- rootless run — execute a file from .root/ as if it were in project root --------*/
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import { fileExists } from '../../utils/fsUtils.js'
6
+ import { createLogger } from '../../utils/logger.js'
7
+
8
+ // Search for a filename across all .root subdirectories
9
+ async function findInRoot(containerPath, name) {
10
+ // Strip leading ./ or .\ (user might type .\.server.ps1)
11
+ const base = name.replace(/^\.[\\/]/g, '')
12
+ for (const sub of ['assets', 'configs', 'env']) {
13
+ const candidate = path.join(containerPath, sub, base)
14
+ if (await fileExists(candidate)) return candidate
15
+ }
16
+ return null
17
+ }
18
+
19
+ function buildSpawnArgs(filePath, extraArgs) {
20
+ const ext = path.extname(filePath).toLowerCase()
21
+ if (ext === '.ps1') {
22
+ return {
23
+ cmd: 'powershell',
24
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', filePath, ...extraArgs],
25
+ }
26
+ }
27
+ if (ext === '.cmd' || ext === '.bat') {
28
+ return { cmd: 'cmd', args: ['/c', filePath, ...extraArgs] }
29
+ }
30
+ if (ext === '.sh') {
31
+ return { cmd: 'bash', args: [filePath, ...extraArgs] }
32
+ }
33
+ return { cmd: filePath, args: extraArgs }
34
+ }
35
+
36
+ export default {
37
+ name: 'run',
38
+ description: 'Run a file from .root/ as if it were in project root — no file copying',
39
+
40
+ async handler(args) {
41
+ const logger = createLogger({ verbose: args.verbose ?? false })
42
+ const scriptArgs = args.scriptArgs ?? []
43
+
44
+ if (scriptArgs.length === 0) {
45
+ logger.fail('No file specified.')
46
+ logger.info('Usage: rootless run <file> [args...]')
47
+ logger.info('Example: rootless run .server.ps1')
48
+ logger.info('Example: rootless run .server.run.cmd')
49
+ process.exitCode = 1
50
+ return
51
+ }
52
+
53
+ const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
54
+ const containerPath = path.join(projectRoot, '.root')
55
+ const [name, ...rest] = scriptArgs
56
+
57
+ // Check project root first, then .root subdirs
58
+ const inRoot = path.join(projectRoot, name.replace(/^\.[\\/]/g, ''))
59
+ let filePath = (await fileExists(inRoot)) ? inRoot : await findInRoot(containerPath, name)
60
+
61
+ if (!filePath) {
62
+ logger.fail(`Not found: "${name}"`)
63
+ logger.info('Searched: project root, .root/assets/, .root/configs/, .root/env/')
64
+ process.exitCode = 1
65
+ return
66
+ }
67
+
68
+ const rel = filePath.startsWith(containerPath)
69
+ ? `.root/${path.relative(containerPath, filePath).replace(/\\/g, '/')}`
70
+ : path.relative(projectRoot, filePath)
71
+
72
+ logger.info(`Running: ${name} [${rel}]`)
73
+ logger.info(`cwd: ${projectRoot}`)
74
+ logger.info('')
75
+
76
+ const { cmd, args: cmdArgs } = buildSpawnArgs(filePath, rest)
77
+
78
+ const child = spawn(cmd, cmdArgs, {
79
+ cwd: projectRoot, // always runs from project root
80
+ stdio: 'inherit',
81
+ shell: false,
82
+ })
83
+
84
+ child.on('error', (err) => {
85
+ logger.fail(`Failed to start: ${err.message}`)
86
+ process.exitCode = 1
87
+ })
88
+
89
+ await new Promise(resolve => child.on('exit', (code) => {
90
+ process.exitCode = code ?? 0
91
+ resolve()
92
+ }))
93
+ },
94
+ }
95
+
@@ -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
+ }
@@ -0,0 +1,118 @@
1
+ /*-------- rootless shell — interactive shell where .root/ files act as if in project root --------*/
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { writeFile, unlink, readdir } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import os from 'node:os'
7
+ import { fileExists } from '../../utils/fsUtils.js'
8
+ import { createLogger } from '../../utils/logger.js'
9
+
10
+ /**
11
+ * Build a temporary PowerShell profile that:
12
+ * 1. Adds .root/assets/ to PATH (so .cmd/.bat/.exe files are runnable by name)
13
+ * 2. Creates a function wrapper for each .ps1 file so it's callable by name
14
+ * 3. Overrides prompt to show [rootless] prefix
15
+ */
16
+ async function buildPsProfile(projectRoot, containerPath) {
17
+ const assetsDir = path.join(containerPath, 'assets')
18
+ const configsDir = path.join(containerPath, 'configs')
19
+ const envDir = path.join(containerPath, 'env')
20
+
21
+ const lines = []
22
+
23
+ // Add .root/assets/ and .root/configs/ to PATH for executable resolution
24
+ lines.push(`$env:PATH = "${assetsDir};${configsDir};$env:PATH"`)
25
+ lines.push('')
26
+
27
+ // Create function wrappers for every .ps1 file in .root/
28
+ for (const dir of [assetsDir, configsDir]) {
29
+ if (!(await fileExists(dir))) continue
30
+ const entries = await readdir(dir, { withFileTypes: true })
31
+ for (const e of entries) {
32
+ if (!e.isFile() || !e.name.endsWith('.ps1')) continue
33
+ const fullPath = path.join(dir, e.name).replace(/\\/g, '\\\\')
34
+ // Function name: strip leading dot for valid PS identifier, but also set alias with dot
35
+ const safeName = e.name.replace(/^\./, '_').replace(/\.ps1$/, '')
36
+ const dotName = e.name.replace(/\.ps1$/, '') // e.g. ".server"
37
+ lines.push(`function global:${safeName} { & "${fullPath}" @args }`)
38
+ // Also register the dotted version via alias if it doesn't start with dot
39
+ if (!dotName.startsWith('.')) {
40
+ lines.push(`Set-Alias -Name "${dotName}.ps1" -Value ${safeName} -Scope Global`)
41
+ }
42
+ }
43
+ }
44
+
45
+ lines.push('')
46
+ lines.push('# Prompt')
47
+ lines.push('function global:prompt {')
48
+ lines.push(' Write-Host "[rootless] " -NoNewline -ForegroundColor Cyan')
49
+ lines.push(' Write-Host (Get-Location) -NoNewline -ForegroundColor White')
50
+ lines.push(' return "> "')
51
+ lines.push('}')
52
+ lines.push('')
53
+
54
+ // Welcome banner
55
+ lines.push('Write-Host ""')
56
+ lines.push('Write-Host " Rootless Shell" -ForegroundColor Cyan')
57
+ lines.push('Write-Host " Files from .root/ work as commands. cwd = project root." -ForegroundColor Gray')
58
+ lines.push('Write-Host " Type exit to leave." -ForegroundColor Gray')
59
+ lines.push('Write-Host ""')
60
+
61
+ // List available .ps1 wrappers
62
+ lines.push('Write-Host " Available:" -ForegroundColor DarkCyan')
63
+ for (const dir of [assetsDir, configsDir]) {
64
+ if (!(await fileExists(dir))) continue
65
+ const entries = await readdir(dir, { withFileTypes: true })
66
+ for (const e of entries) {
67
+ if (!e.isFile()) continue
68
+ const rel = `.root/${path.basename(dir)}/${e.name}`
69
+ lines.push(`Write-Host " ${e.name.padEnd(30)} [${rel}]" -ForegroundColor Gray`)
70
+ }
71
+ }
72
+ lines.push('Write-Host ""')
73
+
74
+ return lines.join('\n')
75
+ }
76
+
77
+ export default {
78
+ name: 'shell',
79
+ description: 'Open an interactive shell where .root/ files are accessible as commands',
80
+
81
+ async handler(args) {
82
+ const logger = createLogger({ verbose: args.verbose ?? false })
83
+ const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
84
+ const containerPath = path.join(projectRoot, '.root')
85
+
86
+ if (!(await fileExists(containerPath))) {
87
+ logger.fail('No .root/ container found. Run: rootless setup')
88
+ process.exitCode = 1
89
+ return
90
+ }
91
+
92
+ // Write temp profile
93
+ const profilePath = path.join(os.tmpdir(), `rootless-profile-${Date.now()}.ps1`)
94
+ const profileContent = await buildPsProfile(projectRoot, containerPath)
95
+ await writeFile(profilePath, profileContent, 'utf8')
96
+
97
+ logger.info('Starting rootless shell…')
98
+
99
+ const child = spawn(
100
+ 'powershell',
101
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-Command', `. "${profilePath}"`],
102
+ {
103
+ cwd: projectRoot,
104
+ stdio: 'inherit',
105
+ shell: false,
106
+ }
107
+ )
108
+
109
+ child.on('error', async (err) => {
110
+ logger.fail(`Failed to start shell: ${err.message}`)
111
+ await unlink(profilePath).catch(() => {})
112
+ process.exitCode = 1
113
+ })
114
+
115
+ await new Promise(resolve => child.on('exit', resolve))
116
+ await unlink(profilePath).catch(() => {})
117
+ },
118
+ }
package/src/cli/index.js CHANGED
@@ -25,15 +25,28 @@ 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
+
32
+ if (cmd.name === 'run') {
33
+ sub.argument('[scriptArgs...]', 'File to run and optional arguments')
34
+ sub.passThroughOptions(true)
35
+ }
36
+
28
37
  sub.option('--verbose', 'Enable verbose output')
29
38
  sub.option('--silent', 'Suppress all output')
30
39
 
31
- sub.action(async (options) => {
40
+ sub.action(async (scriptArgsOrOptions, options) => {
41
+ // For 'run' command, first arg is the positional array
42
+ const opts = cmd.name === 'run'
43
+ ? { ...options, scriptArgs: scriptArgsOrOptions }
44
+ : scriptArgsOrOptions
32
45
  try {
33
- await cmd.handler(options)
46
+ await cmd.handler(opts)
34
47
  } catch (err) {
35
48
  logger.fail(err.message)
36
- if (options.verbose) process.stderr.write(err.stack + '\n')
49
+ if (opts.verbose) process.stderr.write(err.stack + '\n')
37
50
  process.exitCode = 1
38
51
  }
39
52
  })