juice-email-cli 2.1.13 → 2.1.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/context-menu.js +117 -121
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.1.13",
3
+ "version": "2.1.14",
4
4
  "description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,7 +3,12 @@
3
3
  /**
4
4
  * Windows 右键菜单注册 / 取消注册
5
5
  *
6
- * 使用 HKEY_CURRENT_USER,无需管理员权限,仅对当前用户生效。
6
+ * 使用 ExtendedSubCommandsKey 实现级联菜单(参考 WinRAR 和 Windows Defender 的实现)。
7
+ * 子命令存储在 Classes 根下的共享容器中,各文件类型父菜单通过 ExtendedSubCommandsKey 引用。
8
+ *
9
+ * 与旧版 SubCommands + CommandStore 方案的区别:
10
+ * - 不依赖 CommandStore(HKCU CommandStore 在某些 Windows 版本下不被正确解析)
11
+ * - ExtendedSubCommandsKey 指向 Classes 根下的相对路径,子命令内联存储
7
12
  *
8
13
  * 菜单结构(所有文件类型统一):
9
14
  * .html / .htm / .yaml / .yml:
@@ -37,9 +42,6 @@ function getIconPath() {
37
42
 
38
43
  const isWindows = process.platform === 'win32';
39
44
 
40
- /**
41
- * 执行 reg add — 使用 spawnSync 直接调用 reg.exe,避免 cmd.exe 解析特殊字符
42
- */
43
45
  function regAdd(key, valueName, type, data) {
44
46
  if (!isWindows) return false;
45
47
  const args = ['add', key, '/f'];
@@ -60,38 +62,12 @@ function regAdd(key, valueName, type, data) {
60
62
  return true;
61
63
  }
62
64
 
63
- /**
64
- * 执行 reg delete — 键不存在时静默忽略
65
- */
66
65
  function regDelete(key) {
67
66
  if (!isWindows) return false;
68
67
  const result = spawnSync('reg', ['delete', key, '/f'], { stdio: 'pipe' });
69
68
  return result.status === 0;
70
69
  }
71
70
 
72
- /**
73
- * 清理旧版本残留在 HKLM 的注册表项(v1 使用 HKCR/HKLM,需要管理员权限)
74
- *
75
- * 旧版本用管理员权限安装时写入 HKLM,升级到 HKCU 版本后这些残留不会被自动清理。
76
- * Windows 合并 HKLM + HKCU 注册表视图时,HKLM 里的旧菜单名可能覆盖 HKCU 的新菜单名,
77
- * 导致用户更新后重启电脑仍然看到旧版菜单。
78
- *
79
- * 没有管理员权限时 reg delete 静默失败,不影响后续 HKCU 写入。
80
- */
81
- function cleanLegacyHklmEntries() {
82
- if (!isWindows) return;
83
-
84
- // 清理旧版父菜单(HKLM\Software\Classes\SystemFileAssociations\.*\shell\JuiceEmail)
85
- for (const root of LEGACY_HKLM_ROOTS) {
86
- regDelete(`${root}\\${PARENT_KEY_NAME}`);
87
- }
88
-
89
- // 清理旧版子命令(HKLM\...\CommandStore\shell\JuiceEmail.*)
90
- for (const name of Object.values(SUBCMDS)) {
91
- regDelete(`${LEGACY_HKLM_SUBCMD_SPACE}\\${name}`);
92
- }
93
- }
94
-
95
71
  // ─── 菜单结构常量 ─────────────────────────────────────────────────────────────
96
72
 
97
73
  // 使用 HKCU,无需管理员权限
@@ -109,11 +85,23 @@ const YAML_ROOTS = [
109
85
  `${HKCU_SHELL}\\SystemFileAssociations\\.yml\\shell`,
110
86
  ];
111
87
 
112
- // CommandStore 子命令注册空间
113
- const SUBCMD_SPACE = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
88
+ // 子命令名称常量
89
+ const SUBCMDS = {
90
+ generate: 'JuiceEmail.Generate',
91
+ snippet: 'JuiceEmail.Snippet',
92
+ config: 'JuiceEmail.WithConfig',
93
+ pwsh: 'JuiceEmail.OpenPwsh',
94
+ };
95
+
96
+ const PARENT_KEY_NAME = 'JuiceEmail';
97
+
98
+ // ExtendedSubCommandsKey 指向此路径(相对于 HKCU\Software\Classes)
99
+ // 解析结果:HKCU\Software\Classes\JuiceEmail.SubCommands
100
+ const SUB_CMDS_CONTAINER = 'JuiceEmail.SubCommands';
114
101
 
115
- // 旧版本可能残留在 HKLM 的路径(v1 使用 HKCR/HKLM,需要管理员权限)
116
- // 这些残留不清理会导致 Windows 合并注册表视图时显示旧菜单名
102
+ // ─── 旧版残留清理 ─────────────────────────────────────────────────────────────
103
+
104
+ // 旧版 HKLM 父菜单路径(v1 使用 HKCR/HKLM,需管理员权限写入)
117
105
  const LEGACY_HKLM_ROOTS = [
118
106
  'HKEY_LOCAL_MACHINE\\Software\\Classes\\SystemFileAssociations\\.html\\shell',
119
107
  'HKEY_LOCAL_MACHINE\\Software\\Classes\\SystemFileAssociations\\.htm\\shell',
@@ -121,17 +109,33 @@ const LEGACY_HKLM_ROOTS = [
121
109
  'HKEY_LOCAL_MACHINE\\Software\\Classes\\SystemFileAssociations\\.yml\\shell',
122
110
  ];
123
111
 
124
- const LEGACY_HKLM_SUBCMD_SPACE = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
112
+ // 旧版 CommandStore 子命令路径(v1-v2.1.12 使用 SubCommands + CommandStore 方案)
113
+ const LEGACY_HKCU_CMDSTORE = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
114
+ const LEGACY_HKLM_CMDSTORE = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
125
115
 
126
- // 子命令名称常量(register / unregister 共用,避免硬编码不一致)
127
- const SUBCMDS = {
128
- generate: 'JuiceEmail.Generate',
129
- snippet: 'JuiceEmail.Snippet',
130
- config: 'JuiceEmail.WithConfig',
131
- pwsh: 'JuiceEmail.OpenPwsh',
132
- };
116
+ /**
117
+ * 清理旧版本残留在 HKLM 的父菜单(v1 使用 HKCR/HKLM,需要管理员权限)
118
+ * 没有管理员权限时静默失败,不影响后续 HKCU 写入
119
+ */
120
+ function cleanLegacyHklmParents() {
121
+ if (!isWindows) return;
122
+ for (const root of LEGACY_HKLM_ROOTS) {
123
+ regDelete(`${root}\\${PARENT_KEY_NAME}`);
124
+ }
125
+ }
133
126
 
134
- const PARENT_KEY_NAME = 'JuiceEmail';
127
+ /**
128
+ * 清理旧版本 CommandStore 子命令残留(HKCU + HKLM)
129
+ */
130
+ function cleanLegacyCommandStore() {
131
+ if (!isWindows) return;
132
+ for (const name of Object.values(SUBCMDS)) {
133
+ regDelete(`${LEGACY_HKCU_CMDSTORE}\\${name}`);
134
+ regDelete(`${LEGACY_HKLM_CMDSTORE}\\${name}`);
135
+ }
136
+ }
137
+
138
+ // ─── PowerShell 包装 ──────────────────────────────────────────────────────────
135
139
 
136
140
  /**
137
141
  * 为交互式命令包一层 PowerShell:
@@ -141,7 +145,7 @@ const PARENT_KEY_NAME = 'JuiceEmail';
141
145
  function wrapInteractive(nodePath, scriptPath, cliArgs) {
142
146
  const node = nodePath.replace(/'/g, "''");
143
147
  const script = scriptPath.replace(/'/g, "''");
144
- // %1 由 Windows 在 CreateProcess 前展开为实际文件路径
148
+ // %1 由 Windows Explorer 在 CreateProcess 前展开为实际文件路径
145
149
  const ps = [
146
150
  `& '${node}' '${script}' ${cliArgs}`,
147
151
  `if ($LASTEXITCODE) {`,
@@ -155,6 +159,56 @@ function wrapInteractive(nodePath, scriptPath, cliArgs) {
155
159
  return `powershell.exe -Command "${ps}"`;
156
160
  }
157
161
 
162
+ // ─── 工具:查找 PowerShell 7 ─────────────────────────────────────────────────
163
+
164
+ function resolvePwsh() {
165
+ const candidates = [
166
+ 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
167
+ 'C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe',
168
+ path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'PowerShell', 'pwsh.exe'),
169
+ ];
170
+ for (const p of candidates) {
171
+ if (fs.existsSync(p)) return p;
172
+ }
173
+ try {
174
+ const result = spawnSync('where', ['pwsh'], { stdio: 'pipe' });
175
+ const line = result.stdout.toString().trim().split('\n')[0].trim();
176
+ if (line && fs.existsSync(line)) return line;
177
+ } catch (_) {}
178
+ return null;
179
+ }
180
+
181
+ // ─── 注册子命令到共享容器 ────────────────────────────────────────────────────
182
+
183
+ function registerSubCommands(containerPath, nodePath, scriptPath, iconPath, pwshPath) {
184
+ // generate(非交互,无需终端)
185
+ const genKey = `${containerPath}\\shell\\${SUBCMDS.generate}`;
186
+ regAdd(genKey, 'MUIVerb', 'REG_SZ', '📄 作为模板,生成邮件 HTML');
187
+ regAdd(genKey, 'Icon', 'REG_SZ', iconPath);
188
+ regAdd(`${genKey}\\command`, '', 'REG_SZ', `"${nodePath}" "${scriptPath}" -f %1`);
189
+
190
+ // snippet(交互,需要终端)
191
+ const snipKey = `${containerPath}\\shell\\${SUBCMDS.snippet}`;
192
+ regAdd(snipKey, 'MUIVerb', 'REG_SZ', '🧩 作为片段,拼接邮件 HTML');
193
+ regAdd(snipKey, 'Icon', 'REG_SZ', iconPath);
194
+ regAdd(`${snipKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, '-s %1'));
195
+
196
+ // config(交互,需要终端)
197
+ const cfgKey = `${containerPath}\\shell\\${SUBCMDS.config}`;
198
+ regAdd(cfgKey, 'MUIVerb', 'REG_SZ', '⚙️ 作为配置,拼接邮件 HTML');
199
+ regAdd(cfgKey, 'Icon', 'REG_SZ', iconPath);
200
+ regAdd(`${cfgKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, '-c %1'));
201
+
202
+ // pwsh(可选)
203
+ if (pwshPath) {
204
+ const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
205
+ regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 打开 PowerShell');
206
+ regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
207
+ regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
208
+ `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath (Split-Path '%1')"`);
209
+ }
210
+ }
211
+
158
212
  // ─── 注册 ─────────────────────────────────────────────────────────────────────
159
213
 
160
214
  async function registerContextMenu() {
@@ -165,64 +219,30 @@ async function registerContextMenu() {
165
219
 
166
220
  console.log(chalk.cyan('\n 注册 juice 右键菜单(当前用户,无需管理员权限)...\n'));
167
221
 
168
- // 先清理旧版本可能残留在 HKLM 的注册表项,避免新旧菜单名冲突
169
- cleanLegacyHklmEntries();
222
+ // 清理所有旧版残留,避免新旧方案冲突
223
+ cleanLegacyHklmParents();
224
+ cleanLegacyCommandStore();
170
225
 
171
226
  const nodePath = getNodePath();
172
227
  const scriptPath = getJuiceScript();
173
228
  const iconPath = getIconPath();
229
+ const pwshPath = resolvePwsh();
174
230
 
175
231
  let ok = true;
176
232
 
177
- // ── 子命令 1:普通模式 - juice 生成(非交互,不需终端)─────────────────
178
- const generateCmd = `"${nodePath}" "${scriptPath}" -f %1`;
179
- ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}`, '', 'REG_SZ', '📄 作为模板,生成邮件 HTML') && ok;
180
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}`, 'Icon', 'REG_SZ', iconPath);
181
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}\\command`, '', 'REG_SZ', generateCmd);
182
-
183
- // ── 子命令 2:片段模式 - 片段组装(交互,需要终端)────────────────────────
184
- const snippetCmd = wrapInteractive(nodePath, scriptPath, '-s %1');
185
- ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}`, '', 'REG_SZ', '🧩 作为片段,拼接邮件 HTML') && ok;
186
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}`, 'Icon', 'REG_SZ', iconPath);
187
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}\\command`, '', 'REG_SZ', snippetCmd);
188
-
189
- // ── 子命令 3:配置文件模式 - 交互式生成(交互,需要终端)──────────────────
190
- const configCmd = wrapInteractive(nodePath, scriptPath, '-c %1');
191
- ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}`, '', 'REG_SZ', '⚙️ 作为配置,拼接邮件 HTML') && ok;
192
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}`, 'Icon', 'REG_SZ', iconPath);
193
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}\\command`, '', 'REG_SZ', configCmd);
194
-
195
- // ── 子命令 4:在文件目录打开 pwsh(可选)──────────────────────────────────
196
- const pwshPath = resolvePwsh();
197
- if (pwshPath) {
198
- const pwshCmd = `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath (Split-Path '%1')"`;
199
- ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}`, '', 'REG_SZ', '📂 打开 PowerShell') && ok;
200
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}`, 'Icon', 'REG_SZ', pwshPath);
201
- regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}\\command`, '', 'REG_SZ', pwshCmd);
202
- }
233
+ // 子命令注册一次到共享容器(所有文件类型共用)
234
+ const containerPath = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER}`;
235
+ registerSubCommands(containerPath, nodePath, scriptPath, iconPath, pwshPath);
203
236
 
204
- // ── 所有文件类型共用同一套子命令 ────────────────────────────────────────
205
- const allSubs = pwshPath
206
- ? `${SUBCMDS.generate};${SUBCMDS.snippet};${SUBCMDS.config};${SUBCMDS.pwsh}`
207
- : `${SUBCMDS.generate};${SUBCMDS.snippet};${SUBCMDS.config}`;
208
-
209
- // .html / .htm
210
- for (const root of HTML_ROOTS) {
237
+ // 为每种文件类型注册父菜单,通过 ExtendedSubCommandsKey 引用共享容器
238
+ const allRoots = [...HTML_ROOTS, ...YAML_ROOTS];
239
+ for (const root of allRoots) {
211
240
  const parentKey = `${root}\\${PARENT_KEY_NAME}`;
212
241
  ok = regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML') && ok;
213
242
  regAdd(parentKey, 'Icon', 'REG_SZ', iconPath);
214
- regAdd(parentKey, 'SubCommands', 'REG_SZ', allSubs);
243
+ regAdd(parentKey, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER);
215
244
  }
216
245
 
217
- // .yaml / .yml(与 .html 共享同一套子命令)
218
- for (const root of YAML_ROOTS) {
219
- const parentKey = `${root}\\${PARENT_KEY_NAME}`;
220
- ok = regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML') && ok;
221
- regAdd(parentKey, 'Icon', 'REG_SZ', iconPath);
222
- regAdd(parentKey, 'SubCommands', 'REG_SZ', allSubs);
223
- }
224
-
225
- // ── 输出 ──────────────────────────────────────────────────────────────────
226
246
  if (!ok) {
227
247
  console.log(chalk.yellow(' ⚠ 部分注册表项写入失败,右键菜单可能不完整。\n'));
228
248
  }
@@ -253,23 +273,18 @@ async function unregisterContextMenu() {
253
273
 
254
274
  let removed = 0;
255
275
 
256
- // 清理 HKCU 父菜单
257
- for (const root of HTML_ROOTS) {
276
+ // 清理 HKCU 父菜单(4 个文件类型)
277
+ const allRoots = [...HTML_ROOTS, ...YAML_ROOTS];
278
+ for (const root of allRoots) {
258
279
  if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
259
280
  }
260
281
 
261
- // 清理 HKCU YAML 父菜单
262
- for (const root of YAML_ROOTS) {
263
- if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
264
- }
265
-
266
- // 清理 HKCU 子命令
267
- for (const name of Object.values(SUBCMDS)) {
268
- regDelete(`${SUBCMD_SPACE}\\${name}`);
269
- }
282
+ // 清理共享子命令容器
283
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER}`)) removed++;
270
284
 
271
- // 同时清理旧版本可能残留在 HKLM 的注册表项
272
- cleanLegacyHklmEntries();
285
+ // 清理旧版残留(HKLM 父菜单 + CommandStore)
286
+ cleanLegacyHklmParents();
287
+ cleanLegacyCommandStore();
273
288
 
274
289
  if (removed > 0) {
275
290
  console.log(chalk.green(` ✔ 已移除 ${removed} 个右键菜单项。\n`));
@@ -278,23 +293,4 @@ async function unregisterContextMenu() {
278
293
  }
279
294
  }
280
295
 
281
- // ─── 工具:查找 PowerShell 7 ─────────────────────────────────────────────────
282
-
283
- function resolvePwsh() {
284
- const candidates = [
285
- 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
286
- 'C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe',
287
- path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'PowerShell', 'pwsh.exe'),
288
- ];
289
- for (const p of candidates) {
290
- if (fs.existsSync(p)) return p;
291
- }
292
- try {
293
- const result = spawnSync('where', ['pwsh'], { stdio: 'pipe' });
294
- const line = result.stdout.toString().trim().split('\n')[0].trim();
295
- if (line && fs.existsSync(line)) return line;
296
- } catch (_) {}
297
- return null;
298
- }
299
-
300
296
  module.exports = { registerContextMenu, unregisterContextMenu };