@wangnanshan/dsh-nanshan-publish-manager 1.0.1 → 1.0.2

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 (4) hide show
  1. package/README.md +21 -6
  2. package/client.js +222 -16
  3. package/index.js +290 -3
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -84,8 +84,8 @@ DSH 组件就是一个 npm 包:
84
84
  1. **版本与标签** —— 目标版本(写回 `package.json`)、npm tag、`public` / `restricted`。
85
85
  2. **发布目标与选项** —— registry(默认读取你的 `~/.npmrc`)、可选构建脚本(`npm run <script>`)、自动运行发布预检查、先演练、自动写入版本号、发布备注。
86
86
  3. **发布预检查** —— 运行发布预检查;有阻断项时正式发布按钮不可用。
87
- 4. **确认与执行** —— 选择发布验证方式,勾选确认,先「仅演练」(`npm publish --dry-run`,临时写入的目标版本会在演练后还原)或「正式发布」(带最终确认弹窗)。
88
- 5. **发布记录** —— 每次演练 / 发布 / 失败都会写入本机记录,包含版本、tag、access、验证方式、耗时、预检查摘要和命令输出。
87
+ 4. **确认与执行** —— 选择发布验证方式,勾选确认,先「仅演练」(`npm publish --dry-run`,临时写入的目标版本会在演练后还原)或「正式发布」(带最终确认弹窗);本机会话方式还可以在弹窗里选「在终端发布」。
88
+ 5. **发布记录** —— 每次演练 / 发布 / 失败都会写入本机记录,包含版本、tag、access、验证方式、耗时、预检查摘要和命令输出;终端发布会在记录里标记「终端」。
89
89
 
90
90
  ## 发布验证方式
91
91
 
@@ -101,14 +101,27 @@ npm 已在 **2025-12-09 永久撤销全部经典 Token**,本插件不再提供
101
101
 
102
102
  Granular Access Token 可以在 npm 网站 Settings → Tokens 或 `npm token create` 创建;写令牌最长 90 天,无人值守发布建议开启 Bypass 2FA。
103
103
 
104
- 页面内的 OTP 只支持 TOTP(身份验证器 6 位数字)。如果账号使用安全密钥 / 网页型 2FA,npm 需要在交互式终端里打开浏览器授权;这种情况下请在终端执行 `npm publish`,或创建一个开启 Bypass 2FA 的 Granular Access Token 后使用「细粒度令牌」方式。
104
+ 页面内的 OTP 只支持 TOTP(身份验证器 6 位数字)。如果账号使用安全密钥 / 网页型 2FA,npm 需要在交互式终端里打开浏览器授权;这种情况下可以直接在确认弹窗点「在终端发布」,或创建一个开启 Bypass 2FA 的 Granular Access Token 后使用「细粒度令牌」方式。
105
105
 
106
106
  npm 凭据**不经过插件的存储层**:本机会话使用你自己的 `~/.npmrc`;Granular Token 的临时 npmrc 在发布后删除;OTP 只作用于一次子进程。三者都不会写入发布记录。
107
+
108
+ ### 在系统终端发布
109
+
110
+ 在「本机登录会话」方式下,最终确认弹窗提供「在终端发布」:
111
+
112
+ - macOS 打开 Terminal.app;
113
+ - Windows 打开 `cmd.exe`;
114
+ - Linux 尝试 `x-terminal-emulator`、`gnome-terminal`、`konsole`、`xfce4-terminal`、`xterm`。
115
+
116
+ 插件会生成一个临时脚本并自动执行实际发布命令(配置了构建脚本时先执行构建)。你在系统终端里完成 OTP 或浏览器/安全密钥验证;插件等待最多 15 分钟,读取退出码后把结果写入发布记录,记录带「终端」标记。终端输出保留在系统终端窗口,不写入插件记录,避免 OTP 进入日志。
117
+
118
+ 如果终端没有自动出现,或等待超时,插件会弹出居中的错误弹窗;终端窗口里的完整输出仍然保留,可据此排查。
119
+
107
120
  ### OTP 的时机
108
121
 
109
122
  OTP 不是插件强制每次都要填写的字段:
110
123
 
111
- - **本机会话**:当 npm 对你的账号或这个包要求发布 2FA 时,每一次发布尝试都会发起挑战。填写后才随请求发送;不填时若 npm 挑战,会返回 EOTP,弹窗会重新打开并清空旧码。验证码一次性、约 30 秒有效,不能复用。
124
+ - **本机会话**:当 npm 对你的账号或这个包要求发布 2FA 时,每一次发布尝试都会发起挑战。填写后才随请求发送;不填时若 npm 挑战,会返回 EOTP,并弹出居中的错误弹窗、清空旧码;关闭弹窗后重新点「正式发布」即可重试。验证码一次性、约 30 秒有效,不能复用。
112
125
  - **填写时机**:OTP 只在点击「正式发布」后的确认弹窗里填写。UI 已经运行过发布预检查时,服务端不会在点击后再次执行整套 npm 预检查,因此验证码会在确认后的极短时间内使用,避免被前置预检查拖到过期。
113
126
  - **Granular Access Token**:令牌开启 **Bypass 2FA** 时不需要 OTP;未开启时 npm 仍会挑战,建议改用本机会话 + OTP 或换一个开启 Bypass 2FA 的令牌。
114
127
  - **OIDC 可信发布**:由 CI 的 ID Token 换发短期令牌,不需要 OTP。
@@ -125,6 +138,7 @@ OTP 不是插件强制每次都要填写的字段:
125
138
  | 本机会话 + OTP | 同上,OTP 通过 `npm_config_otp` 环境变量传递,日志注 `# auth: local session + OTP (npm_config_otp)` |
126
139
  | 细粒度令牌 | 同上,令牌通过一次性 npmrc(`npm_config_userconfig`)传递,日志注 `# auth: granular access token (temporary npmrc)` |
127
140
  | OIDC | 同上,npm 自动完成 OIDC 交换,日志注 `# auth: OIDC trusted publishing` |
141
+ | 在系统终端发布 | 同上,在系统终端里交互完成 OTP / 网页 2FA,日志注 `# auth: local session (system terminal)` |
128
142
  | 仅演练 | 追加 `--dry-run`,不注认证信息 |
129
143
 
130
144
  预发布版本(如 `1.0.0-beta.1`)选择 `latest` 时会给出警告,建议改用 `beta` / `next`;目标版本低于已发布最新版时,npm 会拒绝把 `latest` 指回旧版本,插件会把该错误翻译成明确的提示。
@@ -135,7 +149,7 @@ OTP 不是插件强制每次都要填写的字段:
135
149
 
136
150
  1. 确认验证码来自**当前登录账号**(`npm whoami` 显示 `wangnanshan`)绑定的身份验证器,并且是刚生成的;旧码不能复用。
137
151
  2. 确认系统时间自动同步。TOTP 依赖本机时钟;当前机器与 npm registry 的偏差约 0.6 秒,属于正常范围。
138
- 3. 如果账号的 2FA 方式是**安全密钥 / 网页认证**而不是身份验证器 TOTP,页面内输入 6 位码不会成功。请在终端执行 `npm publish` 让 npm 打开浏览器授权,或使用下面的令牌方案。
152
+ 3. 如果账号的 2FA 方式是**安全密钥 / 网页认证**而不是身份验证器 TOTP,页面内输入 6 位码不会成功。请在确认弹窗点「在终端发布」,让 npm 在系统终端里打开浏览器授权;或使用下面的令牌方案。
139
153
  4. 使用 **Granular Access Token** 时,必须在创建令牌时勾选 **Bypass 2FA**(`npm token create --bypass-2fa`);只粘贴一个未开启 Bypass 2FA 的令牌仍会返回 EOTP。
140
154
  5. 如果配置了构建脚本,构建会先于发布执行;构建较慢时验证码可能过期,建议使用开启 Bypass 2FA 的令牌。
141
155
 
@@ -209,6 +223,7 @@ HTTP 接口(仅本机、同源写入):
209
223
  - 正式发布需要先 `npm login`(2 小时会话,默认网页授权);未登录时只能演练。
210
224
  - Granular Access Token 若未开启 Bypass 2FA,发布时仍会被要求 OTP;此时请改用本机会话 + OTP。
211
225
  - 发布使用本机 npm 配置,不支持在页面里输入 token;OTP 只用于单次发布,不落盘。
212
- - 需要 macOS / Linux 的 npm 启动方式;Windows 的 `npm.cmd` 尚未适配。
226
+ - 系统终端发布支持 macOS Terminal、Windows `cmd.exe` 和 Linux 常见终端;Windows 的 `npm.cmd` 已做启动适配,但当前环境主要在 macOS 验证。
227
+ - 终端发布最多等待 15 分钟;关闭终端或未完成验证会超时,不会写入成功记录;终端输出不写入插件记录。
213
228
  - 页面直接执行 `npm publish`,请在发布前用「仅演练」确认打包内容。
214
229
  - `npm publish` 会执行组件 `package.json` 中的 publish 生命周期脚本;请只发布你信任的组件。
package/client.js CHANGED
@@ -29,6 +29,8 @@ window.__ModuleLoader__.load({
29
29
  'action.dryRun': '仅演练',
30
30
  'action.dryRunning': '演练中…',
31
31
  'action.publish': '正式发布',
32
+ 'action.publishTerminal': '在终端发布',
33
+ 'action.terminalWaiting': '等待终端…',
32
34
  'action.publishing': '发布中…',
33
35
  'action.cancel': '再想想',
34
36
  'action.confirmPublish': '确认发布',
@@ -140,7 +142,7 @@ window.__ModuleLoader__.load({
140
142
  'auth.sessionHint': '会话仅 2 小时有效;过期后请在终端运行 npm login(默认网页授权)再发布。页面 OTP 仅支持身份验证器 TOTP;安全密钥型 2FA 请在终端发布,或使用开启 Bypass 2FA 的令牌。',
141
143
  'auth.tokenUser': '令牌对应账号:{name}',
142
144
  'auth.tokenUnverified': '令牌已填写;发布预检查可验证账号(发布结果为准)',
143
- 'auth.tokenHint': '只用于本次发布,不保存、不写日志。',
145
+ 'auth.tokenHint': '只用于本次发布,不保存、不写日志;未开启 Bypass 2FA 时仍需 2FA,页面不会弹出浏览器。',
144
146
  'auth.tokenPlaceholder': '粘贴 npm_... 令牌',
145
147
  'auth.needToken': '请填写 Granular Access Token',
146
148
  'auth.oidcReady': 'OIDC 已就绪({provider})',
@@ -174,7 +176,7 @@ window.__ModuleLoader__.load({
174
176
  'authInfo.token.step5': '点击「正式发布」;插件写入权限 0600 的临时 npmrc,发布后立即删除。',
175
177
  'authInfo.token.key1': '是什么:npm 当前的细粒度访问令牌,替代已撤销的经典 Token。',
176
178
  'authInfo.token.key2': '权限:Read and write,仅限选中的包/作用域。',
177
- 'authInfo.token.key3': 'Bypass 2FA:开启后不需要 OTP;未开启仍可能返回 EOTP。',
179
+ 'authInfo.token.key3': 'Bypass 2FA:开启后不需要 OTP;未开启时 npm 会要求 2FA,插件不会弹出浏览器,请改用 Bypass 令牌或本机会话 + OTP。',
178
180
  'authInfo.token.key4': '存储:不保存、不写日志;临时 npmrc 发布后删除。',
179
181
  'authInfo.token.key5': '安全:泄露后立即在 npm 网站撤销。',
180
182
  'authInfo.oidc.title': 'OIDC 可信发布',
@@ -222,12 +224,15 @@ window.__ModuleLoader__.load({
222
224
  'field.accessToken': '访问令牌',
223
225
  'check.token-format.label': 'Granular Token 已填写',
224
226
  'check.token-format.hint': '切到「细粒度令牌」后粘贴有效的 Granular Access Token;建议开启 Bypass 2FA。',
227
+ 'check.token-bypass.label': 'Bypass 2FA 状态',
228
+ 'check.token-bypass.hint': 'Bypass 2FA 是服务端令牌属性,令牌字符串本身不包含它;请确认创建时已勾选,否则发布时仍会被要求 2FA。',
225
229
  'check.oidc-env.label': 'OIDC 发布环境',
226
230
  'check.oidc-env.hint': 'OIDC 仅在 GitHub Actions(id-token: write)或 GitLab CI(NPM_ID_TOKEN)中可用。',
227
231
  'checkDetail.tokenUnverified': '令牌已提供,但 whoami 未通过;发布时以 registry 结果为准',
228
232
  'checkDetail.oidcAtPublish': 'OIDC 将在发布时自动交换令牌',
229
233
  'checkDetail.tokenEmpty': '未填写令牌',
230
234
  'checkDetail.tokenFilled': '已填写',
235
+ 'checkDetail.cannotDetect': '无法从令牌本身判断;请确认创建时已开启 Bypass 2FA',
231
236
  'checkDetail.oidcGithub': 'GitHub Actions 环境',
232
237
  'checkDetail.oidcGitlab': 'GitLab CI 环境',
233
238
  'checkDetail.oidcUnavailable': '未检测到 OIDC 环境',
@@ -237,11 +242,19 @@ window.__ModuleLoader__.load({
237
242
  'modal.dryFirstHint': '本次设置启用了「发布前先演练」,你还没有在本次会话里演练;确认后仍会直接正式发布。',
238
243
  'modal.publishOk': '确认发布',
239
244
  'modal.otpHint': '请填写刚生成的验证码;一次性、约 30 秒有效。',
245
+ 'modal.terminalHint': '也可以打开系统终端完成发布:npm 会在终端里询问 OTP 或打开浏览器完成 2FA,插件会等待结果并写入记录。',
240
246
  'modal.buildBeforeOtp': '当前配置了构建脚本:发布前会先执行构建,耗时较长时验证码可能过期;建议使用开启 Bypass 2FA 的 Granular Token。',
241
247
  'toast.settingsSaved': '发布设置已保存',
242
248
  'toast.checkDone': '预检查完成',
243
249
  'toast.publishOk': '发布成功,记录已写入',
244
250
  'toast.publishFail': '发布没有完成',
251
+ 'error.publishTitle': '发布失败',
252
+ 'error.dryTitle': '演练失败',
253
+ 'error.terminalTitle': '终端发布失败',
254
+ 'error.terminalHint': '如果终端已打开,请查看完整输出;若未打开,请检查系统终端设置后重试。',
255
+ 'error.checkTitle': '发布预检查失败',
256
+ 'error.saveTitle': '保存设置失败',
257
+ 'error.publishHint': '完整命令输出见发布板块下方的结果区域。',
245
258
  'toast.dryOk': '演练完成,包内容可以发布',
246
259
  'toast.dryFail': '演练失败,请查看输出',
247
260
  'toast.added': '已添加组件目录',
@@ -253,6 +266,7 @@ window.__ModuleLoader__.load({
253
266
  'record.showLog': '查看输出({n})',
254
267
  'record.hideLog': '收起输出',
255
268
  'record.otp': '含 OTP',
269
+ 'record.terminal': '终端',
256
270
  'stage.build': '构建阶段',
257
271
  'stage.checks': '预检查阶段',
258
272
  'stage.publish': '发布阶段',
@@ -340,6 +354,8 @@ window.__ModuleLoader__.load({
340
354
  'action.dryRun': 'Dry run',
341
355
  'action.dryRunning': 'Dry running…',
342
356
  'action.publish': 'Publish',
357
+ 'action.publishTerminal': 'Publish in terminal',
358
+ 'action.terminalWaiting': 'Waiting for terminal…',
343
359
  'action.publishing': 'Publishing…',
344
360
  'action.cancel': 'Not yet',
345
361
  'action.confirmPublish': 'Publish now',
@@ -451,7 +467,7 @@ window.__ModuleLoader__.load({
451
467
  'auth.sessionHint': 'Sessions last two hours; when expired, run npm login (web authorization by default) in a terminal. Page OTP supports authenticator TOTP only; security-key 2FA must publish from a terminal or use a token with Bypass 2FA.',
452
468
  'auth.tokenUser': 'Token account: {name}',
453
469
  'auth.tokenUnverified': 'Token provided; the publish pre-check can verify the account (the publish result is authoritative)',
454
- 'auth.tokenHint': 'Used only for this publish; never saved or logged.',
470
+ 'auth.tokenHint': 'Used only for this publish; never saved or logged. Without Bypass 2FA, npm still requires 2FA and this page does not open a browser.',
455
471
  'auth.tokenPlaceholder': 'Paste an npm_... token',
456
472
  'auth.needToken': 'Enter a granular access token',
457
473
  'auth.oidcReady': 'OIDC ready ({provider})',
@@ -485,7 +501,7 @@ window.__ModuleLoader__.load({
485
501
  'authInfo.token.step5': 'Click Publish; the plugin writes a temporary npmrc with mode 0600 and deletes it right after the publish.',
486
502
  'authInfo.token.key1': 'What it is: npm’s current fine-grained token, replacing revoked classic tokens.',
487
503
  'authInfo.token.key2': 'Permissions: Read and write, limited to the selected package/scope.',
488
- 'authInfo.token.key3': 'Bypass 2FA: no OTP is required once enabled; without it the registry may still return EOTP.',
504
+ 'authInfo.token.key3': 'Bypass 2FA: no OTP once enabled; without it npm requires 2FA, and this plugin does not open a browser—use a Bypass token or the local session + OTP.',
489
505
  'authInfo.token.key4': 'Storage: never saved or logged; the temporary npmrc is removed after publishing.',
490
506
  'authInfo.token.key5': 'Security: revoke it on npmjs.com immediately if it leaks.',
491
507
  'authInfo.oidc.title': 'OIDC trusted publishing',
@@ -533,12 +549,15 @@ window.__ModuleLoader__.load({
533
549
  'field.accessToken': 'Access token',
534
550
  'check.token-format.label': 'Granular token provided',
535
551
  'check.token-format.hint': 'Select “Granular token” and paste a valid token; enabling Bypass 2FA is recommended.',
552
+ 'check.token-bypass.label': 'Bypass 2FA status',
553
+ 'check.token-bypass.hint': 'Bypass 2FA is server-side token metadata, not part of the token string; confirm it was enabled when the token was created, or npm will still require 2FA at publish.',
536
554
  'check.oidc-env.label': 'OIDC publish environment',
537
555
  'check.oidc-env.hint': 'OIDC works only in GitHub Actions (id-token: write) or GitLab CI (NPM_ID_TOKEN).',
538
556
  'checkDetail.tokenUnverified': 'Token provided, but whoami failed; the registry result at publish time is authoritative',
539
557
  'checkDetail.oidcAtPublish': 'OIDC exchanges its token automatically at publish time',
540
558
  'checkDetail.tokenEmpty': 'No token entered',
541
559
  'checkDetail.tokenFilled': 'Entered',
560
+ 'checkDetail.cannotDetect': 'Cannot be determined from the token itself; confirm Bypass 2FA was enabled at creation',
542
561
  'checkDetail.oidcGithub': 'GitHub Actions environment',
543
562
  'checkDetail.oidcGitlab': 'GitLab CI environment',
544
563
  'checkDetail.oidcUnavailable': 'No OIDC environment detected',
@@ -548,11 +567,19 @@ window.__ModuleLoader__.load({
548
567
  'modal.dryFirstHint': '“Dry run first” is enabled but no dry run has happened in this session; confirming publishes directly.',
549
568
  'modal.publishOk': 'Publish',
550
569
  'modal.otpHint': 'Enter a freshly generated code; single-use and valid for about 30 seconds.',
570
+ 'modal.terminalHint': 'You can also publish in the system terminal: npm handles OTP or web 2FA there, and the plugin waits and records the result.',
551
571
  'modal.buildBeforeOtp': 'A build script is configured: the build runs before publishing and may outlast the OTP; a Granular Access Token with Bypass 2FA is recommended.',
552
572
  'toast.settingsSaved': 'Release settings saved',
553
573
  'toast.checkDone': 'Pre-check finished',
554
574
  'toast.publishOk': 'Published; the record was saved',
555
575
  'toast.publishFail': 'The publish did not complete',
576
+ 'error.publishTitle': 'Publish failed',
577
+ 'error.dryTitle': 'Dry run failed',
578
+ 'error.terminalTitle': 'Terminal publish failed',
579
+ 'error.terminalHint': 'If the terminal opened, read its full output; otherwise check your system terminal setup and retry.',
580
+ 'error.checkTitle': 'Publish pre-check failed',
581
+ 'error.saveTitle': 'Could not save settings',
582
+ 'error.publishHint': 'The full command output is shown below in the publish result area.',
556
583
  'toast.dryOk': 'Dry run finished; the package can be published',
557
584
  'toast.dryFail': 'The dry run failed; see the output',
558
585
  'toast.added': 'Component directory added',
@@ -564,6 +591,7 @@ window.__ModuleLoader__.load({
564
591
  'record.showLog': 'Show output ({n})',
565
592
  'record.hideLog': 'Hide output',
566
593
  'record.otp': 'OTP sent',
594
+ 'record.terminal': 'Terminal',
567
595
  'stage.build': 'Build stage',
568
596
  'stage.checks': 'Pre-check stage',
569
597
  'stage.publish': 'Publish stage',
@@ -819,6 +847,9 @@ window.__ModuleLoader__.load({
819
847
  .np-result{margin-top:4px;border:1px solid var(--np-line);border-radius:10px;background:var(--np-sheet2);overflow:hidden}
820
848
  .np-result__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:9px 11px;border-bottom:1px solid var(--np-line);font-size:12.5px;font-weight:500}
821
849
  .np-result__head .np-count{margin-left:auto}
850
+ .np-result__close{appearance:none;flex:none;display:grid;place-items:center;width:22px;height:22px;padding:0;border:1px solid color-mix(in srgb,var(--np-bad) 28%,var(--np-line));border-radius:6px;background:color-mix(in srgb,var(--np-bad) 7%,transparent);color:var(--np-bad);cursor:pointer}
851
+ .np-result__close:hover{background:color-mix(in srgb,var(--np-bad) 14%,transparent);border-color:color-mix(in srgb,var(--np-bad) 50%,var(--np-line));color:var(--np-bad)}
852
+ .np-result__close:focus-visible{outline:2px solid var(--np-bad);outline-offset:2px}
822
853
  .np-result__cmd{padding:7px 11px;border-bottom:1px solid var(--np-line);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--np-ink-soft);word-break:break-all}
823
854
  .np-log{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.55;color:var(--np-ink-soft)}
824
855
  .np-railcard{position:relative;padding:16px;background:var(--np-sheet);border:1px solid var(--np-line);border-radius:12px;box-shadow:var(--np-shadow)}
@@ -849,6 +880,10 @@ window.__ModuleLoader__.load({
849
880
  .np-dialog__warn{padding:8px 10px;border:1px solid color-mix(in srgb,var(--np-bad) 30%,var(--np-line));border-radius:8px;background:color-mix(in srgb,var(--np-bad) 6%,transparent);color:var(--np-bad)!important}
850
881
  .np-dialog__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
851
882
  .np-dialog.np-dialog--info{width:min(560px,100%)}
883
+ .np-dialog.np-dialog--error{width:min(540px,100%)}
884
+ .np-dialog--error .np-info__head h2{margin:0;flex:1;min-width:0}
885
+ .np-error__message{max-height:320px;overflow:auto;padding:10px 12px;border:1px solid color-mix(in srgb,var(--np-bad) 30%,var(--np-line));border-radius:8px;background:color-mix(in srgb,var(--np-bad) 5%,transparent);color:var(--np-ink);font-size:12.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word;user-select:text}
886
+ .np-dialog--error .np-error__detail{margin:8px 0 0;font-size:11.5px;line-height:1.55;color:var(--np-ink-soft);word-break:break-word}
852
887
  .np-dialog--info .np-info__head h2{margin:0;flex:1;min-width:0}
853
888
  .np-dialog--info .np-info__intro{margin:8px 0 4px}
854
889
  .np-info__head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
@@ -1012,6 +1047,7 @@ window.__ModuleLoader__.load({
1012
1047
  if (detail === 'OIDC_AT_PUBLISH') return t('checkDetail.oidcAtPublish')
1013
1048
  if (detail === 'EMPTY') return t('checkDetail.tokenEmpty')
1014
1049
  if (detail === 'GRANULAR_TOKEN') return t('checkDetail.tokenFilled')
1050
+ if (detail === 'CANNOT_DETECT') return t('checkDetail.cannotDetect')
1015
1051
  if (detail === 'github-actions') return t('checkDetail.oidcGithub')
1016
1052
  if (detail === 'gitlab') return t('checkDetail.oidcGitlab')
1017
1053
  if (detail === 'UNAVAILABLE') return t('checkDetail.oidcUnavailable')
@@ -1214,6 +1250,10 @@ window.__ModuleLoader__.load({
1214
1250
  play: [
1215
1251
  ['path', { d: 'M5 3.5 12.5 8 5 12.5V3.5Z' }],
1216
1252
  ],
1253
+ terminal: [
1254
+ ['rect', { x: 2.2, y: 3.2, width: 11.6, height: 9.6, rx: 1.6 }],
1255
+ ['path', { d: 'm4.8 6.1 2.4 2.2-2.4 2.2M8.6 10.5h2.8' }],
1256
+ ],
1217
1257
  pencil: [
1218
1258
  ['path', { d: 'M3 13l.9-3.1 6.3-6.3 2.2 2.2-6.3 6.3L3 13Z' }],
1219
1259
  ['path', { d: 'M9.6 4.2 11.8 6.4' }],
@@ -1337,6 +1377,57 @@ window.__ModuleLoader__.load({
1337
1377
  )
1338
1378
  }
1339
1379
 
1380
+ function ErrorDialog(props) {
1381
+ const [t] = useText()
1382
+ const [copied, setCopied] = React.useState(false)
1383
+ const message = props.message || t('common.error')
1384
+ React.useEffect(() => {
1385
+ setCopied(false)
1386
+ }, [props.title, props.message])
1387
+ React.useEffect(() => {
1388
+ const onKeyDown = (event) => {
1389
+ if (event.key === 'Escape') props.onClose()
1390
+ }
1391
+ document.addEventListener('keydown', onKeyDown)
1392
+ return () => document.removeEventListener('keydown', onKeyDown)
1393
+ }, [props.onClose])
1394
+ async function copy() {
1395
+ try {
1396
+ const parts = [props.title, message, props.detail, props.hint].filter(Boolean)
1397
+ await navigator.clipboard.writeText(parts.join('\n\n'))
1398
+ setCopied(true)
1399
+ } catch {
1400
+ // Clipboard may be unavailable; the message stays selectable.
1401
+ }
1402
+ }
1403
+ return h('div', {
1404
+ className: 'np-modal',
1405
+ role: 'presentation',
1406
+ onMouseDown: (event) => {
1407
+ if (event.target === event.currentTarget) props.onClose()
1408
+ },
1409
+ },
1410
+ h('div', { className: 'np-dialog np-dialog--error', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'np-error-title' },
1411
+ h('div', { className: 'np-info__head' },
1412
+ h('h2', { id: 'np-error-title' }, props.title),
1413
+ h('button', {
1414
+ type: 'button',
1415
+ className: 'np-btn np-btn--ghost np-btn--small',
1416
+ 'aria-label': t('action.close'),
1417
+ onClick: props.onClose,
1418
+ }, h(Glyph, { name: 'close', size: 13 })),
1419
+ ),
1420
+ h('div', { className: 'np-error__message' }, message),
1421
+ props.detail ? h('p', { className: 'np-error__detail' }, props.detail) : null,
1422
+ props.hint ? h('p', { className: 'np-error__detail' }, props.hint) : null,
1423
+ h('div', { className: 'np-dialog__actions' },
1424
+ h('button', { type: 'button', className: 'np-btn', onClick: copy }, copied ? t('action.copied') : t('action.copy')),
1425
+ h('button', { type: 'button', className: 'np-btn np-btn--primary', autoFocus: true, onClick: props.onClose }, t('action.close')),
1426
+ ),
1427
+ ),
1428
+ )
1429
+ }
1430
+
1340
1431
  function useToast() {
1341
1432
  const [toast, setToast] = React.useState(null)
1342
1433
  const timer = React.useRef(null)
@@ -1567,6 +1658,7 @@ window.__ModuleLoader__.load({
1567
1658
  const [accessToken, setAccessToken] = React.useState('')
1568
1659
  const [showToken, setShowToken] = React.useState(false)
1569
1660
  const [toast, showToast] = useToast()
1661
+ const [errorDialog, setErrorDialog] = React.useState(null)
1570
1662
  const okButtonRef = React.useRef(null)
1571
1663
  const otpInputRef = React.useRef(null)
1572
1664
  const tokenInputRef = React.useRef(null)
@@ -1655,6 +1747,18 @@ window.__ModuleLoader__.load({
1655
1747
  const effectiveAccess = isScopedPackage ? (settings ? settings.access : 'public') : 'public'
1656
1748
  const isPrerelease = typeof targetVersion === 'string' && targetVersion.includes('-')
1657
1749
 
1750
+ const closeError = React.useCallback(() => setErrorDialog(null), [])
1751
+ const showError = React.useCallback((title, message, detail, hint) => {
1752
+ setModalOpen(false)
1753
+ setInfoMode(null)
1754
+ setErrorDialog({
1755
+ title,
1756
+ message: message || t('common.error'),
1757
+ detail,
1758
+ hint,
1759
+ })
1760
+ }, [t])
1761
+
1658
1762
  async function saveSettings(next, silent) {
1659
1763
  if (!next) return
1660
1764
  setBusy('save')
@@ -1664,7 +1768,7 @@ window.__ModuleLoader__.load({
1664
1768
  if (!silent) showToast('ok', t('toast.settingsSaved'))
1665
1769
  return data.settings
1666
1770
  } catch (failure) {
1667
- showToast('error', failure && failure.message ? failure.message : t('common.error'))
1771
+ showError(t('error.saveTitle'), failure && failure.message ? failure.message : t('common.error'))
1668
1772
  return null
1669
1773
  } finally {
1670
1774
  setBusy('')
@@ -1728,7 +1832,7 @@ window.__ModuleLoader__.load({
1728
1832
  setCheckInfo({ ran: true, at: Date.now(), npmInfo: data.npmInfo || null })
1729
1833
  showToast('ok', t('toast.checkDone'))
1730
1834
  } catch (failure) {
1731
- showToast('error', failure && failure.message ? failure.message : t('common.error'))
1835
+ showError(t('error.checkTitle'), failure && failure.message ? failure.message : t('common.error'))
1732
1836
  } finally {
1733
1837
  setBusy('')
1734
1838
  }
@@ -1771,7 +1875,18 @@ window.__ModuleLoader__.load({
1771
1875
  setAccessToken('')
1772
1876
  }
1773
1877
  } else {
1774
- showToast('error', data.error || (dryRun ? t('toast.dryFail') : t('toast.publishFail')))
1878
+ const failureMessage = data.error
1879
+ || (data.record && data.record.message)
1880
+ || (dryRun ? t('toast.dryFail') : t('toast.publishFail'))
1881
+ const context = dryRun
1882
+ ? `${component.name}@${targetVersion} · ${settings.registry}`
1883
+ : `${component.name}@${targetVersion} · ${t(`auth.mode.${authMode}`)} · ${settings.registry}`
1884
+ showError(
1885
+ dryRun ? t('error.dryTitle') : t('error.publishTitle'),
1886
+ failureMessage,
1887
+ context,
1888
+ t('error.publishHint'),
1889
+ )
1775
1890
  if (Array.isArray(data.checks) && data.checks.length > 0) {
1776
1891
  setChecks(data.checks)
1777
1892
  setCheckInfo({ ran: true, at: Date.now(), npmInfo: checkInfo.npmInfo })
@@ -1779,21 +1894,84 @@ window.__ModuleLoader__.load({
1779
1894
  if (data.otpRequired === true) {
1780
1895
  setOtp('')
1781
1896
  setOtpEnteredAt(0)
1782
- setModalOpen(true)
1783
1897
  }
1784
1898
  }
1785
- const fresh = await apiGet(`/component?dir=${encodeURIComponent(dir)}`)
1786
- payloadRef.current = fresh
1787
- setPayload(fresh)
1788
- setSettings(fresh.settings)
1789
- if (!data.ok) setChecks(Array.isArray(data.checks) && data.checks.length > 0 ? data.checks : fresh.checks)
1899
+ try {
1900
+ const fresh = await apiGet(`/component?dir=${encodeURIComponent(dir)}`)
1901
+ payloadRef.current = fresh
1902
+ setPayload(fresh)
1903
+ setSettings(fresh.settings)
1904
+ if (!data.ok) setChecks(Array.isArray(data.checks) && data.checks.length > 0 ? data.checks : fresh.checks)
1905
+ if (data.ok) {
1906
+ setChecks(fresh.checks)
1907
+ setCheckInfo({ ran: false, at: null, npmInfo: null })
1908
+ }
1909
+ setTargetVersion(fresh.component.version)
1910
+ } catch {
1911
+ // The publish result is already on screen; a refresh failure must
1912
+ // not replace it with a misleading "publish failed" dialog.
1913
+ }
1914
+ } catch (failure) {
1915
+ showError(t('error.publishTitle'), failure && failure.message ? failure.message : t('common.error'))
1916
+ } finally {
1917
+ setBusy('')
1918
+ }
1919
+ }
1920
+
1921
+ async function publishInTerminal() {
1922
+ if (!settings) return
1923
+ setModalOpen(false)
1924
+ setOtp('')
1925
+ setOtpEnteredAt(0)
1926
+ setBusy('terminal')
1927
+ try {
1928
+ const data = await apiPost('/publish-terminal', {
1929
+ dir,
1930
+ version: targetVersion,
1931
+ authMode,
1932
+ registry: settings.registry,
1933
+ tag: settings.tag,
1934
+ access: effectiveAccess,
1935
+ buildScript: settings.buildScript,
1936
+ note: settings.note,
1937
+ requireChecks: settings.requireChecks,
1938
+ checksFresh: settings.requireChecks && checkInfo.ran && summary.fail === 0,
1939
+ checksSummary: settings.requireChecks && checkInfo.ran ? summary : undefined,
1940
+ writeVersion: settings.writeVersion,
1941
+ }, { allowFailure: true })
1942
+ if (data.record) {
1943
+ setResult(data.record)
1944
+ setExpandedRecord(null)
1945
+ }
1790
1946
  if (data.ok) {
1947
+ showToast('ok', t('toast.publishOk'))
1948
+ setConfirmed(false)
1949
+ } else {
1950
+ showError(
1951
+ t('error.terminalTitle'),
1952
+ data.error || t('toast.publishFail'),
1953
+ `${component.name}@${targetVersion}`,
1954
+ t('error.terminalHint'),
1955
+ )
1956
+ }
1957
+ try {
1958
+ const fresh = await apiGet(`/component?dir=${encodeURIComponent(dir)}`)
1959
+ payloadRef.current = fresh
1960
+ setPayload(fresh)
1961
+ setSettings(fresh.settings)
1791
1962
  setChecks(fresh.checks)
1792
1963
  setCheckInfo({ ran: false, at: null, npmInfo: null })
1964
+ setTargetVersion(fresh.component.version)
1965
+ } catch {
1966
+ // Keep the terminal result visible.
1793
1967
  }
1794
- setTargetVersion(fresh.component.version)
1795
1968
  } catch (failure) {
1796
- showToast('error', failure && failure.message ? failure.message : t('common.error'))
1969
+ showError(
1970
+ t('error.terminalTitle'),
1971
+ failure && failure.message ? failure.message : t('common.error'),
1972
+ undefined,
1973
+ t('error.terminalHint'),
1974
+ )
1797
1975
  } finally {
1798
1976
  setBusy('')
1799
1977
  }
@@ -2180,7 +2358,7 @@ window.__ModuleLoader__.load({
2180
2358
  className: 'np-btn np-btn--primary',
2181
2359
  disabled: busy !== '' || blocked !== '',
2182
2360
  onClick: openPublishDialog,
2183
- }, h(Glyph, { name: 'upload', size: 13 }), busy === 'publish' ? t('action.publishing') : t('action.publish')),
2361
+ }, h(Glyph, { name: 'upload', size: 13 }), busy === 'terminal' ? t('action.terminalWaiting') : busy === 'publish' ? t('action.publishing') : t('action.publish')),
2184
2362
  blocked
2185
2363
  ? h('span', { className: 'np-hint' }, blocked)
2186
2364
  : h('span', { className: 'np-hint' }, t('hint.targetVersion')),
@@ -2192,7 +2370,15 @@ window.__ModuleLoader__.load({
2192
2370
  h('span', null, t('execute.resultTitle')),
2193
2371
  h('span', { className: `np-chip ${result.ok ? 'np-chip--ok' : 'np-chip--fail'}` }, result.ok ? t('execute.resultOk') : t('execute.resultFail')),
2194
2372
  result.authMode && !result.dryRun ? h('span', { className: 'np-chip' }, t(`auth.mode.${result.authMode}`)) : null,
2373
+ result.terminal ? h('span', { className: 'np-chip np-chip--accent' }, t('record.terminal')) : null,
2195
2374
  h('span', { className: 'np-count' }, `v${result.version} · ${formatDuration(result.durationMs)}`),
2375
+ h('button', {
2376
+ type: 'button',
2377
+ className: 'np-result__close',
2378
+ title: t('action.close'),
2379
+ 'aria-label': t('action.close'),
2380
+ onClick: () => setResult(null),
2381
+ }, h(Glyph, { name: 'close', size: 12 })),
2196
2382
  ),
2197
2383
  Array.isArray(result.logs) && result.logs.length > 0
2198
2384
  ? result.logs.map((log, index) => h('div', { key: index },
@@ -2245,6 +2431,7 @@ window.__ModuleLoader__.load({
2245
2431
  h('span', { className: `np-chip ${record.ok ? 'np-chip--ok' : 'np-chip--fail'}` }, record.ok ? t('record.ok') : t('record.fail')),
2246
2432
  record.authMode && !record.dryRun ? h('span', { className: 'np-chip' }, t(`auth.mode.${record.authMode}`)) : null,
2247
2433
  record.otpProvided ? h('span', { className: 'np-chip np-chip--accent' }, t('record.otp')) : null,
2434
+ record.terminal ? h('span', { className: 'np-chip np-chip--accent' }, t('record.terminal')) : null,
2248
2435
  record.name && record.name !== packageName ? h('span', { className: 'np-chip', title: record.name }, record.name) : null,
2249
2436
  ),
2250
2437
  h('div', { className: 'np-record__time' },
@@ -2335,12 +2522,21 @@ window.__ModuleLoader__.load({
2335
2522
  ),
2336
2523
  h('p', { className: 'np-hint' }, tr('modal.authNote', { mode: t(`auth.mode.${authMode}`) })),
2337
2524
  authMode === 'session' && settings.buildScript ? h('p', { className: 'np-hint' }, t('modal.buildBeforeOtp')) : null,
2525
+ authMode === 'session' ? h('p', { className: 'np-hint' }, t('modal.terminalHint')) : null,
2338
2526
  blocked ? h('p', { className: 'np-hint' }, blocked) : null,
2339
2527
  h('p', { className: 'np-dialog__warn' }, t('modal.publishWarning')),
2340
2528
  settings.dryRunFirst && !records.some((record) => record.dryRun === true)
2341
2529
  ? h('p', { className: 'np-hint' }, t('modal.dryFirstHint'))
2342
2530
  : null,
2343
2531
  h('div', { className: 'np-dialog__actions' },
2532
+ authMode === 'session'
2533
+ ? h('button', {
2534
+ type: 'button',
2535
+ className: 'np-btn',
2536
+ disabled: busy !== '' || blocked !== '',
2537
+ onClick: publishInTerminal,
2538
+ }, h(Glyph, { name: 'terminal', size: 13 }), busy === 'terminal' ? t('action.terminalWaiting') : t('action.publishTerminal'))
2539
+ : null,
2344
2540
  h('button', { type: 'button', className: 'np-btn np-btn--ghost', onClick: () => setModalOpen(false) }, t('action.cancel')),
2345
2541
  h('button', {
2346
2542
  type: 'button',
@@ -2398,6 +2594,16 @@ window.__ModuleLoader__.load({
2398
2594
  )
2399
2595
  : null,
2400
2596
 
2597
+ errorDialog
2598
+ ? h(ErrorDialog, {
2599
+ title: errorDialog.title,
2600
+ message: errorDialog.message,
2601
+ detail: errorDialog.detail,
2602
+ hint: errorDialog.hint,
2603
+ onClose: closeError,
2604
+ })
2605
+ : null,
2606
+
2401
2607
  h(Toast, { toast }),
2402
2608
  )
2403
2609
  }
package/index.js CHANGED
@@ -234,8 +234,14 @@ export function apply(ctx, config = {}) {
234
234
  seen.add(candidate)
235
235
  candidates.push(candidate)
236
236
  }
237
+ const npmName = process.platform === 'win32' ? 'npm.cmd' : 'npm'
237
238
  for (const dir of String(process.env.PATH || '').split(delimiter)) {
238
- if (dir) add(join(dir, 'npm'))
239
+ if (dir) add(join(dir, npmName))
240
+ }
241
+ if (process.platform === 'win32') {
242
+ if (process.env.APPDATA) add(join(process.env.APPDATA, 'npm', 'npm.cmd'))
243
+ if (process.env.ProgramFiles) add(join(process.env.ProgramFiles, 'nodejs', 'npm.cmd'))
244
+ if (process.env.NVM_SYMLINK) add(join(process.env.NVM_SYMLINK, 'npm.cmd'))
239
245
  }
240
246
  add('/opt/homebrew/bin/npm')
241
247
  add('/usr/local/bin/npm')
@@ -301,11 +307,17 @@ export function apply(ctx, config = {}) {
301
307
  const startedAt = Date.now()
302
308
  let child
303
309
  try {
304
- child = spawn(executable, args, {
310
+ const isWindowsScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)
311
+ const command = isWindowsScript ? (process.env.ComSpec || 'cmd.exe') : executable
312
+ const commandArgs = isWindowsScript
313
+ ? ['/d', '/s', '/c', `"${[executable, ...args].map(quoteCmdArg).join(' ')}"`]
314
+ : args
315
+ child = spawn(command, commandArgs, {
305
316
  cwd: processOptions.cwd,
306
317
  env: processOptions.env || process.env,
307
318
  stdio: ['ignore', 'pipe', 'pipe'],
308
319
  windowsHide: true,
320
+ ...(isWindowsScript ? { windowsVerbatimArguments: true } : {}),
309
321
  })
310
322
  } catch (error) {
311
323
  const message = String(error && error.message ? error.message : error)
@@ -842,6 +854,13 @@ export function apply(ctx, config = {}) {
842
854
  status: tokenProvided ? 'pass' : 'fail',
843
855
  detail: tokenProvided ? 'GRANULAR_TOKEN' : 'EMPTY',
844
856
  })
857
+ if (tokenProvided) {
858
+ // The Bypass 2FA flag is server-side token metadata, not part of the
859
+ // token string. npm exposes it in `npm token list --json`, but that
860
+ // needs an account session and returns masked tokens, so the pasted
861
+ // token cannot be matched reliably here.
862
+ checks.push({ id: 'token-bypass', status: 'warn', detail: 'CANNOT_DETECT' })
863
+ }
845
864
  }
846
865
  }
847
866
 
@@ -1145,6 +1164,7 @@ export function apply(ctx, config = {}) {
1145
1164
  otpAgeMs: Number.isFinite(input.otpAgeMs) ? input.otpAgeMs : null,
1146
1165
  authMode: AUTH_MODES.has(input.authMode) ? input.authMode : 'session',
1147
1166
  tokenProvided: input.tokenProvided === true,
1167
+ terminal: input.terminal === true,
1148
1168
  checksFresh: input.checksFresh === true,
1149
1169
  at: new Date().toISOString(),
1150
1170
  durationMs: commands.reduce((sum, command) => sum + (Number(command.durationMs) || 0), 0),
@@ -1321,6 +1341,7 @@ export function apply(ctx, config = {}) {
1321
1341
  const result = await runNpm(args, { cwd: dir, timeoutMs: options.publishTimeoutMs, extraEnv: env })
1322
1342
  const resultText = `${result.stderr || ''}\n${result.output || ''}`
1323
1343
  const otpRequired = /EOTP|one-time password|one-time pass/i.test(resultText)
1344
+ const bypassRequired = /bypass 2fa|two-factor authentication or granular access token with bypass|granular access token with bypass 2fa enabled/i.test(resultText)
1324
1345
  const versionConflict = /EPUBLISHCONFLICT|previously published|cannot publish over|已发布的版本/i.test(resultText)
1325
1346
  const latestTagConflict = /Cannot implicitly apply the ["']?latest["']? tag|previously published version .* is higher/i.test(resultText)
1326
1347
  const authFailure = /ENEEDAUTH|need auth|not logged in|E401|401 Unauthorized|Unauthorized|E403|403 Forbidden|not authorized/i.test(resultText)
@@ -1356,9 +1377,17 @@ export function apply(ctx, config = {}) {
1356
1377
  message = '该版本已经存在于 registry,不能被覆盖;请提升版本号后重试。'
1357
1378
  } else if (latestTagConflict) {
1358
1379
  message = '目标版本低于已发布的最新版,不能把 latest 指回旧版本;请提升版本号或改用其他 tag。'
1380
+ } else if (bypassRequired) {
1381
+ if (authMode === 'token') {
1382
+ message = '该 Granular Token 未开启 Bypass 2FA:npm 要求发布时进行 2FA(插件不会弹出浏览器)。请重新创建令牌并开启 Bypass 2FA(npm token create --bypass-2fa),或改用「本机登录会话 + OTP」。'
1383
+ } else if (authMode === 'session') {
1384
+ message = 'npm 要求发布时进行 2FA:请填写 OTP;如果账号使用安全密钥/网页型 2FA,请在终端发布或使用开启 Bypass 2FA 的令牌。'
1385
+ } else {
1386
+ message = 'OIDC 发布仍被要求 2FA:请检查 npm 包的 Trusted Publisher 配置。'
1387
+ }
1359
1388
  } else if (otpRequired) {
1360
1389
  if (authMode === 'token') {
1361
- message = '该 Granular Token 仍要求 2FA:请重新创建令牌并开启 Bypass 2FA(npm token create --bypass-2fa),或切换到「本机登录会话 + OTP」。'
1390
+ message = '该 Granular Token 仍要求 2FA(插件不会弹出浏览器):请重新创建令牌并开启 Bypass 2FA(npm token create --bypass-2fa),或切换到「本机登录会话 + OTP」。'
1362
1391
  } else if (authMode === 'oidc') {
1363
1392
  message = 'OIDC 发布仍被要求额外验证:请检查 npm 包的 Trusted Publisher 配置。'
1364
1393
  } else if (otpProvided) {
@@ -1420,6 +1449,220 @@ export function apply(ctx, config = {}) {
1420
1449
  }
1421
1450
  }
1422
1451
 
1452
+ async function openSystemTerminal(scriptPath) {
1453
+ if (process.platform === 'darwin') {
1454
+ const opened = await runProcess('/usr/bin/open', ['-a', 'Terminal', scriptPath], { timeoutMs: 15_000 })
1455
+ if (!opened.ok) {
1456
+ const detail = firstLine(opened.stderr || opened.output) || 'open 失败'
1457
+ throw httpError(500, `无法打开 Terminal:${detail}`)
1458
+ }
1459
+ return 'Terminal'
1460
+ }
1461
+ if (process.platform === 'win32') {
1462
+ try {
1463
+ await launchDetached('cmd.exe', ['/d', '/s', '/c', 'start', '', 'cmd.exe', '/k', scriptPath])
1464
+ return 'cmd'
1465
+ } catch (error) {
1466
+ throw httpError(500, `无法打开命令提示符:${String(error && error.message ? error.message : error)}`)
1467
+ }
1468
+ }
1469
+ if (process.platform === 'linux') {
1470
+ const candidates = [
1471
+ ['x-terminal-emulator', ['-e', 'bash', scriptPath]],
1472
+ ['gnome-terminal', ['--', 'bash', scriptPath]],
1473
+ ['konsole', ['-e', 'bash', scriptPath]],
1474
+ ['xfce4-terminal', ['--command', `bash ${shellQuote(scriptPath)}`]],
1475
+ ['xterm', ['-e', 'bash', scriptPath]],
1476
+ ]
1477
+ for (const [command, args] of candidates) {
1478
+ const resolved = await findOnPath(command)
1479
+ if (!resolved) continue
1480
+ try {
1481
+ await launchDetached(resolved, args)
1482
+ return command
1483
+ } catch {
1484
+ // Try the next terminal emulator.
1485
+ }
1486
+ }
1487
+ throw httpError(500, '未找到可用的系统终端(x-terminal-emulator / gnome-terminal / konsole / xfce4-terminal / xterm)。')
1488
+ }
1489
+ throw httpError(400, `当前系统(${process.platform})不支持自动打开终端。`)
1490
+ }
1491
+
1492
+ async function publishInTerminal(input) {
1493
+ const startedAt = Date.now()
1494
+ const { dir, pkg: initialPkg } = await requireComponentDir(input.dir)
1495
+ if (publishing.has(dir)) throw httpError(409, '该组件正在发布中,请稍候。')
1496
+ publishing.add(dir)
1497
+ try {
1498
+ await ensureState()
1499
+ const settings = mergeSettings({ ...(state.settings[dir] || {}), ...sanitizeSettings(input) })
1500
+ if (!options.allowPublish) throw httpError(403, '当前插件配置禁止正式发布(allowPublish: false)。')
1501
+ const requestedAuthMode = AUTH_MODES.has(input.authMode) ? input.authMode : settings.authMode
1502
+ if (requestedAuthMode !== 'session') {
1503
+ throw httpError(400, '「在系统终端发布」只适用于本机登录会话方式。')
1504
+ }
1505
+ const version = (typeof input.version === 'string' && input.version.trim() !== '')
1506
+ ? input.version.trim()
1507
+ : String(initialPkg.version || '')
1508
+ if (!isValidVersion(version)) throw httpError(400, '目标版本号不是合法的语义化版本,例如 1.2.3 或 1.3.0-beta.1。')
1509
+ const registry = normalizeRegistry(input.registry, settings.registry)
1510
+ const tag = TAGS.has(input.tag) ? input.tag : settings.tag
1511
+ const access = ACCESS.has(input.access) ? input.access : settings.access
1512
+ const isScoped = typeof initialPkg.name === 'string' && initialPkg.name.startsWith('@')
1513
+ const effectiveAccess = isScoped ? access : 'public'
1514
+ const note = typeof input.note === 'string' ? input.note.trim().slice(0, 500) : settings.note
1515
+ const buildScript = typeof input.buildScript === 'string'
1516
+ ? cleanScriptName(input.buildScript)
1517
+ : settings.buildScript
1518
+ if (settings.requireChecks && input.checksFresh !== true) {
1519
+ throw httpError(400, '请先运行发布预检查并通过,再在系统终端发布。')
1520
+ }
1521
+ if (buildScript && (!isPlainObject(initialPkg.scripts) || typeof initialPkg.scripts[buildScript] !== 'string')) {
1522
+ throw httpError(400, `package.json 中没有名为 ${buildScript} 的脚本。`)
1523
+ }
1524
+ const previousVersion = String(initialPkg.version || '')
1525
+ let versionUpdated = false
1526
+ let pkg = initialPkg
1527
+
1528
+ await exclusive(async () => {
1529
+ state.settings[dir] = {
1530
+ ...settings, registry, tag, access: effectiveAccess, note, buildScript, authMode: 'session',
1531
+ }
1532
+ await persist()
1533
+ })
1534
+
1535
+ if (version !== previousVersion) {
1536
+ if (settings.writeVersion === false) {
1537
+ throw httpError(400, `package.json 当前版本为 ${previousVersion},请先改为 ${version},或允许自动写入版本号。`)
1538
+ }
1539
+ await writePackageVersion(dir, initialPkg, version)
1540
+ versionUpdated = true
1541
+ pkg = await readPackage(dir)
1542
+ }
1543
+
1544
+ const npm = await resolveNpm()
1545
+ if (!npm.available) throw httpError(500, '找不到 npm 可执行文件。')
1546
+ const publishArgs = ['publish', '--tag', tag]
1547
+ if (isScoped) publishArgs.push('--access', effectiveAccess)
1548
+ publishArgs.push('--registry', registry)
1549
+ const display = `${buildScript ? `npm run ${buildScript} && ` : ''}npm publish --tag ${tag}${isScoped ? ` --access ${effectiveAccess}` : ''} --registry ${registry} # auth: local session (system terminal)`
1550
+ const id = randomUUID()
1551
+ const taskDir = join(dataDir, 'terminal', id)
1552
+ await mkdir(taskDir, { recursive: true })
1553
+ const statusPath = join(taskDir, 'publish.status')
1554
+ const scriptPath = join(taskDir, process.platform === 'win32' ? 'publish.cmd' : 'publish.command')
1555
+ const binDir = npm.binDir || dirname(npm.executable)
1556
+
1557
+ let script
1558
+ if (process.platform === 'win32') {
1559
+ const q = cmdQuote
1560
+ const lines = [
1561
+ '@echo off',
1562
+ 'chcp 65001 >nul',
1563
+ 'setlocal',
1564
+ `set "PATH=${String(binDir).replace(/%/g, '%%')};%PATH%"`,
1565
+ `cd /d ${q(dir)}`,
1566
+ ]
1567
+ if (buildScript) {
1568
+ lines.push(`${q(npm.executable)} run ${q(buildScript)}`)
1569
+ lines.push('set "code=%ERRORLEVEL%"')
1570
+ lines.push('if not "%code%"=="0" goto done')
1571
+ } else {
1572
+ lines.push('set "code=0"')
1573
+ }
1574
+ lines.push(`${q(npm.executable)} ${publishArgs.map(q).join(' ')}`)
1575
+ lines.push('set "code=%ERRORLEVEL%"')
1576
+ lines.push(':done')
1577
+ lines.push(`> ${q(statusPath)} echo %code%`)
1578
+ lines.push('echo.')
1579
+ lines.push('echo Publish finished with exit code %code%.')
1580
+ lines.push('pause')
1581
+ lines.push('exit /b %code%')
1582
+ lines.push('')
1583
+ script = lines.join('\r\n')
1584
+ } else {
1585
+ const q = shellQuote
1586
+ const lines = [
1587
+ '#!/bin/bash',
1588
+ `export PATH=${q(binDir)}:"$PATH"`,
1589
+ `cd ${q(dir)} || exit 90`,
1590
+ ]
1591
+ if (buildScript) {
1592
+ lines.push(`${q(npm.executable)} run ${q(buildScript)}`)
1593
+ lines.push('code=$?')
1594
+ } else {
1595
+ lines.push('code=0')
1596
+ }
1597
+ lines.push('if [ "$code" -eq 0 ]; then')
1598
+ lines.push(` ${q(npm.executable)} ${publishArgs.map(q).join(' ')}`)
1599
+ lines.push(' code=$?')
1600
+ lines.push('fi')
1601
+ lines.push(`printf '%s\\n' "$code" > ${q(statusPath)}`)
1602
+ lines.push(`printf '\\n发布结束,退出码 %s。按任意键关闭窗口…\\n' "$code"`)
1603
+ lines.push('read -n 1 -s -r')
1604
+ lines.push('exit "$code"')
1605
+ lines.push('')
1606
+ script = lines.join('\n')
1607
+ }
1608
+ await writeFile(scriptPath, script, { encoding: 'utf8', mode: 0o700 })
1609
+
1610
+ const terminalName = await openSystemTerminal(scriptPath)
1611
+ const deadline = Date.now() + 15 * 60_000
1612
+ let code = null
1613
+ while (Date.now() < deadline) {
1614
+ if (await exists(statusPath)) {
1615
+ const text = (await readFile(statusPath, 'utf8')).trim()
1616
+ if (/^-?\d+$/.test(text)) {
1617
+ code = Number(text)
1618
+ break
1619
+ }
1620
+ }
1621
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 1000))
1622
+ }
1623
+ if (code === null) {
1624
+ throw httpError(504, `已打开系统终端(${terminalName}),但等待发布结果超时。请查看终端窗口;如果仍在运行,完成后重新运行发布预检查确认状态。`)
1625
+ }
1626
+ const ok = code === 0
1627
+ const record = makeRecord({
1628
+ dir, pkg, version, tag, access: effectiveAccess, registry, dryRun: false, ok, stage: 'publish',
1629
+ message: ok
1630
+ ? `已在系统终端完成发布 ${String(pkg.name || '')}@${version}。`
1631
+ : `终端发布失败(退出码 ${code});请查看系统终端窗口中的输出。`,
1632
+ note, previousVersion, versionUpdated,
1633
+ commands: [{
1634
+ label: 'publish',
1635
+ command: display,
1636
+ code,
1637
+ signal: null,
1638
+ timedOut: false,
1639
+ durationMs: Date.now() - startedAt,
1640
+ output: '',
1641
+ }],
1642
+ checksSummary: input.checksSummary,
1643
+ checksFresh: true,
1644
+ authMode: 'session',
1645
+ tokenProvided: false,
1646
+ otpProvided: false,
1647
+ otpRequired: false,
1648
+ terminal: true,
1649
+ })
1650
+ await saveRecord(record)
1651
+ overviewCache = { at: 0, value: null }
1652
+ return {
1653
+ ok,
1654
+ record,
1655
+ versionUpdated,
1656
+ previousVersion,
1657
+ authMode: 'session',
1658
+ terminal: terminalName,
1659
+ ...(ok ? {} : { error: record.message }),
1660
+ }
1661
+ } finally {
1662
+ publishing.delete(dir)
1663
+ }
1664
+ }
1665
+
1423
1666
  /* ---------------------------------------------------------------- routing */
1424
1667
 
1425
1668
  async function requireComponentDir(raw) {
@@ -1549,6 +1792,9 @@ export function apply(ctx, config = {}) {
1549
1792
  if (pathname === `${API}/publish`) {
1550
1793
  return sendJson(response, 200, { ok: true, ...(await publishComponent(body || {})) })
1551
1794
  }
1795
+ if (pathname === `${API}/publish-terminal`) {
1796
+ return sendJson(response, 200, { ok: true, ...(await publishInTerminal(body || {})) })
1797
+ }
1552
1798
  return sendJson(response, 404, { ok: false, code: 'not-found', message: '未知的发布管理接口。' })
1553
1799
  } catch (error) {
1554
1800
  const status = error && Number.isInteger(error.status) ? error.status : 500
@@ -1720,6 +1966,47 @@ function isInside(root, target) {
1720
1966
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
1721
1967
  }
1722
1968
 
1969
+ function shellQuote(value) {
1970
+ return `'${String(value).replace(/'/g, "'\\''")}'`
1971
+ }
1972
+
1973
+ function quoteCmdArg(value) {
1974
+ const text = String(value)
1975
+ if (text === '') return '""'
1976
+ if (!/[\s"&|<>^()%!]/.test(text)) return text
1977
+ return `"${text.replace(/"/g, '\\"')}"`
1978
+ }
1979
+
1980
+ function cmdQuote(value) {
1981
+ return `"${String(value).replace(/"/g, '""')}"`
1982
+ }
1983
+
1984
+ async function findOnPath(command) {
1985
+ for (const dir of String(process.env.PATH || '').split(delimiter)) {
1986
+ if (!dir) continue
1987
+ const candidate = join(dir, command)
1988
+ if (await exists(candidate)) return candidate
1989
+ }
1990
+ return null
1991
+ }
1992
+
1993
+ function launchDetached(command, args) {
1994
+ return new Promise((resolveLaunch, rejectLaunch) => {
1995
+ let child
1996
+ try {
1997
+ child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: false })
1998
+ } catch (error) {
1999
+ rejectLaunch(error)
2000
+ return
2001
+ }
2002
+ child.once('error', rejectLaunch)
2003
+ child.once('spawn', () => {
2004
+ child.unref()
2005
+ resolveLaunch()
2006
+ })
2007
+ })
2008
+ }
2009
+
1723
2010
  async function exists(path) {
1724
2011
  try {
1725
2012
  await stat(path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangnanshan/dsh-nanshan-publish-manager",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "【南山】DSH发布管理:以 npm 包(dsh.bundle + dsh.client)的官方形式,管理本机 DSH 组件的发布设置、发布预检查、npm 发布与发布记录。",
6
6
  "license": "MIT",