rootless-config 1.8.2 → 1.9.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 +1 -1
- package/src/cli/commands/repatch.js +138 -0
- package/src/cli/commands/setup.js +3 -4
- package/src/core/scriptPatcher.js +61 -37
package/package.json
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*-------- rootless repatch — re-apply server script patches to files in .root/ --------*/
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { createLogger } from '../../utils/logger.js'
|
|
6
|
+
import { fileExists } from '../../utils/fsUtils.js'
|
|
7
|
+
import { patchServerScript } from '../../core/scriptPatcher.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Strip any previous rootless patches from a PS1 file so that patchServerScript
|
|
11
|
+
* can be applied fresh.
|
|
12
|
+
*
|
|
13
|
+
* Handles two cases:
|
|
14
|
+
* - New-style: content wrapped in `# <rootless>` … `# </rootless>` markers
|
|
15
|
+
* - Old-style: $assetsRoot + $root = (Get-Item ...) two-liner (pre-1.9.0)
|
|
16
|
+
*/
|
|
17
|
+
function stripRootlessPatches(content) {
|
|
18
|
+
// New-style: remove the whole # <rootless> … # </rootless> block and restore
|
|
19
|
+
// the minimal $root = $PSScriptRoot line so patchServerScript can re-match it.
|
|
20
|
+
let out = content.replace(
|
|
21
|
+
/^#[ \t]*<rootless>[\s\S]*?^#[ \t]*<\/rootless>\n?/m,
|
|
22
|
+
"$root = $PSScriptRoot.TrimEnd('\\\\') + '\\\\'\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Old-style (v1.8.x and earlier): $assetsRoot line + $root = (Get-Item ...) line
|
|
26
|
+
// Replace the pair with a minimal $root = $PSScriptRoot assignment so patchServerScript
|
|
27
|
+
// can re-match it and apply the new runtime-detection block.
|
|
28
|
+
out = out.replace(
|
|
29
|
+
/^[ \t]*\$assetsRoot[ \t]*=[ \t]*\$PSScriptRoot[^\n]*\n[ \t]*\$root[ \t]*=[ \t]*\(Get-Item[^\n]*/m,
|
|
30
|
+
"$root = $PSScriptRoot.TrimEnd('\\\\') + '\\\\'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Also strip inline rootless comments added by old patch (file fallback, null guard, 404 fallback)
|
|
34
|
+
// — these are harder to reverse generically, so we leave them in place. The new patch adds its
|
|
35
|
+
// own version above, and the old ones become inert (they still work, just redundant).
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Re-patch a single .ps1 file. Returns 'patched' | 'already-current' | 'skipped'.
|
|
41
|
+
*/
|
|
42
|
+
async function repatchPs1(filePath) {
|
|
43
|
+
const content = await readFile(filePath, 'utf8')
|
|
44
|
+
|
|
45
|
+
// Already has new-style patch — nothing to do
|
|
46
|
+
if (content.includes('# <rootless>')) return 'already-current'
|
|
47
|
+
|
|
48
|
+
// Doesn't look like a server script at all
|
|
49
|
+
if (!content.includes('$PSScriptRoot') || !content.includes('$root')) return 'skipped'
|
|
50
|
+
|
|
51
|
+
const stripped = stripRootlessPatches(content)
|
|
52
|
+
const patched = patchServerScript(stripped)
|
|
53
|
+
if (!patched) return 'skipped'
|
|
54
|
+
|
|
55
|
+
await writeFile(filePath, patched, 'utf8')
|
|
56
|
+
return 'patched'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Re-patch a .cmd / .bat launcher so it uses %~dp0-relative path to the .ps1.
|
|
61
|
+
* Returns true if the file was changed.
|
|
62
|
+
*/
|
|
63
|
+
async function repatchCmd(filePath) {
|
|
64
|
+
const content = await readFile(filePath, 'utf8')
|
|
65
|
+
const patched = content.replace(
|
|
66
|
+
/(-File\s+)(?!")([^\s"]+\.ps1)/gi,
|
|
67
|
+
(_, flag, ps1) => `${flag}"%~dp0${ps1}"`
|
|
68
|
+
)
|
|
69
|
+
if (patched === content) return false
|
|
70
|
+
await writeFile(filePath, patched, 'utf8')
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
name: 'repatch',
|
|
76
|
+
description: 'Re-apply server script patches to .ps1/.cmd files in .root/ (use after upgrading rootless)',
|
|
77
|
+
|
|
78
|
+
async handler(args) {
|
|
79
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
80
|
+
const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
|
|
81
|
+
const containerPath = path.join(projectRoot, '.root')
|
|
82
|
+
|
|
83
|
+
if (!(await fileExists(containerPath))) {
|
|
84
|
+
logger.warn('No .root/ container found. Run: rootless setup')
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
logger.info('Scanning .root/ for patchable scripts…')
|
|
89
|
+
|
|
90
|
+
let ps1Patched = 0
|
|
91
|
+
let ps1Current = 0
|
|
92
|
+
let ps1Skipped = 0
|
|
93
|
+
let cmdPatched = 0
|
|
94
|
+
|
|
95
|
+
for (const sub of ['assets', 'configs', 'env']) {
|
|
96
|
+
const dir = path.join(containerPath, sub)
|
|
97
|
+
if (!(await fileExists(dir))) continue
|
|
98
|
+
|
|
99
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
if (!e.isFile()) continue
|
|
102
|
+
const full = path.join(dir, e.name)
|
|
103
|
+
|
|
104
|
+
if (e.name.endsWith('.ps1')) {
|
|
105
|
+
const result = await repatchPs1(full)
|
|
106
|
+
if (result === 'patched') {
|
|
107
|
+
ps1Patched++
|
|
108
|
+
logger.success(`Repatched: .root/${sub}/${e.name}`)
|
|
109
|
+
} else if (result === 'already-current') {
|
|
110
|
+
ps1Current++
|
|
111
|
+
logger.debug(`Up-to-date: .root/${sub}/${e.name}`)
|
|
112
|
+
} else {
|
|
113
|
+
ps1Skipped++
|
|
114
|
+
logger.debug(`Skipped (not a server script): .root/${sub}/${e.name}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (e.name.endsWith('.cmd') || e.name.endsWith('.bat')) {
|
|
119
|
+
const changed = await repatchCmd(full)
|
|
120
|
+
if (changed) {
|
|
121
|
+
cmdPatched++
|
|
122
|
+
logger.success(`Repatched: .root/${sub}/${e.name}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
logger.info('')
|
|
129
|
+
if (ps1Patched + cmdPatched === 0 && ps1Current === 0) {
|
|
130
|
+
logger.info('Nothing to patch.')
|
|
131
|
+
} else {
|
|
132
|
+
if (ps1Patched) logger.success(`${ps1Patched} .ps1 file(s) repatched`)
|
|
133
|
+
if (cmdPatched) logger.success(`${cmdPatched} .cmd/.bat file(s) repatched`)
|
|
134
|
+
if (ps1Current) logger.info(`${ps1Current} .ps1 file(s) already up-to-date`)
|
|
135
|
+
if (ps1Skipped) logger.debug(`${ps1Skipped} .ps1 file(s) skipped (not server scripts)`)
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
@@ -87,14 +87,13 @@ export default {
|
|
|
87
87
|
logger.info(' .root/assets/ — web assets, HTML, images, scripts')
|
|
88
88
|
logger.info(' .root/configs/ — tool configs (vite, eslint, jest…)')
|
|
89
89
|
logger.info('')
|
|
90
|
-
logger.info('
|
|
91
|
-
logger.info('
|
|
92
|
-
logger.info('')
|
|
93
|
-
logger.info(' rootless prepare — copies files back to root')
|
|
90
|
+
logger.info('Start your server:')
|
|
91
|
+
logger.info(' rootless run .server.run.cmd')
|
|
94
92
|
logger.info('')
|
|
95
93
|
logger.info('Other commands:')
|
|
96
94
|
logger.info(' rootless status — show what is managed')
|
|
97
95
|
logger.info(' rootless watch — auto-sync .root/ → root on changes')
|
|
96
|
+
logger.info(' rootless repatch — re-apply server script patches')
|
|
98
97
|
logger.info('─────────────────────────────────────────')
|
|
99
98
|
},
|
|
100
99
|
}
|
|
@@ -479,50 +479,74 @@ function patchServerScript(content) {
|
|
|
479
479
|
// Only patch scripts that use $PSScriptRoot as web root
|
|
480
480
|
if (!content.includes('$PSScriptRoot') || !content.includes('$root')) return null
|
|
481
481
|
|
|
482
|
+
// Idempotency guard — already patched by rootless
|
|
483
|
+
if (content.includes('# <rootless>')) return null
|
|
484
|
+
|
|
485
|
+
// Must have the `$root = $PSScriptRoot` assignment to know where to inject
|
|
486
|
+
if (!/^[ \t]*\$root[ \t]*=[ \t]*\$PSScriptRoot/m.test(content)) return null
|
|
487
|
+
|
|
482
488
|
let out = content
|
|
483
489
|
|
|
484
|
-
// 1.
|
|
490
|
+
// 1. Replace `$root = $PSScriptRoot...` with a runtime-detection block.
|
|
491
|
+
// Works whether the script lives in .root/assets/ OR has been copied back
|
|
492
|
+
// to the project root (e.g. via 'git checkout' or 'rootless prepare').
|
|
493
|
+
// Using () => replacement to prevent '$' being treated as a regex backreference.
|
|
485
494
|
out = out.replace(
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
495
|
+
/^[ \t]*\$root[ \t]*=[ \t]*\$PSScriptRoot[^\n]*/m,
|
|
496
|
+
() => [
|
|
497
|
+
'# <rootless>',
|
|
498
|
+
'# Runtime-detect location — works from .root/assets/ AND from project root',
|
|
499
|
+
"if ($PSScriptRoot -match [regex]::Escape([System.IO.Path]::Combine('.root', 'assets'))) {",
|
|
500
|
+
" $assetsRoot = $PSScriptRoot.TrimEnd('\\') + '\\'",
|
|
501
|
+
" $root = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\\..')).TrimEnd('\\') + '\\'",
|
|
502
|
+
'} else {',
|
|
503
|
+
" $root = $PSScriptRoot.TrimEnd('\\') + '\\'",
|
|
504
|
+
" $assetsRoot = [System.IO.Path]::GetFullPath((Join-Path $root ('.root' + [System.IO.Path]::DirectorySeparatorChar + 'assets'))).TrimEnd('\\') + '\\'",
|
|
505
|
+
'}',
|
|
506
|
+
'# </rootless>',
|
|
507
|
+
].join('\n')
|
|
489
508
|
)
|
|
490
509
|
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
$
|
|
499
|
-
)
|
|
500
|
-
}
|
|
501
|
-
|
|
510
|
+
// 2. After file-path resolution, fall back to $assetsRoot when the file is not
|
|
511
|
+
// found under the project root (handles files that stayed in .root/assets/).
|
|
512
|
+
out = out.replace(
|
|
513
|
+
/^([ \t]+)(\$filePath[ \t]*=[ \t]*\$resolved)[ \t]*$/m,
|
|
514
|
+
(_, indent, line) => [
|
|
515
|
+
`${indent}${line}`,
|
|
516
|
+
`${indent}# rootless: fallback to .root/assets/ when file not found in project root`,
|
|
517
|
+
`${indent}if ($relPath -ne '' -and -not (Test-Path $filePath)) {`,
|
|
518
|
+
`${indent} $assetsCandidate = [System.IO.Path]::GetFullPath((Join-Path $assetsRoot $relPath))`,
|
|
519
|
+
`${indent} if (Test-Path $assetsCandidate) { $filePath = $assetsCandidate }`,
|
|
520
|
+
`${indent}}`,
|
|
521
|
+
].join('\n')
|
|
522
|
+
)
|
|
502
523
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
524
|
+
// 3. Replace `if (Test-Path $filePath -PathType Leaf)` with:
|
|
525
|
+
// a) $assetsRoot directory fallback (when $root dir has no index.html)
|
|
526
|
+
// b) null guard ($filePath can be $null after a directory miss — Test-Path $null throws)
|
|
527
|
+
out = out.replace(
|
|
528
|
+
/^([ \t]+)(if \(Test-Path \$filePath -PathType Leaf\) \{)[ \t]*$/m,
|
|
529
|
+
(_, indent) => [
|
|
530
|
+
`${indent}# rootless: if $root dir had no index, check $assetsRoot dir too`,
|
|
531
|
+
`${indent}if ($filePath -eq $null -and $relPath -ne '') {`,
|
|
532
|
+
`${indent} $assetsDir = [System.IO.Path]::GetFullPath((Join-Path $assetsRoot $relPath))`,
|
|
533
|
+
`${indent} foreach ($idx in @('index.html', 'index.htm')) {`,
|
|
534
|
+
`${indent} $idxPath = Join-Path $assetsDir $idx`,
|
|
535
|
+
`${indent} if (Test-Path $idxPath -PathType Leaf) { $filePath = $idxPath; break }`,
|
|
536
|
+
`${indent} }`,
|
|
537
|
+
`${indent}}`,
|
|
538
|
+
`${indent}if ($filePath -ne $null -and (Test-Path $filePath -PathType Leaf)) {`,
|
|
539
|
+
].join('\n')
|
|
540
|
+
)
|
|
516
541
|
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
542
|
+
// 4. 404 page lookup — also try $assetsRoot when the file isn't under $root
|
|
543
|
+
out = out.replace(
|
|
544
|
+
/^([ \t]+)(\$candidate[ \t]*=[ \t]*Join-Path \$root \$filename)[ \t]*$/m,
|
|
545
|
+
(_, indent, line) => [
|
|
546
|
+
`${indent}${line}`,
|
|
547
|
+
`${indent}if (-not (Test-Path $candidate -PathType Leaf)) { $candidate = Join-Path $assetsRoot $filename }`,
|
|
548
|
+
].join('\n')
|
|
549
|
+
)
|
|
526
550
|
|
|
527
551
|
return out === content ? null : out
|
|
528
552
|
}
|