rootless-config 1.6.3 → 1.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootless-config",
3
- "version": "1.6.3",
3
+ "version": "1.7.1",
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": {
@@ -22,7 +22,8 @@
22
22
  "test": "vitest run",
23
23
  "test:watch": "vitest",
24
24
  "test:coverage": "vitest run --coverage",
25
- "lint": "eslint src bin"
25
+ "lint": "eslint src bin",
26
+ "postinstall": "node bin/rootless.js activate --profile-only || exit 0"
26
27
  },
27
28
  "dependencies": {
28
29
  "chokidar": "^3.6.0",
@@ -7,6 +7,17 @@ import { spawn } from 'node:child_process'
7
7
  import { fileExists } from '../../utils/fsUtils.js'
8
8
  import { createLogger } from '../../utils/logger.js'
9
9
 
10
+ // Logger that always writes to stderr — safe to use when stdout is piped to Invoke-Expression
11
+ function createStderrLogger() {
12
+ const w = msg => process.stderr.write(msg + '\n')
13
+ return {
14
+ info: msg => w(`\x1b[32m[ROOTLESS] ${msg}\x1b[0m`),
15
+ success: msg => w(`\x1b[32m✔ ${msg}\x1b[0m`),
16
+ warn: msg => w(`\x1b[33m[ROOTLESS] ${msg}\x1b[0m`),
17
+ fail: msg => w(`\x1b[31m✖ ${msg}\x1b[0m`),
18
+ }
19
+ }
20
+
10
21
  // The block we inject into $PROFILE — delimited so we can detect/remove it
11
22
  const HOOK_START = '# <rootless-hook>'
12
23
  const HOOK_END = '# </rootless-hook>'
@@ -91,7 +102,13 @@ export default {
91
102
  description: 'Install PowerShell profile hook — .root/ files become available as commands automatically',
92
103
 
93
104
  async handler(args) {
94
- const logger = createLogger({ verbose: args.verbose ?? false })
105
+ const logger = createStderrLogger()
106
+
107
+ // Работает только на Windows (нужен PowerShell)
108
+ if (process.platform !== 'win32') {
109
+ logger.info('PS profile hook is Windows-only. Skipping.')
110
+ return
111
+ }
95
112
 
96
113
  // --remove flag
97
114
  if (args.remove) {
@@ -105,40 +122,33 @@ export default {
105
122
  return
106
123
  }
107
124
 
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
125
  const profilePath = await getProfilePath()
126
+ const alreadyInstalled = await isHookInstalled(profilePath)
115
127
 
116
- if (await isHookInstalled(profilePath)) {
117
- logger.success('Rootless hook already installed.')
128
+ if (!alreadyInstalled) {
129
+ await installHook(profilePath)
130
+ logger.success('Rootless hook installed in PowerShell profile!')
118
131
  logger.info(`Profile: ${profilePath}`)
132
+ } else {
133
+ logger.success('Rootless hook already installed.')
134
+ }
135
+
136
+ // --profile-only: только установить профиль, без вывода кода в stdout
137
+ // Используется в postinstall при npm install -g
138
+ if (args.profileOnly) return
139
+
140
+ // Если stdout пайпится (Invoke-Expression) — выводим HOOK_BODY
141
+ // Если терминал — показываем подсказку
142
+ if (!process.stdout.isTTY) {
143
+ process.stdout.write(HOOK_BODY + '\n')
144
+ } else {
119
145
  logger.info('')
120
- logger.info('Already active in all new PowerShell sessions.')
121
- logger.info('To activate in THIS session right now:')
146
+ logger.info('To activate the CURRENT session, run:')
122
147
  logger.info('')
123
- logger.info(' rootless activate --env | Invoke-Expression')
124
- return
148
+ logger.info(' rootless activate | Invoke-Expression')
149
+ logger.info('')
150
+ logger.info('New PowerShell windows will activate automatically.')
151
+ logger.info('To remove the hook: rootless activate --remove')
125
152
  }
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
153
  },
144
154
  }
@@ -1,7 +1,7 @@
1
1
  /*-------- rootless migrate — moves existing root configs into .root container --------*/
2
2
 
3
3
  import path from 'node:path'
4
- import { readdir, unlink } from 'node:fs/promises'
4
+ import { readdir, unlink, cp, rm } from 'node:fs/promises'
5
5
  import { createLogger } from '../../utils/logger.js'
6
6
  import { fileExists, ensureDir, atomicWrite, copyFile, readJsonFile } from '../../utils/fsUtils.js'
7
7
  import { confirm } from '../../utils/prompt.js'
@@ -12,6 +12,15 @@ const SYSTEM_FILES = new Set([
12
12
  '.DS_Store', 'Thumbs.db', 'desktop.ini', '.git', '.svn',
13
13
  ])
14
14
 
15
+ // Directories that must never be migrated
16
+ const NEVER_MIGRATE_DIRS = new Set([
17
+ 'node_modules', '.git', '.svn', '.hg', '.root',
18
+ 'dist', 'build', 'out', '.next', '.nuxt', '.output',
19
+ '.github', '.gitlab', '.circleci',
20
+ '.vscode', '.idea', '.vs',
21
+ '__pycache__', '.cache', '.parcel-cache', '.turbo',
22
+ ])
23
+
15
24
  /**
16
25
  * Determine which .root sub-folder a file should land in:
17
26
  * env/ — .env* files (always copied to root by prepare)
@@ -25,21 +34,21 @@ function getDestSubdir(filename) {
25
34
  }
26
35
 
27
36
  /**
28
- * Returns ALL files in projectRoot that should be migrated.
29
- * Excludes: directories, package manager lock files, OS noise.
37
+ * Returns ALL files AND directories in projectRoot that should be migrated.
38
+ * Excludes: package manager locks, OS noise, VCS dirs, build outputs, editor dirs.
30
39
  */
31
40
  async function findMigratableFiles(projectRoot) {
32
41
  const entries = await readdir(projectRoot, { withFileTypes: true })
33
42
  return entries
34
43
  .filter(e => {
35
- if (!e.isFile()) return false
36
44
  if (NEVER_MIGRATE.has(e.name)) return false
37
45
  if (SYSTEM_FILES.has(e.name)) return false
38
46
  if (e.name === '.root') return false
39
- return true // migrate EVERYTHING else
47
+ if (e.isDirectory()) return !NEVER_MIGRATE_DIRS.has(e.name)
48
+ return e.isFile()
40
49
  })
41
- .map(e => e.name)
42
- .sort()
50
+ .map(e => ({ name: e.name, isDir: e.isDirectory() }))
51
+ .sort((a, b) => a.name.localeCompare(b.name))
43
52
  }
44
53
 
45
54
  async function getProjectMode(containerPath) {
@@ -68,7 +77,10 @@ export default {
68
77
  return
69
78
  }
70
79
 
71
- logger.info(`Found ${candidates.length} migratable files:\n ${candidates.join('\n ')}`)
80
+ const fileCount = candidates.filter(c => !c.isDir).length
81
+ const dirCount = candidates.filter(c => c.isDir).length
82
+ const summary = candidates.map(c => c.isDir ? `${c.name}/` : c.name).join('\n ')
83
+ logger.info(`Found ${fileCount} files + ${dirCount} directories to migrate:\n ${summary}`)
72
84
 
73
85
  if (isCleanMode) {
74
86
  logger.info('Mode: clean — originals will be DELETED from root, package.json scripts will be patched')
@@ -82,14 +94,27 @@ export default {
82
94
  return
83
95
  }
84
96
 
85
- for (const name of candidates) {
97
+ for (const { name, isDir } of candidates) {
86
98
  const src = path.join(projectRoot, name)
87
- const destSubdir = getDestSubdir(name)
99
+ // Directories always go to assets/; files use normal routing
100
+ const destSubdir = isDir ? 'assets' : getDestSubdir(name)
88
101
  const destDir = path.join(containerPath, destSubdir)
89
102
 
90
103
  await ensureDir(destDir)
91
104
  const dest = path.join(destDir, name)
92
105
 
106
+ if (isDir) {
107
+ // Recursively copy entire directory tree, then delete source
108
+ await cp(src, dest, { recursive: true })
109
+ if (isCleanMode) {
110
+ await rm(src, { recursive: true, force: true })
111
+ logger.success(`Moved dir .root/${destSubdir}/${name}/ (deleted from root)`)
112
+ } else {
113
+ logger.success(`Copied dir .root/${destSubdir}/${name}/`)
114
+ }
115
+ continue
116
+ }
117
+
93
118
  if (isCleanMode) {
94
119
  // Binary-safe stream copy — works for .png, .js, .css, etc.
95
120
  await copyFile(src, dest)
package/src/cli/index.js CHANGED
@@ -27,8 +27,8 @@ async function run(argv) {
27
27
  }
28
28
 
29
29
  if (cmd.name === 'activate') {
30
- sub.option('--remove', 'Remove the hook from PowerShell profile')
31
- sub.option('--env', 'Print activation snippet for current session only (pipe to Invoke-Expression)')
30
+ sub.option('--remove', 'Remove the hook from PowerShell profile')
31
+ sub.option('--profile-only', 'Install profile hook only, do not activate current session')
32
32
  }
33
33
 
34
34
  if (cmd.name === 'serve') {