dsh-plugin-cicd 0.0.0-stage → 0.5.0

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/client.js ADDED
@@ -0,0 +1,3786 @@
1
+ /**
2
+ * Browser half of the `dsh-plugin-cicd` bundle — 发布台 (Release Console).
3
+ *
4
+ * Four registrations:
5
+ * main the operating view: one compact row per repository
6
+ * sidebar.panellist its sidebar button
7
+ * settings.plugins.tab 「DSH 插件发布台」: the three credentials, one page
8
+ *
9
+ * That settings page holds GitHub, npm and Bilibili as SEGMENTS rather than as three
10
+ * pages of their own. Both shared surfaces this plugin could have taken are flat
11
+ * lists — `settings.section` (the top-level nav) and `settings.plugins.tab` (the strip
12
+ * inside Plugins) — and one row per plugin is this plugin's share of each.
13
+ *
14
+ * The layout rule is density. This panel's job is to answer "which of my
15
+ * repositories needs attention" at a glance, and a card per repository with a
16
+ * path line, a run line and a release list answers it in three screens instead of
17
+ * one. So a row is ~32px and carries only what is actionable; everything else
18
+ * (path, releases, run history, logs) lives in the row's expansion.
19
+ *
20
+ * Nothing here requires a terminal. Signing in runs `gh auth login --web` from the
21
+ * Host and renders the one-time code; registering a repository is a tick on a list
22
+ * fetched from GitHub. The commands still exist — `configure.mjs` is the scripted
23
+ * path, and the setup block shows them collapsed — but they are the fallback, not
24
+ * the instruction.
25
+ *
26
+ * Deliberate choices:
27
+ * - No Harness Client package is required; only `react`, which is a platform
28
+ * seed word in the client module table.
29
+ * - The stylesheet is tagged `data-plugin`/`data-plugin-css` so the loader's
30
+ * claimStyles/removeOwnedStyles cannot take it over or delete it.
31
+ * - A failed BACKGROUND poll never clears the last known state and never raises
32
+ * a banner: it stops updating, so a sleeping machine produces no error the
33
+ * user did not cause.
34
+ * - Colours come only from `--dsw-alias-*`, so both themes stay legible.
35
+ * - Publishing a draft is public and one-way, so it takes two clicks.
36
+ */
37
+
38
+ window.__ModuleLoader__.load({
39
+ id: 'dsh-plugin-cicd',
40
+ factory(require) {
41
+ const React = require('react')
42
+ const h = React.createElement
43
+
44
+ const NS = 'dsh-cicd'
45
+ /** Sidebar entry id and `main` slot key — the same value links the two. */
46
+ const PANEL_ID = 'dsh-cicd'
47
+ /**
48
+ * The console's one page in the Plugins section. `account`, `general` and the rest
49
+ * are shipped sections, so this must not reuse one of their ids.
50
+ */
51
+ const SETTINGS_ID = 'dsh-cicd'
52
+ /**
53
+ * The three credentials, as segments inside that one page.
54
+ *
55
+ * They are not tabs of their own: this plugin would then take three of the rows in
56
+ * a tab strip shared with every other plugin, which is the same unfair share of a
57
+ * shared surface that moved them out of the top-level nav in the first place. As
58
+ * segments they stay one thing — the console's settings — and the strip stays one
59
+ * row per plugin.
60
+ */
61
+ const SEGMENT_GITHUB = 'github'
62
+ const SEGMENT_NPM = 'npm'
63
+ const SEGMENT_BILIBILI = 'bilibili'
64
+ const STYLE_ID = 'dsh-plugin-cicd-styles'
65
+
66
+ const BASE = '/api/dsh-cicd'
67
+ /**
68
+ * The pages a first npm credential needs.
69
+ *
70
+ * Two of these come from GitHub's own migration note for the November 2025 npm
71
+ * token change, which is also where the fact that classic tokens are gone was
72
+ * confirmed — a guide that guessed at either would send a first-time publisher
73
+ * somewhere that no longer exists.
74
+ */
75
+ const NPM_SIGNUP_URL = 'https://www.npmjs.com/signup'
76
+ const NPM_TOKENS_URL = 'https://www.npmjs.com/settings/~/tokens'
77
+ const NPM_TOKEN_DOCS_URL = 'https://docs.npmjs.com/creating-and-viewing-access-tokens'
78
+ /**
79
+ * The Host protocol this page needs.
80
+ *
81
+ * A page refresh replaces this file but not the Host process, so a route can
82
+ * be missing while the button that calls it is on screen. Comparing this
83
+ * number turns that into one sentence instead of a bare HTTP status.
84
+ */
85
+ const PROTOCOL = 6
86
+ const STATUS_TIMEOUT_MS = 20_000
87
+ const OVERVIEW_TIMEOUT_MS = 60_000
88
+ const ACTION_TIMEOUT_MS = 45_000
89
+ /**
90
+ * An update downloads a release tarball and then runs pnpm against the profile.
91
+ * Both are seconds of real work on a machine that is doing nothing else, and the
92
+ * first pnpm run after a store change is the slow one — so this action gets its
93
+ * own, longer bound instead of borrowing the 45 s the dispatch actions use.
94
+ */
95
+ const UPDATE_TIMEOUT_MS = 180_000
96
+ /**
97
+ * Asking npm is one HTTPS request per repository plus a `whoami`; a publish
98
+ * uploads the tarball. Neither is an "action" a person waits on twice, so the
99
+ * status gets a short bound and the publish shares the update's long one.
100
+ */
101
+ const NPM_TIMEOUT_MS = 45_000
102
+ const NOTICE_TTL_MS = 5_000
103
+ /**
104
+ * While an attempt is pending this polls `/auth-state` — and it is the only
105
+ * thing the user is waiting for at that moment. One second rather than two: the
106
+ * code normally exists within a second of the click, this is a loopback call,
107
+ * and it stops as soon as the attempt settles.
108
+ */
109
+ const AUTH_POLL_MS = 1_000
110
+
111
+ /** Simplified Chinese dictionary (key-set source of truth). */
112
+ const zh = {
113
+ 'panel': 'DSH 插件发布台',
114
+ 'title': 'DSH 插件发布台',
115
+ 'subtitle': '持续构建与发布(CI/CD)',
116
+ 'settings.consoleLabel': 'DSH 插件发布台',
117
+ 'settings.segment.github': 'GitHub',
118
+ 'settings.title': 'GitHub 账户',
119
+ 'settings.subtitle': '发布台用这台机器上的 gh 访问 GitHub,插件自己不保存任何凭据。',
120
+ 'action.refresh': '刷新',
121
+ 'action.manage': '管理仓库',
122
+ 'action.close': '收起',
123
+ 'action.commit': '提交',
124
+ 'action.push': '推送',
125
+ 'action.commitConfirm': '确认提交',
126
+ 'action.pushConfirm': '确认推送',
127
+ 'action.build': '构建',
128
+ 'action.release': '发布',
129
+ 'action.bumpRelease': '升版本并发布',
130
+ 'action.publishDraft': '公开草稿',
131
+ 'action.confirmPublish': '确认公开?',
132
+ 'action.update': '更新到 {tag}',
133
+ 'action.installRelease': '装 Release {tag}',
134
+ 'action.confirmUpdate': '确认更新',
135
+ 'action.restartNow': '立即重启 DSH',
136
+ 'action.restartLater': '稍后',
137
+ 'action.open': '打开',
138
+ 'action.logs': '日志',
139
+ 'action.hideLogs': '收起日志',
140
+ 'action.rerun': '重跑',
141
+ 'action.cancel': '取消',
142
+ 'action.expand': '详情',
143
+ 'action.collapse': '收起',
144
+ 'action.add': '添加',
145
+ 'action.remove': '移除',
146
+ 'action.signIn': '用浏览器登录 GitHub',
147
+ 'action.recheck': '重新检测',
148
+ 'action.grantScopes': '补齐权限',
149
+ 'action.copy': '复制',
150
+ 'action.copied': '已复制',
151
+ 'action.openDevicePage': '打开授权页面',
152
+ 'action.cancelSignIn': '取消登录',
153
+ 'action.signOut': '退出登录',
154
+ 'action.signOutConfirm': '确认退出登录?',
155
+ 'state.signedOut': '已退出登录:令牌已从系统凭据存储移除。注意 git 推送会随之失效(git 的凭据本来就由 gh 提供),重新登录后恢复。',
156
+ 'label.runs': '运行记录',
157
+ 'chip.published': '已发布',
158
+ 'chip.unpublished': '未发布',
159
+ 'chip.draft': '草稿',
160
+ 'chip.versionTaken': '版本被占用',
161
+ 'chip.update': '可更新 {tag}',
162
+ 'chip.checkout': '本地检出',
163
+ 'chip.dirty': '未提交 {count}',
164
+ 'chip.ahead': '领先 {count}',
165
+ 'chip.behind': '落后 {count}',
166
+ 'chip.private': '私有',
167
+ 'state.loading': '正在读取…',
168
+ 'state.reading': '读取中…',
169
+ 'state.hostGone': '无法连接 Host;面板会保留上一次的数据,稍后自动重试。',
170
+ 'state.timeout': '请求超时,Host 没有在时限内回答。',
171
+ 'stale.title': '页面与宿主半边版本不一致',
172
+ 'stale.text': '页面已经是最新的,但运行中的宿主半边还是旧版本——它里面没有这条接口,所以按钮会报 401/404。刷新页面不会更新宿主。',
173
+ 'stale.how': '点侧边栏底部的一键重启(或重启 DeepSeek Harness)之后,两半就一致了。',
174
+ 'state.noRuns': '还没有运行记录',
175
+ 'state.noReleases': '还没有 Release',
176
+ 'state.dispatched': '已触发 {workflow}({repo})。',
177
+ 'state.releaseDispatched': '已触发发布流程({repo})。它产出的是草稿——但草稿是否真的出现,要看这次运行的结果:失败时展开该行点【日志】就能看到原因(最常见的是版本号没升)。',
178
+ 'state.releaseBumped': '已把 {from} 升到 {tag} 并推送 {branch},发布流程也已触发。产出的是草稿,是否成功看这次运行的结果。',
179
+ 'state.committed': '已提交并推送到 {branch}({count} 个改动)。现在 构建 与 发布 都能看到它们了。',
180
+ 'state.pushed': '已把 {count} 个本地提交推送到 {branch}。',
181
+ 'commit.noCheckout': '这个仓库没有可用的本地检出,所以没有可提交的东西。',
182
+ 'commit.willCommit': '将提交 {count} 个改动:',
183
+ 'commit.willPush': '工作区干净,有 {count} 个本地提交等着推送——发布构建的是 GitHub 上的提交,不是这台机器上的。',
184
+ 'commit.nothing': '工作区干净,且与上游同步:没有可提交或推送的东西。',
185
+ 'commit.messagePlaceholder': '提交信息',
186
+ 'commit.messageRequired': '提交信息不能为空。',
187
+ 'confirm.push': '把 {count} 个本地提交推送到 {branch}?',
188
+ 'state.published': '{tag} 已公开。',
189
+ 'state.rerun': '已请求重跑。',
190
+ 'state.cancelled': '已请求取消。',
191
+ 'state.actionFailed': '操作失败:{reason}',
192
+ 'state.registered': '已添加 {repo}。',
193
+ 'state.removed': '已移除 {repo}。',
194
+ 'state.truncated': '(只显示最后 {lines} 行)',
195
+ 'drafts.title': '有 {count} 个草稿还没公开',
196
+ 'drafts.text': '草稿对外不可见,也不创建 tag,所以在仓库的 Releases 页面看不到——这不是失败。展开下面带【草稿】标记的行,点【公开草稿】即可公开发布。',
197
+ 'meta.account': '账号',
198
+ 'meta.updated': '更新于 {time}',
199
+ 'meta.cached': '缓存',
200
+ 'meta.version': '本地',
201
+ 'meta.expectedTag': '期望 tag {tag}',
202
+ 'meta.noLocal': '未配置本地路径,无法对比本地状态',
203
+ 'meta.assets': '{count} 个资产',
204
+ 'meta.path': '路径',
205
+ 'meta.scopes': '权限',
206
+ 'meta.noScopes': '(gh 未报告权限列表)',
207
+ 'meta.gh': 'gh',
208
+ 'meta.configFrom': '配置来源',
209
+ 'meta.projectsRoot': '本地检出根目录',
210
+ 'meta.projectsRootUnset': '未设置,添加仓库时无法自动找到本地检出',
211
+ 'run.unknown': '未知',
212
+ 'release.takenTitle': '发布前检查:这个版本号已经属于另一个提交',
213
+ 'release.taken': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release(否则 tag 与资产会不一致),所以直接发布必然失败。先把它升到 {next} 再发布。',
214
+ 'release.takenNoNext': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release,所以直接发布必然失败。先把版本号升到下一号。',
215
+ 'release.dirtyNote': '工作区有 {count} 个未提交改动:发布构建的是已推送的提交,这些改动不会进入发布包(升版本也会因此被拒绝)。',
216
+ 'release.aheadNote': '有 {count} 个提交还没推送:发布构建的是远端分支,推送之前它们不会进入发布包。',
217
+ 'release.dirty': '工作区有 {count} 个未提交改动,先提交或暂存它们。',
218
+ 'release.noUpstream': '分支 {branch} 没有上游,无法推送。',
219
+ 'release.behind': '分支落后上游 {count} 个提交,先拉取再发布。',
220
+ 'release.noCheckout': '这个仓库没有可用的本地检出(找不到 package.json)。',
221
+ 'release.noVersion': '本地读不到可升级的版本号。',
222
+ 'release.pushFailed': '本地已提交 {tag},但推送失败:{reason}(提交还在本地,重试推送即可)',
223
+ 'release.carried': '这次推送同时带上了此前未推送的 {count} 个提交。',
224
+ 'confirm.bumpRelease': '把 package.json 从 {from} 升到 {to}、提交并推送到 {branch},然后触发发布?这会创建一个提交。',
225
+ 'confirm.publish': '公开发布 {tag}?发布后任何人可见,无法收回。',
226
+ 'confirm.cancel': '再想想',
227
+ 'confirm.update': '把 {profile} profile 里的 {package}(现在指向 {spec})换成 Release {tag} 的 tgz 并重装?这会改写该 profile 的依赖条目。',
228
+ 'install.title': '安装',
229
+ 'install.notInstalled': '{package} 不在 {profile} profile 的依赖里,所以没有"已装的那份"可以更新。',
230
+ 'install.noRelease': '这个仓库还没有已发布的 Release(草稿不算),所以没有可以装的东西。',
231
+ 'install.current': 'profile 里就是 Release {tag} 的包,已经是最新的。',
232
+ 'install.checkout': 'profile 里这份指向本地检出({spec}),跑的不是发布出去的那份。装 Release 会用发布包替换它;要回到开发状态再用 dsh plugin add link:<检出路径> 换回来。',
233
+ 'install.ahead': 'profile 里是 v{installed},比这个 Release({tag})新——装它等于用发布包退回到旧版本。',
234
+ 'install.differs': 'profile 里是 {installed},与 Release {tag} 不是同一号版本,无法比较先后。',
235
+ 'state.updated': '{package} 已从 {from} 换成 {tag} 的发布包。新版本要重启 DSH 才会真正生效。',
236
+ 'state.updatedSame': '{package} 已经指向 {tag} 这份发布包了:没有改动,也不需要重启。',
237
+ 'npm.title': 'npm',
238
+ 'npm.chipPublished': 'npm v{version}',
239
+ 'npm.chipPending': 'npm 待推 v{version}',
240
+ 'npm.chipUnregistered': 'npm 上还没有',
241
+ 'npm.account': 'npm {account}',
242
+ 'npm.registry': '源 {registry}',
243
+ 'npm.recheck': '重新检测 npm',
244
+ 'npm.notLoggedIn': '{command} 还没有登录 {registry}——发布需要 npm 的凭据,而插件自己不保存 token。下面是从零开始的四步。',
245
+ 'npm.guide.step1': '① 还没有 npm 账号?',
246
+ 'npm.guide.signup': '打开 npmjs.com 注册',
247
+ 'npm.guide.emailNote': '注册后**必须**到邮箱点确认链接:npm 不允许未验证邮箱的账号发布。',
248
+ 'npm.guide.step2': '② 生成一个 Access Token',
249
+ 'npm.guide.tokens': '打开 token 页面',
250
+ 'npm.guide.tokenType': '类型只有一种可选:Granular Access Token。Classic token 已于 2025-11-19 被 npm 全部撤销,现在也建不出来——别去找它。',
251
+ 'npm.guide.tokenScope': 'Permissions 选 Read and write;Packages 选全部,或只勾这个包(只勾一个包更安全)。',
252
+ 'npm.guide.tokenExpiry': '有效期最长 90 天,这是 npm 对可写 token 的硬限制。到期后 token 就失效了,回到第 ② 步再生成一个。',
253
+ 'npm.guide.token2fa': '只有"给 CI 用、没有人能输一次性密码"时才勾 Bypass 2FA。在这里推**不要勾**:推送时面板会让你填一次性密码。',
254
+ 'npm.guide.blocked': '如果 npmjs.com 在你这台机器上打不开,或者被"我不是机器人"的验证拦住:**换一台设备或换一个网络**(手机流量最省事)打开 npmjs.com 生成 token,再把那串字符粘到第 ③ 步。面板只认这个 token,不在乎它是在哪里生成的——这一步和你本机的浏览器无关。',
255
+ 'npm.guide.step3': '③ 粘贴到下面',
256
+ 'npm.guide.step3Note': '它会写进 {npmrc}(npm 自己读的用户级配置文件,其他行原样保留);插件不保存 token,请求一返回输入框就清空。',
257
+ 'npm.guide.step4': '④ 账号开了 2FA?',
258
+ 'npm.guide.step4Note': '推送的确认行里会出现一次性密码输入框,填 6 位数字再点一次【确认推送】即可。',
259
+ 'npm.guide.docs': 'npm 官方文档',
260
+ 'npm.stepUnknown': '无法自动检测',
261
+ 'npm.stepTodo': '待做',
262
+ 'npm.stepAccountDone': '✓ 已有账号 {account}',
263
+ 'npm.stepTokenDone': '✓ 已有可用的 token',
264
+ 'npm.stepWrittenDone': '✓ {npmrc} 里已有凭据',
265
+ 'npm.status.title': '凭据状态',
266
+ 'npm.status.signedIn': '已登录 {account}',
267
+ 'npm.status.credentialPresent': '已有凭据,whoami 未确认',
268
+ 'npm.status.none': '还没有凭据',
269
+ 'npm.status.unknown': '还没读到 npm 的状态',
270
+ 'npm.status.unknownHint': 'Host 还没回答 /npm-status(可能是运行中的宿主半边还是旧版本,也可能面板刚打开)。点「重新检测」。在没有答案之前,这里不替你下结论。',
271
+ 'npm.packages.title': '每个包在 npm 上的状态',
272
+ 'npm.packages.empty': '还没有登记仓库,所以没有可检查的包。',
273
+ 'npm.packages.recheck': '重新检测',
274
+ 'npm.package.published': '已在 npm 上',
275
+ 'npm.package.unpublished': '待推',
276
+ 'npm.package.unregistered': '名字空闲',
277
+ 'npm.package.unknown': '读不到',
278
+ 'npm.package.local': '本地 {version}',
279
+ 'npm.package.latest': 'npm 上是 {latest}',
280
+ 'npm.credentialPresentShort': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它——granular token 是包级范围的,whoami 是用户级端点,这很正常。直接推即可,真伪由推送来判。',
281
+ 'npm.moreInSettings': '完整引导与每个包的状态在 设置 → 插件 → DSH 插件发布台 · npm。',
282
+ 'npm.ciHint': '要给 CI 自动发布?那不该用这里的 token——npm 支持 trusted publishing(GitHub Actions OIDC),不需要任何长期凭据。',
283
+ 'settings.segment.npm': 'npm',
284
+ 'settings.npmTitle': 'npm 凭据',
285
+ 'settings.npmSubtitle': '发布用 Host 自己的包管理器,凭据走 npm 自己读的 ~/.npmrc——插件不保存任何 token。',
286
+ 'settings.npmGuideTitle': '从零开始的四步',
287
+ 'npm.credentialPresentTitle': '这个源上已经有一行 token',
288
+ 'npm.credentialPresent': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它。granular token 是包级范围的,而 whoami 是用户级端点——它拒绝一个能正常发布的 token 是正常的。所以推送本身才是真正的验证:直接推,失败时下方会告诉你是要填一次性密码、还是重建 token。',
289
+ 'npm.rejectedTitle': '这个 token 没有被接受。按顺序排查:',
290
+ 'npm.rejectedCauses': '① 复制不全(前后带了空格或换行);② 权限不是 Read and write;③ 已被撤销、或超过 90 天有效期;④ token 被限制到别的包或组织;⑤ 账号邮箱还没验证。',
291
+ 'npm.hint.otp-required': '这个账号要求一次性密码:在推送的确认行里填 6 位数字,再点一次【确认推送】。',
292
+ 'npm.hint.email-unverified': '先去邮箱点确认链接——npm 不允许未验证邮箱的账号发布——然后回来重试。',
293
+ 'npm.hint.not-logged-in': 'token 无效、过期或已被撤销:照上面第 ② 步重新生成一个(可写 token 最长 90 天)。',
294
+ 'npm.hint.already-published': '这个版本已经在 npm 上了。npm 不接受同一版本推两次:先用【升版本并发布】升版本,再推。',
295
+ 'npm.hint.payment-required': 'npm 对私有包收费,公开包免费。检查 package.json 里有没有 private 字段、以及发布的是不是公开包。',
296
+ 'npm.hint.forbidden': '这个 token 没有被授权发布这个包:检查 token 的 Packages 范围,以及包名是否属于你的账号或组织。',
297
+ 'npm.hint.not-found': '源上没有这个包名可发布的位置:确认包名拼写、以及账号或组织名。',
298
+ 'npm.hint.rate-limited': '被源限流了,等几分钟再试。',
299
+ 'npm.hint.registry-error': '这是源自己出错(5xx),不是你配置的问题,稍后重试。',
300
+ 'npm.hint.network': '连不上源:检查网络或代理设置。',
301
+ 'npm.hint.timeout': '推送超过时限还没结束:可能是网络慢,也可能源在等你回答什么。重试一次,仍失败就看下面的原话。',
302
+ 'npm.hint.unknown': '没识别出具体原因,看下面 npm 的原话。',
303
+ 'npm.tokenPlaceholder': 'npm_… 或粘贴 Access Token',
304
+ 'npm.writeToken': '写入并验证',
305
+ 'npm.tokenWritten': 'token 已写入 {npmrc},当前账号 {account}。',
306
+ 'npm.npmrcPath': '配置文件',
307
+ 'npm.state.published': '这个版本已经在 npm 上了({version}),没有可推的。',
308
+ 'npm.state.unregistered': 'npm 上还没有这个名字:这次推送就是它的首次发布。',
309
+ 'npm.state.unpublished': 'npm 上是 {latest},本地这个 {version} 还没推过。',
310
+ 'npm.state.unknown': '读不到 npm 的状态({reason}),所以不提供推送——宁可不给按钮,也不给一个注定失败的按钮。',
311
+ 'npm.action.publish': '推送到 npm v{version}',
312
+ 'npm.action.confirm': '确认推送',
313
+ 'npm.otpLabel': '一次性密码',
314
+ 'npm.otpHint': '账号开了 2FA 才需要;填一次即可,不会被保存。',
315
+ 'npm.otpPlaceholder': '6 位数字',
316
+ 'npm.blocked.private-package': 'package.json 里写着 "private": true,npm 会拒绝。这是作者的决定,不是这里该绕过的东西。',
317
+ 'npm.blocked.not-logged-in': '还没有登录 npm,先把 token 写进去。',
318
+ 'npm.blocked.dirty-tree': '工作区有未提交改动:publish 打包的是工作目录,先提交或暂存(files 白名单挡不住名单目录里的新文件)。',
319
+ 'npm.blocked.no-checkout': '这个仓库没有可用的本地检出。',
320
+ 'npm.blocked.no-version': 'package.json 里没有版本号。',
321
+ 'npm.blocked.no-package-name': '读不到包名。',
322
+ 'npm.blocked.already-published': '这个版本已经在 npm 上了(npm 不允许同一版本推第二次),先升版本。',
323
+ 'npm.blocked.registry-unreachable': '连不上 npm 源,无法确认这个版本在不在。',
324
+ 'confirm.npmPublish': '把 {package}@{version} 推到 {registry}?npm 不允许同一版本推第二次,撤回也只在很短的时间内可行——推上去任何人可见。',
325
+ 'state.npmPublished': '已把 {package}@{version} 推到 {registry}。',
326
+ 'state.npmFirstPublish': '{package}@{version} 已首次发布到 {registry}。',
327
+ 'npm.githubDraft': '注意:GitHub 上这个版本只有草稿(或还没有 Release)——npm 会先于 Release 面世。',
328
+ 'state.updatedBuilds': '(注意:这次安装有 {count} 个构建脚本被拦住,没有执行。)',
329
+ 'restart.ask': '{package} 已经更新到 {tag},但要重启 DSH 才会用上它——现在重启?',
330
+ 'restart.scheduled': '已安排重启:DSH 会在一两秒后关闭并自动重新打开。刷新页面不够,模块已经加载在运行中的进程里。',
331
+ 'restart.unavailable': '重启不了:{reason}',
332
+ 'setup.needGh': '没有找到 gh CLI,发布台无法访问 GitHub。',
333
+ 'setup.needSignIn': '还没有登录 GitHub。登录后这里会列出你的仓库。',
334
+ 'setup.needScopes': '当前凭据缺少权限:{scopes}。',
335
+ 'setup.scopeHint': '缺 repo 读不到私有仓库,缺 workflow 无法触发构建。',
336
+ 'setup.needRepos': '还没有登记仓库。',
337
+ 'setup.codeHint': '在浏览器里打开下面的地址,输入这个一次性代码:',
338
+ 'setup.waiting': '等待授权…(在浏览器里完成即可,这里会自动继续)',
339
+ 'setup.requestingCode': '正在向 GitHub 申请一次性码…',
340
+ 'setup.codePending': '授权页面已打开——请把下面出现的码输入进去。码还没到,稍等一下它就会出现。',
341
+ 'setup.stalled': '已等待 {seconds} 秒仍没有一次性码:{detail}。最常见的原因是 github.com 不可达(面板读取仓库用的 api.github.com 是另一条线路,可能仍然正常)。你可以继续等——网络恢复后码会自己出现——也可以取消后重试。',
342
+ 'setup.stalledNoOutput': 'gh 完全没有输出',
343
+ 'setup.stalledQuietOutput': 'gh 只输出了空白内容(通常在重试连接)',
344
+ 'setup.stalledSaid': 'gh 说:{text}',
345
+ 'setup.unreachableTitle': 'github.com 当前连不上',
346
+ 'setup.unreachable': '提示:从本机连不上 github.com:443,而设备码流程需要它——如果一直不出码,多半是这个原因。gh 已经在尝试;若它其实能连上(例如走代理),码仍会出现。',
347
+ 'setup.dnsTitle': 'DNS 解析 github.com 失败',
348
+ 'setup.dns': '提示:本机解析不出 github.com 的地址,而地址本身是通的(实测直连它的 IP 只需几十毫秒)。gh 也要自行解析域名,所以它连请求都发不出去,这就是一直不出码的原因。换个 DNS 或稍后重试即可;gh 已在尝试,解析一恢复码就会出现。',
349
+ 'setup.succeeded': '授权成功,正在读取账号…',
350
+ 'setup.failed': '授权未完成:{reason}',
351
+ 'setup.expired': '一次性代码已过期,请重新开始。',
352
+ 'setup.cancelled': '已取消登录。',
353
+ 'setup.advanced': '用命令行配置(可选)',
354
+ 'setup.ghInstall': '安装 GitHub CLI(任选其一)',
355
+ 'picker.title': '添加仓库',
356
+ 'picker.search': '搜索仓库',
357
+ 'picker.empty': '没有可添加的仓库(可能是没登录,或账号下没有仓库)。',
358
+ 'picker.loading': '正在读取你的仓库…',
359
+ 'picker.failed': '读取仓库列表失败:{reason}',
360
+ 'picker.local': '本地检出',
361
+ 'picker.noLocal': '未找到本地检出',
362
+ 'picker.hint': '勾选即添加,取消勾选即移除;改动立刻生效,不需要重启。',
363
+ 'picker.reload': '重新载入',
364
+
365
+ /* -- Bilibili update notes ------------------------------------------- */
366
+ 'settings.segment.bilibili': 'Bilibili',
367
+ 'bili.settings.title': 'B 站更新播报',
368
+ 'bili.settings.subtitle': '插件发版后,在对应视频的评论区自动补一条更新说明;凭据只落在插件自己的目录里,插件不回显它。',
369
+ 'bili.credential.title': '登录状态',
370
+ 'bili.credential.ready': '已登录 {uname}',
371
+ 'bili.credential.none': '还没有 B 站凭据',
372
+ 'bili.credential.unreadable': '凭据读不出来',
373
+ 'bili.credential.incomplete': '凭据不完整',
374
+ 'bili.credential.not-logged-in': '这份凭据发不了评论',
375
+ 'bili.credential.unverified': '还没有验证这份凭据',
376
+ 'bili.credential.none.hint': '发评论走的是 B 站 Web 接口,所以凭据必须是网页登录。biliup 默认写下的 cookies.json 是 APP/TV 登录(platform=BiliTV),Web 会员接口一律回 -101「账号未登录」——它能投稿,但发不了评论。',
377
+ 'bili.credential.source': '凭据文件 {path}',
378
+ 'bili.credential.external': '这份凭据来自配置指定的外部文件(不是面板写入的):{path}',
379
+ 'bili.signIn': '登录 B 站',
380
+ 'bili.signIn.hint': '点一下会生成一个登录链接:用手机 B 站扫码,或在你已经登录 B 站的浏览器里打开并确认。这里会自动继续,不需要终端。',
381
+ 'bili.openLogin': '打开登录页面',
382
+ 'bili.waiting': '等待扫码…',
383
+ 'bili.waitingScanned': '已扫码,请在手机上确认…',
384
+ 'bili.signIn.done': '已登录:{uname}',
385
+ 'bili.signIn.doneNoName': '已登录,但没能读到账号名。',
386
+ 'bili.signIn.expired': '二维码已过期,重新点一次【登录 B 站】。',
387
+ 'bili.cancelSignIn': '取消登录',
388
+ 'bili.paste.title': '或者粘贴一次',
389
+ 'bili.paste.hint': '浏览器里 F12 → Application → Cookies → bilibili.com,复制 SESSDATA 与 bili_jct(后者就是 csrf)。只写进插件自己的文件,验证通过才保存。',
390
+ 'bili.paste.sessdata': 'SESSDATA',
391
+ 'bili.paste.csrf': 'bili_jct',
392
+ 'bili.paste.save': '保存并验证',
393
+ 'bili.paste.saved': '凭据已保存:{uname}',
394
+ 'bili.signOut': '退出 B 站登录',
395
+ 'bili.signOut.confirm': '确认退出?',
396
+ 'bili.signOut.done': '已删除插件保存的 B 站凭据。',
397
+ 'bili.signOut.fallback': '注意:还有一份外部凭据继续生效({path}),它属于别的工具,这里没有动它。',
398
+ 'bili.bind.title': '绑定的视频',
399
+ 'bili.chip.announced': '已播报 {tag}',
400
+ 'bili.chip.pending': '待播报 {tag}',
401
+ 'bili.bind.placeholder': 'BV 号,如 BV1RopP6FEJp',
402
+ 'bili.bind.save': '绑定',
403
+ 'bili.bind.unbind': '解绑',
404
+ 'bili.bind.done': '已绑定 {bvid}{baseline}',
405
+ 'bili.bind.baseline': '(基线 {tag}:只有之后发布的新版本会自动播报)',
406
+ 'bili.bind.baselineUnknown': '(读不到 Release 列表,绑定时刻之前的版本都按已公开处理)',
407
+ 'bili.unbound.done': '已解绑 {repo} 的 B 站视频,播报记录保留。',
408
+ 'bili.auto.on': '自动播报',
409
+ 'bili.auto.off': '仅手动',
410
+ 'bili.auto.toggle': '切换自动播报',
411
+ 'bili.announce': '发更新评论',
412
+ 'bili.announce.again': '已播报过,重新预览',
413
+ 'bili.announce.preview': '将发送这条评论',
414
+ 'bili.announce.confirm': '确认发送',
415
+ 'bili.announce.force': '仍然发送',
416
+ 'bili.announce.cancel': '取消',
417
+ 'bili.announce.sent': '已发送到 {bvid} 的评论区({tag})。',
418
+ 'bili.announce.previewFailed': '预览失败:{reason}',
419
+ 'bili.repo.none': '这个仓库还没有绑定 B 站视频。',
420
+ 'bili.state.already': '{tag} 已经播报过了。',
421
+ 'bili.state.baseline': '{tag} 在绑定视频之前就已经发布了,不算这次更新。',
422
+ 'bili.state.no-release': '还没有已公开的 Release(草稿不算)——先在发布台把草稿公开。',
423
+ 'bili.state.gave-up': '这条已经失败 {count} 次,先看看原因再手动重试。',
424
+ 'bili.state.unbound': '这个仓库还没有绑定 B 站视频。',
425
+ 'bili.needCredential': '有仓库绑定了 B 站视频,但还没有可用的 B 站凭据,更新评论发不出去。',
426
+ 'bili.openSettings': '在 设置 → 插件 → DSH 插件发布台 · Bilibili 里管理',
427
+ 'bili.lastSweep': '自动巡检 {time}:{summary}',
428
+ 'bili.sweep.none': '还没有需要播报的新版本',
429
+ 'bili.sweep.announced': '已播报 {repo} {tag}',
430
+ 'bili.sweep.failed': '{repo} {tag} 播报失败',
431
+ 'bili.neverSwept': '自动巡检还没有跑过',
432
+ 'bili.ledger.problem': '播报记录读不出来({problem})。在修好之前不会发送任何评论——否则会把已经发过的评论再发一遍。',
433
+ 'bili.template': '模板',
434
+ 'bili.template.hint': '可用占位符:{tag} {version} {label} {repo} {title} {summary} {url} {date}。默认不含链接,因为带链接的评论更容易被 B 站过滤。',
435
+ 'bili.unknownPlaceholder': '模板里有不认识的占位符:{names}',
436
+ 'bili.video.failed': '读不到视频:{reason}',
437
+ 'bili.watch.off': '自动巡检已关(bilibiliWatchSeconds: 0),只剩手动按钮与公开草稿后的那一次。',
438
+ 'bili.repos.title': '每个仓库',
439
+ 'bili.repos.empty': '还没有登记仓库。',
440
+ 'bili.announced.title': '已播报',
441
+ 'bili.announced.none': '还没有播报过。',
442
+ 'bili.failure.last': '上次失败:{message}',
443
+ }
444
+
445
+ /** English dictionary, same key set. */
446
+ const en = {
447
+ 'panel': 'DSH Plugin CI/CD',
448
+ 'title': 'DSH Plugin CI/CD',
449
+ 'subtitle': 'Continuous builds and releases for DSH plugins',
450
+ 'settings.consoleLabel': 'DSH Release Console',
451
+ 'settings.segment.github': 'GitHub',
452
+ 'settings.title': 'GitHub account',
453
+ 'settings.subtitle': 'The console reaches GitHub through the gh CLI on this machine; the plugin stores no credential of its own.',
454
+ 'action.refresh': 'Refresh',
455
+ 'action.manage': 'Repositories',
456
+ 'action.close': 'Done',
457
+ 'action.commit': 'Commit',
458
+ 'action.push': 'Push',
459
+ 'action.commitConfirm': 'Commit',
460
+ 'action.pushConfirm': 'Push',
461
+ 'action.build': 'Build',
462
+ 'action.release': 'Release',
463
+ 'action.bumpRelease': 'Bump and release',
464
+ 'action.publishDraft': 'Publish',
465
+ 'action.confirmPublish': 'Publish?',
466
+ 'action.update': 'Update to {tag}',
467
+ 'action.installRelease': 'Install release {tag}',
468
+ 'action.confirmUpdate': 'Update',
469
+ 'action.restartNow': 'Restart DSH now',
470
+ 'action.restartLater': 'Later',
471
+ 'action.open': 'Open',
472
+ 'action.logs': 'Logs',
473
+ 'action.hideLogs': 'Hide logs',
474
+ 'action.rerun': 'Re-run',
475
+ 'action.cancel': 'Cancel',
476
+ 'action.expand': 'Details',
477
+ 'action.collapse': 'Less',
478
+ 'action.add': 'Add',
479
+ 'action.remove': 'Remove',
480
+ 'action.signIn': 'Sign in with a browser',
481
+ 'action.recheck': 'Re-check',
482
+ 'action.grantScopes': 'Grant scopes',
483
+ 'action.copy': 'Copy',
484
+ 'action.copied': 'Copied',
485
+ 'action.openDevicePage': 'Open the authorization page',
486
+ 'action.cancelSignIn': 'Cancel sign-in',
487
+ 'action.signOut': 'Sign out',
488
+ 'action.signOutConfirm': 'Confirm sign out?',
489
+ 'state.signedOut': 'Signed out: the token was removed from the operating system credential store. git push stops working until you sign in again — git takes its credential from gh.',
490
+ 'label.runs': 'Runs',
491
+ 'chip.published': 'Released',
492
+ 'chip.unpublished': 'Unreleased',
493
+ 'chip.draft': 'Draft',
494
+ 'chip.versionTaken': 'version taken',
495
+ 'chip.update': 'update {tag}',
496
+ 'chip.checkout': 'local checkout',
497
+ 'chip.dirty': '{count} uncommitted',
498
+ 'chip.ahead': '{count} ahead',
499
+ 'chip.behind': '{count} behind',
500
+ 'chip.private': 'private',
501
+ 'state.loading': 'Loading…',
502
+ 'state.reading': 'reading…',
503
+ 'state.hostGone': 'Cannot reach the Host; the panel keeps the last known data and retries on its own.',
504
+ 'state.timeout': 'The request timed out without an answer from the Host.',
505
+ 'stale.title': 'The page and the Host half are different versions',
506
+ 'stale.text': 'This page is current, but the running Host half is older and does not have this route, so the buttons answer 401/404. Refreshing the page does not replace the Host.',
507
+ 'stale.how': 'Use the one-click restart at the foot of the sidebar (or restart DeepSeek Harness) and the two halves match again.',
508
+ 'state.noRuns': 'no runs yet',
509
+ 'state.noReleases': 'no releases yet',
510
+ 'state.dispatched': 'Triggered {workflow} on {repo}.',
511
+ 'state.releaseDispatched': 'Release workflow triggered for {repo}. It produces a DRAFT — but whether a draft actually appears depends on that run: if it fails, expand the row and press Logs to see why (a stale version number is the usual reason).',
512
+ 'state.releaseBumped': 'Bumped {from} to {tag} and pushed {branch}; the release workflow is triggered. It produces a draft, and whether it succeeds depends on that run.',
513
+ 'state.committed': 'Committed and pushed {branch} ({count} change(s)). Build and Release can see them now.',
514
+ 'state.pushed': 'Pushed {count} local commit(s) to {branch}.',
515
+ 'commit.noCheckout': 'This repository has no usable local checkout, so there is nothing here to commit.',
516
+ 'commit.willCommit': 'This will commit {count} change(s):',
517
+ 'commit.willPush': 'The tree is clean and {count} local commit(s) are waiting to be pushed — a release builds the commit on GitHub, not the one on this disk.',
518
+ 'commit.nothing': 'The tree is clean and in sync with its upstream: nothing to commit or push.',
519
+ 'commit.messagePlaceholder': 'Commit message',
520
+ 'commit.messageRequired': 'A commit needs a message.',
521
+ 'confirm.push': 'Push {count} local commit(s) to {branch}?',
522
+ 'state.published': '{tag} is public now.',
523
+ 'state.rerun': 'Re-run requested.',
524
+ 'state.cancelled': 'Cancellation requested.',
525
+ 'state.actionFailed': 'The action failed: {reason}',
526
+ 'state.registered': 'Added {repo}.',
527
+ 'state.removed': 'Removed {repo}.',
528
+ 'state.truncated': '(showing the last {lines} lines)',
529
+ 'drafts.title': '{count} draft(s) not published yet',
530
+ 'drafts.text': 'A draft is invisible to everyone else and creates no tag, so it does not appear on the repository Releases page — that is not a failure. Expand the rows marked Draft below and press Publish to make one public.',
531
+ 'meta.account': 'Account',
532
+ 'meta.updated': 'Updated {time}',
533
+ 'meta.cached': 'cached',
534
+ 'meta.version': 'local',
535
+ 'meta.expectedTag': 'expected tag {tag}',
536
+ 'meta.noLocal': 'no local path, so local state cannot be compared',
537
+ 'meta.assets': '{count} assets',
538
+ 'meta.path': 'Path',
539
+ 'meta.scopes': 'Scopes',
540
+ 'meta.noScopes': '(gh reported no scope list)',
541
+ 'meta.gh': 'gh',
542
+ 'meta.configFrom': 'Config from',
543
+ 'meta.projectsRoot': 'Checkout root',
544
+ 'meta.projectsRootUnset': 'not set, so adding a repository cannot find its local checkout',
545
+ 'run.unknown': 'unknown',
546
+ 'release.takenTitle': 'Release preflight: this version already belongs to another commit',
547
+ 'release.taken': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit (the tag and its assets would disagree), so dispatching now cannot succeed. Bump it to {next} first.',
548
+ 'release.takenNoNext': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit, so dispatching now cannot succeed. Bump the version first.',
549
+ 'release.dirtyNote': '{count} uncommitted change(s): a release builds the commit that was pushed, so these files would not be in the published package (and a version bump is refused for the same reason).',
550
+ 'release.aheadNote': '{count} commit(s) not pushed yet: a release builds the remote branch, so they would not be in the published package until they are pushed.',
551
+ 'release.dirty': 'The checkout has {count} uncommitted change(s); commit or stash them first.',
552
+ 'release.noUpstream': 'Branch {branch} has no upstream to push to.',
553
+ 'release.behind': 'The branch is {count} commit(s) behind its upstream; pull before releasing.',
554
+ 'release.noCheckout': 'This repository has no usable local checkout (no package.json).',
555
+ 'release.noVersion': 'No bumpable version number was found locally.',
556
+ 'release.pushFailed': 'Committed {tag} locally, but the push failed: {reason} (the commit is still local — retry the push)',
557
+ 'release.carried': 'This push also delivered {count} commit(s) that were not pushed before.',
558
+ 'confirm.bumpRelease': 'Bump package.json from {from} to {to}, commit it, push {branch}, then trigger the release? This creates a commit.',
559
+ 'confirm.publish': 'Publish {tag} publicly? Once published, anyone can see it.',
560
+ 'confirm.cancel': 'Not yet',
561
+ 'confirm.update': 'Replace {package} in the {profile} profile (currently {spec}) with the tgz from release {tag} and reinstall? This rewrites that profile dependency.',
562
+ 'install.title': 'Install',
563
+ 'install.notInstalled': '{package} is not a dependency of the {profile} profile, so there is no installed copy to update.',
564
+ 'install.noRelease': 'This repository has no published release yet (a draft does not count), so there is nothing to install from.',
565
+ 'install.current': 'The profile already holds the package from release {tag}; it is current.',
566
+ 'install.checkout': 'This copy points at a local checkout ({spec}), so it is not the code anyone downloaded. Installing the release replaces it; switch back with dsh plugin add link:<checkout path>.',
567
+ 'install.ahead': 'The profile holds v{installed}, which is newer than this release ({tag}) — installing it would move backwards to the published copy.',
568
+ 'install.differs': 'The profile holds {installed}, which is not the same version number as release {tag}, so the two cannot be ordered.',
569
+ 'state.updated': '{package} was replaced with the release package {tag}. The new version takes effect only after DSH restarts.',
570
+ 'state.updatedSame': '{package} already points at the {tag} release package: nothing changed, and no restart is needed.',
571
+ 'npm.title': 'npm',
572
+ 'npm.chipPublished': 'npm v{version}',
573
+ 'npm.chipPending': 'npm v{version} pending',
574
+ 'npm.chipUnregistered': 'not on npm',
575
+ 'npm.account': 'npm {account}',
576
+ 'npm.registry': 'registry {registry}',
577
+ 'npm.recheck': 'Re-check npm',
578
+ 'npm.notLoggedIn': '{command} is not signed in to {registry} — publishing needs npm credentials, and this plugin stores no token of its own. Four steps, from nothing.',
579
+ 'npm.guide.step1': '(1) No npm account yet?',
580
+ 'npm.guide.signup': 'Open npmjs.com to sign up',
581
+ 'npm.guide.emailNote': 'You MUST confirm the link npm emails you: an account with an unverified email cannot publish.',
582
+ 'npm.guide.step2': '(2) Create an access token',
583
+ 'npm.guide.tokens': 'Open the token page',
584
+ 'npm.guide.tokenType': 'There is only one kind left: a Granular Access Token. Classic tokens were all revoked on 2025-11-19 and can no longer be created — do not go looking for one.',
585
+ 'npm.guide.tokenScope': 'Set Permissions to Read and write; scope Packages to everything, or to just this package (narrower is safer).',
586
+ 'npm.guide.tokenExpiry': 'The maximum lifetime is 90 days, which is npm\'s hard limit for a write token. When it expires it simply stops working; come back to step 2 and make another.',
587
+ 'npm.guide.token2fa': 'Tick Bypass 2FA only for CI, where nobody can type a one-time password. For pushing here, leave it OFF — the panel will ask for the code.',
588
+ 'npm.guide.blocked': 'If npmjs.com will not open on this machine, or an "are you a robot" check blocks it: create the token on ANOTHER device or network (a phone on mobile data is easiest) and paste the string into step 3. The panel only needs the token — where it was made makes no difference, and this step has nothing to do with this machine\'s browser.',
589
+ 'npm.guide.step3': '(3) Paste it below',
590
+ 'npm.guide.step3Note': 'It is written to {npmrc} (the user-level file npm itself reads; every other line is preserved). This plugin stores no token, and the field is cleared as soon as the request answers.',
591
+ 'npm.guide.step4': '(4) Is 2FA on the account?',
592
+ 'npm.guide.step4Note': 'The push confirmation grows a one-time password field: enter the 6 digits and press Push again.',
593
+ 'npm.guide.docs': 'npm documentation',
594
+ 'npm.stepUnknown': 'cannot be detected',
595
+ 'npm.stepTodo': 'to do',
596
+ 'npm.stepAccountDone': '✓ account {account} exists',
597
+ 'npm.stepTokenDone': '✓ a working token exists',
598
+ 'npm.stepWrittenDone': '✓ a credential is already in {npmrc}',
599
+ 'npm.status.title': 'Credential',
600
+ 'npm.status.signedIn': 'signed in as {account}',
601
+ 'npm.status.credentialPresent': 'credential present, whoami unconfirmed',
602
+ 'npm.status.none': 'no credential yet',
603
+ 'npm.status.unknown': 'npm has not answered yet',
604
+ 'npm.status.unknownHint': 'The Host has not answered /npm-status (the running Host half may be an older version, or the panel just opened). Press Re-check. Until there is an answer, this page will not draw a conclusion for you.',
605
+ 'npm.packages.title': 'Every package on npm',
606
+ 'npm.packages.empty': 'No repository is registered, so there is no package to check.',
607
+ 'npm.packages.recheck': 'Re-check',
608
+ 'npm.package.published': 'on npm',
609
+ 'npm.package.unpublished': 'pending',
610
+ 'npm.package.unregistered': 'name is free',
611
+ 'npm.package.unknown': 'unreadable',
612
+ 'npm.package.local': 'local {version}',
613
+ 'npm.package.latest': 'npm has {latest}',
614
+ 'npm.credentialPresentShort': '.npmrc already carries a credential for {registry}, but whoami would not confirm it — a granular token is scoped to packages while whoami is a user-level endpoint, so that is normal. Just push; the publish decides whether it is real.',
615
+ 'npm.moreInSettings': 'The full guide and every package\'s state live in Settings → Plugins → DSH Release Console · npm.',
616
+ 'npm.ciHint': 'Publishing from CI? This token is not the way — npm supports trusted publishing (GitHub Actions OIDC), which needs no long-lived credential at all.',
617
+ 'settings.segment.npm': 'npm',
618
+ 'settings.npmTitle': 'npm credentials',
619
+ 'settings.npmSubtitle': 'Publishing uses the Host\'s own package manager, and the credential comes from the ~/.npmrc npm itself reads — this plugin stores no token.',
620
+ 'settings.npmGuideTitle': 'Four steps, from nothing',
621
+ 'npm.credentialPresentTitle': 'This registry already has a token line',
622
+ 'npm.credentialPresent': '.npmrc carries a credential for {registry}, but whoami would not confirm it. A granular token is scoped to PACKAGES while whoami is a user-level endpoint, so refusing a token that publishes perfectly well is normal. The publish itself is the real test: just push, and if it fails the panel says whether to enter a one-time password or make a new token.',
623
+ 'npm.rejectedTitle': 'That token was not accepted. Check, in order:',
624
+ 'npm.rejectedCauses': '(1) it was copied incompletely (leading or trailing whitespace); (2) Permissions are not Read and write; (3) it was revoked, or passed its 90-day limit; (4) it is scoped to another package or organization; (5) the account email is still unverified.',
625
+ 'npm.hint.otp-required': 'This account requires a one-time password: enter the 6 digits in the push confirmation and press Push again.',
626
+ 'npm.hint.email-unverified': 'Confirm the link npm emailed you first — an unverified email cannot publish — then try again.',
627
+ 'npm.hint.not-logged-in': 'The token is invalid, expired or revoked: make a new one as in step 2 (a write token lasts at most 90 days).',
628
+ 'npm.hint.already-published': 'This version is already on npm, which never accepts the same version twice: bump the version first, then push.',
629
+ 'npm.hint.payment-required': 'npm charges for private packages and not for public ones. Check for a private flag in package.json and whether this is meant to be public.',
630
+ 'npm.hint.forbidden': 'This token is not allowed to publish this package: check its Packages scope, and whether the name belongs to your account or organization.',
631
+ 'npm.hint.not-found': 'The registry has no publishable place for that name: check the spelling and the account or organization.',
632
+ 'npm.hint.rate-limited': 'The registry is rate-limiting you; try again in a few minutes.',
633
+ 'npm.hint.registry-error': 'The registry itself failed (5xx). That is not your configuration — try again shortly.',
634
+ 'npm.hint.network': 'The registry cannot be reached: check the network or proxy settings.',
635
+ 'npm.hint.timeout': 'The publish did not finish in time: a slow network, or the registry waiting for an answer. Retry once, and read its own words below if it fails again.',
636
+ 'npm.hint.unknown': 'No specific cause was recognised; npm\'s own words are below.',
637
+ 'npm.tokenPlaceholder': 'npm_… or paste an access token',
638
+ 'npm.writeToken': 'Write and verify',
639
+ 'npm.tokenWritten': 'The token is in {npmrc}; signed in as {account}.',
640
+ 'npm.npmrcPath': 'config file',
641
+ 'npm.state.published': 'This version is already on npm ({version}); there is nothing to push.',
642
+ 'npm.state.unregistered': 'Nobody owns this name on npm yet: this push would be its first release.',
643
+ 'npm.state.unpublished': 'npm has {latest}; the local {version} has not been pushed.',
644
+ 'npm.state.unknown': 'The npm state cannot be read ({reason}), so no push is offered — a missing button beats one that must fail.',
645
+ 'npm.action.publish': 'Push to npm v{version}',
646
+ 'npm.action.confirm': 'Push',
647
+ 'npm.otpLabel': 'One-time password',
648
+ 'npm.otpHint': 'Only needed with 2FA on the account, and it is never stored.',
649
+ 'npm.otpPlaceholder': '6 digits',
650
+ 'npm.blocked.private-package': 'package.json says "private": true, which npm refuses. That is the author\'s decision, not something to work around here.',
651
+ 'npm.blocked.not-logged-in': 'Not signed in to npm yet; write a token above first.',
652
+ 'npm.blocked.dirty-tree': 'The checkout has uncommitted changes: publish packs the working directory, so commit or stash first (the files allow-list does not protect a new file inside a listed directory).',
653
+ 'npm.blocked.no-checkout': 'This repository has no usable local checkout.',
654
+ 'npm.blocked.no-version': 'package.json has no version.',
655
+ 'npm.blocked.no-package-name': 'No package name could be read.',
656
+ 'npm.blocked.already-published': 'This version is already on npm, which never accepts the same version twice — bump the version first.',
657
+ 'npm.blocked.registry-unreachable': 'The npm registry cannot be reached, so whether this version exists is unknown.',
658
+ 'confirm.npmPublish': 'Push {package}@{version} to {registry}? npm never accepts the same version twice, and unpublishing is only possible briefly — once it is up, anyone can see it.',
659
+ 'state.npmPublished': 'Pushed {package}@{version} to {registry}.',
660
+ 'state.npmFirstPublish': '{package}@{version} is now published to {registry} for the first time.',
661
+ 'npm.githubDraft': 'Note: GitHub has only a draft (or no release) for this version — npm would go public before the Release does.',
662
+ 'state.updatedBuilds': '(Note: {count} build script(s) were blocked and did not run.)',
663
+ 'restart.ask': '{package} is updated to {tag}, but DSH has to restart to use it — restart now?',
664
+ 'restart.scheduled': 'Restart scheduled: DSH closes in a second or two and reopens by itself. Refreshing the page is not enough; the module is already loaded in the running process.',
665
+ 'restart.unavailable': 'Cannot restart: {reason}',
666
+ 'setup.needGh': 'The gh CLI was not found, so the console cannot reach GitHub.',
667
+ 'setup.needSignIn': 'Not signed in to GitHub yet. Once you are, your repositories appear here.',
668
+ 'setup.needScopes': 'The current credential is missing: {scopes}.',
669
+ 'setup.scopeHint': 'Without repo, private repositories cannot be read; without workflow, a build cannot be triggered.',
670
+ 'setup.needRepos': 'No repository is registered yet.',
671
+ 'setup.codeHint': 'Open the address below in your browser and enter this one-time code:',
672
+ 'setup.waiting': 'Waiting for authorization… finish in the browser and this continues by itself.',
673
+ 'setup.requestingCode': 'Requesting a one-time code from GitHub…',
674
+ 'setup.codePending': 'The authorization page is open — enter the code below once it appears. It has not arrived yet; give it a moment.',
675
+ 'setup.stalled': 'No one-time code after {seconds}s: {detail}. The usual cause is that github.com cannot be reached (the panel reads repositories over api.github.com, a different route, so that can still work). You can keep waiting — the code appears once the network recovers — or cancel and try again.',
676
+ 'setup.stalledNoOutput': 'gh has printed nothing at all',
677
+ 'setup.stalledQuietOutput': 'gh has printed only whitespace (it is usually retrying the connection)',
678
+ 'setup.stalledSaid': 'gh said: {text}',
679
+ 'setup.unreachableTitle': 'github.com is not reachable right now',
680
+ 'setup.unreachable': 'Note: this machine cannot open a connection to github.com:443, which the device-code flow needs — if no code appears, that is the likely reason. gh is already trying; if it can reach GitHub by another route (a proxy, for instance) the code will still appear.',
681
+ 'setup.dnsTitle': 'Cannot resolve github.com',
682
+ 'setup.dns': 'Note: this machine cannot resolve github.com to an address, while the address itself is reachable (connecting straight to its IP takes tens of milliseconds). gh resolves the name too, so it cannot even send the request — which is why no code appears. A different resolver, or simply retrying later, fixes it; gh is already trying and the code appears as soon as resolution works.',
683
+ 'setup.succeeded': 'Authorized. Reading the account…',
684
+ 'setup.failed': 'Sign-in did not finish: {reason}',
685
+ 'setup.expired': 'The one-time code expired. Start again.',
686
+ 'setup.cancelled': 'Sign-in cancelled.',
687
+ 'setup.advanced': 'Configure from a command line (optional)',
688
+ 'setup.ghInstall': 'Install GitHub CLI (either one)',
689
+ 'picker.title': 'Add repositories',
690
+ 'picker.search': 'Search repositories',
691
+ 'picker.empty': 'Nothing to add (not signed in, or this account has no repositories).',
692
+ 'picker.loading': 'Reading your repositories…',
693
+ 'picker.failed': 'Reading the repository list failed: {reason}',
694
+ 'picker.local': 'local checkout',
695
+ 'picker.noLocal': 'no local checkout found',
696
+ 'picker.hint': 'Ticking adds, unticking removes; the change applies at once, with no restart.',
697
+ 'picker.reload': 'Reload',
698
+
699
+ /* -- Bilibili update notes ------------------------------------------- */
700
+ 'settings.segment.bilibili': 'Bilibili',
701
+ 'bili.settings.title': 'Bilibili update notes',
702
+ 'bili.settings.subtitle': 'When a plugin is released, one short note is added to the comment section of the video that introduces it. The credential lives only in this plugin\'s own directory, and is never echoed back.',
703
+ 'bili.credential.title': 'Sign-in',
704
+ 'bili.credential.ready': 'Signed in as {uname}',
705
+ 'bili.credential.none': 'No Bilibili credential yet',
706
+ 'bili.credential.unreadable': 'The credential cannot be read',
707
+ 'bili.credential.incomplete': 'The credential is incomplete',
708
+ 'bili.credential.not-logged-in': 'This credential cannot comment',
709
+ 'bili.credential.unverified': 'Not verified yet',
710
+ 'bili.credential.none.hint': 'Commenting is a WEB API, so the credential has to be a web session. The cookies.json biliup writes by default is an APP/TV login (platform=BiliTV): it can upload, and every web member endpoint answers it with -101 账号未登录 — so it cannot comment.',
711
+ 'bili.credential.source': 'Credential file {path}',
712
+ 'bili.credential.external': 'This credential comes from the external file named in the config, not from this panel: {path}',
713
+ 'bili.signIn': 'Sign in to Bilibili',
714
+ 'bili.signIn.hint': 'One click produces a sign-in link: scan it with the Bilibili app, or open it in a browser that is already signed in and confirm. This page continues by itself — no terminal needed.',
715
+ 'bili.openLogin': 'Open the sign-in page',
716
+ 'bili.waiting': 'Waiting to be scanned…',
717
+ 'bili.waitingScanned': 'Scanned — confirm it on your phone…',
718
+ 'bili.signIn.done': 'Signed in: {uname}',
719
+ 'bili.signIn.doneNoName': 'Signed in, but the account name could not be read.',
720
+ 'bili.signIn.expired': 'The code expired. Press 【Sign in to Bilibili】 again.',
721
+ 'bili.cancelSignIn': 'Cancel sign-in',
722
+ 'bili.paste.title': 'Or paste one',
723
+ 'bili.paste.hint': 'In the browser: F12 → Application → Cookies → bilibili.com, and copy SESSDATA and bili_jct (the latter is the csrf). It is written to this plugin\'s own file, and only after Bilibili accepts it.',
724
+ 'bili.paste.sessdata': 'SESSDATA',
725
+ 'bili.paste.csrf': 'bili_jct',
726
+ 'bili.paste.save': 'Save and verify',
727
+ 'bili.paste.saved': 'Credential saved: {uname}',
728
+ 'bili.signOut': 'Sign out of Bilibili',
729
+ 'bili.signOut.confirm': 'Sign out?',
730
+ 'bili.signOut.done': 'The credential this plugin stored has been deleted.',
731
+ 'bili.signOut.fallback': 'Note: an external credential is still in use ({path}). It belongs to another tool and was left alone.',
732
+ 'bili.bind.title': 'Bound video',
733
+ 'bili.chip.announced': 'Announced {tag}',
734
+ 'bili.chip.pending': 'Waiting to announce {tag}',
735
+ 'bili.bind.placeholder': 'BV id, e.g. BV1RopP6FEJp',
736
+ 'bili.bind.save': 'Bind',
737
+ 'bili.bind.unbind': 'Unbind',
738
+ 'bili.bind.done': 'Bound {bvid}{baseline}',
739
+ 'bili.bind.baseline': ' (baseline {tag}: only releases published after this are announced automatically)',
740
+ 'bili.bind.baselineUnknown': ' (the release list could not be read, so everything published before now counts as already public)',
741
+ 'bili.unbound.done': 'Unbound the video for {repo}; the announcement history is kept.',
742
+ 'bili.auto.on': 'Automatic',
743
+ 'bili.auto.off': 'Manual only',
744
+ 'bili.auto.toggle': 'Toggle automatic announcing',
745
+ 'bili.announce': 'Post update note',
746
+ 'bili.announce.again': 'Already announced — preview again',
747
+ 'bili.announce.preview': 'This is the comment that would be posted',
748
+ 'bili.announce.confirm': 'Post it',
749
+ 'bili.announce.force': 'Post anyway',
750
+ 'bili.announce.cancel': 'Cancel',
751
+ 'bili.announce.sent': 'Posted to the comments under {bvid} ({tag}).',
752
+ 'bili.announce.previewFailed': 'Preview failed: {reason}',
753
+ 'bili.repo.none': 'This repository has no video bound.',
754
+ 'bili.state.already': '{tag} has already been announced.',
755
+ 'bili.state.baseline': '{tag} was published before the video was bound, so it is not this update.',
756
+ 'bili.state.no-release': 'There is no published release yet (a draft does not count) — publish the draft from the panel first.',
757
+ 'bili.state.gave-up': 'This one has failed {count} times; read why before retrying by hand.',
758
+ 'bili.state.unbound': 'This repository has no video bound.',
759
+ 'bili.needCredential': 'A repository is bound to a video, but there is no usable Bilibili credential, so update notes cannot be posted.',
760
+ 'bili.openSettings': 'Manage under Settings → Plugins → DSH Release Console · Bilibili',
761
+ 'bili.lastSweep': 'Sweep {time}: {summary}',
762
+ 'bili.sweep.none': 'nothing new to announce',
763
+ 'bili.sweep.announced': 'announced {repo} {tag}',
764
+ 'bili.sweep.failed': '{repo} {tag} failed to announce',
765
+ 'bili.neverSwept': 'The sweep has not run yet',
766
+ 'bili.ledger.problem': 'The announcement ledger cannot be read ({problem}). Nothing will be posted until it can — otherwise every comment already posted would be posted again.',
767
+ 'bili.template': 'Template',
768
+ 'bili.template.hint': 'Placeholders: {tag} {version} {label} {repo} {title} {summary} {url} {date}. The default carries no link, because a comment with a link is filtered more often.',
769
+ 'bili.unknownPlaceholder': 'The template uses placeholders that do not exist: {names}',
770
+ 'bili.video.failed': 'The video cannot be read: {reason}',
771
+ 'bili.watch.off': 'The sweep is off (bilibiliWatchSeconds: 0); only the manual button and the sweep after publishing a draft remain.',
772
+ 'bili.repos.title': 'Per repository',
773
+ 'bili.repos.empty': 'No repository is registered yet.',
774
+ 'bili.announced.title': 'Announced',
775
+ 'bili.announced.none': 'Nothing has been announced yet.',
776
+ 'bili.failure.last': 'Last failure: {message}',
777
+ }
778
+
779
+ /* ------------------------------------------------------------- styles -- */
780
+
781
+ const CSS = `
782
+ /* One scale for the whole panel. Every rule below reads these; nothing picks its
783
+ own size, height or radius, because that is how a UI drifts into six different
784
+ font sizes and three different button heights. */
785
+ .dsc-root {
786
+ --dsc-fs-sm: 11px; /* secondary: meta, chips, hints, detail, logs */
787
+ --dsc-fs-md: 12px; /* primary: rows, buttons, inputs, notices */
788
+ --dsc-fs-lg: 15px; /* titles, and the one-time code */
789
+ --dsc-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
790
+ --dsc-ctl-h: 26px; /* every clickable control is this tall */
791
+ --dsc-ctl-r: 6px; /* every control, code block and chip container */
792
+ --dsc-box-r: 10px; /* every container */
793
+ --dsc-gap: 8px;
794
+ display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 20px;
795
+ height: 100%; overflow: auto;
796
+ font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1.5;
797
+ color: var(--dsw-alias-label-primary);
798
+ }
799
+ .dsc-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
800
+ .dsc-heading { display: flex; align-items: baseline; gap: var(--dsc-gap); min-width: 0; }
801
+ .dsc-title { font-size: var(--dsc-fs-lg); font-weight: 600; }
802
+ .dsc-subtitle { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
803
+ .dsc-meta { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
804
+ .dsc-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
805
+
806
+ /* One button. data-kind changes colour only; data-square changes width only. */
807
+ .dsc-btn {
808
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
809
+ height: var(--dsc-ctl-h); padding: 0 10px;
810
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
811
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
812
+ font-family: inherit; font-size: var(--dsc-fs-md); font-weight: 400; line-height: 1;
813
+ cursor: pointer; white-space: nowrap;
814
+ }
815
+ .dsc-btn:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
816
+ .dsc-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
817
+ .dsc-btn[disabled] { opacity: .5; cursor: default; }
818
+ .dsc-btn[data-kind="primary"] { border-color: transparent; background: var(--dsw-alias-brand-primary); color: #fff; }
819
+ .dsc-btn[data-kind="danger"] { border-color: transparent; background: var(--dsw-alias-state-error-primary); color: #fff; }
820
+ .dsc-btn[data-kind="quiet"] { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
821
+ .dsc-btn[data-kind="quiet"]:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
822
+ .dsc-btn[data-square="true"] { width: var(--dsc-ctl-h); padding: 0; }
823
+
824
+ .dsc-chip {
825
+ display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 7px;
826
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 999px;
827
+ font-size: var(--dsc-fs-sm); line-height: 1; color: var(--dsw-alias-label-secondary); white-space: nowrap; flex: none;
828
+ }
829
+ .dsc-chip[data-state="success"] { color: var(--dsw-alias-state-success-primary); border-color: currentColor; }
830
+ .dsc-chip[data-state="error"] { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
831
+ .dsc-chip[data-state="warn"] { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
832
+ .dsc-chip[data-state="busy"] { color: var(--dsw-alias-brand-primary); border-color: currentColor; }
833
+ .dsc-chip[data-state="idle"] { color: var(--dsw-alias-state-idle-primary); border-color: currentColor; }
834
+ .dsc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); flex: none; }
835
+ .dsc-dot[data-state="success"] { background: var(--dsw-alias-state-success-primary); }
836
+ .dsc-dot[data-state="error"] { background: var(--dsw-alias-state-error-primary); }
837
+ .dsc-dot[data-state="warn"] { background: var(--dsw-alias-state-warn-primary); }
838
+ .dsc-dot[data-state="busy"] { background: var(--dsw-alias-brand-primary); }
839
+
840
+ /* One container, one row per repository, separators instead of card gaps. */
841
+ .dsc-list { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
842
+ .dsc-item { border-top: 1px solid var(--dsw-alias-border-l1); }
843
+ .dsc-item:first-child { border-top: none; }
844
+ .dsc-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; min-height: 32px; }
845
+ .dsc-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 70px; flex: 0 1 auto; }
846
+ .dsc-grow { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font-size: var(--dsc-fs-sm); }
847
+ .dsc-right { margin-left: auto; display: flex; align-items: center; gap: 6px; flex: none; }
848
+ .dsc-detail { padding: 4px 10px 10px 28px; display: flex; flex-direction: column; gap: var(--dsc-gap); font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
849
+ .dsc-mono { font-family: var(--dsc-mono); overflow-wrap: anywhere; }
850
+ .dsc-sub { display: flex; flex-direction: column; gap: 3px; }
851
+ .dsc-line { display: flex; align-items: center; gap: var(--dsc-gap); padding: 3px 0; border-top: 1px dashed var(--dsw-alias-border-l1); }
852
+ .dsc-line:first-child { border-top: none; }
853
+ .dsc-empty { padding: var(--dsc-gap) 2px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
854
+ /* A line that only points somewhere else: quieter than a warning, and never a
855
+ control, so it cannot be mistaken for one. */
856
+ .dsc-quiet { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
857
+ /* The commit confirmation: a sentence, the file list, a message field, then the two
858
+ buttons. A column, because the list wraps and a row would push the buttons off. */
859
+ .dsc-commit { display: flex; flex-direction: column; gap: var(--dsc-gap); padding: var(--dsc-gap) 0; font-size: var(--dsc-fs-sm); }
860
+ /* The console's own segment strip, inside its one Plugins tab. Left-aligned and with a
861
+ rule under it, so it reads as "which part of this page" rather than as a second set
862
+ of tabs floating in the middle: the dsc-bar rule alone would spread three buttons
863
+ across the full width. No backticks in here — this whole sheet is one template
864
+ literal, and a backtick ends it. */
865
+ .dsc-segments { justify-content: flex-start; gap: 6px; padding-bottom: var(--dsc-gap); border-bottom: 1px solid var(--dsw-alias-border-l1); margin-bottom: var(--dsc-gap); }
866
+ .dsc-settings { display: flex; flex-direction: column; gap: var(--dsc-gap); }
867
+
868
+ .dsc-notice, .dsc-warn, .dsc-error {
869
+ padding: 6px 10px; border-radius: var(--dsc-ctl-r); font-size: var(--dsc-fs-md); line-height: 1.5;
870
+ border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
871
+ }
872
+ .dsc-warn { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
873
+ .dsc-error { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
874
+ .dsc-notice code, .dsc-setup code, .dsc-cmd code {
875
+ font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm);
876
+ background: var(--dsw-alias-bg-layer-2); padding: 1px 5px; border-radius: 4px;
877
+ }
878
+ .dsc-setup {
879
+ border: 1px solid var(--dsw-alias-brand-primary); border-radius: var(--dsc-box-r);
880
+ background: var(--dsw-alias-bg-layer-1); padding: 10px 12px;
881
+ display: flex; flex-direction: column; gap: var(--dsc-gap);
882
+ font-size: var(--dsc-fs-md); color: var(--dsw-alias-label-secondary);
883
+ }
884
+ .dsc-setup-line { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; }
885
+ .dsc-setup-strong { color: var(--dsw-alias-label-primary); }
886
+ .dsc-code { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 10px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
887
+ .dsc-code-value { font-family: var(--dsc-mono); font-size: var(--dsc-fs-lg); letter-spacing: .12em; font-weight: 600; color: var(--dsw-alias-label-primary); }
888
+ .dsc-cmd { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; padding: 5px 8px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
889
+ .dsc-cmd code { flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere; }
890
+ .dsc-logs {
891
+ margin: 0; padding: var(--dsc-gap) 10px; max-height: 200px; overflow: auto; border-radius: var(--dsc-ctl-r);
892
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
893
+ font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm); line-height: 1.5; white-space: pre-wrap;
894
+ }
895
+ .dsc-picker { border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-box-r); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; overflow: hidden; }
896
+ .dsc-picker-head { display: flex; align-items: center; gap: var(--dsc-gap); padding: var(--dsc-gap) 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
897
+ .dsc-input {
898
+ flex: 1 1 auto; min-width: 0; height: var(--dsc-ctl-h); padding: 0 10px;
899
+ font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1;
900
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
901
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
902
+ }
903
+ .dsc-input:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
904
+ /* The one-time password field is a control, not a form field: it shares the control
905
+ height and radius so it sits on a confirmation line with the buttons. */
906
+ .dsc-otp { flex: 0 0 110px; min-width: 0; }
907
+ .dsc-picker-list { max-height: 260px; overflow: auto; }
908
+ .dsc-picker-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; border-top: 1px solid var(--dsw-alias-border-l1); font-size: var(--dsc-fs-md); cursor: pointer; }
909
+ .dsc-picker-row:first-child { border-top: none; }
910
+ .dsc-picker-row:hover { background: var(--dsw-alias-bg-layer-2); }
911
+ .dsc-check { width: 14px; height: 14px; flex: none; accent-color: var(--dsw-alias-brand-primary); }
912
+ .dsc-hint { padding: 7px 10px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); border-top: 1px solid var(--dsw-alias-border-l1); }
913
+ `
914
+
915
+ /**
916
+ * Inject the stylesheet once per document.
917
+ *
918
+ * The loader's materialize step claims every `<style>` that carries no
919
+ * `data-plugin` attribute for the package being materialized, and a later HMR
920
+ * replace of THAT package deletes what it claimed. Tagging the element with
921
+ * this package's own identity keeps it out of both paths.
922
+ */
923
+ function ensureStyles() {
924
+ if (document.getElementById(STYLE_ID) !== null) return
925
+ const style = document.createElement('style')
926
+ style.id = STYLE_ID
927
+ style.setAttribute('data-plugin', 'dsh-plugin-cicd')
928
+ style.setAttribute('data-plugin-css', STYLE_ID)
929
+ style.textContent = CSS
930
+ document.head.append(style)
931
+ }
932
+
933
+ /** POST one JSON body with a hard timeout; never throws. */
934
+ async function postJson(path, body, timeoutMs) {
935
+ const controller = new AbortController()
936
+ const timer = globalThis.setTimeout(() => {
937
+ controller.abort()
938
+ }, timeoutMs)
939
+ try {
940
+ const response = await fetch(`${BASE}${path}`, {
941
+ method: 'POST',
942
+ headers: { 'content-type': 'application/json' },
943
+ body: JSON.stringify(body ?? {}),
944
+ signal: controller.signal,
945
+ })
946
+ let payload = null
947
+ try {
948
+ payload = await response.json()
949
+ } catch {
950
+ payload = null
951
+ }
952
+ return { ok: true, response, payload }
953
+ } catch (error) {
954
+ return { ok: false, aborted: controller.signal.aborted, error: error instanceof Error ? error.message : String(error) }
955
+ } finally {
956
+ globalThis.clearTimeout(timer)
957
+ }
958
+ }
959
+
960
+ /** A short local time stamp; an unparseable value is shown as-is. */
961
+ function stamp(iso) {
962
+ if (typeof iso !== 'string' || iso === '') return ''
963
+ const parsed = new Date(iso)
964
+ if (Number.isNaN(parsed.getTime())) return iso
965
+ return parsed.toLocaleString()
966
+ }
967
+
968
+ /** A compact "how long did it take" label for a settled run. */
969
+ function duration(run) {
970
+ if (run === null || run.createdAt === '' || run.updatedAt === '') return ''
971
+ const from = new Date(run.createdAt).getTime()
972
+ const to = new Date(run.updatedAt).getTime()
973
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return ''
974
+ const seconds = Math.round((to - from) / 1000)
975
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`
976
+ }
977
+
978
+ /** Map a run's state onto the chip vocabulary. */
979
+ function runState(run) {
980
+ if (run === null) return 'idle'
981
+ if (run.status !== 'completed') return 'busy'
982
+ if (run.conclusion === 'success') return 'success'
983
+ if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'error'
984
+ return 'idle'
985
+ }
986
+
987
+ /** The 7-character commit form git itself prints, for a message a person reads. */
988
+ function shortSha(value) {
989
+ return typeof value === 'string' && value !== '' ? value.slice(0, 7) : '?'
990
+ }
991
+
992
+ /**
993
+ * A failed release route's reason, in the panel's language.
994
+ *
995
+ * The Host answers with a stable `code` plus the facts, and its `message` is the
996
+ * English fallback a log would show. Rendering the fallback here would put an
997
+ * English sentence in an otherwise Chinese panel, so the codes that the panel
998
+ * can explain are translated and anything unknown falls back to the raw message.
999
+ *
1000
+ * @param {Function} t - dictionary lookup.
1001
+ * @param {object|null} payload - route body.
1002
+ * @param {object|null} response - fetch response, for the status fallback.
1003
+ * @returns {string} a sentence for the error line.
1004
+ */
1005
+ function releaseFailure(t, payload, response) {
1006
+ const code = typeof payload?.code === 'string' ? payload.code : ''
1007
+ const value = payload?.value ?? {}
1008
+ if (code === 'dirty-tree') return t('release.dirty', { count: String(value.dirty ?? '?') })
1009
+ if (code === 'no-upstream') return t('release.noUpstream', { branch: String(value.branch ?? '?') })
1010
+ if (code === 'behind') return t('release.behind', { count: String(value.behind ?? '?') })
1011
+ if (code === 'no-checkout') return t('release.noCheckout')
1012
+ if (code === 'unusable-version' || code === 'unusable-manifest') return t('release.noVersion')
1013
+ if (code === 'push-failed') return t('release.pushFailed', { tag: String(value.tag ?? ''), reason: String(payload?.message ?? '') })
1014
+ if (code === 'version-taken') {
1015
+ return t(value.nextTag === null || value.nextTag === undefined ? 'release.takenNoNext' : 'release.taken', {
1016
+ tag: String(value.tag ?? ''),
1017
+ owner: shortSha(value.owner),
1018
+ built: shortSha(value.built),
1019
+ next: String(value.nextTag ?? ''),
1020
+ })
1021
+ }
1022
+ return typeof payload?.message === 'string' && payload.message !== '' ? payload.message : `HTTP ${String(response?.status ?? '?')}`
1023
+ }
1024
+
1025
+ /** A compact isometric package, drawn with `currentColor`. */
1026
+ function ConsoleIcon(props) {
1027
+ const size = Number.isFinite(props?.size) ? props.size : 16
1028
+ return h(
1029
+ 'svg',
1030
+ { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, focusable: 'false', style: { display: 'block' } },
1031
+ h('path', { d: 'M8 1.6 2.4 4.5v7L8 14.4l5.6-2.9v-7L8 1.6Z', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
1032
+ h('path', { d: 'M2.6 4.6 8 7.3l5.4-2.7M8 7.3v6.9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
1033
+ )
1034
+ }
1035
+
1036
+ /** One pill. */
1037
+ function Chip(props) {
1038
+ return h('span', { className: 'dsc-chip', 'data-state': props.state ?? 'idle', title: props.title }, props.children)
1039
+ }
1040
+
1041
+ /**
1042
+ * One button.
1043
+ *
1044
+ * `kind` changes colour and `square` changes width — nothing is allowed to
1045
+ * change its own height or font size, which is how a toolbar ends up with
1046
+ * three different button sizes.
1047
+ */
1048
+ function Btn(props) {
1049
+ return h(
1050
+ 'button',
1051
+ {
1052
+ type: 'button',
1053
+ className: 'dsc-btn',
1054
+ 'data-kind': props.kind,
1055
+ 'data-square': props.square === true ? 'true' : undefined,
1056
+ title: props.title,
1057
+ disabled: props.disabled === true,
1058
+ onClick: props.onClick,
1059
+ },
1060
+ props.children,
1061
+ )
1062
+ }
1063
+
1064
+ /** A value with a copy button — the fallback path, never the instruction. */
1065
+ function CopyLine(props) {
1066
+ const { t, command } = props
1067
+ /**
1068
+ * When the value is already on screen, showing it again beside the copy button
1069
+ * just prints the same string twice. The button is then the whole control.
1070
+ */
1071
+ const hideCommand = props.hideCommand === true
1072
+ const [copied, setCopied] = React.useState(false)
1073
+ React.useEffect(() => {
1074
+ if (!copied) return undefined
1075
+ const timer = globalThis.setTimeout(() => {
1076
+ setCopied(false)
1077
+ }, 2_000)
1078
+ return () => {
1079
+ globalThis.clearTimeout(timer)
1080
+ }
1081
+ }, [copied])
1082
+ return h(
1083
+ 'div',
1084
+ { className: 'dsc-cmd' },
1085
+ hideCommand ? null : h('code', null, command),
1086
+ h(
1087
+ Btn,
1088
+ {
1089
+ kind: 'quiet',
1090
+ onClick: () => {
1091
+ // Clipboard access needs a secure context; the loopback GUI is one,
1092
+ // but a refusal must not be reported as a successful copy.
1093
+ const write = globalThis.navigator?.clipboard?.writeText
1094
+ if (typeof write !== 'function') return
1095
+ void write.call(globalThis.navigator.clipboard, command).then(() => setCopied(true)).catch(() => setCopied(false))
1096
+ },
1097
+ },
1098
+ copied ? t('action.copied') : t('action.copy'),
1099
+ ),
1100
+ )
1101
+ }
1102
+
1103
+ /**
1104
+ * The browser sign-in, driven from the panel.
1105
+ *
1106
+ * The Host runs `gh auth login --web` and streams back the one-time code, so
1107
+ * the user's whole job is: click, open the linked page, type the code. No
1108
+ * terminal — and the plugin still never sees the token, because `gh` writes it.
1109
+ */
1110
+ function SignIn(props) {
1111
+ const { t, status, onChanged } = props
1112
+ const [attempt, setAttempt] = React.useState(status?.auth ?? { state: 'idle' })
1113
+ const [busy, setBusy] = React.useState(false)
1114
+ const [error, setError] = React.useState(null)
1115
+ const gh = status?.gh ?? {}
1116
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
1117
+
1118
+ const start = React.useCallback(
1119
+ async (mode) => {
1120
+ setBusy(true)
1121
+ setError(null)
1122
+ const result = await postJson('/auth-start', { mode, scopes: mode === 'refresh' ? missing : [] }, ACTION_TIMEOUT_MS)
1123
+ setBusy(false)
1124
+ if (!result.ok) {
1125
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1126
+ return
1127
+ }
1128
+ if (!result.response.ok || result.payload?.ok !== true) {
1129
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1130
+ return
1131
+ }
1132
+ setAttempt(result.payload.value)
1133
+ },
1134
+ [missing, t],
1135
+ )
1136
+
1137
+ // Poll only while an attempt is pending: waking up every two seconds for the
1138
+ // rest of the session would be work nobody asked for.
1139
+ const pending = attempt.state === 'pending' || attempt.state === 'running'
1140
+ /**
1141
+ * `onChanged` is passed as an inline arrow, so its identity changes on every
1142
+ * render of the parent. Keeping it in a dependency array therefore tore this
1143
+ * interval down and restarted it whenever anything re-rendered — and a timer
1144
+ * that is reset more often than it fires never fires at all, which is how the
1145
+ * one-time code could sit unread in the Host while the panel showed nothing.
1146
+ * A ref carries the latest callback without being a dependency.
1147
+ */
1148
+ const onChangedRef = React.useRef(onChanged)
1149
+ onChangedRef.current = onChanged
1150
+ React.useEffect(() => {
1151
+ if (!pending) return undefined
1152
+ let cancelled = false
1153
+ /* Ask once immediately: the code usually exists by the time /auth-start
1154
+ answers, so waiting a full interval just to see it is pure latency. */
1155
+ const poll = async () => {
1156
+ const result = await postJson('/auth-state', {}, STATUS_TIMEOUT_MS)
1157
+ if (cancelled || !result.ok || result.payload?.ok !== true) return
1158
+ const next = result.payload.value
1159
+ setAttempt(next)
1160
+ if (next.state === 'succeeded') onChangedRef.current()
1161
+ }
1162
+ void poll()
1163
+ const timer = globalThis.setInterval(() => { void poll() }, AUTH_POLL_MS)
1164
+ return () => {
1165
+ cancelled = true
1166
+ globalThis.clearInterval(timer)
1167
+ }
1168
+ }, [pending])
1169
+
1170
+ const cancel = React.useCallback(async () => {
1171
+ await postJson('/auth-cancel', {}, STATUS_TIMEOUT_MS)
1172
+ setAttempt({ state: 'idle' })
1173
+ }, [])
1174
+
1175
+ const code = typeof attempt.code === 'string' && attempt.code !== '' ? attempt.code : null
1176
+ const url = typeof attempt.url === 'string' && attempt.url !== '' ? attempt.url : 'https://github.com/login/device'
1177
+
1178
+ /**
1179
+ * Nothing left to do.
1180
+ *
1181
+ * Once the credential carries every scope the panel needs, this component has
1182
+ * no button to offer and no status worth reporting. Returning `null` retires
1183
+ * it: leaving a redundant "sign in" button, or the remains of "authorized,
1184
+ * reading the account…", made a finished login look stuck — and it stayed
1185
+ * that way until something else happened to re-render the block.
1186
+ */
1187
+ if (gh.available === true && gh.authenticated === true && missing.length === 0 && !pending) return null
1188
+
1189
+ return h(
1190
+ 'div',
1191
+ { className: 'dsc-sub' },
1192
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, error) : null,
1193
+ pending
1194
+ ? h(
1195
+ React.Fragment,
1196
+ null,
1197
+ h('span', { className: 'dsc-setup-strong' }, code === null ? t('setup.requestingCode') : t('setup.codeHint')),
1198
+ h(
1199
+ 'div',
1200
+ { className: 'dsc-code' },
1201
+ h('span', { className: 'dsc-code-value' }, code ?? '····-····'),
1202
+ /* Opening the page before the code exists is the reported failure:
1203
+ the browser shows GitHub's device page and there is nothing to
1204
+ type. The button therefore stays disabled until there is a code. */
1205
+ /* Always usable: the device page's address is static, so opening it
1206
+ costs nothing and waiting for the code to enable it was the reason
1207
+ nothing at all appeared to happen. */
1208
+ h(Btn, { kind: 'primary', onClick: () => globalThis.open(url, '_blank', 'noopener,noreferrer') }, t('action.openDevicePage')),
1209
+ code !== null ? h(CopyLine, { t, command: code, hideCommand: true }) : null,
1210
+ ),
1211
+ h('span', null, code === null ? t('setup.codePending') : t('setup.waiting')),
1212
+ /* Advisory, never a refusal: the probe cannot see a proxy that gh
1213
+ might be using, so this reports a likely cause without stopping
1214
+ an attempt that could still succeed. */
1215
+ pending && attempt.reachable === false
1216
+ ? h(
1217
+ 'span',
1218
+ { className: 'dsc-warn' },
1219
+ h('strong', null, t(attempt.reachabilityStage === 'dns' ? 'setup.dnsTitle' : 'setup.unreachableTitle')),
1220
+ ' — ',
1221
+ t(attempt.reachabilityStage === 'dns' ? 'setup.dns' : 'setup.unreachable'),
1222
+ )
1223
+ : null,
1224
+ /* A stalled attempt keeps running — the code has been observed to
1225
+ arrive once the network recovers — so this reports the wait
1226
+ instead of declaring a failure the user would have to retry. */
1227
+ pending && attempt.stalled === true
1228
+ ? h('span', { className: 'dsc-warn' }, t('setup.stalled', {
1229
+ seconds: String(Math.max(1, Math.round((typeof attempt.waitedMs === 'number' ? attempt.waitedMs : 0) / 1000))),
1230
+ detail: typeof attempt.outputExcerpt === 'string' && attempt.outputExcerpt !== ''
1231
+ ? t('setup.stalledSaid', { text: attempt.outputExcerpt })
1232
+ : (typeof attempt.outputBytes === 'number' && attempt.outputBytes > 0 ? t('setup.stalledQuietOutput') : t('setup.stalledNoOutput')),
1233
+ }))
1234
+ : null,
1235
+ h('div', { className: 'dsc-actions' }, h(Btn, { onClick: cancel }, t('action.cancelSignIn'))),
1236
+ )
1237
+ : h(
1238
+ 'div',
1239
+ { className: 'dsc-actions' },
1240
+ gh.available !== true
1241
+ ? h(Btn, { kind: 'primary', onClick: () => globalThis.open('https://cli.github.com/', '_blank', 'noopener,noreferrer') }, 'cli.github.com')
1242
+ : missing.length > 0
1243
+ ? h(Btn, { kind: 'primary', disabled: busy, onClick: () => void start('refresh') }, t('action.grantScopes'))
1244
+ /*
1245
+ * Open the page from inside the click.
1246
+ *
1247
+ * The address is static and does not depend on the code, and opening
1248
+ * it here is what makes the click keep its promise. It has to happen
1249
+ * synchronously in the handler: a window opened from an effect after
1250
+ * an await is outside the user gesture and browsers block it. gh's
1251
+ * own browser launch does not happen until it has a code — which is
1252
+ * precisely the situation where nothing seems to happen.
1253
+ */
1254
+ : h(Btn, { kind: 'primary', disabled: busy, onClick: () => {
1255
+ globalThis.open('https://github.com/login/device', '_blank', 'noopener,noreferrer')
1256
+ void start('login')
1257
+ } }, t('action.signIn')),
1258
+ h(Btn, { disabled: busy, onClick: onChanged }, t('action.recheck')),
1259
+ ),
1260
+ attempt.state === 'succeeded' ? h('span', null, t('setup.succeeded')) : null,
1261
+ attempt.state === 'expired' ? h('span', { className: 'dsc-warn' }, t('setup.expired')) : null,
1262
+ attempt.state === 'cancelled' ? h('span', null, t('setup.cancelled')) : null,
1263
+ attempt.state === 'failed' ? h('span', { className: 'dsc-warn' }, t('setup.failed', { reason: attempt.message ?? t('run.unknown') })) : null,
1264
+ )
1265
+ }
1266
+
1267
+ /**
1268
+ * The repository list: fetched from GitHub, ticked to register.
1269
+ *
1270
+ * This is what replaced "run `configure.mjs add <repo> --path <absolute dir>`".
1271
+ * The local checkout is looked up from the configured root, so the user ticks a
1272
+ * box and types nothing.
1273
+ */
1274
+ function RepoPicker(props) {
1275
+ const { t, onChanged } = props
1276
+ const [repos, setRepos] = React.useState(null)
1277
+ const [busy, setBusy] = React.useState('')
1278
+ const [error, setError] = React.useState(null)
1279
+ const [filter, setFilter] = React.useState('')
1280
+
1281
+ const load = React.useCallback(async () => {
1282
+ setError(null)
1283
+ setRepos(null)
1284
+ const result = await postJson('/repos-available', { limit: 200 }, OVERVIEW_TIMEOUT_MS)
1285
+ if (!result.ok) {
1286
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1287
+ return
1288
+ }
1289
+ if (!result.response.ok || result.payload?.ok !== true) {
1290
+ // A missing route is a version mismatch, not a failure of the account.
1291
+ setError(isMissingRoute(result.response.status)
1292
+ ? t('stale.text')
1293
+ : t('picker.failed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1294
+ return
1295
+ }
1296
+ setRepos(result.payload.value.repos ?? [])
1297
+ }, [t])
1298
+
1299
+ React.useEffect(() => {
1300
+ void load()
1301
+ }, [load])
1302
+
1303
+ const toggle = React.useCallback(
1304
+ async (entry) => {
1305
+ setBusy(entry.fullName)
1306
+ setError(null)
1307
+ /**
1308
+ * Add with the full `owner/name`: the picker knows it, and it is
1309
+ * self-sufficient — a fresh install has no `owner` configured, and a bare
1310
+ * name would then register something the Host cannot resolve to a slug.
1311
+ * Remove with whatever form is actually stored.
1312
+ */
1313
+ const target = entry.registered && typeof entry.registeredAs === 'string' ? entry.registeredAs : entry.fullName
1314
+ const result = entry.registered
1315
+ ? await postJson('/config-remove', { repo: target }, ACTION_TIMEOUT_MS)
1316
+ : await postJson('/config-add', { repo: target }, ACTION_TIMEOUT_MS)
1317
+ setBusy('')
1318
+ if (!result.ok) {
1319
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1320
+ return
1321
+ }
1322
+ if (!result.response.ok || result.payload?.ok !== true) {
1323
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1324
+ return
1325
+ }
1326
+ setRepos((current) => (current ?? []).map((item) => (item.fullName === entry.fullName ? { ...item, registered: !entry.registered } : item)))
1327
+ onChanged()
1328
+ },
1329
+ [onChanged, t],
1330
+ )
1331
+
1332
+ const needle = filter.trim().toLowerCase()
1333
+ const visible = (repos ?? []).filter((entry) => needle === ''
1334
+ || entry.fullName.toLowerCase().includes(needle)
1335
+ || entry.description.toLowerCase().includes(needle))
1336
+
1337
+ return h(
1338
+ 'div',
1339
+ { className: 'dsc-picker' },
1340
+ h(
1341
+ 'div',
1342
+ { className: 'dsc-picker-head' },
1343
+ h('input', {
1344
+ className: 'dsc-input',
1345
+ type: 'search',
1346
+ value: filter,
1347
+ placeholder: t('picker.search'),
1348
+ onChange: (event) => setFilter(event.target.value),
1349
+ }),
1350
+ h(Btn, { onClick: () => void load() }, t('picker.reload')),
1351
+ ),
1352
+ error !== null ? h('div', { className: 'dsc-warn' }, error) : null,
1353
+ repos === null
1354
+ ? h('div', { className: 'dsc-empty' }, t('picker.loading'))
1355
+ : visible.length === 0
1356
+ ? h('div', { className: 'dsc-empty' }, t('picker.empty'))
1357
+ : h(
1358
+ 'div',
1359
+ { className: 'dsc-picker-list' },
1360
+ visible.map((entry) =>
1361
+ h(
1362
+ 'label',
1363
+ { className: 'dsc-picker-row', key: entry.fullName },
1364
+ h('input', {
1365
+ className: 'dsc-check',
1366
+ type: 'checkbox',
1367
+ checked: entry.registered,
1368
+ disabled: busy !== '',
1369
+ onChange: () => void toggle(entry),
1370
+ }),
1371
+ h('span', { className: 'dsc-name', style: { minWidth: '0' } }, entry.bare),
1372
+ entry.private ? h(Chip, { state: 'idle' }, t('chip.private')) : null,
1373
+ h('span', { className: 'dsc-grow' }, entry.description),
1374
+ entry.localPath !== ''
1375
+ ? h(Chip, { state: 'success', title: entry.localPath }, t('picker.local'))
1376
+ : h(Chip, { state: 'idle' }, t('picker.noLocal')),
1377
+ ),
1378
+ ),
1379
+ ),
1380
+ h('div', { className: 'dsc-hint' }, t('picker.hint')),
1381
+ )
1382
+ }
1383
+
1384
+ /** One repository row, expandable into path, releases, runs and logs. */
1385
+ function RepoRow(props) {
1386
+ const { t, data, busy, onAction, onBump, onCommit, onPublish, onUpdate, npm, onNpmPublish, bili, onBiliBind, onBiliPreview, onBiliAnnounce, logs, onLogs } = props
1387
+ const [open, setOpen] = React.useState(false)
1388
+ /**
1389
+ * A draft release is the panel's one invisible outcome: it exists, it is not
1390
+ * a failure, and it cannot be seen anywhere until it is published. So a row
1391
+ * that has one opens itself — the Publish button is the answer to "why is
1392
+ * this not released", and it should not be behind a click that nobody knows
1393
+ * to make. The effect keys on the tag, so collapsing the row by hand stays
1394
+ * collapsed until the draft state actually changes.
1395
+ */
1396
+ React.useEffect(() => {
1397
+ if (data.draftTag !== null && data.draftTag !== undefined) setOpen(true)
1398
+ }, [data.draftTag])
1399
+ const [confirming, setConfirming] = React.useState(null)
1400
+ /** The bump confirmation is inline, like publishing a draft: it creates a commit. */
1401
+ const [bumping, setBumping] = React.useState(false)
1402
+ /** 提交 asks first, and asks for a message: it writes a commit on this disk. */
1403
+ const [committing, setCommitting] = React.useState(false)
1404
+ const [commitMessage, setCommitMessage] = React.useState('')
1405
+ /** Installing a release rewrites a profile dependency, so it asks first too. */
1406
+ const [updating, setUpdating] = React.useState(false)
1407
+ /** A publish cannot be undone, so it asks first — and 2FA needs somewhere to type. */
1408
+ const [publishing, setPublishing] = React.useState(false)
1409
+ const [otp, setOtp] = React.useState('')
1410
+ const run = data.latestRun
1411
+ const local = data.local ?? { available: false }
1412
+ const dirty = Number.isFinite(local.dirty) ? local.dirty : null
1413
+ const ahead = Number.isFinite(local.ahead) ? local.ahead : null
1414
+ /*
1415
+ * 提交 is two actions in one button, because they are one trap: a release builds
1416
+ * the PUSHED commit, so work that never left the working tree and work that never
1417
+ * left the machine are equally absent from the released package. Changes → commit
1418
+ * and push. Clean but ahead → push. Neither → nothing to do, and the button says
1419
+ * so instead of offering a call that could only fail.
1420
+ */
1421
+ const canCommit = local.available === true && dirty !== null && dirty > 0
1422
+ const canPush = local.available === true && dirty === 0 && ahead !== null && ahead > 0
1423
+ const commitLabel = canPush ? 'action.push' : 'action.commit'
1424
+ const branchName = typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main'
1425
+ const commitExplain = local.available !== true
1426
+ ? t('commit.noCheckout')
1427
+ : canCommit
1428
+ ? t('commit.willCommit', { count: String(dirty) })
1429
+ : canPush
1430
+ ? t('commit.willPush', { count: String(ahead) })
1431
+ : t('commit.nothing')
1432
+ /**
1433
+ * What this profile has installed for this repository's package.
1434
+ *
1435
+ * The Host decides, not the page: the answer depends on the profile this Host
1436
+ * is running from, and a panel that guessed it from the row's version number
1437
+ * would offer "update" where the two copies are the same code and hide it where
1438
+ * they are not (a `link:` checkout and a released tarball can carry the same
1439
+ * version).
1440
+ */
1441
+ const install = data.install ?? null
1442
+ const installState = install === null ? '' : String(install.state ?? '')
1443
+ /**
1444
+ * The one state where installing is the row's business: the profile holds an
1445
+ * older PUBLISHED copy, so the action is the reason the console exists.
1446
+ */
1447
+ const installable = installState === 'update'
1448
+ /**
1449
+ * Installing is possible in three more states, and in none of them does it
1450
+ * belong on the row.
1451
+ *
1452
+ * `checkout` is the normal state on the machine that DEVELOPED the plugin: the
1453
+ * profile points at the working copy, so the row would carry a button whose only
1454
+ * effect is to replace the code being edited with a tarball. The row is for the
1455
+ * work, not for undoing the setup that makes the work possible. `ahead` is worse
1456
+ * than noise — the profile holds something NEWER, so the button offers a
1457
+ * downgrade. `differs` cannot be ordered at all, so it is a shot in the dark.
1458
+ *
1459
+ * They stay in the expansion, as quiet buttons sitting next to the sentence that
1460
+ * says what they would do, because that is still where "does the released
1461
+ * artifact actually install?" gets answered.
1462
+ */
1463
+ const installSecondary = installState === 'checkout' || installState === 'ahead' || installState === 'differs'
1464
+ const installLabel = installState === 'update' ? 'action.update' : 'action.installRelease'
1465
+ const installExplain = (() => {
1466
+ if (install === null) return null
1467
+ const params = {
1468
+ package: String(install.packageName ?? ''),
1469
+ profile: String(install.profile ?? ''),
1470
+ tag: String(install.latestTag ?? ''),
1471
+ spec: String(install.spec ?? ''),
1472
+ installed: String(install.installedVersion ?? '?'),
1473
+ }
1474
+ if (installState === 'checkout') return t('install.checkout', params)
1475
+ if (installState === 'ahead') return t('install.ahead', params)
1476
+ if (installState === 'differs') return t('install.differs', params)
1477
+ if (installState === 'current') return t('install.current', params)
1478
+ if (installState === 'no-release') return t('install.noRelease', params)
1479
+ return t('install.notInstalled', params)
1480
+ })()
1481
+ /**
1482
+ * What npm holds for this package, and why a push is or is not offered.
1483
+ *
1484
+ * Fetched on demand by the page rather than baked into the overview, so it is
1485
+ * `null` until the first answer arrives — and a `null` renders nothing at all,
1486
+ * which is the honest thing to show when the registry has not been asked.
1487
+ */
1488
+ const npmInfo = npm ?? null
1489
+ const npmBlockers = Array.isArray(npmInfo?.blockers) ? npmInfo.blockers : []
1490
+ const npmCanPush = npmInfo !== null && npmInfo.canPublish === true
1491
+ const npmExplain = (() => {
1492
+ if (npmInfo === null) return null
1493
+ const params = {
1494
+ version: String(npmInfo.version ?? '?'),
1495
+ latest: String(npmInfo.latest ?? '—'),
1496
+ reason: String(npmInfo.registryProblem ?? npmInfo.state ?? ''),
1497
+ }
1498
+ if (npmInfo.canPublish === true) {
1499
+ if (npmInfo.state === 'unregistered') return t('npm.state.unregistered', params)
1500
+ if (npmInfo.state === 'published') return t('npm.state.published', params)
1501
+ return t('npm.state.unpublished', params)
1502
+ }
1503
+ /* The first blocker is the one to fix first; the rest are consequences. */
1504
+ const first = npmBlockers[0]
1505
+ const key = `npm.blocked.${first ?? 'registry-unreachable'}`
1506
+ const text = t(key, params)
1507
+ return text === key ? t('npm.state.unknown', params) : text
1508
+ })()
1509
+ /**
1510
+ * Whether releasing the local version is possible, decided by the Host so this
1511
+ * panel and the dispatch route cannot disagree. `blocked` is only ever proven
1512
+ * (the version's release belongs to another commit), never guessed.
1513
+ */
1514
+ const check = data.releaseCheck ?? null
1515
+ const blocked = check !== null && check.state === 'blocked'
1516
+ const state = runState(run)
1517
+
1518
+ /* Only non-zero local signals are shown: a column of "clean" chips is noise,
1519
+ and the row exists to make the exceptions visible. */
1520
+ const chips = []
1521
+ /* A version that is already taken makes 发布 impossible, so the row says so
1522
+ before the button is pressed rather than after a run has failed. */
1523
+ if (blocked) chips.push(h(Chip, { key: 'v', state: 'warn', title: t('release.takenTitle') }, t('chip.versionTaken')))
1524
+ /* An installed copy that is genuinely behind the release is the one update
1525
+ worth a chip; a `link:` checkout is the normal state here and saying so on
1526
+ every row would be a column of noise. */
1527
+ if (installState === 'update' && typeof install?.latestTag === 'string') {
1528
+ chips.push(h(Chip, { key: 'u', state: 'warn', title: installExplain ?? undefined }, t('chip.update', { tag: install.latestTag })))
1529
+ }
1530
+ /*
1531
+ * The npm chip is the whole row-level surface for the second channel: a version
1532
+ * that is on npm is a fact worth seeing, and a version that is not is a job
1533
+ * waiting. Anything else — private, dirty, not signed in — belongs in the
1534
+ * expansion, where there is room to say what to do about it.
1535
+ */
1536
+ if (npmInfo !== null && npmInfo.state === 'published') {
1537
+ chips.push(h(Chip, { key: 'n', state: 'success', title: String(npmInfo.pageUrl ?? '') }, t('npm.chipPublished', { version: String(npmInfo.version ?? '') })))
1538
+ } else if (npmCanPush) {
1539
+ chips.push(h(Chip, { key: 'n', state: 'warn', title: npmExplain ?? undefined }, t('npm.chipPending', { version: String(npmInfo.version ?? '') })))
1540
+ }
1541
+ /*
1542
+ * A published release whose update note has not gone out yet. It is a chip
1543
+ * rather than a sentence because the row is scanned: the answer to "why has
1544
+ * this not been announced" belongs behind the expansion, next to the button.
1545
+ */
1546
+ if (bili?.binding != null && typeof data.publishedTag === 'string' && data.publishedTag !== '') {
1547
+ const announced = Array.isArray(bili.announced) ? bili.announced : []
1548
+ const baselineTag = bili.baseline === null || bili.baseline === undefined ? null : bili.baseline.tag
1549
+ const covered = announced.some((entry) => entry.tag === data.publishedTag) || baselineTag === data.publishedTag || (bili.baseline !== null && baselineTag === null)
1550
+ if (!covered) chips.push(h(Chip, { key: 'bl', state: 'warn' }, t('bili.chip.pending', { tag: data.publishedTag })))
1551
+ }
1552
+ if (local.available === true) {
1553
+ if (Number.isFinite(local.dirty) && local.dirty > 0) chips.push(h(Chip, { key: 'd', state: 'warn' }, t('chip.dirty', { count: String(local.dirty) })))
1554
+ if (Number.isFinite(local.ahead) && local.ahead > 0) chips.push(h(Chip, { key: 'a', state: 'warn' }, `↑${local.ahead}`))
1555
+ if (Number.isFinite(local.behind) && local.behind > 0) chips.push(h(Chip, { key: 'b', state: 'idle' }, `↓${local.behind}`))
1556
+ }
1557
+
1558
+ return h(
1559
+ 'div',
1560
+ { className: 'dsc-item' },
1561
+ h(
1562
+ 'div',
1563
+ { className: 'dsc-row' },
1564
+ h('span', { className: 'dsc-dot', 'data-state': state, title: run === null ? t('state.noRuns') : `${run.workflow} · ${run.conclusion || run.status}` }),
1565
+ h('span', { className: 'dsc-name' }, data.label),
1566
+ /* Registered, but GitHub has not been read yet: say so rather than
1567
+ showing "未发布", which would be a claim the panel cannot make. */
1568
+ data.pending === true
1569
+ ? h(Chip, { state: 'busy' }, t('state.reading'))
1570
+ : data.versionKnown === true && data.version !== null ? h(Chip, { state: 'idle' }, `v${data.version}`) : null,
1571
+ data.pending === true
1572
+ ? null
1573
+ : data.draftTag !== null
1574
+ ? h(Chip, { state: 'warn' }, `${t('chip.draft')} ${data.draftTag}`)
1575
+ : data.published
1576
+ // Name the tag: without a local checkout the panel knows the release
1577
+ // from GitHub alone, and the tag is the fact it actually has.
1578
+ ? h(Chip, { state: 'success' }, data.publishedTag === null ? t('chip.published') : `${t('chip.published')} ${data.publishedTag}`)
1579
+ : h(Chip, { state: 'idle' }, t('chip.unpublished')),
1580
+ chips,
1581
+ h('span', { className: 'dsc-grow' }, data.pending === true ? t('state.reading') : run === null ? t('state.noRuns') : `${run.workflow} · ${duration(run)} · ${stamp(run.createdAt)}`),
1582
+ h(
1583
+ 'div',
1584
+ { className: 'dsc-right' },
1585
+ /* 提交, 构建, 发布 — the order the work happens in. 提交 first because the
1586
+ other two act on what is on GitHub: a release builds the pushed commit, and
1587
+ a build runs the pushed commit, so neither can see work that is still only
1588
+ on this disk. It is never primary: it is the step, not the goal. */
1589
+ local.available === true
1590
+ ? h(Btn, {
1591
+ disabled: busy !== '' || (!canCommit && !canPush),
1592
+ title: commitExplain,
1593
+ onClick: () => {
1594
+ setOpen(true)
1595
+ setCommitting(true)
1596
+ },
1597
+ }, busy === `commit:${data.repo}` ? '…' : t(commitLabel))
1598
+ : null,
1599
+ data.hasBuildWorkflow ? h(Btn, { disabled: busy !== '', title: t('action.build'), onClick: () => onAction('build', data) }, busy === `build:${data.repo}` ? '…' : t('action.build')) : null,
1600
+ data.hasReleaseWorkflow
1601
+ ? blocked && typeof check.nextTag === 'string' && check.nextTag !== ''
1602
+ /* The version is the obstacle, so the button that works is the one
1603
+ that removes it — refusing to dispatch and leaving the user to edit
1604
+ package.json by hand would be the same dead end, one click later. */
1605
+ ? h(Btn, {
1606
+ kind: 'primary',
1607
+ disabled: busy !== '',
1608
+ title: t('action.bumpRelease'),
1609
+ onClick: () => {
1610
+ setOpen(true)
1611
+ setBumping(true)
1612
+ },
1613
+ }, busy === `bump:${data.repo}` ? '…' : t('action.bumpRelease'))
1614
+ : h(Btn, { kind: 'primary', disabled: busy !== '', title: t('action.release'), onClick: () => onAction('release', data) }, busy === `release:${data.repo}` ? '…' : t('action.release'))
1615
+ : null,
1616
+ /* The other half of the loop the console exists for: 发布 cuts the
1617
+ release, this one installs it into the profile that is running. */
1618
+ installable && install?.latestTag
1619
+ ? h(Btn, {
1620
+ disabled: busy !== '',
1621
+ title: installExplain ?? undefined,
1622
+ onClick: () => {
1623
+ setOpen(true)
1624
+ setUpdating(true)
1625
+ },
1626
+ }, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
1627
+ : null,
1628
+ h(Btn, { kind: 'quiet', square: true, onClick: () => setOpen((value) => !value), title: open ? t('action.collapse') : t('action.expand') }, open ? '▴' : '▾'),
1629
+ ),
1630
+ ),
1631
+ open
1632
+ ? h(
1633
+ 'div',
1634
+ { className: 'dsc-detail' },
1635
+ data.localPath === ''
1636
+ ? h('span', null, t('meta.noLocal'))
1637
+ : h('span', { className: 'dsc-mono' }, `${t('meta.path')} ${data.localPath}`),
1638
+ data.problems.length > 0 ? h('div', { className: 'dsc-warn' }, data.problems.join(' · ')) : null,
1639
+
1640
+ /* Why 发布 cannot work, and what would make it work. Both notes are
1641
+ about the same trap: a release builds the pushed commit, so anything
1642
+ only in the working tree or only local is silently absent from it. */
1643
+ blocked
1644
+ ? h('div', { className: 'dsc-warn' }, check.nextTag === null
1645
+ ? t('release.takenNoNext', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built) })
1646
+ : t('release.taken', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built), next: check.nextTag }))
1647
+ : null,
1648
+ local.available === true && Number.isFinite(local.dirty) && local.dirty > 0
1649
+ ? h('div', { className: 'dsc-warn' }, t('release.dirtyNote', { count: String(local.dirty) }))
1650
+ : null,
1651
+ local.available === true && Number.isFinite(local.ahead) && local.ahead > 0
1652
+ ? h('div', { className: 'dsc-warn' }, t('release.aheadNote', { count: String(local.ahead) }))
1653
+ : null,
1654
+ bumping && blocked && typeof check.nextTag === 'string'
1655
+ ? h(
1656
+ 'div',
1657
+ { className: 'dsc-line' },
1658
+ h('span', { className: 'dsc-grow' }, t('confirm.bumpRelease', {
1659
+ from: data.version ?? '?',
1660
+ to: check.nextTag,
1661
+ branch: typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main',
1662
+ })),
1663
+ h(Btn, {
1664
+ kind: 'danger',
1665
+ disabled: busy !== '',
1666
+ onClick: () => {
1667
+ setBumping(false)
1668
+ onBump(data)
1669
+ },
1670
+ }, t('action.bumpRelease')),
1671
+ h(Btn, { kind: 'quiet', onClick: () => setBumping(false) }, t('confirm.cancel')),
1672
+ )
1673
+ : null,
1674
+ /*
1675
+ * 提交 asks first, and asks for a message — it writes a commit on this disk
1676
+ * and pushes it. `git add -A` is the one action in this panel that can
1677
+ * sweep in a file the author never meant to publish, so the list is on
1678
+ * screen above the button: the count alone ("7 changes") could be the three
1679
+ * you meant and four you have never seen.
1680
+ */
1681
+ committing && (canCommit || canPush)
1682
+ ? h(
1683
+ 'div',
1684
+ { className: 'dsc-commit' },
1685
+ canCommit ? h('span', null, t('commit.willCommit', { count: String(dirty) })) : null,
1686
+ canCommit && Array.isArray(local.files) && local.files.length > 0
1687
+ ? h('span', { className: 'dsc-mono dsc-quiet' }, `${local.files.join(' · ')}${dirty > local.files.length ? ` …+${String(dirty - local.files.length)}` : ''}`)
1688
+ : null,
1689
+ canCommit
1690
+ ? h('input', {
1691
+ className: 'dsc-input',
1692
+ type: 'text',
1693
+ value: commitMessage,
1694
+ placeholder: t('commit.messagePlaceholder'),
1695
+ onChange: (event) => setCommitMessage(String(event?.target?.value ?? '')),
1696
+ })
1697
+ : h('span', null, t('confirm.push', { count: String(ahead), branch: branchName })),
1698
+ h(
1699
+ 'div',
1700
+ { className: 'dsc-line' },
1701
+ h(Btn, {
1702
+ kind: 'danger',
1703
+ disabled: busy !== '' || (canCommit && commitMessage.trim() === ''),
1704
+ title: canCommit && commitMessage.trim() === '' ? t('commit.messageRequired') : undefined,
1705
+ onClick: () => {
1706
+ const message = canCommit ? commitMessage.trim() : ''
1707
+ setCommitting(false)
1708
+ setCommitMessage('')
1709
+ onCommit(data, message)
1710
+ },
1711
+ }, t(canCommit ? 'action.commitConfirm' : 'action.pushConfirm')),
1712
+ h(Btn, { kind: 'quiet', onClick: () => setCommitting(false) }, t('confirm.cancel')),
1713
+ ),
1714
+ )
1715
+ : null,
1716
+
1717
+ /* What this profile has installed, and the one action that changes it.
1718
+ Without this the panel could cut a release and never take it: the
1719
+ installed copy is the half of "is it published?" that GitHub cannot
1720
+ answer. */
1721
+ install !== null
1722
+ ? h(
1723
+ 'div',
1724
+ { className: 'dsc-sub' },
1725
+ h('span', null, t('install.title')),
1726
+ h(
1727
+ 'div',
1728
+ { className: 'dsc-line' },
1729
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(install.packageName ?? '')),
1730
+ /* No version chip when none could be read: the explanation
1731
+ below says which copy this is, and an invented "v?" would
1732
+ be a claim the Host did not make. */
1733
+ install.installedVersion === null
1734
+ ? null
1735
+ : h(Chip, { state: installState === 'current' ? 'success' : installState === 'update' ? 'warn' : 'idle' }, `v${String(install.installedVersion)}`),
1736
+ installState === 'checkout' ? h(Chip, { state: 'warn' }, t('chip.checkout')) : null,
1737
+ h('span', { className: 'dsc-grow', title: String(install.spec ?? '') }, install.present === true ? String(install.spec ?? '') : `— ${String(install.profile ?? '')}`),
1738
+ (installable || installSecondary) && install.latestTag
1739
+ ? h(Btn, {
1740
+ kind: installState === 'update' ? 'primary' : 'quiet',
1741
+ disabled: busy !== '',
1742
+ title: installExplain ?? undefined,
1743
+ onClick: () => setUpdating(true),
1744
+ }, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
1745
+ : null,
1746
+ ),
1747
+ h('span', null, installExplain),
1748
+ )
1749
+ : null,
1750
+ /* The second channel. A GitHub Release and an npm version are separate
1751
+ facts about the same package, and this is where they sit next to each
1752
+ other instead of in two browser tabs. */
1753
+ npmInfo !== null
1754
+ ? h(
1755
+ 'div',
1756
+ { className: 'dsc-sub' },
1757
+ h('span', null, t('npm.title')),
1758
+ h(
1759
+ 'div',
1760
+ { className: 'dsc-line' },
1761
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(npmInfo.packageName ?? '')),
1762
+ npmInfo.state === 'published'
1763
+ ? h(Chip, { state: 'success' }, `v${String(npmInfo.version ?? '?')}`)
1764
+ : npmInfo.state === 'unregistered'
1765
+ ? h(Chip, { state: 'warn' }, t('npm.chipUnregistered'))
1766
+ : h(Chip, { state: 'idle' }, `v${String(npmInfo.version ?? '?')}`),
1767
+ npmInfo.latest !== null && npmInfo.latest !== undefined
1768
+ ? h('span', { className: 'dsc-grow' }, `npm latest ${String(npmInfo.latest)}`)
1769
+ : h('span', { className: 'dsc-grow' }, String(npmInfo.pageUrl ?? '')),
1770
+ npmCanPush
1771
+ ? h(Btn, {
1772
+ disabled: busy !== '',
1773
+ title: npmExplain ?? undefined,
1774
+ onClick: () => setPublishing(true),
1775
+ }, busy === `npm:${data.repo}` ? '…' : t('npm.action.publish', { version: String(npmInfo.version ?? '') }))
1776
+ : null,
1777
+ ),
1778
+ h('span', null, npmExplain),
1779
+ /* A draft (or missing) GitHub release on the same version is worth
1780
+ saying out loud: the two channels are about to disagree in public. */
1781
+ npmCanPush && data.publishedTag !== `v${String(npmInfo.version ?? '')}`
1782
+ ? h('span', { className: 'dsc-warn' }, t('npm.githubDraft'))
1783
+ : null,
1784
+ )
1785
+ : null,
1786
+ publishing && npmCanPush
1787
+ ? h(
1788
+ 'div',
1789
+ { className: 'dsc-line' },
1790
+ h('span', { className: 'dsc-grow' }, t('confirm.npmPublish', {
1791
+ package: String(npmInfo.packageName ?? ''),
1792
+ version: String(npmInfo.version ?? ''),
1793
+ registry: String(npmInfo.registry ?? ''),
1794
+ })),
1795
+ h('input', {
1796
+ className: 'dsc-input dsc-otp',
1797
+ type: 'text',
1798
+ inputMode: 'numeric',
1799
+ autoComplete: 'one-time-code',
1800
+ placeholder: t('npm.otpPlaceholder'),
1801
+ title: t('npm.otpHint'),
1802
+ 'aria-label': t('npm.otpLabel'),
1803
+ value: otp,
1804
+ onChange: (event) => setOtp(String(event?.target?.value ?? '').replace(/[^0-9]/g, '').slice(0, 8)),
1805
+ }),
1806
+ h(Btn, {
1807
+ kind: 'danger',
1808
+ disabled: busy !== '',
1809
+ onClick: () => {
1810
+ setPublishing(false)
1811
+ onNpmPublish(data, otp)
1812
+ },
1813
+ }, t('npm.action.confirm')),
1814
+ h(Btn, { kind: 'quiet', onClick: () => setPublishing(false) }, t('confirm.cancel')),
1815
+ )
1816
+ : null,
1817
+
1818
+ /* The confirmation has to appear for every button that can be pressed,
1819
+ including the quiet ones — a secondary action whose confirm dialog was
1820
+ gated on the row's narrower condition would be a dead button. */
1821
+ updating && (installable || installSecondary) && install?.latestTag
1822
+ ? h(
1823
+ 'div',
1824
+ { className: 'dsc-line' },
1825
+ h('span', { className: 'dsc-grow' }, t('confirm.update', {
1826
+ package: String(install.packageName ?? ''),
1827
+ profile: String(install.profile ?? ''),
1828
+ spec: install.present === true ? String(install.spec ?? '') : '—',
1829
+ tag: String(install.latestTag),
1830
+ })),
1831
+ h(Btn, {
1832
+ kind: 'danger',
1833
+ disabled: busy !== '',
1834
+ onClick: () => {
1835
+ setUpdating(false)
1836
+ onUpdate(data)
1837
+ },
1838
+ }, t('action.confirmUpdate')),
1839
+ h(Btn, { kind: 'quiet', onClick: () => setUpdating(false) }, t('confirm.cancel')),
1840
+ )
1841
+ : null,
1842
+
1843
+ /* The third channel, inside the row that owns it: which video carries
1844
+ this repository's update notes, and the button that posts one now. */
1845
+ bili !== null && bili !== undefined
1846
+ ? h(BiliBinding, { t, data, bili, busy, onBind: onBiliBind, onPreview: onBiliPreview, onAnnounce: onBiliAnnounce })
1847
+ : null,
1848
+
1849
+ h(
1850
+ 'div',
1851
+ { className: 'dsc-sub' },
1852
+ h('span', null, `${t('meta.version')} ${data.version ?? '?'}${data.expectedTag === null ? '' : ` · ${t('meta.expectedTag', { tag: data.expectedTag })}`}`),
1853
+ data.releases.length === 0
1854
+ ? h('span', null, t('state.noReleases'))
1855
+ : data.releases.map((release) =>
1856
+ h(
1857
+ 'div',
1858
+ { className: 'dsc-line', key: release.tag },
1859
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, release.tag),
1860
+ release.draft ? h(Chip, { state: 'warn' }, t('chip.draft')) : h(Chip, { state: 'success' }, t('chip.published')),
1861
+ h('span', { className: 'dsc-grow' }, `${stamp(release.createdAt)} · ${t('meta.assets', { count: String(release.assets.length) })}`),
1862
+ release.draft
1863
+ ? confirming === release.tag
1864
+ ? h(
1865
+ React.Fragment,
1866
+ null,
1867
+ h(Btn, { kind: 'danger', disabled: busy !== '', onClick: () => onPublish(release.tag) }, t('action.confirmPublish')),
1868
+ h(Btn, { kind: 'quiet', onClick: () => setConfirming(null) }, t('confirm.cancel')),
1869
+ )
1870
+ : h(Btn, { onClick: () => setConfirming(release.tag) }, t('action.publishDraft'))
1871
+ : null,
1872
+ release.url !== '' ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(release.url, '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
1873
+ ),
1874
+ ),
1875
+ ),
1876
+
1877
+ h(
1878
+ 'div',
1879
+ { className: 'dsc-sub' },
1880
+ h('span', null, t('label.runs')),
1881
+ data.runs.length === 0
1882
+ ? h('span', null, t('state.noRuns'))
1883
+ : data.runs.slice(0, 4).map((entry) =>
1884
+ h(
1885
+ 'div',
1886
+ { className: 'dsc-line', key: String(entry.id) },
1887
+ h(Chip, { state: runState(entry) }, entry.conclusion || entry.status || t('run.unknown')),
1888
+ h('span', { className: 'dsc-grow', title: entry.title }, `${entry.workflow} · ${entry.title}`),
1889
+ h('span', null, duration(entry)),
1890
+ entry.status !== 'completed' && entry.id !== null
1891
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('cancel', data, { runId: entry.id }) }, t('action.cancel'))
1892
+ : null,
1893
+ entry.status === 'completed' && entry.id !== null
1894
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('rerun-failed', data, { runId: entry.id }) }, t('action.rerun'))
1895
+ : null,
1896
+ entry.status === 'completed' && entry.id !== null
1897
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onLogs(data, entry) }, logs !== null && logs.runId === entry.id ? t('action.hideLogs') : t('action.logs'))
1898
+ : null,
1899
+ ),
1900
+ ),
1901
+ ),
1902
+
1903
+ logs !== null && logs.repo === data.repo
1904
+ ? h(
1905
+ 'div',
1906
+ null,
1907
+ h('pre', { className: 'dsc-logs' }, logs.lines.join('\n')),
1908
+ logs.truncated ? h('span', null, t('state.truncated', { lines: String(logs.lines.length) })) : null,
1909
+ )
1910
+ : null,
1911
+ )
1912
+ : null,
1913
+ )
1914
+ }
1915
+
1916
+ /** Shared data plumbing for the panel and the settings page. */
1917
+ function useConsoleState() {
1918
+ const [status, setStatus] = React.useState(null)
1919
+ const [overview, setOverview] = React.useState(null)
1920
+ const [npm, setNpm] = React.useState(null)
1921
+ const [bili, setBili] = React.useState(null)
1922
+ const [error, setError] = React.useState(null)
1923
+
1924
+ const loadStatus = React.useCallback(async () => {
1925
+ const result = await postJson('/status', {}, STATUS_TIMEOUT_MS)
1926
+ if (!result.ok) {
1927
+ setError(result.aborted ? 'timeout' : 'host')
1928
+ return null
1929
+ }
1930
+ if (result.response.ok && result.payload?.ok === true) {
1931
+ setStatus(result.payload.value ?? null)
1932
+ setError(null)
1933
+ return result.payload.value
1934
+ }
1935
+ setError(isMissingRoute(result.response.status) ? 'stale-host' : (result.payload?.message ?? `HTTP ${String(result.response.status)}`))
1936
+ return null
1937
+ }, [])
1938
+
1939
+ /**
1940
+ * `silent` is what the poll uses: a failed BACKGROUND request must never
1941
+ * clear the last known state nor raise a banner the user did not ask for.
1942
+ */
1943
+ const loadOverview = React.useCallback(async (options) => {
1944
+ const result = await postJson('/overview', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
1945
+ if (!result.ok) {
1946
+ if (options?.silent !== true) setError(result.aborted ? 'timeout' : 'host')
1947
+ return
1948
+ }
1949
+ if (result.response.ok && result.payload?.ok === true) {
1950
+ setOverview(result.payload.value ?? null)
1951
+ if (options?.silent !== true) setError(null)
1952
+ }
1953
+ }, [])
1954
+
1955
+ /**
1956
+ * The npm registry's view of every configured package.
1957
+ *
1958
+ * Deliberately NOT part of the overview. The overview is polled every thirty
1959
+ * seconds and answers from GitHub; this costs one HTTPS request per repository
1960
+ * plus a `whoami`, and its answer changes when someone publishes — so it is
1961
+ * fetched when the panel opens, after a publish, and when asked.
1962
+ *
1963
+ * A failure is reported as "no npm information", never as an empty one: a chip
1964
+ * that says "not on npm" because the registry could not be reached would be a
1965
+ * statement the Host never made.
1966
+ */
1967
+ const loadNpm = React.useCallback(async (options) => {
1968
+ const result = await postJson('/npm-status', { force: options?.force === true }, NPM_TIMEOUT_MS)
1969
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
1970
+ setNpm(result.payload.value ?? null)
1971
+ }, [])
1972
+
1973
+ /**
1974
+ * The Bilibili side: which credential, which video, and what was said.
1975
+ *
1976
+ * Also not polled, and for a second reason on top of npm's: the answer
1977
+ * includes a video lookup and an account lookup, and the panel asks for it
1978
+ * when it opens, after an action, and when a person asks. The Host caches
1979
+ * both answers (`bilibiliVerifyTtlMs`), so even a refresh loop is bounded.
1980
+ */
1981
+ const loadBili = React.useCallback(async (options) => {
1982
+ const result = await postJson('/bilibili-status', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
1983
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
1984
+ setBili(result.payload.value ?? null)
1985
+ }, [])
1986
+
1987
+ return { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili }
1988
+ }
1989
+
1990
+ /** Human-readable text for the transport and version failures. */
1991
+ function errorText(t, code) {
1992
+ if (code === 'timeout') return t('state.timeout')
1993
+ if (code === 'host') return t('state.hostGone')
1994
+ if (code === 'stale-host') return t('stale.text')
1995
+ return code
1996
+ }
1997
+
1998
+ /**
1999
+ * Whether a response means "this Host half does not have that route".
2000
+ *
2001
+ * The local web server answers an unmounted `/api/*` path with 401; a Host
2002
+ * that mounts a different set can also 404. Both are version mismatches first
2003
+ * and request failures second, so they get the version message.
2004
+ */
2005
+ function isMissingRoute(status) {
2006
+ return status === 401 || status === 404
2007
+ }
2008
+
2009
+ /** Whether a sign-in or a scope grant is still required. */
2010
+ function needsAccountSetup(gh) {
2011
+ if (gh.available !== true) return true
2012
+ if (gh.authenticated !== true) return true
2013
+ return Array.isArray(gh.missingScopes) && gh.missingScopes.length > 0
2014
+ }
2015
+
2016
+ /** The console panel: compact rows, and the picker behind one button. */
2017
+ function ConsolePage(props) {
2018
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
2019
+ const { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili } = useConsoleState()
2020
+ const [busy, setBusy] = React.useState('')
2021
+ const [notice, setNotice] = React.useState(null)
2022
+ const [logs, setLogs] = React.useState(null)
2023
+ /**
2024
+ * The npm token being typed, held only until the request that writes it.
2025
+ *
2026
+ * It lives in page state rather than anywhere durable on purpose: the Host
2027
+ * writes it to the user-level `.npmrc` that npm itself reads, and clears it from
2028
+ * the field the moment that answers.
2029
+ */
2030
+ const [npmToken, setNpmToken] = React.useState('')
2031
+ /** Set when a pasted token was refused, which is when the checklist is read. */
2032
+ const [npmTokenRejected, setNpmTokenRejected] = React.useState(false)
2033
+ /** The classified outcome of the last publish: `otp-required`, `E403`, … */
2034
+ const [npmFailure, setNpmFailure] = React.useState(null)
2035
+ /**
2036
+ * The update that just landed and the restart it needs.
2037
+ *
2038
+ * Held as state rather than folded into the notice, because a notice is a
2039
+ * sentence that expires and this is a question with two buttons. It is also
2040
+ * the one thing here that cannot be discovered later: an updated plugin looks
2041
+ * identical until the app restarts, so "you are not running the new code yet"
2042
+ * has to be on screen while the user is still looking.
2043
+ */
2044
+ const [restartPrompt, setRestartPrompt] = React.useState(null)
2045
+ /**
2046
+ * Which view this panel opens in.
2047
+ *
2048
+ * A first visit is about setting the tool up, so it opens on the repository
2049
+ * list; afterwards the last choice wins. Without this, re-entering the panel
2050
+ * dropped back to the setup view every time — even for someone who had just
2051
+ * finished configuring it and wanted to see the repositories.
2052
+ */
2053
+ const [managing, setManaging] = React.useState(() => {
2054
+ try {
2055
+ const stored = globalThis.localStorage?.getItem('dsh-cicd.managing')
2056
+ if (stored === 'true') return true
2057
+ if (stored === 'false') return false
2058
+ } catch {
2059
+ /* storage can be unavailable; the default below is fine */
2060
+ }
2061
+ return true
2062
+ })
2063
+ React.useEffect(() => {
2064
+ try {
2065
+ globalThis.localStorage?.setItem('dsh-cicd.managing', String(managing))
2066
+ } catch {
2067
+ /* a preference that cannot be stored is not worth failing over */
2068
+ }
2069
+ }, [managing])
2070
+
2071
+ React.useEffect(() => {
2072
+ void loadStatus()
2073
+ void loadOverview({ force: true })
2074
+ // Once, not on the poll: the npm answer changes when someone publishes.
2075
+ void loadNpm({})
2076
+ // And likewise: a Bilibili answer includes an account and a video lookup.
2077
+ void loadBili({})
2078
+ }, [loadStatus, loadOverview, loadNpm, loadBili])
2079
+
2080
+ const pollSeconds = status?.config?.pollSeconds ?? 30
2081
+ React.useEffect(() => {
2082
+ const timer = globalThis.setInterval(() => {
2083
+ void loadOverview({ silent: true })
2084
+ }, Math.max(10, pollSeconds) * 1000)
2085
+ return () => {
2086
+ globalThis.clearInterval(timer)
2087
+ }
2088
+ }, [loadOverview, pollSeconds])
2089
+
2090
+ /**
2091
+ * Refresh when the panel comes back into view.
2092
+ *
2093
+ * This panel can stay mounted while another one is selected, so re-entering it
2094
+ * used to show whatever had last been rendered — up to a full poll interval
2095
+ * old, which reads as "it still shows the old screen".
2096
+ */
2097
+ React.useEffect(() => {
2098
+ const onVisible = () => {
2099
+ if (document.visibilityState === 'visible') void loadOverview({ force: true })
2100
+ }
2101
+ document.addEventListener('visibilitychange', onVisible)
2102
+ globalThis.addEventListener?.('focus', onVisible)
2103
+ return () => {
2104
+ document.removeEventListener('visibilitychange', onVisible)
2105
+ globalThis.removeEventListener?.('focus', onVisible)
2106
+ }
2107
+ }, [loadOverview])
2108
+
2109
+ React.useEffect(() => {
2110
+ if (notice === null) return undefined
2111
+ const timer = globalThis.setTimeout(() => {
2112
+ setNotice(null)
2113
+ }, NOTICE_TTL_MS)
2114
+ return () => {
2115
+ globalThis.clearTimeout(timer)
2116
+ }
2117
+ }, [notice])
2118
+
2119
+ /** Run one mutating route, then refresh so the panel shows its effect. */
2120
+ const run = React.useCallback(
2121
+ async (key, path, body, successKey, params) => {
2122
+ setBusy(key)
2123
+ setNotice(null)
2124
+ setError(null)
2125
+ const result = await postJson(path, body, ACTION_TIMEOUT_MS)
2126
+ setBusy('')
2127
+ if (!result.ok) {
2128
+ setError(result.aborted ? 'timeout' : 'host')
2129
+ return
2130
+ }
2131
+ if (!result.response.ok || result.payload?.ok !== true) {
2132
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2133
+ return
2134
+ }
2135
+ setNotice(t(successKey, params))
2136
+ await loadOverview({ force: true })
2137
+ },
2138
+ [loadOverview, setError, t],
2139
+ )
2140
+
2141
+ const onAction = React.useCallback(
2142
+ (kind, data, extra) => {
2143
+ if (kind === 'build') {
2144
+ const workflow = status?.config?.buildWorkflow ?? 'ci.yml'
2145
+ void run(`build:${data.repo}`, '/dispatch', { repo: data.repo, workflow }, 'state.dispatched', { workflow, repo: data.repo })
2146
+ return
2147
+ }
2148
+ if (kind === 'release') {
2149
+ void run(`release:${data.repo}`, '/dispatch', { repo: data.repo, workflow: status?.config?.releaseWorkflow ?? 'release.yml', inputs: { draft: 'true' } }, 'state.releaseDispatched', { repo: data.repo })
2150
+ return
2151
+ }
2152
+ void run(`${kind}:${data.repo}`, '/run-action', { repo: data.repo, runId: extra?.runId, action: kind }, kind === 'cancel' ? 'state.cancelled' : 'state.rerun', {})
2153
+ },
2154
+ [run, status],
2155
+ )
2156
+
2157
+ const onPublish = React.useCallback(
2158
+ (repo, tag) => {
2159
+ void run(`publish:${repo}`, '/release-action', { repo, tag, action: 'publish' }, 'state.published', { tag })
2160
+ },
2161
+ [run],
2162
+ )
2163
+
2164
+ /**
2165
+ * Bump the version, push it, then dispatch the release.
2166
+ *
2167
+ * One action rather than two buttons because neither half stands alone: a bump
2168
+ * with no release leaves a commit nobody asked for, and a release with no bump
2169
+ * is the run that could only fail. The new version is the Host's own
2170
+ * arithmetic — the number in the confirmation is the number that gets written —
2171
+ * and a failed bump reports why without dispatching anything.
2172
+ */
2173
+ const onBump = React.useCallback(
2174
+ async (data) => {
2175
+ const key = `bump:${data.repo}`
2176
+ setBusy(key)
2177
+ setNotice(null)
2178
+ setError(null)
2179
+ const bumped = await postJson('/version-bump', { repo: data.repo, release: 'patch' }, ACTION_TIMEOUT_MS)
2180
+ if (!bumped.ok) {
2181
+ setBusy('')
2182
+ setError('timeout')
2183
+ return
2184
+ }
2185
+ if (!bumped.response.ok || bumped.payload?.ok !== true) {
2186
+ setBusy('')
2187
+ setError(t('state.actionFailed', { reason: releaseFailure(t, bumped.payload, bumped.response) }))
2188
+ await loadOverview({ force: true })
2189
+ return
2190
+ }
2191
+ const value = bumped.payload.value ?? {}
2192
+ const dispatched = await postJson('/dispatch', {
2193
+ repo: data.repo,
2194
+ workflow: status?.config?.releaseWorkflow ?? 'release.yml',
2195
+ inputs: { draft: 'true' },
2196
+ }, ACTION_TIMEOUT_MS)
2197
+ setBusy('')
2198
+ if (!dispatched.ok || !dispatched.response.ok || dispatched.payload?.ok !== true) {
2199
+ setError(t('state.actionFailed', { reason: releaseFailure(t, dispatched.payload, dispatched.response) }))
2200
+ await loadOverview({ force: true })
2201
+ return
2202
+ }
2203
+ setNotice(t('state.releaseBumped', {
2204
+ from: String(value.from ?? '?'),
2205
+ tag: String(value.tag ?? ''),
2206
+ branch: String(value.branch ?? ''),
2207
+ }))
2208
+ await loadOverview({ force: true })
2209
+ },
2210
+ [loadOverview, setError, status, t],
2211
+ )
2212
+
2213
+ /**
2214
+ * Commit the working tree, and push it.
2215
+ *
2216
+ * The step 构建 and 发布 both depend on and neither can substitute for: a workflow
2217
+ * runs the pushed commit, so work that is still only on this disk is invisible to
2218
+ * both of them. `message === ''` means "push what is already committed".
2219
+ */
2220
+ const onCommit = React.useCallback(
2221
+ async (data, message) => {
2222
+ const key = `commit:${data.repo}`
2223
+ setBusy(key)
2224
+ setNotice(null)
2225
+ setError(null)
2226
+ const result = await postJson('/commit', { repo: data.repo, message }, ACTION_TIMEOUT_MS)
2227
+ setBusy('')
2228
+ if (!result.ok) {
2229
+ setError('timeout')
2230
+ return
2231
+ }
2232
+ if (!result.response.ok || result.payload?.ok !== true) {
2233
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2234
+ await loadOverview({ force: true })
2235
+ return
2236
+ }
2237
+ const value = result.payload.value ?? {}
2238
+ const branch = String(value.branch ?? '')
2239
+ setNotice(value.committed === true
2240
+ ? t('state.committed', { count: String(Array.isArray(value.files) ? value.files.length : 0), branch })
2241
+ : t('state.pushed', { count: String(value.carried ?? 0), branch }))
2242
+ await loadOverview({ force: true })
2243
+ },
2244
+ [loadOverview, setError, t],
2245
+ )
2246
+
2247
+ /**
2248
+ * Install the release into this profile, then ask about the restart.
2249
+ *
2250
+ * Deliberately not routed through `run`: that helper reports success with one
2251
+ * sentence and refreshes, and this action's result is a question. The Host is
2252
+ * the one that says whether a restart is needed (`restartRequired`), so the
2253
+ * prompt follows the answer instead of assuming it.
2254
+ */
2255
+ const onUpdate = React.useCallback(
2256
+ async (data) => {
2257
+ const key = `update:${data.repo}`
2258
+ const tag = String(data?.install?.latestTag ?? '')
2259
+ setBusy(key)
2260
+ setNotice(null)
2261
+ setError(null)
2262
+ setRestartPrompt(null)
2263
+ const result = await postJson('/update', { repo: data.repo }, UPDATE_TIMEOUT_MS)
2264
+ setBusy('')
2265
+ if (!result.ok) {
2266
+ setError(result.aborted ? 'timeout' : 'host')
2267
+ return
2268
+ }
2269
+ if (!result.response.ok || result.payload?.ok !== true) {
2270
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2271
+ await loadOverview({ force: true })
2272
+ return
2273
+ }
2274
+ const value = result.payload.value ?? {}
2275
+ const builds = Array.isArray(value.pendingBuilds) ? value.pendingBuilds.length : 0
2276
+ /*
2277
+ * `changed: false` is the honest answer when the profile already pointed at
2278
+ * exactly this tarball: nothing was written, so nothing needs restarting,
2279
+ * and saying "已从 X 换成 Y" would describe a write that did not happen.
2280
+ */
2281
+ setNotice(value.changed === false
2282
+ ? t('state.updatedSame', { package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
2283
+ : t('state.updated', {
2284
+ package: String(value.packageName ?? data.repo),
2285
+ from: value.from === null || value.from === undefined ? '—' : `v${String(value.from)}`,
2286
+ tag: String(value.tag ?? tag),
2287
+ }) + (builds > 0 ? ` ${t('state.updatedBuilds', { count: String(builds) })}` : ''))
2288
+ if (value.restartRequired === true) {
2289
+ setRestartPrompt({ package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
2290
+ }
2291
+ await loadOverview({ force: true })
2292
+ },
2293
+ [loadOverview, setError, t],
2294
+ )
2295
+
2296
+ /**
2297
+ * Ask the one-click restart plugin to restart DSH.
2298
+ *
2299
+ * The Host forwards this to `/api/dsh-restart/restart` on its own authority, so
2300
+ * a missing restart plugin comes back as a named 501 rather than a bare
2301
+ * failure — and this page never talks to another plugin directly.
2302
+ */
2303
+ const onRestart = React.useCallback(async () => {
2304
+ setBusy('restart')
2305
+ setError(null)
2306
+ const result = await postJson('/restart', {}, ACTION_TIMEOUT_MS)
2307
+ setBusy('')
2308
+ if (!result.ok) {
2309
+ setError(result.aborted ? 'timeout' : 'host')
2310
+ return
2311
+ }
2312
+ if (!result.response.ok || result.payload?.ok !== true) {
2313
+ setError(t('restart.unavailable', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2314
+ return
2315
+ }
2316
+ setRestartPrompt(null)
2317
+ setNotice(t('restart.scheduled'))
2318
+ }, [setError, t])
2319
+
2320
+ /**
2321
+ * Push one package to npm.
2322
+ *
2323
+ * The one-time password is passed through and then thrown away, and every
2324
+ * refusal comes back as the Host's own sentence — `private-package`,
2325
+ * `dirty-tree` and `already-published` are three different fixes and the panel
2326
+ * does not try to paraphrase them.
2327
+ */
2328
+ const onNpmPublish = React.useCallback(
2329
+ async (data, otp) => {
2330
+ const key = `npm:${data.repo}`
2331
+ setBusy(key)
2332
+ setNotice(null)
2333
+ setError(null)
2334
+ const result = await postJson('/npm-publish', { repo: data.repo, otp: String(otp ?? '') }, UPDATE_TIMEOUT_MS)
2335
+ setBusy('')
2336
+ if (!result.ok) {
2337
+ setError(result.aborted ? 'timeout' : 'host')
2338
+ return
2339
+ }
2340
+ if (!result.response.ok || result.payload?.ok !== true) {
2341
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2342
+ /* The named outcome is what turns npm's own words into a next step. */
2343
+ setNpmFailure(String(result.payload?.code ?? 'unknown'))
2344
+ await loadNpm({ force: true })
2345
+ return
2346
+ }
2347
+ setNpmFailure(null)
2348
+ const value = result.payload.value ?? {}
2349
+ setNotice(t(value.wasUnregistered === true ? 'state.npmFirstPublish' : 'state.npmPublished', {
2350
+ package: String(value.packageName ?? data.repo),
2351
+ version: String(value.version ?? ''),
2352
+ registry: String(value.registry ?? ''),
2353
+ }))
2354
+ await loadNpm({ force: true })
2355
+ },
2356
+ [loadNpm, setError, t],
2357
+ )
2358
+
2359
+ /**
2360
+ * Write an npm token where npm itself reads it, and prove it works.
2361
+ *
2362
+ * The promise the panel makes about credentials is the one it already keeps for
2363
+ * `gh`: it stores none. The token goes into the user-level `.npmrc` — the same
2364
+ * file `npm login` writes — and is cleared from the field as soon as the Host
2365
+ * answers, so it is not left sitting in a page that stays open for hours.
2366
+ */
2367
+ const onNpmLogin = React.useCallback(async () => {
2368
+ setBusy('npm-login')
2369
+ setNotice(null)
2370
+ setError(null)
2371
+ const result = await postJson('/npm-login', { token: npmToken }, ACTION_TIMEOUT_MS)
2372
+ setBusy('')
2373
+ setNpmToken('')
2374
+ if (!result.ok) {
2375
+ setError(result.aborted ? 'timeout' : 'host')
2376
+ return
2377
+ }
2378
+ if (!result.response.ok || result.payload?.ok !== true) {
2379
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2380
+ setNpmTokenRejected(result.payload?.code === 'token-rejected')
2381
+ await loadNpm({ force: true })
2382
+ return
2383
+ }
2384
+ setNpmTokenRejected(false)
2385
+ const value = result.payload.value ?? {}
2386
+ setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
2387
+ await loadNpm({ force: true })
2388
+ }, [loadNpm, npmToken, setError, t])
2389
+
2390
+ const onLogs = React.useCallback(async (data, entry) => {
2391
+ if (logs !== null && logs.repo === data.repo && logs.runId === entry.id) {
2392
+ setLogs(null)
2393
+ return
2394
+ }
2395
+ const result = await postJson('/logs', { repo: data.repo, runId: entry.id }, ACTION_TIMEOUT_MS)
2396
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) {
2397
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? 'logs unavailable' }))
2398
+ return
2399
+ }
2400
+ setLogs({ repo: data.repo, runId: entry.id, lines: result.payload.value?.lines ?? [], truncated: result.payload.value?.truncated === true })
2401
+ }, [logs, setError, t])
2402
+
2403
+ /**
2404
+ * Bind or unbind a repository's video.
2405
+ *
2406
+ * `auto` is carried through on every save so that toggling the switch does not
2407
+ * silently re-enable a binding someone deliberately made manual — the Host
2408
+ * takes the whole binding on each write, and the panel sends the whole binding.
2409
+ */
2410
+ const onBiliBind = React.useCallback(
2411
+ async (data, bvid, auto) => {
2412
+ const key = `bili-bind:${data.repo}`
2413
+ setBusy(key)
2414
+ setNotice(null)
2415
+ setError(null)
2416
+ const result = await postJson('/bilibili-bind', { repo: data.repo, bvid: String(bvid ?? ''), auto: auto !== false }, ACTION_TIMEOUT_MS)
2417
+ setBusy('')
2418
+ if (!result.ok) {
2419
+ setError(result.aborted ? 'timeout' : 'host')
2420
+ return
2421
+ }
2422
+ if (!result.response.ok || result.payload?.ok !== true) {
2423
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2424
+ await loadBili({ force: true })
2425
+ return
2426
+ }
2427
+ const value = result.payload.value ?? {}
2428
+ if (String(value.bvid ?? '') === '') {
2429
+ setNotice(t('bili.unbound.done', { repo: data.repo }))
2430
+ } else {
2431
+ const baseline = value.baseline
2432
+ ? t('bili.bind.baseline', { tag: String(value.baseline) })
2433
+ : (value.note ? t('bili.bind.baselineUnknown') : '')
2434
+ setNotice(t('bili.bind.done', { bvid: String(value.bvid), baseline }))
2435
+ }
2436
+ await loadBili({ force: true })
2437
+ await loadOverview({ force: true })
2438
+ },
2439
+ [loadBili, loadOverview, setError, t],
2440
+ )
2441
+
2442
+ /**
2443
+ * Ask the Host what the comment would say, without sending it.
2444
+ *
2445
+ * A dry run rather than a client-side composition: the template and the
2446
+ * summary are the Host's, and a page that assembled its own preview could
2447
+ * show one sentence and post another.
2448
+ */
2449
+ const onBiliPreview = React.useCallback(
2450
+ async (data, tag) => {
2451
+ const key = `bili-preview:${data.repo}`
2452
+ setBusy(key)
2453
+ setError(null)
2454
+ const result = await postJson('/bilibili-announce', { repo: data.repo, tag: String(tag ?? ''), dryRun: true }, ACTION_TIMEOUT_MS)
2455
+ setBusy('')
2456
+ if (!result.ok) {
2457
+ setError(result.aborted ? 'timeout' : 'host')
2458
+ return null
2459
+ }
2460
+ if (!result.response.ok || result.payload?.ok !== true) {
2461
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2462
+ return null
2463
+ }
2464
+ return result.payload.value ?? null
2465
+ },
2466
+ [setError, t],
2467
+ )
2468
+
2469
+ /** Send the previewed comment, exactly as previewed unless it was edited. */
2470
+ const onBiliAnnounce = React.useCallback(
2471
+ async (data, preview, text, force) => {
2472
+ const key = `bili-announce:${data.repo}`
2473
+ setBusy(key)
2474
+ setNotice(null)
2475
+ setError(null)
2476
+ const result = await postJson('/bilibili-announce', {
2477
+ repo: data.repo,
2478
+ tag: String(preview?.tag ?? ''),
2479
+ text: String(text ?? ''),
2480
+ force: force === true,
2481
+ }, ACTION_TIMEOUT_MS)
2482
+ setBusy('')
2483
+ if (!result.ok) {
2484
+ setError(result.aborted ? 'timeout' : 'host')
2485
+ return null
2486
+ }
2487
+ if (!result.response.ok || result.payload?.ok !== true) {
2488
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2489
+ await loadBili({ force: true })
2490
+ return null
2491
+ }
2492
+ const value = result.payload.value ?? {}
2493
+ setNotice(t('bili.announce.sent', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') }))
2494
+ await loadBili({ force: true })
2495
+ return value
2496
+ },
2497
+ [loadBili, setError, t],
2498
+ )
2499
+
2500
+ /**
2501
+ * The configured list comes from `/status`, the loaded detail from
2502
+ * `/overview`.
2503
+ *
2504
+ * They are not interchangeable. `/status` is cheap and knows the
2505
+ * configuration; `/overview` fans out three GitHub calls per repository and
2506
+ * takes a moment. Deciding "is anything registered?" from the expensive one
2507
+ * meant that any moment before it landed — or any failure — made the panel
2508
+ * announce 还没有登记仓库 to someone who had just registered five, and it hid
2509
+ * the reason at the same time.
2510
+ */
2511
+ const configured = status?.repos ?? []
2512
+ const repos = overview?.repos ?? []
2513
+ const gh = status?.gh ?? {}
2514
+ /**
2515
+ * npm's answers, keyed by the configured repository string.
2516
+ *
2517
+ * The overview and the npm status arrive independently, so a row renders
2518
+ * without its npm chip until the second answer lands — and renders without one
2519
+ * forever if that answer never comes, which is the point.
2520
+ */
2521
+ const npmByRepo = new Map((npm?.repos ?? []).map((entry) => [entry.repo, entry]))
2522
+ const npmAuth = npm?.auth ?? null
2523
+ /**
2524
+ * Bilibili's answers, keyed the same way and arriving independently.
2525
+ *
2526
+ * A row renders without its binding block until this lands, which is the
2527
+ * honest state: the panel has not been told which video this repository
2528
+ * speaks to yet.
2529
+ */
2530
+ const biliByRepo = new Map((bili?.repos ?? []).map((entry) => [entry.repo, entry]))
2531
+ /**
2532
+ * Whether the Bilibili side is blocked on a credential.
2533
+ *
2534
+ * Shown in the panel only when a video is actually bound and the credential
2535
+ * cannot post: a repository nobody bound must not be nagged about a feature
2536
+ * that is not in use, and a bound one that cannot post is exactly the state
2537
+ * where a silent sweep would fail forever.
2538
+ */
2539
+ const biliBlocked = bili !== null
2540
+ && (bili.repos ?? []).some((entry) => entry.binding !== null)
2541
+ && bili.credential?.state !== 'ready'
2542
+ /**
2543
+ * Three states, not a boolean — the Host's `npmAuthState`.
2544
+ *
2545
+ * A granular token is scoped to packages, so `whoami` (a user-level endpoint)
2546
+ * can refuse it while `publish` accepts it. Collapsing that into "not signed in"
2547
+ * would show the four-step guide to someone who just finished step 3.
2548
+ */
2549
+ const npmCredential = npmAuth === null
2550
+ // No answer is not "no credential": rendering the four-step guide here would
2551
+ // claim the machine has nothing to authenticate with, which nobody has said.
2552
+ ? 'unknown'
2553
+ : String(npmAuth.state ?? (npmAuth.loggedIn === true ? 'signed-in' : 'none'))
2554
+ const npmHintText = (() => {
2555
+ if (npmFailure === null) return null
2556
+ const text = t(`npm.hint.${npmFailure}`)
2557
+ return text === `npm.hint.${npmFailure}` ? null : text
2558
+ })()
2559
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
2560
+ const setupNeeded = status !== null && (needsAccountSetup(gh) || configured.length === 0)
2561
+ const draftCount = repos.filter((entry) => entry.draftTag !== null && entry.draftTag !== undefined).length
2562
+ /** Configured repositories whose detail has not arrived yet, shown as placeholders. */
2563
+ const pendingRows = configured.length > repos.length
2564
+ ? configured
2565
+ .filter((entry) => !repos.some((loaded) => loaded.repo === entry.repo))
2566
+ .map((entry) => ({
2567
+ repo: entry.repo,
2568
+ label: entry.label,
2569
+ localPath: entry.localPath,
2570
+ pending: true,
2571
+ version: null,
2572
+ versionKnown: false,
2573
+ expectedTag: null,
2574
+ published: false,
2575
+ publishedTag: null,
2576
+ draftTag: null,
2577
+ latestRun: null,
2578
+ runs: [],
2579
+ releases: [],
2580
+ workflows: [],
2581
+ hasBuildWorkflow: false,
2582
+ hasReleaseWorkflow: false,
2583
+ problems: [],
2584
+ local: { available: false },
2585
+ }))
2586
+ : []
2587
+ // The page and the Host half load independently, so they can disagree.
2588
+ const stale = status !== null && status.protocol !== PROTOCOL
2589
+
2590
+ return h(
2591
+ 'div',
2592
+ { className: 'dsc-root' },
2593
+ h(
2594
+ 'div',
2595
+ { className: 'dsc-bar' },
2596
+ h(
2597
+ 'div',
2598
+ { className: 'dsc-heading' },
2599
+ h('span', { className: 'dsc-title' }, t('title')),
2600
+ h('span', { className: 'dsc-subtitle' }, t('subtitle')),
2601
+ ),
2602
+ h(
2603
+ 'div',
2604
+ { className: 'dsc-meta' },
2605
+ gh.account !== null && gh.account !== undefined ? h(Chip, { state: 'idle' }, `${t('meta.account')} ${gh.account}`) : null,
2606
+ npmAuth !== null && npmAuth.loggedIn === true
2607
+ ? h(Chip, { state: 'idle', title: String(npm.registry ?? '') }, t('npm.account', { account: String(npmAuth.account ?? '') }))
2608
+ : null,
2609
+ overview !== null && typeof overview.fetchedAt === 'string'
2610
+ ? h('span', null, t('meta.updated', { time: stamp(overview.fetchedAt) }), overview.cached === true ? ` (${t('meta.cached')})` : '')
2611
+ : null,
2612
+ h(Btn, { onClick: () => { void loadStatus(); void loadOverview({ force: true }) } }, t('action.refresh')),
2613
+ h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
2614
+ ),
2615
+ ),
2616
+
2617
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
2618
+ /* The next step for a named npm failure. Rendered only when a hint exists for
2619
+ that code, so an unmapped name can never print a dictionary key on screen. */
2620
+ npmHintText !== null ? h('div', { className: 'dsc-warn', role: 'status' }, npmHintText) : null,
2621
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
2622
+ /* The question an update leaves behind. It sits above the list rather than
2623
+ inside the row, because the row is about a repository and this is about
2624
+ the application the user is looking at. */
2625
+ restartPrompt !== null
2626
+ ? h(
2627
+ 'div',
2628
+ { className: 'dsc-setup', role: 'status' },
2629
+ h('span', { className: 'dsc-setup-strong' }, t('restart.ask', { package: restartPrompt.package, tag: restartPrompt.tag })),
2630
+ h(
2631
+ 'div',
2632
+ { className: 'dsc-setup-line' },
2633
+ h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void onRestart() } }, busy === 'restart' ? '…' : t('action.restartNow')),
2634
+ h(Btn, { kind: 'quiet', onClick: () => setRestartPrompt(null) }, t('action.restartLater')),
2635
+ ),
2636
+ )
2637
+ : null,
2638
+ /*
2639
+ * The npm credential, in the operational view.
2640
+ *
2641
+ * The four steps live on their own settings page, because that is where
2642
+ * configuration belongs and where every package's npm state is listed. Here the
2643
+ * panel says which of the two states this machine is in, offers the one control
2644
+ * that resolves it, and points at the guide — so a first-time publisher who
2645
+ * lands on the push button still has a way forward without a terminal, without
2646
+ * four steps of onboarding sitting above a live repository list.
2647
+ */
2648
+ npmCredential === 'none' || npmCredential === 'credential-present'
2649
+ ? h(
2650
+ 'div',
2651
+ { className: 'dsc-setup', role: 'status' },
2652
+ h('span', { className: 'dsc-setup-strong' }, npmCredential === 'none'
2653
+ ? t('npm.notLoggedIn', {
2654
+ command: String(npm?.packageManager?.command ?? 'pnpm'),
2655
+ registry: String(npm?.registry ?? ''),
2656
+ })
2657
+ : t('npm.credentialPresentTitle')),
2658
+ npmCredential === 'none'
2659
+ ? null
2660
+ : h('span', null, t('npm.credentialPresentShort', { registry: String(npm?.registry ?? '') })),
2661
+ /* What `whoami` actually said, because "not signed in" and "this token was
2662
+ revoked" look identical and need different fixes. */
2663
+ npmAuth?.message !== null && npmAuth?.message !== undefined
2664
+ ? h('span', { className: 'dsc-mono' }, String(npmAuth.message))
2665
+ : null,
2666
+ h(
2667
+ 'div',
2668
+ { className: 'dsc-setup-line' },
2669
+ h('input', {
2670
+ className: 'dsc-input',
2671
+ type: 'password',
2672
+ autoComplete: 'off',
2673
+ spellCheck: 'false',
2674
+ placeholder: t('npm.tokenPlaceholder'),
2675
+ 'aria-label': t('npm.tokenPlaceholder'),
2676
+ value: npmToken,
2677
+ onChange: (event) => setNpmToken(String(event?.target?.value ?? '')),
2678
+ }),
2679
+ h(Btn, {
2680
+ kind: 'primary',
2681
+ disabled: busy !== '' || npmToken.trim() === '',
2682
+ onClick: () => { void onNpmLogin() },
2683
+ }, busy === 'npm-login' ? '…' : t('npm.writeToken')),
2684
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void loadNpm({ force: true }) } }, t('npm.recheck')),
2685
+ ),
2686
+ npmTokenRejected
2687
+ ? h(
2688
+ React.Fragment,
2689
+ null,
2690
+ h('span', { className: 'dsc-warn' }, t('npm.rejectedTitle')),
2691
+ h('span', null, t('npm.rejectedCauses')),
2692
+ )
2693
+ : null,
2694
+ h('span', { className: 'dsc-quiet' }, t('npm.moreInSettings')),
2695
+ )
2696
+ : null,
2697
+ stale
2698
+ ? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
2699
+ : null,
2700
+ /*
2701
+ * A bound video with no usable credential is the one Bilibili state the
2702
+ * panel has to raise on its own: every subsequent release would silently
2703
+ * fail to announce, and a failure nobody sees is the shape this project
2704
+ * keeps writing tests against.
2705
+ */
2706
+ biliBlocked && !stale
2707
+ ? h(
2708
+ 'div',
2709
+ { className: 'dsc-setup', role: 'status' },
2710
+ h('span', { className: 'dsc-setup-strong' }, t('bili.needCredential')),
2711
+ h('span', { className: 'dsc-quiet' }, t('bili.openSettings')),
2712
+ h(BilibiliSignIn, {
2713
+ t,
2714
+ bili,
2715
+ busy,
2716
+ setBusy,
2717
+ setNotice,
2718
+ setError,
2719
+ onChanged: () => loadBili({ force: true }),
2720
+ }),
2721
+ )
2722
+ : null,
2723
+ /* The one outcome the panel could report as "nothing happened". Say it, and
2724
+ say what to press, instead of leaving it inside a collapsed row. */
2725
+ draftCount > 0
2726
+ ? h(
2727
+ 'div',
2728
+ { className: 'dsc-warn', role: 'status' },
2729
+ h('strong', null, t('drafts.title', { count: String(draftCount) })),
2730
+ ' — ',
2731
+ t('drafts.text'),
2732
+ )
2733
+ : null,
2734
+
2735
+ setupNeeded && !stale
2736
+ ? h(
2737
+ 'div',
2738
+ { className: 'dsc-setup' },
2739
+ h('span', { className: 'dsc-setup-strong' }, gh.available !== true
2740
+ ? t('setup.needGh')
2741
+ : gh.authenticated !== true
2742
+ ? t('setup.needSignIn')
2743
+ : missing.length > 0
2744
+ ? t('setup.needScopes', { scopes: missing.join(', ') })
2745
+ : t('setup.needRepos')),
2746
+ missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
2747
+ h(SignIn, { t, status, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }),
2748
+ h(
2749
+ 'details',
2750
+ null,
2751
+ h('summary', null, t('setup.advanced')),
2752
+ h(
2753
+ 'div',
2754
+ { className: 'dsc-sub' },
2755
+ h('span', null, t('setup.ghInstall')),
2756
+ h(CopyLine, { t, command: 'winget install --id GitHub.cli' }),
2757
+ h(CopyLine, { t, command: `node "${status?.helper?.configureScript ?? 'scripts/configure.mjs'}" list` }),
2758
+ ),
2759
+ ),
2760
+ )
2761
+ : null,
2762
+
2763
+ managing && !stale ? h(RepoPicker, { t, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }) : null,
2764
+
2765
+ repos.length > 0 || pendingRows.length > 0
2766
+ ? h(
2767
+ 'div',
2768
+ { className: 'dsc-list' },
2769
+ repos.map((data) => h(RepoRow, {
2770
+ key: data.repo,
2771
+ t,
2772
+ data,
2773
+ busy,
2774
+ onAction,
2775
+ onBump: () => onBump(data),
2776
+ onCommit: (row, message) => { void onCommit(row, message) },
2777
+ onPublish: (tag) => onPublish(data.repo, tag),
2778
+ onUpdate: () => { void onUpdate(data) },
2779
+ npm: npmByRepo.get(data.repo) ?? null,
2780
+ onNpmPublish,
2781
+ bili: biliByRepo.get(data.repo) ?? null,
2782
+ onBiliBind,
2783
+ onBiliPreview,
2784
+ onBiliAnnounce,
2785
+ logs,
2786
+ onLogs,
2787
+ })),
2788
+ // Registered but not read yet: show the row with an honest "reading"
2789
+ // state instead of rendering nothing and implying an empty list.
2790
+ pendingRows.map((data) => h(RepoRow, {
2791
+ key: data.repo,
2792
+ t,
2793
+ data,
2794
+ busy,
2795
+ onAction,
2796
+ onBump: () => {},
2797
+ onCommit: () => {},
2798
+ onPublish: () => {},
2799
+ logs: null,
2800
+ onLogs: async () => {},
2801
+ })),
2802
+ )
2803
+ : null,
2804
+ )
2805
+ }
2806
+
2807
+ /**
2808
+ * The npm credential control, shared by the panel and the settings page.
2809
+ *
2810
+ * One implementation because the two surfaces must not drift: the panel shows the
2811
+ * short state and the settings page shows the four steps, but both write the same
2812
+ * file through the same route and have to report the same outcome. The token lives
2813
+ * here only until the request that writes it answers, and is cleared then.
2814
+ */
2815
+ function useNpmCredential(t, loadNpm, setError, setNotice) {
2816
+ const [token, setToken] = React.useState('')
2817
+ const [busy, setBusy] = React.useState(false)
2818
+ const [rejected, setRejected] = React.useState(false)
2819
+ const login = React.useCallback(async () => {
2820
+ setBusy(true)
2821
+ setRejected(false)
2822
+ setError(null)
2823
+ const result = await postJson('/npm-login', { token }, ACTION_TIMEOUT_MS)
2824
+ setBusy(false)
2825
+ setToken('')
2826
+ if (!result.ok) {
2827
+ setError(result.aborted ? 'timeout' : 'host')
2828
+ return
2829
+ }
2830
+ if (!result.response.ok || result.payload?.ok !== true) {
2831
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2832
+ setRejected(result.payload?.code === 'token-rejected')
2833
+ await loadNpm({ force: true })
2834
+ return
2835
+ }
2836
+ setRejected(false)
2837
+ const value = result.payload.value ?? {}
2838
+ setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
2839
+ await loadNpm({ force: true })
2840
+ }, [loadNpm, setError, setNotice, t, token])
2841
+ return { token, setToken, busy, rejected, login }
2842
+ }
2843
+
2844
+ /** The token field and its two buttons — the control step ③ is about. */
2845
+ function NpmTokenLine(props) {
2846
+ const { t, credential, busy } = props
2847
+ return h(
2848
+ 'div',
2849
+ { className: 'dsc-setup-line' },
2850
+ h('input', {
2851
+ className: 'dsc-input',
2852
+ type: 'password',
2853
+ autoComplete: 'off',
2854
+ spellCheck: 'false',
2855
+ placeholder: t('npm.tokenPlaceholder'),
2856
+ 'aria-label': t('npm.tokenPlaceholder'),
2857
+ value: credential.token,
2858
+ onChange: (event) => credential.setToken(String(event?.target?.value ?? '')),
2859
+ }),
2860
+ h(Btn, {
2861
+ kind: 'primary',
2862
+ disabled: busy !== '' || credential.token.trim() === '',
2863
+ onClick: () => { void credential.login() },
2864
+ }, credential.busy ? '…' : t('npm.writeToken')),
2865
+ )
2866
+ }
2867
+
2868
+ /** The checklist a refused token needs, shown exactly when it is refused. */
2869
+ function NpmRejectedNote(props) {
2870
+ if (props.rejected !== true) return null
2871
+ return h(
2872
+ React.Fragment,
2873
+ null,
2874
+ h('span', { className: 'dsc-warn' }, props.t('npm.rejectedTitle')),
2875
+ h('span', null, props.t('npm.rejectedCauses')),
2876
+ )
2877
+ }
2878
+
2879
+ /**
2880
+ * The four steps, as a configuration guide rather than a paragraph.
2881
+ *
2882
+ * A step gets a status only where the Host can actually know one. It can see a
2883
+ * token line in `.npmrc`, and it can see an account once `whoami` answers — it
2884
+ * cannot see whether an account was registered or a token was generated, and a
2885
+ * guide that ticked those off would be inventing progress. So ① and ② carry a
2886
+ * tick only after a credential proves they happened, and say "cannot be detected"
2887
+ * until then; ③ is the one step with a live state of its own.
2888
+ */
2889
+ function NpmCredentialGuide(props) {
2890
+ const { t, npm, credential } = props
2891
+ const account = npm?.auth?.account ?? null
2892
+ const registry = String(npm?.registry ?? '')
2893
+ const npmrcPath = String(npm?.auth?.npmrcPath ?? '')
2894
+ const hasToken = npm?.auth?.npmrcHasToken === true
2895
+ const proven = account !== null
2896
+ const status = (kind, text) => h(Chip, { state: kind === 'done' ? 'success' : kind === 'todo' ? 'warn' : 'idle' }, text)
2897
+ return h(
2898
+ React.Fragment,
2899
+ null,
2900
+ h(
2901
+ 'div',
2902
+ { className: 'dsc-setup-line' },
2903
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step1')),
2904
+ proven
2905
+ ? status('done', t('npm.stepAccountDone', { account: String(account) }))
2906
+ : status('unknown', t('npm.stepUnknown')),
2907
+ ),
2908
+ h(
2909
+ 'div',
2910
+ { className: 'dsc-setup-line' },
2911
+ h(Btn, { onClick: () => globalThis.open(NPM_SIGNUP_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.signup')),
2912
+ ),
2913
+ h('span', null, t('npm.guide.emailNote')),
2914
+
2915
+ h(
2916
+ 'div',
2917
+ { className: 'dsc-setup-line' },
2918
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step2')),
2919
+ proven ? status('done', t('npm.stepTokenDone')) : status('unknown', t('npm.stepUnknown')),
2920
+ ),
2921
+ h(
2922
+ 'div',
2923
+ { className: 'dsc-setup-line' },
2924
+ h(Btn, { onClick: () => globalThis.open(NPM_TOKENS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.tokens')),
2925
+ h(Btn, { kind: 'quiet', onClick: () => globalThis.open(NPM_TOKEN_DOCS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.docs')),
2926
+ ),
2927
+ h('span', null, t('npm.guide.tokenType')),
2928
+ h('span', null, t('npm.guide.tokenScope')),
2929
+ h('span', null, t('npm.guide.tokenExpiry')),
2930
+ h('span', null, t('npm.guide.token2fa')),
2931
+ /* The step that is not on this machine: npmjs.com sits behind a bot challenge
2932
+ that answers whole-site 403s to some networks, and the token does not care
2933
+ where it was minted. Saying so here is the difference between a five-minute
2934
+ workaround and an afternoon spent on the wrong problem. */
2935
+ h('span', { className: 'dsc-quiet' }, t('npm.guide.blocked')),
2936
+
2937
+ h(
2938
+ 'div',
2939
+ { className: 'dsc-setup-line' },
2940
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step3')),
2941
+ hasToken ? status('done', t('npm.stepWrittenDone', { npmrc: npmrcPath })) : status('todo', t('npm.stepTodo')),
2942
+ ),
2943
+ h(NpmTokenLine, { t, credential, busy: props.busy }),
2944
+ h('span', null, t('npm.guide.step3Note', { npmrc: npmrcPath })),
2945
+ h(NpmRejectedNote, { t, rejected: credential.rejected }),
2946
+
2947
+ h(
2948
+ 'div',
2949
+ { className: 'dsc-setup-line' },
2950
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step4')),
2951
+ status('unknown', t('npm.stepUnknown')),
2952
+ ),
2953
+ h('span', null, t('npm.guide.step4Note')),
2954
+
2955
+ /* What the package manager said, verbatim: "not signed in" and "that token was
2956
+ revoked" are the same blank state and different problems. */
2957
+ npm?.auth?.message !== null && npm?.auth?.message !== undefined
2958
+ ? h('span', { className: 'dsc-mono' }, String(npm.auth.message))
2959
+ : null,
2960
+ h('span', { className: 'dsc-quiet' }, t('npm.ciHint')),
2961
+ )
2962
+ }
2963
+
2964
+ /**
2965
+ * The Settings page for npm credentials: the guide, and every package's state.
2966
+ *
2967
+ * The panel answers "which repository needs attention"; this page answers "can
2968
+ * this machine publish at all, and which of my packages are already up there" —
2969
+ * which is configuration, not operation, and belongs where the GitHub account
2970
+ * page already lives.
2971
+ */
2972
+ function NpmCredentialsPage(props) {
2973
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
2974
+ const { npm, error, setError, loadNpm } = useConsoleState()
2975
+ const [busy, setBusy] = React.useState('')
2976
+ const [notice, setNotice] = React.useState(null)
2977
+ const credential = useNpmCredential(t, loadNpm, setError, setNotice)
2978
+
2979
+ React.useEffect(() => {
2980
+ void loadNpm({ force: true })
2981
+ }, [loadNpm])
2982
+
2983
+ const auth = npm?.auth ?? null
2984
+ const state = auth === null ? 'unknown' : String(auth.state ?? (auth.loggedIn === true ? 'signed-in' : 'none'))
2985
+
2986
+ const recheck = React.useCallback(async () => {
2987
+ setBusy('recheck')
2988
+ await loadNpm({ force: true })
2989
+ setBusy('')
2990
+ }, [loadNpm])
2991
+
2992
+ const packageState = (entry) => {
2993
+ if (entry.state === 'published') return { kind: 'success', text: t('npm.package.published') }
2994
+ if (entry.state === 'unregistered') return { kind: 'warn', text: t('npm.package.unregistered') }
2995
+ if (entry.state === 'unpublished') return { kind: 'warn', text: t('npm.package.unpublished') }
2996
+ return { kind: 'idle', text: t('npm.package.unknown') }
2997
+ }
2998
+ const blockerText = (entry) => {
2999
+ const first = Array.isArray(entry.blockers) ? entry.blockers[0] : null
3000
+ if (first === null) return null
3001
+ const key = `npm.blocked.${first}`
3002
+ const text = t(key)
3003
+ return text === key ? null : text
3004
+ }
3005
+
3006
+ return h(
3007
+ 'div',
3008
+ { className: 'dsc-root' },
3009
+ h(
3010
+ 'div',
3011
+ { className: 'dsc-bar' },
3012
+ h(
3013
+ 'div',
3014
+ { className: 'dsc-heading' },
3015
+ h('span', { className: 'dsc-title' }, t('settings.npmTitle')),
3016
+ h('span', { className: 'dsc-subtitle' }, t('settings.npmSubtitle')),
3017
+ ),
3018
+ h(Btn, { disabled: busy !== '', onClick: () => { void recheck() } }, busy === 'recheck' ? '…' : t('npm.packages.recheck')),
3019
+ ),
3020
+
3021
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
3022
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
3023
+
3024
+ npm === null
3025
+ ? h('div', { className: 'dsc-setup' }, h('span', { className: 'dsc-setup-strong' }, t('npm.status.unknown')), h('span', null, t('npm.status.unknownHint')))
3026
+ : h(
3027
+ React.Fragment,
3028
+ null,
3029
+ h(
3030
+ 'div',
3031
+ { className: 'dsc-setup-line' },
3032
+ h('span', { className: 'dsc-setup-strong' }, t('npm.status.title')),
3033
+ state === 'signed-in'
3034
+ ? h(Chip, { state: 'success' }, t('npm.status.signedIn', { account: String(auth.account ?? '?') }))
3035
+ : state === 'credential-present'
3036
+ ? h(Chip, { state: 'idle' }, t('npm.status.credentialPresent'))
3037
+ : h(Chip, { state: 'warn' }, t('npm.status.none')),
3038
+ h('span', { className: 'dsc-mono' }, t('npm.registry', { registry: String(npm.registry ?? '') })),
3039
+ ),
3040
+ /* "Signed in" and "a credential that whoami will not confirm" are the same
3041
+ blank state on screen and different situations, so this page spells the
3042
+ second one out instead of collapsing it into the first. */
3043
+ state === 'credential-present'
3044
+ ? h('span', null, t('npm.credentialPresent', { registry: String(npm.registry ?? ''), npmrc: String(auth.npmrcPath ?? '') }))
3045
+ : null,
3046
+ /* The guide is the page's reason to exist, so it is always here — the
3047
+ panel is where it would be in the way. */
3048
+ h(
3049
+ 'div',
3050
+ { className: 'dsc-setup' },
3051
+ h('span', { className: 'dsc-setup-strong' }, t('settings.npmGuideTitle')),
3052
+ h(NpmCredentialGuide, { t, npm, credential, busy }),
3053
+ ),
3054
+ h(
3055
+ 'div',
3056
+ { className: 'dsc-sub' },
3057
+ h('span', { className: 'dsc-setup-strong' }, t('npm.packages.title')),
3058
+ (npm.repos ?? []).length === 0
3059
+ ? h('span', null, t('npm.packages.empty'))
3060
+ : (npm.repos ?? []).map((entry) =>
3061
+ h(
3062
+ 'div',
3063
+ { className: 'dsc-line', key: entry.repo },
3064
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.packageName ?? entry.repo)),
3065
+ h(Chip, { state: packageState(entry).kind }, packageState(entry).text),
3066
+ h('span', { className: 'dsc-grow' }, `${t('npm.package.local', { version: String(entry.version ?? '?') })}${entry.latest === null || entry.latest === undefined ? '' : ` · ${t('npm.package.latest', { latest: String(entry.latest) })}`}`),
3067
+ blockerText(entry) === null ? null : h('span', { className: 'dsc-quiet' }, blockerText(entry)),
3068
+ entry.pageUrl ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.pageUrl), '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
3069
+ ),
3070
+ ),
3071
+ ),
3072
+ ),
3073
+ )
3074
+ }
3075
+
3076
+ /**
3077
+ * The Bilibili credential, in one component used in two places.
3078
+ *
3079
+ * The panel shows it only when it is the obstacle (a video is bound and the
3080
+ * credential cannot post); the settings page shows it always, because that is
3081
+ * where a credential is configured. One component, so the two cannot drift
3082
+ * into two different explanations of the same `-101`.
3083
+ */
3084
+ function BilibiliSignIn(props) {
3085
+ const { t, bili, busy, setBusy, setNotice, setError, onChanged } = props
3086
+ const credential = bili?.credential ?? null
3087
+ const login = bili?.login ?? { state: 'idle' }
3088
+ const [sessdata, setSessdata] = React.useState('')
3089
+ const [csrf, setCsrf] = React.useState('')
3090
+ const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
3091
+
3092
+ /** One request, with the two failures this page has to tell apart. */
3093
+ const post = React.useCallback(async (key, path, body) => {
3094
+ setBusy(key)
3095
+ setError(null)
3096
+ const result = await postJson(path, body, ACTION_TIMEOUT_MS)
3097
+ setBusy('')
3098
+ if (!result.ok) {
3099
+ setError(result.aborted ? 'timeout' : 'host')
3100
+ return null
3101
+ }
3102
+ if (!result.response.ok || result.payload?.ok !== true) {
3103
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
3104
+ await onChanged()
3105
+ return null
3106
+ }
3107
+ return result.payload.value ?? {}
3108
+ }, [onChanged, setBusy, setError, t])
3109
+
3110
+ const start = React.useCallback(async () => {
3111
+ const value = await post('bili-login', '/bilibili-login-start', {})
3112
+ if (value !== null) await onChanged()
3113
+ }, [onChanged, post])
3114
+
3115
+ const cancel = React.useCallback(async () => {
3116
+ await post('bili-cancel', '/bilibili-login-cancel', {})
3117
+ await onChanged()
3118
+ }, [onChanged, post])
3119
+
3120
+ const save = React.useCallback(async () => {
3121
+ const value = await post('bili-paste', '/bilibili-credential', { sessdata, bili_jct: csrf })
3122
+ setSessdata('')
3123
+ setCsrf('')
3124
+ if (value === null) return
3125
+ setNotice(t('bili.paste.saved', { uname: String(value.account?.uname ?? '') }))
3126
+ await onChanged()
3127
+ }, [csrf, onChanged, post, sessdata, setNotice, t])
3128
+
3129
+ const signOut = React.useCallback(async () => {
3130
+ if (confirmingSignOut !== true) {
3131
+ setConfirmingSignOut(true)
3132
+ return
3133
+ }
3134
+ setConfirmingSignOut(false)
3135
+ const value = await post('bili-signout', '/bilibili-logout', {})
3136
+ if (value === null) return
3137
+ /* An external credential keeps working after this one is deleted, and
3138
+ reporting a sign-out that did not happen would be a lie the next sweep
3139
+ exposes anyway. */
3140
+ setNotice(value.stillAvailable ? t('bili.signOut.fallback', { path: String(value.stillAvailable.path ?? '') }) : t('bili.signOut.done'))
3141
+ await onChanged()
3142
+ }, [confirmingSignOut, onChanged, post, setNotice, t])
3143
+
3144
+ const running = login.state === 'waiting' || login.state === 'scanned'
3145
+ React.useEffect(() => {
3146
+ if (!running) return undefined
3147
+ let cancelled = false
3148
+ const tick = async () => {
3149
+ const result = await postJson('/bilibili-login-poll', {}, STATUS_TIMEOUT_MS)
3150
+ if (cancelled) return
3151
+ const value = result.payload?.value ?? {}
3152
+ if (result.ok && result.response.ok && result.payload?.ok === true) {
3153
+ if (value.state === 'succeeded') {
3154
+ setNotice(value.account?.uname
3155
+ ? t('bili.signIn.done', { uname: String(value.account.uname) })
3156
+ : t('bili.signIn.doneNoName'))
3157
+ }
3158
+ if (value.state === 'expired') setError(t('bili.signIn.expired'))
3159
+ } else if (result.ok && !result.aborted) {
3160
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
3161
+ }
3162
+ await onChanged()
3163
+ }
3164
+ const timer = globalThis.setInterval(() => { void tick() }, AUTH_POLL_MS)
3165
+ return () => {
3166
+ cancelled = true
3167
+ globalThis.clearInterval(timer)
3168
+ }
3169
+ }, [onChanged, running, setError, setNotice, t])
3170
+
3171
+ if (credential === null) return null
3172
+ const state = String(credential.state ?? 'none')
3173
+ const stateText = (() => {
3174
+ if (state === 'ready') {
3175
+ return credential.account?.uname
3176
+ ? t('bili.credential.ready', { uname: String(credential.account.uname) })
3177
+ : t('bili.credential.ready', { uname: '?' })
3178
+ }
3179
+ const key = `bili.credential.${state}`
3180
+ const text = t(key)
3181
+ return text === key ? t('bili.credential.unverified') : text
3182
+ })()
3183
+
3184
+ return h(
3185
+ React.Fragment,
3186
+ null,
3187
+ h(
3188
+ 'div',
3189
+ { className: 'dsc-setup', role: 'status' },
3190
+ h(
3191
+ 'div',
3192
+ { className: 'dsc-setup-line' },
3193
+ h('span', { className: 'dsc-setup-strong' }, t('bili.credential.title')),
3194
+ h(Chip, { state: state === 'ready' ? 'success' : state === 'none' ? 'warn' : 'idle' }, stateText),
3195
+ credential.path
3196
+ ? h('span', { className: 'dsc-mono', title: String(credential.path) }, t('bili.credential.source', { path: String(credential.path) }))
3197
+ : null,
3198
+ ),
3199
+ /* Why it cannot be used, in Bilibili's own terms. A bare "-101" sends the
3200
+ reader to re-login in the wrong place when the credential is an APP one. */
3201
+ state !== 'ready' && credential.message ? h('span', { className: 'dsc-warn' }, String(credential.message)) : null,
3202
+ state !== 'ready' ? h('span', null, t('bili.credential.none.hint')) : null,
3203
+ credential.source === 'configured'
3204
+ ? h('span', { className: 'dsc-quiet' }, t('bili.credential.external', { path: String(credential.configuredPath ?? '') }))
3205
+ : null,
3206
+
3207
+ running
3208
+ ? h(
3209
+ 'div',
3210
+ { className: 'dsc-setup-line' },
3211
+ h('span', { className: 'dsc-grow' }, login.state === 'scanned' ? t('bili.waitingScanned') : t('bili.waiting')),
3212
+ login.url
3213
+ ? h(Btn, { disabled: busy !== '', onClick: () => globalThis.open(String(login.url), '_blank', 'noopener,noreferrer') }, t('bili.openLogin'))
3214
+ : null,
3215
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void cancel() } }, t('bili.cancelSignIn')),
3216
+ )
3217
+ : h(
3218
+ 'div',
3219
+ { className: 'dsc-setup-line' },
3220
+ h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void start() } }, busy === 'bili-login' ? '…' : t('bili.signIn')),
3221
+ h('span', { className: 'dsc-quiet' }, t('bili.signIn.hint')),
3222
+ ),
3223
+ state === 'ready'
3224
+ ? h(
3225
+ 'div',
3226
+ { className: 'dsc-setup-line' },
3227
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void signOut() } }, confirmingSignOut ? t('bili.signOut.confirm') : t('bili.signOut')),
3228
+ )
3229
+ : null,
3230
+
3231
+ h('span', { className: 'dsc-setup-strong' }, t('bili.paste.title')),
3232
+ h('span', { className: 'dsc-quiet' }, t('bili.paste.hint')),
3233
+ h(
3234
+ 'div',
3235
+ { className: 'dsc-setup-line' },
3236
+ h('input', {
3237
+ className: 'dsc-input',
3238
+ type: 'password',
3239
+ autoComplete: 'off',
3240
+ spellCheck: 'false',
3241
+ placeholder: t('bili.paste.sessdata'),
3242
+ 'aria-label': t('bili.paste.sessdata'),
3243
+ value: sessdata,
3244
+ onChange: (event) => setSessdata(String(event?.target?.value ?? '')),
3245
+ }),
3246
+ h('input', {
3247
+ className: 'dsc-input',
3248
+ type: 'password',
3249
+ autoComplete: 'off',
3250
+ spellCheck: 'false',
3251
+ placeholder: t('bili.paste.csrf'),
3252
+ 'aria-label': t('bili.paste.csrf'),
3253
+ value: csrf,
3254
+ onChange: (event) => setCsrf(String(event?.target?.value ?? '')),
3255
+ }),
3256
+ h(Btn, {
3257
+ kind: 'primary',
3258
+ disabled: busy !== '' || sessdata.trim() === '' || csrf.trim() === '',
3259
+ onClick: () => { void save() },
3260
+ }, busy === 'bili-paste' ? '…' : t('bili.paste.save')),
3261
+ ),
3262
+ ),
3263
+ )
3264
+ }
3265
+
3266
+ /**
3267
+ * One repository's video binding, inside its row.
3268
+ *
3269
+ * The binding is edited here because this is where the question is asked: the
3270
+ * row already knows what was released and whether the profile has it, and "who
3271
+ * reads about it" is the same repository's business. The announcement itself is
3272
+ * always previewed first — the comment is public, and the sentence shown is the
3273
+ * sentence the Host would send, composed on the Host.
3274
+ */
3275
+ function BiliBinding(props) {
3276
+ const { t, data, bili, busy, onBind, onPreview, onAnnounce } = props
3277
+ const binding = bili?.binding ?? null
3278
+ const [draft, setDraft] = React.useState(binding === null ? '' : String(binding.bvid))
3279
+ const [preview, setPreview] = React.useState(null)
3280
+
3281
+ /* The Host is the source of truth for the binding: after a save the field
3282
+ shows what was actually stored, not what was typed. */
3283
+ React.useEffect(() => {
3284
+ setDraft(binding === null ? '' : String(binding.bvid))
3285
+ }, [binding === null ? '' : String(binding.bvid)])
3286
+
3287
+ const announced = Array.isArray(bili?.announced) ? bili.announced : []
3288
+ const last = announced.length > 0 ? announced[announced.length - 1] : null
3289
+ const failures = Array.isArray(bili?.failures) ? bili.failures : []
3290
+ const failure = failures.length > 0 ? failures[failures.length - 1] : null
3291
+ /**
3292
+ * Why the Host would not post this one, in the reader's language where the
3293
+ * dictionary knows the state, and in the Host's own words otherwise. The Host
3294
+ * composes its refusals in one language; a known state deserves the other.
3295
+ */
3296
+ const previewReason = (value) => {
3297
+ const key = `bili.state.${String(value?.state ?? '')}`
3298
+ const text = t(key, { count: String(value?.attempts ?? '') })
3299
+ return text === key ? String(value?.message ?? '') : text
3300
+ }
3301
+ /**
3302
+ * What would be announced if the sweep ran now, as far as the panel can tell.
3303
+ * The decision itself is the Host's; this is a chip, and it stays silent when
3304
+ * the baseline is unknown rather than guessing at the one case that matters.
3305
+ */
3306
+ const pendingTag = (() => {
3307
+ if (binding === null) return null
3308
+ const tag = typeof data.publishedTag === 'string' && data.publishedTag !== '' ? data.publishedTag : null
3309
+ if (tag === null) return null
3310
+ if (announced.some((entry) => entry.tag === tag)) return null
3311
+ if (bili?.baseline === null || bili?.baseline === undefined) return null
3312
+ if (bili.baseline.tag === null) return null
3313
+ if (bili.baseline.tag === tag) return null
3314
+ return tag
3315
+ })()
3316
+
3317
+ return h(
3318
+ 'div',
3319
+ { className: 'dsc-sub' },
3320
+ h('span', null, t('bili.bind.title')),
3321
+ h(
3322
+ 'div',
3323
+ { className: 'dsc-line' },
3324
+ h('input', {
3325
+ className: 'dsc-input',
3326
+ type: 'text',
3327
+ spellCheck: 'false',
3328
+ placeholder: t('bili.bind.placeholder'),
3329
+ 'aria-label': t('bili.bind.placeholder'),
3330
+ value: draft,
3331
+ onChange: (event) => setDraft(String(event?.target?.value ?? '').trim()),
3332
+ }),
3333
+ h(Btn, {
3334
+ kind: binding === null ? 'primary' : undefined,
3335
+ disabled: busy !== '' || draft.trim() === '',
3336
+ onClick: () => { void onBind(data, draft.trim(), binding === null ? true : binding.auto !== false) },
3337
+ }, busy === `bili-bind:${data.repo}` ? '…' : t('bili.bind.save')),
3338
+ binding === null
3339
+ ? null
3340
+ : h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, '', true) } }, t('bili.bind.unbind')),
3341
+ h('span', { className: 'dsc-grow' }, binding === null
3342
+ ? t('bili.repo.none')
3343
+ : bili?.video?.ok === true
3344
+ ? `${String(bili.video.title ?? '')}${bili.video.owner ? ` · ${String(bili.video.owner)}` : ''}`
3345
+ : t('bili.video.failed', { reason: String(bili?.video?.message ?? '?') })),
3346
+ ),
3347
+ binding !== null
3348
+ ? h(
3349
+ 'div',
3350
+ { className: 'dsc-line' },
3351
+ h(Chip, { state: binding.auto === false ? 'idle' : 'success' }, binding.auto === false ? t('bili.auto.off') : t('bili.auto.on')),
3352
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, binding.bvid, binding.auto === false) } }, t('bili.auto.toggle')),
3353
+ h('span', { className: 'dsc-grow' }, last === null
3354
+ ? t('bili.announced.none')
3355
+ : `${t('bili.chip.announced', { tag: String(last.tag) })}${last.url ? '' : ''}`),
3356
+ pendingTag !== null ? h(Chip, { state: 'warn' }, t('bili.chip.pending', { tag: pendingTag })) : null,
3357
+ last !== null && typeof last.url === 'string' && last.url !== ''
3358
+ ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(last.url), '_blank', 'noopener,noreferrer') }, t('action.open'))
3359
+ : null,
3360
+ )
3361
+ : null,
3362
+ failure !== null
3363
+ ? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(failure.message ?? failure.failure ?? '') }))
3364
+ : null,
3365
+ binding !== null
3366
+ ? h(
3367
+ 'div',
3368
+ { className: 'dsc-line' },
3369
+ h(Btn, {
3370
+ kind: 'primary',
3371
+ disabled: busy !== '',
3372
+ onClick: () => {
3373
+ void (async () => {
3374
+ const value = await onPreview(data)
3375
+ if (value !== null) setPreview(value)
3376
+ })()
3377
+ },
3378
+ }, busy === `bili-preview:${data.repo}` ? '…' : (announced.length > 0 ? t('bili.announce.again') : t('bili.announce'))),
3379
+ )
3380
+ : null,
3381
+ preview !== null
3382
+ ? h(
3383
+ React.Fragment,
3384
+ null,
3385
+ h('span', { className: 'dsc-setup-strong' }, t('bili.announce.preview')),
3386
+ preview.state !== 'ready'
3387
+ ? h('span', { className: 'dsc-warn' }, previewReason(preview))
3388
+ : null,
3389
+ h('pre', { className: 'dsc-logs' }, String(preview.text ?? '')),
3390
+ Array.isArray(preview.unknown) && preview.unknown.length > 0
3391
+ ? h('span', { className: 'dsc-warn' }, t('bili.unknownPlaceholder', { names: preview.unknown.join(', ') }))
3392
+ : null,
3393
+ h(
3394
+ 'div',
3395
+ { className: 'dsc-line' },
3396
+ preview.state === 'ready'
3397
+ ? h(Btn, {
3398
+ kind: 'danger',
3399
+ disabled: busy !== '',
3400
+ onClick: () => {
3401
+ setPreview(null)
3402
+ void onAnnounce(data, preview, String(preview.text ?? ''), false)
3403
+ },
3404
+ }, t('bili.announce.confirm'))
3405
+ : h(Btn, {
3406
+ kind: 'danger',
3407
+ disabled: busy !== '',
3408
+ onClick: () => {
3409
+ setPreview(null)
3410
+ void onAnnounce(data, preview, String(preview.text ?? ''), true)
3411
+ },
3412
+ }, t('bili.announce.force')),
3413
+ h(Btn, { kind: 'quiet', onClick: () => setPreview(null) }, t('bili.announce.cancel')),
3414
+ ),
3415
+ )
3416
+ : null,
3417
+ )
3418
+ }
3419
+
3420
+ /**
3421
+ * The Settings page for Bilibili update notes.
3422
+ *
3423
+ * The panel answers "what would be posted, and to which video"; this page
3424
+ * answers "can this machine post at all, and what has it already said" — which
3425
+ * is configuration plus a public record, and belongs beside the two credential
3426
+ * pages that already live here.
3427
+ */
3428
+ function BilibiliPage(props) {
3429
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
3430
+ const { bili, error, setError, loadBili } = useConsoleState()
3431
+ const [busy, setBusy] = React.useState('')
3432
+ const [notice, setNotice] = React.useState(null)
3433
+
3434
+ React.useEffect(() => {
3435
+ void loadBili({ force: true })
3436
+ }, [loadBili])
3437
+
3438
+ const reload = React.useCallback(() => loadBili({ force: true }), [loadBili])
3439
+ const repositories = Array.isArray(bili?.repos) ? bili.repos : []
3440
+ const lastSweep = bili?.lastSweep ?? null
3441
+ const sweepText = (() => {
3442
+ if (bili === null) return null
3443
+ if (lastSweep === null || lastSweep === undefined) return t('bili.neverSwept')
3444
+ const results = Array.isArray(lastSweep.results) ? lastSweep.results : []
3445
+ const announced = results.filter((entry) => entry.state === 'announced')
3446
+ const failed = results.filter((entry) => entry.state === 'failed')
3447
+ const summary = announced.length > 0
3448
+ ? announced.map((entry) => t('bili.sweep.announced', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
3449
+ : failed.length > 0
3450
+ ? failed.map((entry) => t('bili.sweep.failed', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
3451
+ : t('bili.sweep.none')
3452
+ return t('bili.lastSweep', { time: stamp(String(lastSweep.at ?? '')), summary })
3453
+ })()
3454
+
3455
+ return h(
3456
+ 'div',
3457
+ { className: 'dsc-root' },
3458
+ h(
3459
+ 'div',
3460
+ { className: 'dsc-bar' },
3461
+ h(
3462
+ 'div',
3463
+ { className: 'dsc-heading' },
3464
+ h('span', { className: 'dsc-title' }, t('bili.settings.title')),
3465
+ h('span', { className: 'dsc-subtitle' }, t('bili.settings.subtitle')),
3466
+ ),
3467
+ h(Btn, { disabled: busy !== '', onClick: () => { void reload() } }, t('action.recheck')),
3468
+ ),
3469
+
3470
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
3471
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
3472
+
3473
+ bili === null
3474
+ ? h('div', { className: 'dsc-empty' }, t('state.loading'))
3475
+ : h(
3476
+ React.Fragment,
3477
+ null,
3478
+ bili.enabled === false
3479
+ ? h('div', { className: 'dsc-warn' }, 'bilibiliEnabled: false')
3480
+ : null,
3481
+ bili.watchSeconds === 0 ? h('div', { className: 'dsc-warn' }, t('bili.watch.off')) : null,
3482
+ bili.ledger?.problem
3483
+ ? h('div', { className: 'dsc-error', role: 'alert' }, t('bili.ledger.problem', { problem: String(bili.ledger.problem) }))
3484
+ : null,
3485
+ h(BilibiliSignIn, {
3486
+ t,
3487
+ bili,
3488
+ busy,
3489
+ setBusy,
3490
+ setNotice,
3491
+ setError,
3492
+ onChanged: () => loadBili({ force: true }),
3493
+ }),
3494
+ h(
3495
+ 'div',
3496
+ { className: 'dsc-setup' },
3497
+ h('span', { className: 'dsc-setup-strong' }, t('bili.template')),
3498
+ h('span', { className: 'dsc-mono' }, String(bili.template === '' ? '【更新 {tag}】{summary}' : bili.template)),
3499
+ h('span', { className: 'dsc-quiet' }, t('bili.template.hint')),
3500
+ sweepText === null ? null : h('span', { className: 'dsc-quiet' }, sweepText),
3501
+ ),
3502
+ h(
3503
+ 'div',
3504
+ { className: 'dsc-sub' },
3505
+ h('span', { className: 'dsc-setup-strong' }, t('bili.repos.title')),
3506
+ repositories.length === 0
3507
+ ? h('span', null, t('bili.repos.empty'))
3508
+ : repositories.map((entry) =>
3509
+ h(
3510
+ 'div',
3511
+ { className: 'dsc-line', key: entry.repo },
3512
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.label ?? entry.repo)),
3513
+ entry.binding === null
3514
+ ? h(Chip, { state: 'idle' }, t('bili.repo.none'))
3515
+ : h(Chip, { state: 'success' }, String(entry.binding.bvid)),
3516
+ h('span', { className: 'dsc-grow' }, entry.video?.ok === true
3517
+ ? String(entry.video.title ?? '')
3518
+ : (Array.isArray(entry.announced) && entry.announced.length > 0
3519
+ ? t('bili.chip.announced', { tag: String(entry.announced[entry.announced.length - 1].tag) })
3520
+ : t('bili.announced.none'))),
3521
+ Array.isArray(entry.failures) && entry.failures.length > 0
3522
+ ? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(entry.failures[entry.failures.length - 1].message ?? '') }))
3523
+ : null,
3524
+ entry.commentUrl
3525
+ ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.commentUrl), '_blank', 'noopener,noreferrer') }, t('action.open'))
3526
+ : null,
3527
+ ),
3528
+ ),
3529
+ ),
3530
+ ),
3531
+ )
3532
+ }
3533
+
3534
+ /** The Settings page: account, scopes, and the repository list. */
3535
+ function AccountPage(props) {
3536
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
3537
+ const { status, error, setError, loadStatus } = useConsoleState()
3538
+ const [managing, setManaging] = React.useState(false)
3539
+ const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
3540
+ const [signOutBusy, setSignOutBusy] = React.useState(false)
3541
+ const [notice, setNotice] = React.useState(null)
3542
+
3543
+ React.useEffect(() => {
3544
+ void loadStatus()
3545
+ }, [loadStatus])
3546
+
3547
+ const gh = status?.gh ?? {}
3548
+ const config = status?.config ?? {}
3549
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
3550
+ const scopes = Array.isArray(gh.scopes) ? gh.scopes : []
3551
+ const registered = Array.isArray(status?.repos) ? status.repos.length : 0
3552
+ const stale = status !== null && status.protocol !== PROTOCOL
3553
+
3554
+ return h(
3555
+ 'div',
3556
+ { className: 'dsc-root' },
3557
+ h(
3558
+ 'div',
3559
+ { className: 'dsc-bar' },
3560
+ h(
3561
+ 'div',
3562
+ { className: 'dsc-heading' },
3563
+ h('span', { className: 'dsc-title' }, t('settings.title')),
3564
+ h('span', { className: 'dsc-subtitle' }, t('settings.subtitle')),
3565
+ ),
3566
+ h(Btn, { onClick: () => void loadStatus() }, t('action.recheck')),
3567
+ /* Signing out is destructive and easy to hit by accident, so it takes two
3568
+ clicks — the same shape as publishing a draft. */
3569
+ gh.authenticated === true
3570
+ ? h(Btn, {
3571
+ kind: 'quiet',
3572
+ disabled: signOutBusy,
3573
+ title: t('state.signedOut'),
3574
+ onClick: () => {
3575
+ if (!confirmingSignOut) {
3576
+ setConfirmingSignOut(true)
3577
+ return
3578
+ }
3579
+ void (async () => {
3580
+ setSignOutBusy(true)
3581
+ const result = await postJson('/auth-logout', {}, ACTION_TIMEOUT_MS)
3582
+ setSignOutBusy(false)
3583
+ setConfirmingSignOut(false)
3584
+ if (!result.ok || result.payload?.ok !== true) {
3585
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
3586
+ return
3587
+ }
3588
+ setNotice(t('state.signedOut'))
3589
+ void loadStatus()
3590
+ })()
3591
+ },
3592
+ }, signOutBusy ? '…' : confirmingSignOut ? t('action.signOutConfirm') : t('action.signOut'))
3593
+ : null,
3594
+ ),
3595
+
3596
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
3597
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
3598
+ stale
3599
+ ? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
3600
+ : null,
3601
+
3602
+ status === null
3603
+ ? h('div', { className: 'dsc-empty' }, t('state.loading'))
3604
+ : h(
3605
+ React.Fragment,
3606
+ null,
3607
+ h(
3608
+ 'div',
3609
+ { className: 'dsc-list' },
3610
+ h(
3611
+ 'div',
3612
+ { className: 'dsc-item' },
3613
+ h(
3614
+ 'div',
3615
+ { className: 'dsc-row' },
3616
+ h('span', { className: 'dsc-dot', 'data-state': gh.available !== true ? 'error' : gh.authenticated !== true ? 'warn' : 'success' }),
3617
+ h('span', { className: 'dsc-name' }, t('meta.gh')),
3618
+ h('span', { className: 'dsc-grow' }, gh.available !== true ? (gh.message ?? 'not found') : (gh.version ?? 'present')),
3619
+ gh.authenticated === true && typeof gh.account === 'string' && gh.account !== ''
3620
+ ? h(Chip, { state: 'success' }, gh.account)
3621
+ : h(Chip, { state: 'warn' }, t('setup.needSignIn')),
3622
+ ),
3623
+ ),
3624
+ h(
3625
+ 'div',
3626
+ { className: 'dsc-item' },
3627
+ h(
3628
+ 'div',
3629
+ { className: 'dsc-row' },
3630
+ h('span', { className: 'dsc-dot', 'data-state': missing.length > 0 ? 'warn' : 'success' }),
3631
+ h('span', { className: 'dsc-name' }, t('meta.scopes')),
3632
+ h('span', { className: 'dsc-grow' }, gh.authenticated !== true ? '—' : scopes.length > 0 ? scopes.join(', ') : t('meta.noScopes')),
3633
+ missing.length > 0 ? h(Chip, { state: 'warn' }, missing.join(', ')) : null,
3634
+ ),
3635
+ ),
3636
+ h(
3637
+ 'div',
3638
+ { className: 'dsc-item' },
3639
+ h(
3640
+ 'div',
3641
+ { className: 'dsc-row' },
3642
+ h('span', { className: 'dsc-dot', 'data-state': 'idle' }),
3643
+ h('span', { className: 'dsc-name' }, t('meta.configFrom')),
3644
+ h('span', { className: 'dsc-grow dsc-mono' }, `${status.configSource ?? '—'} · ${status.configFile ?? ''}`),
3645
+ h('span', null, String(registered)),
3646
+ ),
3647
+ ),
3648
+ ),
3649
+
3650
+ needsAccountSetup(gh) && !stale
3651
+ ? h(
3652
+ 'div',
3653
+ { className: 'dsc-setup' },
3654
+ h('span', { className: 'dsc-setup-strong' }, gh.available !== true
3655
+ ? t('setup.needGh')
3656
+ : gh.authenticated !== true
3657
+ ? t('setup.needSignIn')
3658
+ : t('setup.needScopes', { scopes: missing.join(', ') })),
3659
+ missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
3660
+ h(SignIn, { t, status, onChanged: () => void loadStatus() }),
3661
+ )
3662
+ : null,
3663
+
3664
+ h(
3665
+ 'div',
3666
+ { className: 'dsc-bar' },
3667
+ h('span', { className: 'dsc-subtitle' }, `${t('meta.projectsRoot')}: ${typeof config.projectsRoot === 'string' && config.projectsRoot !== '' ? config.projectsRoot : t('meta.projectsRootUnset')}`),
3668
+ h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
3669
+ ),
3670
+ managing && !stale ? h(RepoPicker, { t, onChanged: () => void loadStatus() }) : null,
3671
+ ),
3672
+ )
3673
+ }
3674
+
3675
+ /**
3676
+ * The console's settings page: three credentials, one page.
3677
+ *
3678
+ * One segment mounts at a time, and that is deliberate twice over. It keeps the
3679
+ * page from probing all three credential systems the moment someone opens it —
3680
+ * each segment asks its own Host route when it mounts, as it did as a page of its
3681
+ * own. And it keeps a failure local: the Bilibili half parses a ledger and a cookie
3682
+ * file, and a malformed one takes down the segment it is in, not the GitHub token
3683
+ * state someone opened this page to read.
3684
+ *
3685
+ * The segments render here rather than in the slot because `settings.plugins.tab`
3686
+ * is a flat list — the platform gives one strip of tabs and no nesting — so the
3687
+ * inner strip is ours. `order` in that outer strip is one row per plugin; inside,
3688
+ * the order is the workflow: release, distribute on npm, then announce.
3689
+ */
3690
+ function ConsoleSettingsPage(props) {
3691
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
3692
+ const [segment, setSegment] = React.useState(SEGMENT_GITHUB)
3693
+ const segments = [
3694
+ { key: SEGMENT_GITHUB, label: 'settings.segment.github', page: AccountPage },
3695
+ { key: SEGMENT_NPM, label: 'settings.segment.npm', page: NpmCredentialsPage },
3696
+ { key: SEGMENT_BILIBILI, label: 'settings.segment.bilibili', page: BilibiliPage },
3697
+ ]
3698
+ const active = segments.find((entry) => entry.key === segment) ?? segments[0]
3699
+ return h(
3700
+ 'div',
3701
+ { className: 'dsc-settings', 'data-segment': active.key },
3702
+ h(
3703
+ 'div',
3704
+ { className: 'dsc-bar dsc-segments' },
3705
+ ...segments.map((entry) => h(
3706
+ Btn,
3707
+ {
3708
+ key: entry.key,
3709
+ kind: entry.key === active.key ? 'primary' : 'quiet',
3710
+ 'data-segment': entry.key,
3711
+ title: t(entry.label),
3712
+ onClick: () => setSegment(entry.key),
3713
+ },
3714
+ t(entry.label),
3715
+ )),
3716
+ ),
3717
+ h(active.page, { t }),
3718
+ )
3719
+ }
3720
+
3721
+ return {
3722
+ name: 'dsh-cicd-client',
3723
+ inject: ['slots', 'locale'],
3724
+ apply(ctx) {
3725
+ ensureStyles()
3726
+ ctx.effect(
3727
+ () => ctx.locale.register(NS, { zh, en }),
3728
+ 'dsh-plugin-cicd: dictionaries',
3729
+ )
3730
+ const t = ctx.locale.bind(NS)
3731
+ const disposers = [
3732
+ ctx.slots.inject('main', () =>
3733
+ ctx.slots.register(
3734
+ { name: 'main', key: PANEL_ID, locale: NS },
3735
+ ConsolePage,
3736
+ ),
3737
+ ),
3738
+ // `order: 16` sits the entry after the knowledge console (15) and before
3739
+ // whatever a later plugin adds; the sidebar owns the button itself.
3740
+ ctx.slots.inject('sidebar.panellist', () =>
3741
+ ctx.slots.register(
3742
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 16, label: () => t('panel'), locale: NS },
3743
+ ConsoleIcon,
3744
+ ),
3745
+ ),
3746
+ /*
3747
+ * ONE page, in the Plugins section, holding the three credentials.
3748
+ *
3749
+ * Two shares of a shared surface were given back here. The top-level nav
3750
+ * (`settings.section`) is a FLAT list — one page per entry, no grouping, no
3751
+ * sub-navigation — and this plugin was taking 3 of its 13 rows, all of them
3752
+ * above 通用 / 模型 / 插件, while every other third-party plugin on this machine
3753
+ * takes exactly one. `settings.plugins.tab` is the seat the platform keeps for
3754
+ * a plugin's own pages ("One page inside the Plugins settings section"), so the
3755
+ * configuration belongs there — but three tabs in that strip would be the same
3756
+ * unfair share one level down: a strip shared with every plugin, three of its
3757
+ * rows belonging to this one. So the three are segments inside one page, and
3758
+ * the strip is one row per plugin.
3759
+ *
3760
+ * `order` 20 puts it after the inventory (`all`, 10) and before the marketplace
3761
+ * (60): it configures an installed plugin, which is closer to the inventory than
3762
+ * to a shop.
3763
+ *
3764
+ * A fresh id is required — reusing a shipped id would REPLACE that page instead
3765
+ * of adding one. The `locale` option is what supplies the `t` prop
3766
+ * (`PropsLocale`), and it is the registration's own option rather than anything
3767
+ * the slot owner gives, so the page translates exactly as the sections did.
3768
+ */
3769
+ ctx.slots.inject('settings.plugins.tab', () =>
3770
+ ctx.slots.register(
3771
+ { name: 'settings.plugins.tab', id: SETTINGS_ID, order: 20, label: () => t('settings.consoleLabel'), locale: NS },
3772
+ ConsoleSettingsPage,
3773
+ ),
3774
+ ),
3775
+ ]
3776
+ if (typeof ctx.effect === 'function') {
3777
+ ctx.effect(() => () => {
3778
+ for (const dispose of disposers) {
3779
+ if (typeof dispose === 'function') dispose()
3780
+ }
3781
+ }, 'dsh-plugin-cicd: panel registrations')
3782
+ }
3783
+ },
3784
+ }
3785
+ },
3786
+ })