endef 2.0.3 → 2.0.4

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `endef` 是一个纯 Node.js 的文件旁路转换工具。
4
4
 
5
- 它适合这样的场景:某些常见源码或文本后缀的文件在系统读取、复制、粘贴、编辑器打开时出现乱码或异常,但通过 Node.js 脚本读取仍然正常。`endef` 会先把目标文件内容写到同目录的 `.endef` 副本里,再通过临时 `.tar.gz` 恢复包解压回原文件名。
5
+ 它适合这样的场景:某些常见源码或文本后缀的文件在系统读取、复制、粘贴、编辑器打开时出现乱码或异常,但通过 Node.js 脚本读取仍然正常。`endef` 会先把目标文件内容写到同目录的 `.endef` 副本里,再用脚本批量删除原文件并把 `.endef` 改回原文件名。
6
6
 
7
7
  示例:
8
8
 
@@ -16,10 +16,10 @@ README.md -> README.md.endef -> README.md
16
16
 
17
17
  ## 特性
18
18
 
19
- - Node.js 实现,不依赖 Bash、find、xargs、grep shell 工具
19
+ - Node.js CLI,恢复阶段可自动调用 Bash PowerShell 脚本
20
20
  - 默认旁路后缀为 `.endef`
21
21
  - `en` 默认转换所有匹配后缀的文件
22
- - `de` 会把 `.endef` 临时副本做成恢复包,解压回原文件名,再删除 `.endef`
22
+ - `de` 会用脚本批量删除原文件,并把 `.endef` 临时副本改回原文件名
23
23
  - `pack` 会把恢复后的目录打包成 `.tar.gz`
24
24
  - 不引入生产依赖
25
25
 
@@ -41,7 +41,7 @@ npm install -g endef
41
41
  endef en /path/to/project
42
42
  ```
43
43
 
44
- 通过临时恢复包解压回原文件名,并删除 `.endef`:
44
+ 用脚本批量删除原文件,并把 `.endef` 改回原文件名:
45
45
 
46
46
  ```bash
47
47
  endef de /path/to/project
@@ -82,11 +82,11 @@ endef all [directory] [options]
82
82
 
83
83
  `en` 会扫描目标目录,把匹配后缀的文件读取出来,并写成同目录的 `.endef` 副本。
84
84
 
85
- `de` 会扫描目标目录中的 `.endef` 文件,生成一个临时恢复包,把 `.endef` 内容映射回原文件名并解压,然后删除 `.endef` 副本。这个命令会直接修改文件,请在执行前确认目录和备份策略。
85
+ `de` 会扫描目标目录中的 `.endef` 文件,并用外部脚本批量删除原文件、把 `.endef` 改回原文件名。默认模式是 `auto`:优先使用 Bash 的 `find + xargs -P`,没有 Bash 时在 Windows 上使用 PowerShell。这个命令会直接修改文件,请在执行前确认目录和备份策略。
86
86
 
87
87
  `pack` 会按当前配置扫描当前目录,把没有被 `excludeDirs` 排除的文件打包成 `.tar.gz`。如果输出文件已存在,会直接覆盖。
88
88
 
89
- `all` 会先执行 `en`,然后生成一次最终恢复包;这个包既会被解压回目录,也会作为最终 `.tar.gz` 保留下来,避免 `de` 和 `pack` 重复打包。
89
+ `all` 会按顺序执行 `en`、`de`、`pack`,适合确认配置后一次性完成旁路转换、脚本恢复和打包。
90
90
 
91
91
  ## 参数
92
92
 
@@ -97,6 +97,7 @@ endef all [directory] [options]
97
97
  --exclude <list> 排除目录名,多个值用英文逗号分隔
98
98
  --concurrency <n> 并发文件操作数量
99
99
  --out <file> 压缩包输出名称
100
+ --mode <mode> de/all 恢复模式:auto、bash、powershell、node
100
101
  ```
101
102
 
102
103
  示例:
@@ -166,13 +167,25 @@ endef all . --out recovered
166
167
  file.ext.endef
167
168
  ```
168
169
 
169
- 通过临时恢复包解压为:
170
+ 恢复为:
170
171
 
171
172
  ```text
172
173
  file.ext
173
174
  ```
174
175
 
175
- `de` 会先生成临时 `.tar.gz` 恢复包,包内路径使用原文件名;随后解压这个恢复包覆盖原文件,解压成功后删除 `file.ext.endef` 和临时恢复包。这个行为符合工具的核心假设:原文件可能已经被异常内容污染,而 `.endef` 是通过旁路读取保存出来的可恢复内容。
176
+ `de` 默认使用脚本恢复。`auto` 模式会优先尝试 Bash,因为 `find + xargs -P` 可以并行处理大量文件;如果没有 Bash,则在 Windows 上使用单个 PowerShell 进程批量处理。恢复时会把:
177
+
178
+ ```text
179
+ file.ext.endef
180
+ ```
181
+
182
+ 改回:
183
+
184
+ ```text
185
+ file.ext
186
+ ```
187
+
188
+ 如果 `file.ext` 已经存在,会先被删除。这个行为符合工具的核心假设:原文件可能已经被异常内容污染,而 `.endef` 是通过旁路读取保存出来的可恢复内容。
176
189
 
177
190
  ## 打包策略
178
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "endef",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Recover text and source files through safe .endef sidecar copies.",
5
5
  "bin": {
6
6
  "endef": "bin/main.js"
package/src/config.js CHANGED
@@ -35,7 +35,8 @@ const defaultConfig = {
35
35
  ],
36
36
  excludeDirs: ['node_modules', '.git', 'dist', '.next', '.nuxt', '.cache'],
37
37
  excludeFiles: ['.endef.json'],
38
- concurrency: 32
38
+ concurrency: 32,
39
+ restoreMode: 'auto'
39
40
  }
40
41
 
41
42
  function loadConfig(cwd, explicitConfigFile, targetDirectory) {
package/src/de.js CHANGED
@@ -1,48 +1,233 @@
1
1
  const fs = require('fs/promises')
2
2
  const path = require('path')
3
+ const { execFile } = require('child_process')
4
+ const { promisify } = require('util')
3
5
  const { defaultConfig } = require('./config')
4
- const {
5
- collectRecoveryEntries,
6
- createPackConfig,
7
- extractTarGz,
8
- removeSidecars,
9
- writeTarGz
10
- } = require('./pack')
6
+
7
+ const execFileAsync = promisify(execFile)
11
8
 
12
9
  async function restoreFiles(directory, options = {}) {
13
- const config = createPackConfig(directory, {
10
+ const config = {
14
11
  ...defaultConfig,
15
- ...options
16
- })
17
- const archivePath = path.join(config.directory, `.endef-restore-${Date.now()}.tar.gz`)
18
- const entries = await collectRecoveryEntries(config, {
19
- onlySidecars: true,
20
- outputPath: archivePath
12
+ ...options,
13
+ directory: path.resolve(directory || process.cwd()),
14
+ restoreMode: options.restoreMode || 'auto'
15
+ }
16
+ const mode = await resolveRestoreMode(config.restoreMode)
17
+
18
+ if (mode === 'bash') {
19
+ return restoreWithBash(config)
20
+ }
21
+
22
+ if (mode === 'powershell') {
23
+ return restoreWithPowerShell(config)
24
+ }
25
+
26
+ return restoreWithNode(config)
27
+ }
28
+
29
+ async function resolveRestoreMode(mode) {
30
+ if (mode !== 'auto') {
31
+ return mode
32
+ }
33
+
34
+ if (await hasCommand('bash', ['--version'])) {
35
+ return 'bash'
36
+ }
37
+
38
+ if (process.platform === 'win32') {
39
+ return 'powershell'
40
+ }
41
+
42
+ return 'node'
43
+ }
44
+
45
+ async function hasCommand(command, args) {
46
+ try {
47
+ await execFileAsync(command, args, { windowsHide: true })
48
+ return true
49
+ } catch (error) {
50
+ return false
51
+ }
52
+ }
53
+
54
+ async function restoreWithBash(config) {
55
+ const root = await toBashPath(config.directory)
56
+ const excludeDirs = config.excludeDirs.join('\n')
57
+ const jobs = String(config.concurrency || 32)
58
+ const script = `
59
+ set -e
60
+ root=$1
61
+ suffix=$2
62
+ jobs=$3
63
+ cd "$root"
64
+ find_args=(.)
65
+ while IFS= read -r dir; do
66
+ if [ -n "$dir" ]; then
67
+ find_args+=( -path "*/$dir" -prune -o )
68
+ fi
69
+ done <<< "$ENDEF_EXCLUDE_DIRS"
70
+ find_args+=( -type f -name "*$suffix" -print0 )
71
+ find "\${find_args[@]}" | xargs -0 -r -P "$jobs" -I{} bash -c '
72
+ suffix="$1"
73
+ file="$2"
74
+ target="\${file%$suffix}"
75
+ rm -f -- "$target"
76
+ mv -f -- "$file" "$target"
77
+ printf "%s\\0" "$target"
78
+ ' _ "$suffix" {}
79
+ `
80
+
81
+ const { stdout } = await execFileAsync('bash', ['-lc', script, '_', root, config.suffix, jobs], {
82
+ env: {
83
+ ...process.env,
84
+ ENDEF_EXCLUDE_DIRS: excludeDirs
85
+ },
86
+ maxBuffer: 1024 * 1024 * 64,
87
+ windowsHide: true
21
88
  })
89
+ const restored = countNullSeparated(stdout)
22
90
 
23
- if (entries.length === 0) {
24
- console.log('Done. restored=0, deleted=0, failed=0')
25
- return {
26
- restored: 0,
27
- deleted: 0,
28
- failed: 0
29
- }
91
+ console.log(`Done. mode=bash, restored=${restored}, failed=0`)
92
+ return {
93
+ mode: 'bash',
94
+ restored,
95
+ failed: 0
96
+ }
97
+ }
98
+
99
+ async function toBashPath(filePath) {
100
+ if (process.platform !== 'win32') {
101
+ return filePath
30
102
  }
31
103
 
32
104
  try {
33
- await writeTarGz(config.directory, archivePath, entries)
34
- const restored = await extractTarGz(archivePath, config.directory)
35
- const deleted = await removeSidecars(config.directory, config)
36
-
37
- console.log(`Done. restored=${restored}, deleted=${deleted}, failed=0`)
38
- return {
39
- restored,
40
- deleted,
41
- failed: 0
105
+ const { stdout } = await execFileAsync('bash', ['-lc', 'cygpath -u "$1"', '_', filePath], {
106
+ windowsHide: true
107
+ })
108
+
109
+ return stdout.trim() || filePath
110
+ } catch (error) {
111
+ return filePath
112
+ }
113
+ }
114
+
115
+ async function restoreWithPowerShell(config) {
116
+ const script = `
117
+ $root = $env:ENDEF_ROOT
118
+ $suffix = $env:ENDEF_SUFFIX
119
+ $excludeText = $env:ENDEF_EXCLUDE_DIRS
120
+ $excludeDirs = @()
121
+ if ($excludeText) {
122
+ $excludeDirs = $excludeText -split "\\n" | Where-Object { $_ }
123
+ }
124
+ $restored = 0
125
+ Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*$suffix" | Where-Object {
126
+ $cleanRoot = $root.TrimEnd('\\', '/')
127
+ $relative = $_.FullName.Substring($cleanRoot.Length).TrimStart('\\', '/')
128
+ $parts = $relative -split '[\\\\/]'
129
+ $skip = $false
130
+ foreach ($dir in $excludeDirs) {
131
+ if ($parts -contains $dir) {
132
+ $skip = $true
133
+ break
42
134
  }
43
- } finally {
44
- await fs.rm(archivePath, { force: true })
45
135
  }
136
+ -not $skip
137
+ } | ForEach-Object {
138
+ $target = $_.FullName.Substring(0, $_.FullName.Length - $suffix.Length)
139
+ Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
140
+ Move-Item -LiteralPath $_.FullName -Destination $target -Force
141
+ $restored += 1
142
+ }
143
+ Write-Output $restored
144
+ `
145
+ const { stdout } = await execFileAsync(
146
+ 'powershell.exe',
147
+ [
148
+ '-NoProfile',
149
+ '-NonInteractive',
150
+ '-ExecutionPolicy',
151
+ 'Bypass',
152
+ '-Command',
153
+ script
154
+ ],
155
+ {
156
+ env: {
157
+ ...process.env,
158
+ ENDEF_ROOT: config.directory,
159
+ ENDEF_SUFFIX: config.suffix,
160
+ ENDEF_EXCLUDE_DIRS: config.excludeDirs.join('\n')
161
+ },
162
+ maxBuffer: 1024 * 1024 * 64,
163
+ windowsHide: true
164
+ }
165
+ )
166
+ const restored = Number(stdout.trim()) || 0
167
+
168
+ console.log(`Done. mode=powershell, restored=${restored}, failed=0`)
169
+ return {
170
+ mode: 'powershell',
171
+ restored,
172
+ failed: 0
173
+ }
174
+ }
175
+
176
+ async function restoreWithNode(config) {
177
+ const stats = {
178
+ restored: 0,
179
+ failed: 0
180
+ }
181
+
182
+ await restoreNodeDirectory(config.directory, config, stats)
183
+ console.log(`Done. mode=node, restored=${stats.restored}, failed=${stats.failed}`)
184
+ return {
185
+ mode: 'node',
186
+ restored: stats.restored,
187
+ failed: stats.failed
188
+ }
189
+ }
190
+
191
+ async function restoreNodeDirectory(directory, config, stats) {
192
+ const entries = await fs.readdir(directory, { withFileTypes: true })
193
+
194
+ await Promise.all(
195
+ entries.map(async entry => {
196
+ const filePath = path.join(directory, entry.name)
197
+
198
+ if (entry.isDirectory()) {
199
+ if (config.excludeDirs.includes(entry.name)) {
200
+ return
201
+ }
202
+
203
+ await restoreNodeDirectory(filePath, config, stats)
204
+ return
205
+ }
206
+
207
+ if (!entry.isFile() || !filePath.endsWith(config.suffix)) {
208
+ return
209
+ }
210
+
211
+ try {
212
+ const targetPath = filePath.slice(0, -config.suffix.length)
213
+ await fs.rm(targetPath, { force: true })
214
+ await fs.rename(filePath, targetPath)
215
+ stats.restored += 1
216
+ } catch (error) {
217
+ stats.failed += 1
218
+ console.error(`Failed: ${filePath}`)
219
+ console.error(error && error.message ? error.message : error)
220
+ }
221
+ })
222
+ )
223
+ }
224
+
225
+ function countNullSeparated(value) {
226
+ if (!value) {
227
+ return 0
228
+ }
229
+
230
+ return value.split('\0').filter(Boolean).length
46
231
  }
47
232
 
48
233
  module.exports = restoreFiles
package/src/run.js CHANGED
@@ -2,14 +2,6 @@ const enF = require('./en')
2
2
  const deF = require('./de')
3
3
  const packF = require('./pack')
4
4
  const { loadConfig } = require('./config')
5
- const {
6
- collectRecoveryEntries,
7
- createPackConfig,
8
- extractTarGz,
9
- removeSidecars,
10
- resolveOutputPath,
11
- writeTarGz
12
- } = require('./pack')
13
5
 
14
6
  function printHelp() {
15
7
  console.log(`Usage:
@@ -25,6 +17,7 @@ Options:
25
17
  --exclude <list> Comma separated excluded directory names
26
18
  --concurrency <n> Concurrent file operation limit
27
19
  --out <file> Archive output name
20
+ --mode <mode> Restore mode for de/all: auto, bash, powershell, node
28
21
  `)
29
22
  }
30
23
 
@@ -103,6 +96,17 @@ function parseArgs(command, args) {
103
96
  continue
104
97
  }
105
98
 
99
+ if (arg.startsWith('--mode=')) {
100
+ result.restoreMode = arg.slice('--mode='.length)
101
+ continue
102
+ }
103
+
104
+ if (arg === '--mode') {
105
+ result.restoreMode = args[index + 1]
106
+ index += 1
107
+ continue
108
+ }
109
+
106
110
  if (!arg.startsWith('-')) {
107
111
  result.positionals.push(arg)
108
112
  }
@@ -168,28 +172,14 @@ async function run(args) {
168
172
 
169
173
  if (command === 'all') {
170
174
  await enF(options.directory, options)
171
- await runAll(options.directory, options)
175
+ await deF(options.directory, options)
176
+ await packF(options.directory, options)
172
177
  return
173
178
  }
174
179
 
175
180
  printHelp()
176
181
  }
177
182
 
178
- async function runAll(directory, options) {
179
- const config = createPackConfig(directory, options)
180
- const outputPath = resolveOutputPath(config.directory, config.out)
181
- const entries = await collectRecoveryEntries(config, {
182
- outputPath
183
- })
184
-
185
- await writeTarGz(config.directory, outputPath, entries)
186
- const restored = await extractTarGz(outputPath, config.directory)
187
- const deleted = await removeSidecars(config.directory, config)
188
-
189
- console.log(`Packed: ${outputPath}`)
190
- console.log(`Done. files=${entries.length}, restored=${restored}, deleted=${deleted}`)
191
- }
192
-
193
183
  function omitUndefined(value) {
194
184
  return Object.keys(value).reduce((result, key) => {
195
185
  if (value[key] !== undefined) {