endef 2.0.3 → 2.0.5

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,8 @@ 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
101
+ --verbose 输出处理明细
100
102
  ```
101
103
 
102
104
  示例:
@@ -166,13 +168,25 @@ endef all . --out recovered
166
168
  file.ext.endef
167
169
  ```
168
170
 
169
- 通过临时恢复包解压为:
171
+ 恢复为:
170
172
 
171
173
  ```text
172
174
  file.ext
173
175
  ```
174
176
 
175
- `de` 会先生成临时 `.tar.gz` 恢复包,包内路径使用原文件名;随后解压这个恢复包覆盖原文件,解压成功后删除 `file.ext.endef` 和临时恢复包。这个行为符合工具的核心假设:原文件可能已经被异常内容污染,而 `.endef` 是通过旁路读取保存出来的可恢复内容。
177
+ `de` 默认使用脚本恢复。`auto` 模式会优先尝试 Bash,因为 `find + xargs -P` 可以并行处理大量文件;如果没有 Bash,则在 Windows 上使用单个 PowerShell 进程批量处理。恢复时会把:
178
+
179
+ ```text
180
+ file.ext.endef
181
+ ```
182
+
183
+ 改回:
184
+
185
+ ```text
186
+ file.ext
187
+ ```
188
+
189
+ 如果 `file.ext` 已经存在,会先被删除。这个行为符合工具的核心假设:原文件可能已经被异常内容污染,而 `.endef` 是通过旁路读取保存出来的可恢复内容。
176
190
 
177
191
  ## 打包策略
178
192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "endef",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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,261 @@
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 restoredFiles = splitNullSeparated(stdout)
90
+ const restored = restoredFiles.length
22
91
 
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
- }
92
+ if (config.verbose) {
93
+ restoredFiles.forEach(file => {
94
+ console.log(`Restored: ${file}`)
95
+ })
96
+ }
97
+
98
+ console.log(`Done. mode=bash, restored=${restored}, failed=0`)
99
+ return {
100
+ mode: 'bash',
101
+ restored,
102
+ failed: 0
103
+ }
104
+ }
105
+
106
+ async function toBashPath(filePath) {
107
+ if (process.platform !== 'win32') {
108
+ return filePath
30
109
  }
31
110
 
32
111
  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
112
+ const { stdout } = await execFileAsync('bash', ['-lc', 'cygpath -u "$1"', '_', filePath], {
113
+ windowsHide: true
114
+ })
115
+
116
+ return stdout.trim() || filePath
117
+ } catch (error) {
118
+ return filePath
119
+ }
120
+ }
121
+
122
+ async function restoreWithPowerShell(config) {
123
+ const script = `
124
+ $root = $env:ENDEF_ROOT
125
+ $suffix = $env:ENDEF_SUFFIX
126
+ $excludeText = $env:ENDEF_EXCLUDE_DIRS
127
+ $excludeDirs = @()
128
+ $verbose = $env:ENDEF_VERBOSE -eq '1'
129
+ if ($excludeText) {
130
+ $excludeDirs = $excludeText -split "\\n" | Where-Object { $_ }
131
+ }
132
+ $restored = 0
133
+ $restoredFiles = New-Object System.Collections.Generic.List[string]
134
+ Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*$suffix" | Where-Object {
135
+ $cleanRoot = $root.TrimEnd('\\', '/')
136
+ $relative = $_.FullName.Substring($cleanRoot.Length).TrimStart('\\', '/')
137
+ $parts = $relative -split '[\\\\/]'
138
+ $skip = $false
139
+ foreach ($dir in $excludeDirs) {
140
+ if ($parts -contains $dir) {
141
+ $skip = $true
142
+ break
42
143
  }
43
- } finally {
44
- await fs.rm(archivePath, { force: true })
45
144
  }
145
+ -not $skip
146
+ } | ForEach-Object {
147
+ $target = $_.FullName.Substring(0, $_.FullName.Length - $suffix.Length)
148
+ Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
149
+ Move-Item -LiteralPath $_.FullName -Destination $target -Force
150
+ $restored += 1
151
+ if ($verbose) {
152
+ $restoredFiles.Add($target)
153
+ }
154
+ }
155
+ if ($verbose) {
156
+ foreach ($file in $restoredFiles) {
157
+ Write-Output "Restored: $file"
158
+ }
159
+ }
160
+ Write-Output $restored
161
+ `
162
+ const { stdout } = await execFileAsync(
163
+ 'powershell.exe',
164
+ [
165
+ '-NoProfile',
166
+ '-NonInteractive',
167
+ '-ExecutionPolicy',
168
+ 'Bypass',
169
+ '-Command',
170
+ script
171
+ ],
172
+ {
173
+ env: {
174
+ ...process.env,
175
+ ENDEF_ROOT: config.directory,
176
+ ENDEF_SUFFIX: config.suffix,
177
+ ENDEF_EXCLUDE_DIRS: config.excludeDirs.join('\n'),
178
+ ENDEF_VERBOSE: config.verbose ? '1' : ''
179
+ },
180
+ maxBuffer: 1024 * 1024 * 64,
181
+ windowsHide: true
182
+ }
183
+ )
184
+ const lines = stdout.trim().split(/\r?\n/).filter(Boolean)
185
+ const restored = Number(lines[lines.length - 1]) || 0
186
+
187
+ if (config.verbose) {
188
+ lines.slice(0, -1).forEach(line => {
189
+ console.log(line)
190
+ })
191
+ }
192
+
193
+ console.log(`Done. mode=powershell, restored=${restored}, failed=0`)
194
+ return {
195
+ mode: 'powershell',
196
+ restored,
197
+ failed: 0
198
+ }
199
+ }
200
+
201
+ async function restoreWithNode(config) {
202
+ const stats = {
203
+ restored: 0,
204
+ failed: 0
205
+ }
206
+
207
+ await restoreNodeDirectory(config.directory, config, stats)
208
+ console.log(`Done. mode=node, restored=${stats.restored}, failed=${stats.failed}`)
209
+ return {
210
+ mode: 'node',
211
+ restored: stats.restored,
212
+ failed: stats.failed
213
+ }
214
+ }
215
+
216
+ async function restoreNodeDirectory(directory, config, stats) {
217
+ const entries = await fs.readdir(directory, { withFileTypes: true })
218
+
219
+ await Promise.all(
220
+ entries.map(async entry => {
221
+ const filePath = path.join(directory, entry.name)
222
+
223
+ if (entry.isDirectory()) {
224
+ if (config.excludeDirs.includes(entry.name)) {
225
+ return
226
+ }
227
+
228
+ await restoreNodeDirectory(filePath, config, stats)
229
+ return
230
+ }
231
+
232
+ if (!entry.isFile() || !filePath.endsWith(config.suffix)) {
233
+ return
234
+ }
235
+
236
+ try {
237
+ const targetPath = filePath.slice(0, -config.suffix.length)
238
+ await fs.rm(targetPath, { force: true })
239
+ await fs.rename(filePath, targetPath)
240
+ stats.restored += 1
241
+ if (config.verbose) {
242
+ console.log(`Restored: ${targetPath}`)
243
+ }
244
+ } catch (error) {
245
+ stats.failed += 1
246
+ console.error(`Failed: ${filePath}`)
247
+ console.error(error && error.message ? error.message : error)
248
+ }
249
+ })
250
+ )
251
+ }
252
+
253
+ function splitNullSeparated(value) {
254
+ if (!value) {
255
+ return []
256
+ }
257
+
258
+ return value.split('\0').filter(Boolean)
46
259
  }
47
260
 
48
261
  module.exports = restoreFiles
package/src/en.js CHANGED
@@ -30,7 +30,9 @@ async function processFiles(directory, options = {}) {
30
30
  const data = await fs.readFile(filePath)
31
31
 
32
32
  await fs.writeFile(outputPath, data)
33
- console.log(`Written: ${outputPath}`)
33
+ if (config.verbose) {
34
+ console.log(`Written: ${outputPath}`)
35
+ }
34
36
  stats.written += 1
35
37
  }).catch(error => {
36
38
  stats.failed += 1
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,8 @@ 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
21
+ --verbose Print each processed file
28
22
  `)
29
23
  }
30
24
 
@@ -103,6 +97,22 @@ function parseArgs(command, args) {
103
97
  continue
104
98
  }
105
99
 
100
+ if (arg === '--verbose') {
101
+ result.verbose = true
102
+ continue
103
+ }
104
+
105
+ if (arg.startsWith('--mode=')) {
106
+ result.restoreMode = arg.slice('--mode='.length)
107
+ continue
108
+ }
109
+
110
+ if (arg === '--mode') {
111
+ result.restoreMode = args[index + 1]
112
+ index += 1
113
+ continue
114
+ }
115
+
106
116
  if (!arg.startsWith('-')) {
107
117
  result.positionals.push(arg)
108
118
  }
@@ -168,28 +178,14 @@ async function run(args) {
168
178
 
169
179
  if (command === 'all') {
170
180
  await enF(options.directory, options)
171
- await runAll(options.directory, options)
181
+ await deF(options.directory, options)
182
+ await packF(options.directory, options)
172
183
  return
173
184
  }
174
185
 
175
186
  printHelp()
176
187
  }
177
188
 
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
189
  function omitUndefined(value) {
194
190
  return Object.keys(value).reduce((result, key) => {
195
191
  if (value[key] !== undefined) {