rootless-config 1.8.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootless-config",
3
- "version": "1.8.3",
3
+ "version": "1.9.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,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('⚠ Files were REMOVED from root.')
91
- logger.info(' Run the following before deploying or starting the project:')
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,69 +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. Redefine $root project root (2 levels up), keep $assetsRoot for fallback
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
- /\$root(\s*)=(\s*)\$PSScriptRoot\.TrimEnd\('\\\\?'\)\s*\+\s*'\\\\'?/,
487
- `$assetsRoot$1=$2$PSScriptRoot.TrimEnd('\\') + '\\'
488
- $root$1=$2(Get-Item "$PSScriptRoot\\..\\..").FullName.TrimEnd('\\') + '\\'`
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
- // If the above didn't match (different whitespace/quoting), try simpler form
492
- if (out === content) {
493
- const marker = `$root = $PSScriptRoot.TrimEnd('\\') + '\\'`
494
- if (content.includes(marker)) {
495
- out = out.replace(
496
- marker,
497
- `$assetsRoot = $PSScriptRoot.TrimEnd('\\') + '\\'
498
- $root = (Get-Item "$PSScriptRoot\\..\\..").FullName.TrimEnd('\\') + '\\'`
499
- )
500
- }
501
- }
502
-
503
- // 2. Add rootless fallback after $filePath = $resolved
504
- const filePathLine = ' $filePath = $resolved'
505
- if (out.includes(filePathLine)) {
506
- out = out.replace(
507
- filePathLine,
508
- filePathLine + `
509
- # rootless: fallback to .root/assets/ when file not found in project root
510
- if ($relPath -ne '' -and -not (Test-Path $filePath)) {
511
- $assetsCandidate = [System.IO.Path]::GetFullPath((Join-Path $assetsRoot $relPath))
512
- if (Test-Path $assetsCandidate) { $filePath = $assetsCandidate }
513
- }`
514
- )
515
- }
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
+ )
516
523
 
517
- // 3. Fix 404 page lookup to also check $assetsRoot
518
- const notFoundLine = ' $candidate = Join-Path $root $filename'
519
- if (out.includes(notFoundLine)) {
520
- out = out.replace(
521
- notFoundLine,
522
- notFoundLine + `
523
- if (-not (Test-Path $candidate -PathType Leaf)) { $candidate = Join-Path $assetsRoot $filename }`
524
- )
525
- }
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
+ )
526
541
 
527
- // 4. Null guard + assets fallback for directory-with-no-index case
528
- // When $root has the directory but no index.html, $filePath becomes $null → Test-Path throws.
529
- // Fix: check $assetsRoot for the same path, then add null guard on the leaf check.
530
- const leafCheckLine = ' if (Test-Path $filePath -PathType Leaf) {'
531
- if (out.includes(leafCheckLine)) {
532
- out = out.replace(
533
- leafCheckLine,
534
- ` # rootless: if $root dir had no index, check $assetsRoot dir too
535
- if ($filePath -eq $null -and $relPath -ne '') {
536
- $assetsDir = [System.IO.Path]::GetFullPath((Join-Path $assetsRoot $relPath))
537
- foreach ($idx in @("index.html", "index.htm")) {
538
- $idxPath = Join-Path $assetsDir $idx
539
- if (Test-Path $idxPath -PathType Leaf) { $filePath = $idxPath; break }
540
- }
541
- }
542
- if ($filePath -ne $null -and (Test-Path $filePath -PathType Leaf)) {`
543
- )
544
- }
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
+ )
545
550
 
546
551
  return out === content ? null : out
547
552
  }