rootless-config 1.5.0 → 1.6.1
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 +1 -1
- package/src/cli/commands/activate.js +144 -0
- package/src/cli/commands/run.js +95 -0
- package/src/cli/commands/shell.js +118 -0
- package/src/cli/index.js +17 -3
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/*-------- rootless activate — install PS profile hook for auto PATH injection --------*/
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { fileExists } from '../../utils/fsUtils.js'
|
|
8
|
+
import { createLogger } from '../../utils/logger.js'
|
|
9
|
+
|
|
10
|
+
// The block we inject into $PROFILE — delimited so we can detect/remove it
|
|
11
|
+
const HOOK_START = '# <rootless-hook>'
|
|
12
|
+
const HOOK_END = '# </rootless-hook>'
|
|
13
|
+
|
|
14
|
+
const HOOK_BODY = `
|
|
15
|
+
# <rootless-hook>
|
|
16
|
+
# Auto-injected by rootless-config — adds .root/assets + .root/configs to PATH
|
|
17
|
+
# when entering a project that has a .root/ container.
|
|
18
|
+
function _Rootless_Activate {
|
|
19
|
+
param([string]$Dir = $PWD)
|
|
20
|
+
$rootPath = Join-Path $Dir ".root"
|
|
21
|
+
if (-not (Test-Path $rootPath -PathType Container)) { return }
|
|
22
|
+
$slots = @(
|
|
23
|
+
(Join-Path $rootPath "assets"),
|
|
24
|
+
(Join-Path $rootPath "configs")
|
|
25
|
+
)
|
|
26
|
+
foreach ($slot in $slots) {
|
|
27
|
+
if ((Test-Path $slot) -and ($env:PATH -notlike "*$slot*")) {
|
|
28
|
+
$env:PATH = "$slot;$env:PATH"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Hook into the prompt so activation runs after every cd
|
|
34
|
+
$_RootlessOriginalPrompt = if (Get-Command prompt -ErrorAction SilentlyContinue) {
|
|
35
|
+
${function:prompt}
|
|
36
|
+
} else { $null }
|
|
37
|
+
|
|
38
|
+
function global:prompt {
|
|
39
|
+
_Rootless_Activate
|
|
40
|
+
if ($_RootlessOriginalPrompt) {
|
|
41
|
+
& $_RootlessOriginalPrompt
|
|
42
|
+
} else {
|
|
43
|
+
"PS $($PWD)> "
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Activate immediately for current directory
|
|
48
|
+
_Rootless_Activate
|
|
49
|
+
# </rootless-hook>
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
async function getProfilePath() {
|
|
53
|
+
// Ask PowerShell for the actual profile path
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let out = ''
|
|
56
|
+
const ps = spawn('powershell', ['-NoProfile', '-Command', 'echo $PROFILE'], {
|
|
57
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
58
|
+
})
|
|
59
|
+
ps.stdout.on('data', d => { out += d.toString() })
|
|
60
|
+
ps.on('exit', () => resolve(out.trim()))
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readProfile(profilePath) {
|
|
65
|
+
try { return await readFile(profilePath, 'utf8') } catch { return '' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function isHookInstalled(profilePath) {
|
|
69
|
+
const content = await readProfile(profilePath)
|
|
70
|
+
return content.includes(HOOK_START)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installHook(profilePath) {
|
|
74
|
+
await mkdir(path.dirname(profilePath), { recursive: true })
|
|
75
|
+
const existing = await readProfile(profilePath)
|
|
76
|
+
await writeFile(profilePath, existing + '\n' + HOOK_BODY, 'utf8')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function removeHook(profilePath) {
|
|
80
|
+
const content = await readProfile(profilePath)
|
|
81
|
+
const startIdx = content.indexOf(HOOK_START)
|
|
82
|
+
const endIdx = content.indexOf(HOOK_END)
|
|
83
|
+
if (startIdx === -1) return false
|
|
84
|
+
const cleaned = content.slice(0, startIdx) + content.slice(endIdx + HOOK_END.length)
|
|
85
|
+
await writeFile(profilePath, cleaned.trim() + '\n', 'utf8')
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default {
|
|
90
|
+
name: 'activate',
|
|
91
|
+
description: 'Install PowerShell profile hook — .root/ files become available as commands automatically',
|
|
92
|
+
|
|
93
|
+
async handler(args) {
|
|
94
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
95
|
+
|
|
96
|
+
// --remove flag
|
|
97
|
+
if (args.remove) {
|
|
98
|
+
const profilePath = await getProfilePath()
|
|
99
|
+
const removed = await removeHook(profilePath)
|
|
100
|
+
if (removed) {
|
|
101
|
+
logger.success('Rootless hook removed from PowerShell profile.')
|
|
102
|
+
} else {
|
|
103
|
+
logger.info('No rootless hook found in profile.')
|
|
104
|
+
}
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --env flag: just print the activation snippet for current session
|
|
109
|
+
if (args.env) {
|
|
110
|
+
process.stdout.write(HOOK_BODY + '\n')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const profilePath = await getProfilePath()
|
|
115
|
+
|
|
116
|
+
if (await isHookInstalled(profilePath)) {
|
|
117
|
+
logger.success('Rootless hook already installed.')
|
|
118
|
+
logger.info(`Profile: ${profilePath}`)
|
|
119
|
+
logger.info('')
|
|
120
|
+
logger.info('Already active in all new PowerShell sessions.')
|
|
121
|
+
logger.info('To activate in THIS session right now:')
|
|
122
|
+
logger.info('')
|
|
123
|
+
logger.info(' rootless activate --env | Invoke-Expression')
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await installHook(profilePath)
|
|
128
|
+
|
|
129
|
+
logger.success('Rootless hook installed!')
|
|
130
|
+
logger.info(`Profile: ${profilePath}`)
|
|
131
|
+
logger.info('')
|
|
132
|
+
logger.info('From now on, in ANY new PowerShell session:')
|
|
133
|
+
logger.info(' → navigate to a project with .root/')
|
|
134
|
+
logger.info(' → all files in .root/assets/ and .root/configs/ are in PATH')
|
|
135
|
+
logger.info(' → type .server.run, .server.ps1 etc. directly')
|
|
136
|
+
logger.info('')
|
|
137
|
+
logger.info('To activate in THIS session right now:')
|
|
138
|
+
logger.info('')
|
|
139
|
+
logger.info(' rootless activate --env | Invoke-Expression')
|
|
140
|
+
logger.info('')
|
|
141
|
+
logger.info('To remove the hook later:')
|
|
142
|
+
logger.info(' rootless activate --remove')
|
|
143
|
+
},
|
|
144
|
+
}
|
|
@@ -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,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,19 +25,33 @@ async function run(argv) {
|
|
|
25
25
|
sub.option('--no-yes', 'Auto-decline all file override prompts')
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
if (cmd.name === 'activate') {
|
|
29
|
+
sub.option('--remove', 'Remove the hook from PowerShell profile')
|
|
30
|
+
sub.option('--env', 'Print activation snippet for current session only (pipe to Invoke-Expression)')
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
if (cmd.name === 'serve') {
|
|
29
34
|
sub.option('--port <number>', 'Port to listen on (default: 3000)')
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
if (cmd.name === 'run') {
|
|
38
|
+
sub.argument('[scriptArgs...]', 'File to run and optional arguments')
|
|
39
|
+
sub.passThroughOptions(true)
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
sub.option('--verbose', 'Enable verbose output')
|
|
33
43
|
sub.option('--silent', 'Suppress all output')
|
|
34
44
|
|
|
35
|
-
sub.action(async (options) => {
|
|
45
|
+
sub.action(async (scriptArgsOrOptions, options) => {
|
|
46
|
+
// For 'run' command, first arg is the positional array
|
|
47
|
+
const opts = cmd.name === 'run'
|
|
48
|
+
? { ...options, scriptArgs: scriptArgsOrOptions }
|
|
49
|
+
: scriptArgsOrOptions
|
|
36
50
|
try {
|
|
37
|
-
await cmd.handler(
|
|
51
|
+
await cmd.handler(opts)
|
|
38
52
|
} catch (err) {
|
|
39
53
|
logger.fail(err.message)
|
|
40
|
-
if (
|
|
54
|
+
if (opts.verbose) process.stderr.write(err.stack + '\n')
|
|
41
55
|
process.exitCode = 1
|
|
42
56
|
}
|
|
43
57
|
})
|