@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.
- package/README.md +21 -6
- package/client.js +222 -16
- package/index.js +290 -3
- 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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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