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/index.js ADDED
@@ -0,0 +1,4662 @@
1
+ /**
2
+ * Host half of the `dsh-plugin-cicd` bundle — 发布台 (Release Console).
3
+ *
4
+ * Why it exists: the repositories in `F:\CodeProj` each carry a release workflow,
5
+ * but the answer to "what is built, what is published, and is the tree ahead of
6
+ * the release?" lives in three places at once — GitHub Actions, the Releases
7
+ * page, and the local checkout. This half collapses those into one loopback
8
+ * surface the browser half renders.
9
+ *
10
+ * Why it shells out to `gh` instead of calling the REST API with a token:
11
+ * `gh` is already authenticated on this machine and already knows the token's
12
+ * scopes. Reusing it means the panel needs no credential of its own, nothing is
13
+ * stored in the plugin, and the same command a human would run is the one the
14
+ * panel runs (`gh run list`, `gh workflow run`, `gh release edit`).
15
+ *
16
+ * Surface (all POST, loopback + same-origin gated, see `isTrustedRequest`):
17
+ * /api/dsh-cicd/status gh identity + the resolved repository list
18
+ * /api/dsh-cicd/overview per-repo runs, releases, and local git state
19
+ * /api/dsh-cicd/runs one repository's recent runs
20
+ * /api/dsh-cicd/dispatch trigger a workflow (`workflow_dispatch`)
21
+ * /api/dsh-cicd/run-action rerun / rerun-failed / cancel one run
22
+ * /api/dsh-cicd/release-action publish a draft, or delete a release
23
+ * /api/dsh-cicd/version-bump bump package.json, commit it, push the branch
24
+ * /api/dsh-cicd/logs the tail of the failed steps of one run
25
+ * /api/dsh-cicd/update install this profile's copy from the release's tgz
26
+ * /api/dsh-cicd/restart hand a restart to dsh-plugin-restart, if it is mounted
27
+ *
28
+ * And the third channel, which is not a package at all: the update note under the
29
+ * video that introduces the plugin.
30
+ *
31
+ * /api/dsh-cicd/bilibili-status credential, bindings, and what is pending
32
+ * /api/dsh-cicd/bilibili-login-start begin the Bilibili web sign-in (QR / browser)
33
+ * /api/dsh-cicd/bilibili-login-poll ask once whether it was confirmed
34
+ * /api/dsh-cicd/bilibili-login-cancel give up on the sign-in
35
+ * /api/dsh-cicd/bilibili-credential store a pasted SESSDATA + bili_jct
36
+ * /api/dsh-cicd/bilibili-logout forget the credential this plugin stored
37
+ * /api/dsh-cicd/bilibili-bind bind a repository to a video's comments
38
+ * /api/dsh-cicd/bilibili-announce post (or compose) one update comment
39
+ *
40
+ * The release dispatch carries a preflight, and that is not a convenience. Every
41
+ * repository in this set releases from `package.json`'s version, and its release
42
+ * workflow refuses to reuse a version that already belongs to another commit
43
+ * (otherwise the tag and the uploaded assets silently disagree). So pressing
44
+ * 发布 without bumping the version produced a run that could only fail — measured
45
+ * four times across two repositories, each ~10 s in, at the first step — while the
46
+ * panel announced a draft that was never created. `releasePreflight` turns that
47
+ * into one answer before anything is dispatched, and `version-bump` is the step
48
+ * the button was missing.
49
+ *
50
+ * Every route answers `{ ok: true, value }` or `{ ok: false, code, message }`.
51
+ *
52
+ * @module dsh-plugin-cicd
53
+ */
54
+
55
+ import { execFile, execFileSync, spawn } from 'node:child_process'
56
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
57
+ import { connect } from 'node:net'
58
+ import { lookup } from 'node:dns/promises'
59
+ import { dirname, isAbsolute, join } from 'node:path'
60
+ import { fileURLToPath } from 'node:url'
61
+ import { promisify } from 'node:util'
62
+ import {
63
+ isValidRepoName,
64
+ normalizeBinding,
65
+ normalizeEntry,
66
+ parseConfig,
67
+ readConfig,
68
+ writeConfig,
69
+ } from './lib/config-store.mjs'
70
+ import {
71
+ announcementVerdict,
72
+ composeComment,
73
+ cookieHeader,
74
+ createBilibiliClient,
75
+ credentialVerdict,
76
+ emptyLedger,
77
+ findLedgerEntry,
78
+ latestBaseline,
79
+ newestPublishedRelease,
80
+ parseCookieJar,
81
+ parseLedger,
82
+ recordLedgerEntry,
83
+ withDeviceIds,
84
+ } from './lib/bilibili.mjs'
85
+
86
+ /** Plugin name shown in loader logs. */
87
+ export const name = 'dsh-plugin-cicd'
88
+
89
+ /** The Web server is the only required service: it carries the routes. */
90
+ export const inject = ['webServer']
91
+
92
+ const runFile = promisify(execFile)
93
+
94
+ /** This module's directory, so the panel can quote a command that really runs here. */
95
+ const moduleDir = dirname(fileURLToPath(import.meta.url))
96
+
97
+ /** Route namespace owned by this plugin. */
98
+ const ROUTE_PREFIX = '/api/dsh-cicd'
99
+
100
+ const DEFAULT_TIMEOUT_MS = 20_000
101
+ const MIN_TIMEOUT_MS = 2_000
102
+ const MAX_TIMEOUT_MS = 120_000
103
+ const DEFAULT_OVERVIEW_TTL_MS = 15_000
104
+ const MAX_OVERVIEW_TTL_MS = 300_000
105
+ const DEFAULT_POLL_SECONDS = 30
106
+ const MAX_REPOS = 40
107
+ const DEFAULT_LOG_TAIL_LINES = 120
108
+ const MAX_LOG_TAIL_LINES = 400
109
+ const MAX_BODY_BYTES = 32 * 1024
110
+
111
+ /**
112
+ * How long a forwarded restart request may take before it is reported as
113
+ * unreachable. The restart plugin answers before it arms the supervisor, so this
114
+ * is a bound on talking to a sibling route on loopback, not on restarting.
115
+ */
116
+ const RESTART_FORWARD_TIMEOUT_MS = 15_000
117
+
118
+ /** Where a release tarball is downloaded to, beside the managed config file. */
119
+ const DOWNLOAD_DIR_NAME = 'downloads'
120
+
121
+ /**
122
+ * The two files the Bilibili feature owns, both beside `repos.json`.
123
+ *
124
+ * The credential is stored here rather than in the row config because it is a
125
+ * live session, not a setting: it expires, it gets replaced, and it must never
126
+ * be echoed back to the panel. The ledger is the opposite — it is the record of
127
+ * what has already been said in public, and losing it re-posts every comment.
128
+ */
129
+ const BILIBILI_CREDENTIAL_FILE_NAME = 'bilibili-cookies.json'
130
+ const BILIBILI_LEDGER_FILE_NAME = 'bilibili-announcements.json'
131
+
132
+ /** How often the Host looks for a newly published release, and the ceiling on that. */
133
+ const DEFAULT_BILIBILI_WATCH_SECONDS = 90
134
+ const MAX_BILIBILI_WATCH_SECONDS = 3600
135
+
136
+ /** How long a credential/video answer is reused before Bilibili is asked again. */
137
+ const DEFAULT_BILIBILI_VERIFY_TTL_MS = 300_000
138
+ const MAX_BILIBILI_VERIFY_TTL_MS = 3_600_000
139
+
140
+ /** A Bilibili sign-in QR code is valid for about three minutes. */
141
+ const BILIBILI_LOGIN_TTL_MS = 180_000
142
+
143
+ /** How many failed attempts at one release before the sweep stops retrying. */
144
+ const BILIBILI_MAX_ATTEMPTS = 3
145
+
146
+ /** How many ledger entries are kept; older ones are the ones nobody reads. */
147
+ const BILIBILI_MAX_LEDGER_ENTRIES = 500
148
+
149
+ /** The registry this console publishes to, unless the row config names another. */
150
+ const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'
151
+
152
+ /**
153
+ * One packument read is a single HTTPS request, so it is bounded tightly; a publish
154
+ * is a package-manager run that uploads the tarball, so it is not.
155
+ */
156
+ const NPM_STATUS_TIMEOUT_MS = 8_000
157
+ const NPM_PUBLISH_TIMEOUT_MS = 180_000
158
+
159
+ /**
160
+ * How long one registry answer is reused.
161
+ *
162
+ * The npm state is asked for on demand — when the panel opens, after a push, after a
163
+ * sign-in — and never on the 30-second poll, because a request per repository per
164
+ * poll is a cost the panel does not need to pay for a fact that changes when someone
165
+ * publishes.
166
+ */
167
+ const NPM_STATUS_TTL_MS = 60_000
168
+
169
+ /**
170
+ * Wire protocol of the browser half this Host half can serve.
171
+ *
172
+ * The two halves do not reload together: the browser bundle is read from disk on
173
+ * every page load, while this module is imported once per Host process. A page
174
+ * refresh therefore produces "new client, old host", where the client calls a
175
+ * route that does not exist yet and reports it as a request failure — which reads
176
+ * like a GitHub or credential problem and is neither. The client compares this
177
+ * number and says what to do instead.
178
+ *
179
+ * 3: `version-bump`, the `releaseCheck` verdict on every overview row, and the
180
+ * release dispatch's preflight. A 2.x client asking a 2.x host to publish a
181
+ * version that is already taken is the bug this protocol bump retires.
182
+ *
183
+ * 5: `npm-status`, `npm-login` and `npm-publish`. A 4.x host has no `npm-*` route at
184
+ * all, so a 5.x client's npm button would read a 401 as "the registry rejected me".
185
+ *
186
+ * 6: the `bilibili-*` family. Same trap, more of it: a 5.x host mounts none of
187
+ * those routes, and this feature's failures are the kind that get misread — a 401
188
+ * from `bilibili-credential` looks like "Bilibili refused the cookie" and a 401
189
+ * from `bilibili-announce` looks like "not signed in", while both really mean the
190
+ * running Host is older than the page.
191
+ *
192
+ * 4: `update` and `restart`, and the `install` block on every overview row. A 3.x
193
+ * client renders rows without it, so the two halves would still agree on the old
194
+ * surface — but the new buttons post to routes a 3.x host does not mount, which is
195
+ * exactly the 401-reads-as-a-credential-problem this number exists to prevent.
196
+ */
197
+ const PROTOCOL = 6
198
+
199
+ /** The managed repository list, written by `scripts/configure.mjs`. */
200
+ const DEFAULT_CONFIG_FILE_NAME = 'repos.json'
201
+
202
+ /**
203
+ * How long a browser sign-in attempt may stay pending. GitHub expires the
204
+ * one-time code after about fifteen minutes; the panel should not hold a polling
205
+ * child longer than a person would plausibly take.
206
+ */
207
+ const AUTH_TIMEOUT_MS = 10 * 60 * 1000
208
+
209
+ /**
210
+ * How long `gh auth login` may go without printing a one-time code before the
211
+ * attempt is reported as stalled.
212
+ *
213
+ * This is not a nicety. Measured on a machine where `github.com:443` was blocked:
214
+ * `gh auth login --web` printed NOTHING and was still running after 20 seconds —
215
+ * no code, no error, no exit. The code otherwise arrives in well under a second, so
216
+ * six seconds without one is already abnormal and worth saying out loud. The report
217
+ * is advisory: the attempt keeps running, because gh was measured producing the code
218
+ * once the network recovered.
219
+ */
220
+ const AUTH_CODE_DEADLINE_MS = 6_000
221
+
222
+ /**
223
+ * Probe `host:port` and say WHICH layer failed.
224
+ *
225
+ * Resolving first is the whole point. A DNS failure and a blocked port look
226
+ * identical from a bare connect, and they have different causes and different
227
+ * fixes. Measured on this machine: connecting to github.com by name timed out,
228
+ * while connecting to the very address it resolves to succeeded in 83ms — so the
229
+ * name resolution is what flaps, and a message saying "cannot open a connection to
230
+ * github.com:443" described the wrong layer.
231
+ *
232
+ * @param {string} host - hostname to resolve and connect to.
233
+ * @param {number} port - TCP port.
234
+ * @param {number} timeoutMs - per-stage budget.
235
+ * @returns {Promise<{ok: boolean, stage: 'ok'|'dns'|'tcp', address: string|null}>} probe result.
236
+ */
237
+ export async function probeHost(host, port, timeoutMs) {
238
+ let address = null
239
+ try {
240
+ const resolved = await withTimeout(lookup(host, { all: true }), timeoutMs)
241
+ const first = Array.isArray(resolved) ? resolved[0] : resolved
242
+ address = typeof first?.address === 'string' ? first.address : null
243
+ } catch {
244
+ return { ok: false, stage: 'dns', address: null }
245
+ }
246
+ if (address === null) return { ok: false, stage: 'dns', address: null }
247
+ const connected = await new Promise((resolve) => {
248
+ let settled = false
249
+ const socket = connect({ host: address, port })
250
+ const finish = (value) => {
251
+ if (settled) return
252
+ settled = true
253
+ socket.removeAllListeners()
254
+ socket.destroy()
255
+ resolve(value)
256
+ }
257
+ socket.setTimeout(timeoutMs)
258
+ socket.once('connect', () => finish(true))
259
+ socket.once('timeout', () => finish(false))
260
+ socket.once('error', () => finish(false))
261
+ })
262
+ return connected ? { ok: true, stage: 'ok', address } : { ok: false, stage: 'tcp', address }
263
+ }
264
+
265
+ /**
266
+ * Work out which proxy `gh` should use.
267
+ *
268
+ * `gh` and `git` honour the HTTP(S)_PROXY environment variables and ignore the
269
+ * Windows internet settings that a browser follows. On a machine whose browser can
270
+ * open github.com while `gh auth login` prints nothing, that difference is the whole
271
+ * story, so the system setting is read here and handed to the gh children.
272
+ *
273
+ * @param {string} explicit - `proxy` from the row config; wins when set.
274
+ * @param {string} [platform] - injectable for tests.
275
+ * @returns {{url: string|null, source: 'config'|'windows'|'none'}} the proxy to use.
276
+ */
277
+ export function resolveProxy(explicit, platform = process.platform) {
278
+ if (typeof explicit === 'string' && explicit.trim() !== '') {
279
+ const value = explicit.trim()
280
+ return { url: value.includes('://') ? value : `http://${value}`, source: 'config' }
281
+ }
282
+ if (platform !== 'win32') return { url: null, source: 'none' }
283
+ try {
284
+ const out = execFileSync(
285
+ 'reg',
286
+ ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'],
287
+ { encoding: 'utf8', windowsHide: true, timeout: 5_000 },
288
+ )
289
+ const enabled = /ProxyEnable\s+REG_DWORD\s+0x1/i.test(out)
290
+ const server = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(out)?.[1] ?? null
291
+ if (!enabled || server === null) return { url: null, source: 'none' }
292
+ return { url: server.includes('://') ? server : `http://${server}`, source: 'windows' }
293
+ } catch {
294
+ return { url: null, source: 'none' }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Environment additions that make `gh` use a proxy.
300
+ * @param {string|null} url - proxy URL, or null for none.
301
+ * @returns {object} environment entries to spread into a child's env.
302
+ */
303
+ export function proxyEnvironment(url) {
304
+ if (url === null) return {}
305
+ return {
306
+ HTTP_PROXY: url,
307
+ HTTPS_PROXY: url,
308
+ ALL_PROXY: url,
309
+ // The loopback surface this plugin serves must never be routed through it.
310
+ NO_PROXY: 'localhost,127.0.0.1,::1',
311
+ no_proxy: 'localhost,127.0.0.1,::1',
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Proxy for the gh children, set once by `apply`.
317
+ *
318
+ * A module-level value because `runTool` is a plain function called from dozens of
319
+ * host handlers; threading the config through all of them would be a larger change
320
+ * than this deserves, and one plugin instance serves one process.
321
+ */
322
+ let activeProxyEnvironment = {}
323
+
324
+ /**
325
+ * Can this process open a TCP connection to `host:port`, name resolution included?
326
+ * @param {string} host - hostname.
327
+ * @param {number} port - TCP port.
328
+ * @param {number} timeoutMs - per-stage budget.
329
+ * @returns {Promise<boolean>} whether the connection was established.
330
+ */
331
+ export async function canReach(host, port, timeoutMs) {
332
+ return (await probeHost(host, port, timeoutMs)).ok
333
+ }
334
+
335
+ /**
336
+ * Resolve a promise, or reject once `timeoutMs` has passed.
337
+ * @param {Promise<unknown>} promise - work to bound.
338
+ * @param {number} timeoutMs - budget in milliseconds.
339
+ * @returns {Promise<unknown>} the value, or a rejection on timeout.
340
+ */
341
+ function withTimeout(promise, timeoutMs) {
342
+ return new Promise((resolve, reject) => {
343
+ const timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs} ms`)), timeoutMs)
344
+ if (typeof timer.unref === 'function') timer.unref()
345
+ promise.then(
346
+ (value) => {
347
+ clearTimeout(timer)
348
+ resolve(value)
349
+ },
350
+ (error) => {
351
+ clearTimeout(timer)
352
+ reject(error)
353
+ },
354
+ )
355
+ })
356
+ }
357
+
358
+ /**
359
+ * Scopes the panel actually needs. `repo` covers private repositories, releases
360
+ * and the Actions API; `workflow` is what allows dispatching one. Nothing here
361
+ * needs `admin:*`, so the guide asks for the minimum that makes the buttons work.
362
+ */
363
+ const REQUIRED_SCOPES = ['repo', 'workflow']
364
+
365
+ /** A repository argument is passed to `gh` as one argv element; keep it shaped like a slug. */
366
+ const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
367
+ const SLUG_WITH_OWNER = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
368
+
369
+ /* ------------------------------------------------------------------ config -- */
370
+
371
+ /** Clamp a number into range, falling back to a default for anything unusable. */
372
+ function clampNumber(value, min, max, fallback) {
373
+ const parsed = typeof value === 'number' ? value : Number(value)
374
+ if (!Number.isFinite(parsed)) return fallback
375
+ return Math.min(max, Math.max(min, Math.round(parsed)))
376
+ }
377
+
378
+ /** Trim a value that should be a non-empty string, or return the fallback. */
379
+ function text(value, fallback = '') {
380
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
381
+ }
382
+
383
+ /**
384
+ * Normalize one configured repository entry.
385
+ *
386
+ * The rules live in `lib/config-store.mjs` because `scripts/configure.mjs` writes
387
+ * the same file the Host reads: two copies of "what a repository name may look
388
+ * like" would eventually disagree, and the failure mode of that disagreement is a
389
+ * registered repository the panel silently ignores.
390
+ *
391
+ * @param {unknown} entry - one element of `config.repos`.
392
+ * @returns {{repo: string, localPath: string, label: string}|null}
393
+ */
394
+ export function normalizeRepoEntry(entry) {
395
+ return normalizeEntry(entry)
396
+ }
397
+
398
+ /**
399
+ * Resolve the row config into the plugin's working shape.
400
+ *
401
+ * Values are clamped rather than rejected: a hand-written patch must never be
402
+ * able to stop the Host from starting, so the worst case is a route that reports
403
+ * "not configured" instead of a plugin that fails to load.
404
+ *
405
+ * @param {object|undefined} raw - row config from the profile patch.
406
+ * @returns {object} resolved config.
407
+ */
408
+ export function resolveConfig(raw) {
409
+ const repos = []
410
+ const seen = new Set()
411
+ const declared = Array.isArray(raw?.repos) ? raw.repos : []
412
+ for (const entry of declared.slice(0, MAX_REPOS)) {
413
+ const normalized = normalizeRepoEntry(entry)
414
+ if (normalized === null || seen.has(normalized.repo)) continue
415
+ seen.add(normalized.repo)
416
+ repos.push(normalized)
417
+ }
418
+ return {
419
+ enabled: raw?.enabled !== false,
420
+ owner: text(raw?.owner),
421
+ repos,
422
+ ghPath: text(raw?.ghPath),
423
+ defaultBranch: text(raw?.defaultBranch, 'main'),
424
+ buildWorkflow: text(raw?.buildWorkflow, 'ci.yml'),
425
+ releaseWorkflow: text(raw?.releaseWorkflow, 'release.yml'),
426
+ requestTimeoutMs: clampNumber(raw?.requestTimeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
427
+ overviewTtlMs: clampNumber(raw?.overviewTtlMs, 0, MAX_OVERVIEW_TTL_MS, DEFAULT_OVERVIEW_TTL_MS),
428
+ logTailLines: clampNumber(raw?.logTailLines, 20, MAX_LOG_TAIL_LINES, DEFAULT_LOG_TAIL_LINES),
429
+ pollSeconds: clampNumber(raw?.pollSeconds, 10, 900, DEFAULT_POLL_SECONDS),
430
+ configFile: text(raw?.configFile),
431
+ /**
432
+ * Where local checkouts live. Used only to fill `localPath` automatically when
433
+ * a repository is registered from the panel: asking a person to type an
434
+ * absolute path is exactly the kind of step this plugin exists to remove.
435
+ */
436
+ projectsRoot: text(raw?.projectsRoot),
437
+ /**
438
+ * An explicit proxy for gh, e.g. `http://127.0.0.1:7897`.
439
+ *
440
+ * Empty means "work it out from the system settings". gh, like git, reads the
441
+ * HTTP(S)_PROXY environment variables and ignores the Windows proxy that the
442
+ * browser uses — which is how a machine can open github.com in a browser while
443
+ * `gh auth login` sits there printing nothing at all.
444
+ */
445
+ proxy: text(raw?.proxy),
446
+ /**
447
+ * The npm registry the push targets. Configurable because "publish to npm" means
448
+ * npmjs.com on most machines and a private registry on others, and a plugin that
449
+ * hard-coded the first would be wrong on the second without saying so.
450
+ */
451
+ npmRegistry: normalizeRegistry(raw?.npmRegistry),
452
+ /**
453
+ * Bilibili update notes: when a repository's release goes public, say so under
454
+ * the video that introduces it.
455
+ *
456
+ * `bilibiliEnabled` and `bilibiliAuto` are separate switches on purpose. Turning
457
+ * the feature off must silence the sweep; turning `auto` off must keep the panel's
458
+ * manual button working. One boolean could not express both.
459
+ */
460
+ bilibiliEnabled: raw?.bilibiliEnabled !== false,
461
+ bilibiliAuto: raw?.bilibiliAuto !== false,
462
+ bilibiliWatchSeconds: clampNumber(raw?.bilibiliWatchSeconds, 0, MAX_BILIBILI_WATCH_SECONDS, DEFAULT_BILIBILI_WATCH_SECONDS),
463
+ bilibiliTemplate: typeof raw?.bilibiliTemplate === 'string' ? raw.bilibiliTemplate : '',
464
+ /**
465
+ * An external credential file — biliup's `cookies.json` is the one this machine
466
+ * has. Read as a FALLBACK: what the panel's own sign-in wrote always wins, so a
467
+ * configured path can never shadow a credential the user just created.
468
+ */
469
+ bilibiliCookieFile: text(raw?.bilibiliCookieFile),
470
+ bilibiliVerifyTtlMs: clampNumber(raw?.bilibiliVerifyTtlMs, 0, MAX_BILIBILI_VERIFY_TTL_MS, DEFAULT_BILIBILI_VERIFY_TTL_MS),
471
+ /**
472
+ * A `fetch` the Bilibili transport should use. Not a setting: it exists so the
473
+ * route tests can drive the whole announce path — compose, post, ledger — against
474
+ * a fake instead of against a real account.
475
+ */
476
+ bilibiliFetch: typeof raw?.bilibiliFetch === 'function' ? raw.bilibiliFetch : null,
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Where the managed repository list lives.
482
+ *
483
+ * The row config is the hand-written source of truth, and editing YAML by hand is
484
+ * exactly the cost this file removes: `scripts/configure.mjs` owns a JSON file
485
+ * instead, and the row only has to say where it is. One path per machine (under
486
+ * `DSH_HOME`, not per profile) because "which repositories do I watch" is a
487
+ * property of the machine, not of a profile.
488
+ *
489
+ * @param {object} config - resolved config.
490
+ * @returns {string} absolute path of the managed file.
491
+ */
492
+ export function resolveConfigFilePath(config) {
493
+ if (typeof config.configFile === 'string' && config.configFile !== '') return config.configFile
494
+ const home = text(process.env.DSH_HOME) !== '' ? text(process.env.DSH_HOME) : join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh')
495
+ return join(home, 'dsh-plugin-cicd', DEFAULT_CONFIG_FILE_NAME)
496
+ }
497
+
498
+ /**
499
+ * Parse the managed repository file.
500
+ *
501
+ * Rejects rather than repairs: a file this plugin wrote and cannot read is a
502
+ * symptom worth showing, and silently falling back to "no repositories" would
503
+ * look identical to "you configured nothing yet". The rules themselves live in
504
+ * `lib/config-store.mjs`, which `scripts/configure.mjs` and the mutation routes
505
+ * also use — one definition of the format, three callers.
506
+ *
507
+ * @param {string} raw - file contents.
508
+ * @returns {{ok: true, owner: string, repos: object[], dropped: number}|{ok: false, message: string}}
509
+ */
510
+ export function parseReposFile(raw) {
511
+ return parseConfig(raw)
512
+ }
513
+
514
+ /**
515
+ * Resolve the configuration a request should actually use.
516
+ *
517
+ * The managed file is read on every call rather than at plugin load, so a change
518
+ * made from the panel or from `configure.mjs` takes effect on the next poll
519
+ * instead of at the next restart. The row config stays the fallback for a machine
520
+ * that never created the file, which keeps a hand-written patch working exactly as
521
+ * documented.
522
+ *
523
+ * @param {object} config - the row-resolved config.
524
+ * @returns {object} config plus `repos`, `owner`, `configSource`, `configFile`, `configProblem`.
525
+ */
526
+ export function effectiveConfig(config) {
527
+ const file = resolveConfigFilePath(config)
528
+ const stored = readConfig(file)
529
+ if (!stored.exists) {
530
+ return { ...config, configFile: file, configSource: config.repos.length > 0 ? 'row' : 'none', configProblem: null }
531
+ }
532
+ if (stored.problem !== null) {
533
+ return { ...config, configFile: file, configSource: 'file-invalid', configProblem: stored.problem }
534
+ }
535
+ return {
536
+ ...config,
537
+ owner: stored.owner !== '' ? stored.owner : config.owner,
538
+ repos: stored.repos,
539
+ configFile: file,
540
+ configSource: 'file',
541
+ configProblem: null,
542
+ configDropped: stored.dropped,
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Parse `gh auth status`.
548
+ *
549
+ * `gh` prints this on stderr and includes a "Token scopes: '...'" line only when
550
+ * the credential actually carries scopes. The scope list is the part that
551
+ * matters: an authenticated token without `workflow` can read a run but cannot
552
+ * dispatch one, and that difference is invisible until a button fails.
553
+ *
554
+ * @param {string} text - combined stdout and stderr of `gh auth status`.
555
+ * @returns {{authenticated: boolean, account: string|null, scopes: string[], missingScopes: string[]}}
556
+ */
557
+ export function parseAuthStatus(text) {
558
+ const source = typeof text === 'string' ? text : ''
559
+ const authenticated = /Logged in to \S+ account/i.test(source)
560
+ const accountMatch = /Logged in to \S+ account ([A-Za-z0-9-]+)/i.exec(source)
561
+ const scopesMatch = /Token scopes:\s*(.+)/i.exec(source)
562
+ const scopes = []
563
+ if (scopesMatch !== null) {
564
+ for (const token of scopesMatch[1].split(/[,\s]+/)) {
565
+ const cleaned = token.replace(/['"]/g, '').trim()
566
+ if (cleaned !== '') scopes.push(cleaned)
567
+ }
568
+ }
569
+ return {
570
+ authenticated,
571
+ account: accountMatch === null ? null : accountMatch[1],
572
+ scopes,
573
+ // An empty scope list is "unknown", not "none": `gh` omits the line for a
574
+ // credential it cannot introspect (a fine-grained token), and reporting that
575
+ // as "missing repo, workflow" would send the user to fix a non-problem.
576
+ missingScopes: scopes.length === 0 ? [] : REQUIRED_SCOPES.filter((scope) => !scopes.includes(scope)),
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Read the current authentication posture from `gh`.
582
+ * @param {string} ghPath - resolved executable.
583
+ * @param {number} timeoutMs - deadline.
584
+ * @returns {Promise<object>} what the panel needs to guide a first run.
585
+ */
586
+ export async function readAuthStatus(ghPath, timeoutMs) {
587
+ const result = await runTool(ghPath, ['auth', 'status'], timeoutMs)
588
+ // `gh auth status` exits non-zero when nothing is logged in, so the streams are
589
+ // the evidence and the exit code is not.
590
+ const combined = `${result.stdout}\n${result.stderr}`
591
+ return { ...parseAuthStatus(combined), exitCode: result.code, raw: combined.trim() }
592
+ }
593
+
594
+ /**
595
+ * Whether a candidate is a command name to look up on PATH, rather than a path.
596
+ *
597
+ * This has to be decided by shape, not by `path.isAbsolute`: on POSIX a Windows
598
+ * path like `C:\Program Files\GitHub CLI\gh.exe` is *not* absolute, so an
599
+ * `isAbsolute` test reads it as a bare command name and hands it straight to
600
+ * `spawn`. That is exactly the bug the mount check caught on a Linux runner —
601
+ * "spawn C:\Program Files\GitHub CLI\gh.exe ENOENT" — and it would have broken
602
+ * gh resolution for every non-Windows install.
603
+ *
604
+ * @param {string} value - a trimmed candidate.
605
+ * @returns {boolean} true when the value names a command to resolve through PATH.
606
+ */
607
+ function isCommandName(value) {
608
+ return !/[/\\]/.test(value)
609
+ }
610
+
611
+ /**
612
+ * Where `gh` is.
613
+ *
614
+ * Precedence is deliberate: an explicit configuration wins outright, even when
615
+ * the file is not there. A typo should surface as `ENOENT` naming the path the
616
+ * user configured, not as a silent switch to whichever `gh` happens to be on
617
+ * PATH — that is the kind of "works on my machine" failure nobody can debug.
618
+ * Only the built-in guesses are checked against the filesystem, and the Windows
619
+ * ones are only considered on Windows.
620
+ *
621
+ * @param {object} config - resolved config.
622
+ * @param {string} [platform] - `process.platform`, injectable so the Windows
623
+ * candidate list can be tested from a POSIX machine and vice versa.
624
+ * @returns {string} an absolute path, or a command name for PATH lookup.
625
+ */
626
+ export function resolveGhPath(config, platform = process.platform) {
627
+ const configured = text(config?.ghPath)
628
+ if (configured !== '') return configured
629
+
630
+ for (const value of [process.env.DSH_GH_PATH, process.env.DSH_GITHUB_CLI]) {
631
+ const fromEnv = text(value)
632
+ if (fromEnv !== '') return fromEnv
633
+ }
634
+
635
+ // The Windows installer's locations are only candidates where they can exist;
636
+ // on POSIX they would be dead weight at best and — as this function learned the
637
+ // hard way — an outright wrong answer when tested for absoluteness.
638
+ const candidates = []
639
+ if (platform === 'win32') {
640
+ candidates.push(
641
+ 'C:\\Program Files\\GitHub CLI\\gh.exe',
642
+ join(process.env.LOCALAPPDATA ?? '', 'Programs', 'GitHub CLI', 'gh.exe'),
643
+ join(process.env.ProgramFiles ?? '', 'GitHub CLI', 'gh.exe'),
644
+ )
645
+ }
646
+ for (const candidate of candidates) {
647
+ if (isCommandName(candidate)) continue
648
+ if (isAbsolute(candidate) && existsSync(candidate)) return candidate
649
+ }
650
+ // Nothing confirmed on disk: let the OS search PATH, which is right on POSIX
651
+ // and is the honest answer anywhere else.
652
+ return 'gh'
653
+ }
654
+
655
+ /* -------------------------------------------------------------- gh runner -- */
656
+
657
+ /** First non-empty line of a message, for a panel-sized error string. */
658
+ function firstLine(value) {
659
+ const source = typeof value === 'string' ? value : ''
660
+ for (const line of source.split('\n')) {
661
+ const trimmed = line.trim()
662
+ if (trimmed !== '') return trimmed
663
+ }
664
+ return ''
665
+ }
666
+
667
+ /** How many changed file names the overview carries; the count is carried in full. */
668
+ const LOCAL_FILE_LIMIT = 20
669
+
670
+ /**
671
+ * The path out of one `git status --porcelain` line.
672
+ *
673
+ * V1 format is `XY <path>`, quoted when the name holds unusual bytes, and a rename
674
+ * reads `XY <old> -> <new>`. Both are passed through rather than prettified: this list
675
+ * exists so a person recognises what is about to be committed, and a path this code
676
+ * rewrote is one they would not recognise.
677
+ *
678
+ * @param {string} line - one trimmed porcelain line.
679
+ * @returns {string} what to show.
680
+ */
681
+ export function statusPath(line) {
682
+ /*
683
+ * No leading trim. Porcelain v1 is `XY <path>`, and for an unstaged modification X is
684
+ * a SPACE — ` M package.json`. Trimming first turns that into `M package.json`, whose
685
+ * first three characters then eat the `p` of the path: the list promised to help
686
+ * someone recognise what they are about to commit would read `ackage.json`.
687
+ */
688
+ const raw = String(line).replace(/\r$/, '')
689
+ return raw.length <= 3 ? raw.trim() : raw.slice(3).trim()
690
+ }
691
+
692
+ /**
693
+ * The one line of a failed command's output that explains it.
694
+ *
695
+ * A failed `pnpm publish` writes EVERY byte to STDOUT — measured, stderr is 0 bytes —
696
+ * and its first line is the progress line (`📦 pkg@1.0.0 → registry`) while its last
697
+ * few hundred are a stack trace. Node then adds `Command failed: <the whole command>`
698
+ * as the child-process error's message. So "the first line of stderr" is the progress
699
+ * bar, the wrapper, or nothing at all: the sentence that says WHY sits in the middle
700
+ * and is the only part worth putting on screen.
701
+ *
702
+ * Preference: a line carrying an error code (`[E403]`, `npm ERR!`, `ERR_PNPM_…`) →
703
+ * a `pnpm: ` sentence → the first line that is not noise. Noise is the progress line,
704
+ * the wrapper, stack frames, blank lines.
705
+ *
706
+ * @param {unknown} output - combined stdout and stderr.
707
+ * @param {string} [fallback] - what to answer when nothing meaningful is there.
708
+ * @returns {string} the line to show.
709
+ */
710
+ export function firstMeaningfulLine(output, fallback = '') {
711
+ const lines = String(output ?? '')
712
+ .split(/\r?\n/)
713
+ .map((line) => line.trim())
714
+ .filter((line) => line !== '')
715
+ const isNoise = (line) => line.startsWith('📦')
716
+ || line.startsWith('Command failed:')
717
+ || line.startsWith('at ')
718
+ || line.startsWith('throw ')
719
+ || line === '^'
720
+ || line.startsWith('node:internal')
721
+ || /^Progress: /.test(line)
722
+ for (const line of lines) {
723
+ if (/^\[(?:E[A-Z0-9_]+|ERR_[A-Z0-9_]+)\]/.test(line) || line.startsWith('npm ERR!') || /ERR_PNPM_[A-Z_]+/.test(line)) return line
724
+ }
725
+ for (const line of lines) {
726
+ if (line.startsWith('pnpm: ')) return line
727
+ }
728
+ for (const line of lines) {
729
+ if (!isNoise(line)) return line
730
+ }
731
+ return fallback
732
+ }
733
+
734
+ /**
735
+ * `firstMeaningfulLine` for a `runTool`/`runSpawn` result: both streams, then the
736
+ * child-process error as the last resort.
737
+ *
738
+ * @param {object} result - a failed spawn result.
739
+ * @param {string} fallback - the sentence for "it failed and said nothing usable".
740
+ * @returns {string} the line to show.
741
+ */
742
+ export function commandFailureLine(result, fallback) {
743
+ return firstMeaningfulLine(`${result?.stdout ?? ''}\n${result?.stderr ?? ''}`, result?.error || fallback)
744
+ }
745
+
746
+ /**
747
+ * The file name of a workflow reference, lowercased.
748
+ *
749
+ * `release.yml`, `./.github/workflows/release.yml` and a Windows-spelled path all
750
+ * name the same workflow, and the configured value is a file name while a caller
751
+ * may pass the path GitHub reports. Comparing the last segment is what makes the
752
+ * release preflight apply to every spelling of "the release workflow" instead of
753
+ * only to the one the panel happens to send.
754
+ *
755
+ * @param {unknown} value - workflow reference.
756
+ * @returns {string} lowercased file name.
757
+ */
758
+ function workflowFileName(value) {
759
+ const trimmed = text(value).replace(/\\/g, '/')
760
+ return trimmed.slice(trimmed.lastIndexOf('/') + 1).toLowerCase()
761
+ }
762
+
763
+ /**
764
+ * Run one command in a directory of its own, with stdin closed.
765
+ *
766
+ * `runTool` is the gh- and git-shaped case; this is the package-manager-shaped one,
767
+ * which needs a working directory and extra environment. The stdin decision is the
768
+ * important one: a publish on an account with two-factor auth asks for a one-time
769
+ * password, and a child waiting on a stdin nobody holds would show a spinner until
770
+ * the timeout. `ignore` gives it an immediate EOF, so it fails with a message the
771
+ * panel can act on — which is what turns "it hangs" into "enter the code".
772
+ *
773
+ * @param {string} executable - resolved path or bare command name.
774
+ * @param {string[]} argv - arguments, passed without a shell.
775
+ * @param {object} options - `cwd`, `timeoutMs`, and environment additions.
776
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null, killed: boolean}>}
777
+ */
778
+ async function runSpawn(executable, argv, { cwd = undefined, timeoutMs, env = {} } = {}) {
779
+ try {
780
+ const { stdout, stderr } = await runFile(executable, argv, {
781
+ timeout: timeoutMs,
782
+ windowsHide: true,
783
+ maxBuffer: 8 * 1024 * 1024,
784
+ stdio: ['ignore', 'pipe', 'pipe'],
785
+ ...(cwd === undefined ? {} : { cwd }),
786
+ env: {
787
+ ...process.env,
788
+ ...activeProxyEnvironment,
789
+ ...env,
790
+ NO_COLOR: '1',
791
+ // npm's own progress bars and prompts are useless to a captured pipe and have
792
+ // been known to keep a child alive after its work is done.
793
+ npm_config_progress: 'false',
794
+ npm_config_fund: 'false',
795
+ npm_config_audit: 'false',
796
+ },
797
+ })
798
+ return { ok: true, stdout, stderr, code: 0, killed: false }
799
+ } catch (error) {
800
+ return {
801
+ ok: false,
802
+ stdout: typeof error?.stdout === 'string' ? error.stdout : '',
803
+ /*
804
+ * Exactly what the child wrote — an empty stderr is a fact about the child, not
805
+ * a slot to fill. Node's `Command failed: <the whole command>` goes in `error`
806
+ * instead, so a caller reaches for it only after the real streams were empty.
807
+ * Filling `stderr` with it is how a wrapper line came to shadow the reason:
808
+ * pnpm writes every byte of a failed publish to STDOUT (measured: stderr = 0).
809
+ */
810
+ stderr: typeof error?.stderr === 'string' ? error.stderr : '',
811
+ error: typeof error?.message === 'string' ? error.message : String(error),
812
+ code: Number.isInteger(error?.code) ? error.code : null,
813
+ killed: error?.killed === true,
814
+ }
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Run one external command.
820
+ *
821
+ * `GH_PROMPT_DISABLED` matters more than it looks: without it a `gh` that
822
+ * decides to ask a question (a missing credential, an unreviewed release) waits
823
+ * on a stdin nobody is holding, and the panel would show a spinner until the
824
+ * timeout instead of the reason. It is harmless for `git`, which this also runs.
825
+ *
826
+ * @param {string} executable - resolved path or bare command name.
827
+ * @param {string[]} args - argv, passed without a shell.
828
+ * @param {number} timeoutMs - hard deadline.
829
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null, killed: boolean}>}
830
+ */
831
+ export async function runTool(executable, args, timeoutMs) {
832
+ try {
833
+ const { stdout, stderr } = await runFile(executable, args, {
834
+ timeout: timeoutMs,
835
+ windowsHide: true,
836
+ maxBuffer: 8 * 1024 * 1024,
837
+ env: {
838
+ ...process.env,
839
+ // gh ignores the Windows proxy settings that a browser follows. Without
840
+ // this, a machine can open github.com in a browser while gh cannot send a
841
+ // single request — which is exactly what was happening here.
842
+ ...activeProxyEnvironment,
843
+ GH_PROMPT_DISABLED: '1',
844
+ GH_NO_UPDATE_NOTIFIER: '1',
845
+ GH_PAGER: 'cat',
846
+ NO_COLOR: '1',
847
+ },
848
+ })
849
+ return { ok: true, stdout, stderr, code: 0, killed: false }
850
+ } catch (error) {
851
+ return {
852
+ ok: false,
853
+ stdout: typeof error?.stdout === 'string' ? error.stdout : '',
854
+ /*
855
+ * Exactly what the child wrote — an empty stderr is a fact about the child, not
856
+ * a slot to fill. Node's `Command failed: <the whole command>` goes in `error`
857
+ * instead, so a caller reaches for it only after the real streams were empty.
858
+ * Filling `stderr` with it is how a wrapper line came to shadow the reason:
859
+ * pnpm writes every byte of a failed publish to STDOUT (measured: stderr = 0).
860
+ */
861
+ stderr: typeof error?.stderr === 'string' ? error.stderr : '',
862
+ error: typeof error?.message === 'string' ? error.message : String(error),
863
+ code: Number.isInteger(error?.code) ? error.code : null,
864
+ killed: error?.killed === true,
865
+ }
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Run one `gh` invocation and parse stdout as JSON.
871
+ * @returns {Promise<{ok: true, value: unknown}|{ok: false, message: string}>}
872
+ */
873
+ export async function ghJson(ghPath, args, timeoutMs) {
874
+ const result = await runTool(ghPath, [...args], timeoutMs)
875
+ if (!result.ok) {
876
+ return { ok: false, message: result.killed ? `gh timed out after ${timeoutMs} ms` : commandFailureLine(result, 'gh failed') }
877
+ }
878
+ const raw = result.stdout.trim()
879
+ if (raw === '') return { ok: true, value: null }
880
+ try {
881
+ return { ok: true, value: JSON.parse(raw) }
882
+ } catch {
883
+ return { ok: false, message: 'gh returned output that is not JSON' }
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Run one `gh` invocation that is expected to succeed, mapping failure to a message.
889
+ * @returns {Promise<{ok: true, stdout: string}|{ok: false, message: string}>}
890
+ */
891
+ export async function ghRun(ghPath, args, timeoutMs) {
892
+ const result = await runTool(ghPath, [...args], timeoutMs)
893
+ if (result.ok) return { ok: true, stdout: result.stdout }
894
+ return {
895
+ ok: false,
896
+ message: result.killed ? `gh timed out after ${timeoutMs} ms` : commandFailureLine(result, 'gh failed'),
897
+ }
898
+ }
899
+
900
+ /* ------------------------------------------------------------------ trust -- */
901
+
902
+ /** Whether a socket address is the loopback interface. */
903
+ function isLoopbackAddress(address) {
904
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
905
+ }
906
+
907
+ /** Parse one bare Host authority into a URL, or undefined when malformed. */
908
+ function parseAuthority(authority) {
909
+ if (typeof authority !== 'string' || authority.trim() !== authority || authority === '') return undefined
910
+ const match = authority.startsWith('[') ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority)
911
+ if (match === null) return undefined
912
+ try {
913
+ const url = new URL(`http://${authority}`)
914
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined
915
+ const rawPort = match[1]
916
+ if (rawPort !== undefined && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65535)) return undefined
917
+ return url
918
+ } catch {
919
+ return undefined
920
+ }
921
+ }
922
+
923
+ /** Whether a request is same-origin with the (loopback) Host it reached. */
924
+ function isSameOriginRequest(request, hostUrl) {
925
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
926
+ const origin = request.headers.origin
927
+ if (origin === undefined) return true
928
+ try {
929
+ return new URL(origin).host === hostUrl.host
930
+ } catch {
931
+ return false
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Loopback-only, same-origin trust decision.
937
+ *
938
+ * These routes act with the machine's GitHub credentials, so the decision is the
939
+ * same one the shipped settings bridge applies to its own loopback routes: only a
940
+ * request that came from this machine AND from the document this Host served.
941
+ * @param {object} request - incoming request.
942
+ * @returns {boolean}
943
+ */
944
+ export function isTrustedRequest(request) {
945
+ if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
946
+ const host = request.headers.host
947
+ if (typeof host !== 'string') return false
948
+ const hostUrl = parseAuthority(host)
949
+ if (hostUrl === undefined) return false
950
+ return isSameOriginRequest(request, hostUrl)
951
+ }
952
+
953
+ /** Write one JSON response without depending on any Host helper. */
954
+ function writeJson(res, status, body) {
955
+ const payload = JSON.stringify(body)
956
+ res.writeHead(status, {
957
+ 'content-type': 'application/json; charset=utf-8',
958
+ 'referrer-policy': 'no-referrer',
959
+ 'cache-control': 'no-store',
960
+ 'content-length': Buffer.byteLength(payload),
961
+ })
962
+ res.end(payload)
963
+ }
964
+
965
+ /** Read a bounded JSON body; null for a blank, oversized or invalid body. */
966
+ async function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
967
+ const chunks = []
968
+ let size = 0
969
+ for await (const chunk of req) {
970
+ size += chunk.length
971
+ if (size > maxBytes) {
972
+ req.destroy()
973
+ return null
974
+ }
975
+ chunks.push(chunk)
976
+ }
977
+ const text = Buffer.concat(chunks).toString('utf8')
978
+ if (text.trim() === '') return null
979
+ try {
980
+ return JSON.parse(text)
981
+ } catch {
982
+ return null
983
+ }
984
+ }
985
+
986
+ /* ------------------------------------------------------------- local state -- */
987
+
988
+ /** Read the `version` field of a checkout's package.json, or null. */
989
+ export function readLocalVersion(localPath) {
990
+ if (localPath === '' || !existsSync(localPath)) return null
991
+ try {
992
+ const parsed = JSON.parse(readFileSync(join(localPath, 'package.json'), 'utf8'))
993
+ return typeof parsed?.version === 'string' ? parsed.version : null
994
+ } catch {
995
+ return null
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * A full commit id, or null.
1001
+ *
1002
+ * Only a 40-character id counts as evidence. GitHub stores a release's
1003
+ * `target_commitish` as whatever `gh release create --target` was given, and a
1004
+ * hand-made release can carry a branch name there; resolving that name locally
1005
+ * would answer a different question than the one that matters (which commit the
1006
+ * *workflow* will build), so it is reported as unknown instead.
1007
+ *
1008
+ * @param {unknown} value - candidate.
1009
+ * @returns {string|null} lowercase id, or null when it is not one.
1010
+ */
1011
+ export function fullSha(value) {
1012
+ if (typeof value !== 'string') return null
1013
+ const trimmed = value.trim().toLowerCase()
1014
+ return /^[0-9a-f]{40}$/.test(trimmed) ? trimmed : null
1015
+ }
1016
+
1017
+ /**
1018
+ * Whether the release workflow can publish the version this checkout declares.
1019
+ *
1020
+ * The workflow refuses to touch a version that already belongs to another commit,
1021
+ * and it is right to: publishing it would create the tag at the old commit, whose
1022
+ * own build would overwrite the fresh assets — a release whose tag and contents
1023
+ * disagree, produced silently. The cost of that guard is that a dispatch without a
1024
+ * version bump cannot succeed, which is invisible from the panel and was measured
1025
+ * failing four times. So the panel asks the same question first, and only answers
1026
+ * `blocked` when it can PROVE the mismatch: a release whose target commit is a full
1027
+ * id that differs from the commit the run would build. Anything unprovable — no
1028
+ * local version, a branch name for a target, an unknown build commit — is `ready`,
1029
+ * because a preflight that refuses a release that would have worked is worse than
1030
+ * no preflight at all.
1031
+ *
1032
+ * @param {object} params - the two sides of the comparison.
1033
+ * @param {string|null} params.version - local package.json version.
1034
+ * @param {string|null} params.expectedTag - `v<version>`, when known.
1035
+ * @param {object[]} params.releases - normalized releases.
1036
+ * @param {string|null} params.builtSha - commit the dispatch would build.
1037
+ * @returns {{state: string, code: string, tag: string|null, owner: string|null, built: string|null, message: string}}
1038
+ */
1039
+ export function releasePreflight({ version = null, expectedTag = null, releases = [], builtSha = null } = {}) {
1040
+ const tag = typeof expectedTag === 'string' && expectedTag !== ''
1041
+ ? expectedTag
1042
+ : (typeof version === 'string' && version !== '' ? `v${version}` : null)
1043
+ if (tag === null) {
1044
+ return { state: 'unknown', code: 'no-local-version', tag: null, owner: null, built: null, message: 'no package.json version to release' }
1045
+ }
1046
+ const match = Array.isArray(releases) ? releases.find((release) => release?.tag === tag) ?? null : null
1047
+ if (match === null) {
1048
+ return { state: 'ready', code: 'version-free', tag, owner: null, built: null, message: '' }
1049
+ }
1050
+ const owner = fullSha(match.targetCommitish)
1051
+ const built = fullSha(builtSha)
1052
+ if (owner !== null && built !== null && owner !== built) {
1053
+ return {
1054
+ state: 'blocked',
1055
+ code: 'version-taken',
1056
+ tag,
1057
+ owner,
1058
+ built,
1059
+ message: `release ${tag} was created from ${owner.slice(0, 7)}, but this run would build ${built.slice(0, 7)} — bump the version first`,
1060
+ }
1061
+ }
1062
+ return { state: 'ready', code: match.draft ? 'draft-replace' : 'same-commit', tag, owner, built, message: '' }
1063
+ }
1064
+
1065
+ /**
1066
+ * The next version in a `major.minor.patch` line, as a string.
1067
+ *
1068
+ * Deliberately refuses anything else. A prerelease or build suffix (`1.0.0-rc.1`)
1069
+ * is a human's decision about what the release means, and guessing a successor for
1070
+ * it is how a console publishes something nobody asked for.
1071
+ *
1072
+ * @param {string} version - current version.
1073
+ * @param {string} kind - `patch`, `minor`, or `major`.
1074
+ * @returns {{ok: true, from: string, to: string}|{ok: false, message: string}}
1075
+ */
1076
+ export function nextVersion(version, kind) {
1077
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(typeof version === 'string' ? version.trim() : '')
1078
+ if (match === null) return { ok: false, message: `package.json version is not a plain major.minor.patch: ${String(version)}` }
1079
+ if (!['patch', 'minor', 'major'].includes(kind)) return { ok: false, message: `unsupported release kind: ${String(kind)}` }
1080
+ const [major, minor, patch] = [Number(match[1]), Number(match[2]), Number(match[3])]
1081
+ const to = kind === 'major' ? `${major + 1}.0.0` : kind === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`
1082
+ return { ok: true, from: `${major}.${minor}.${patch}`, to }
1083
+ }
1084
+
1085
+ /**
1086
+ * Rewrite the top-level `version` of a package.json text, byte-for-byte elsewhere.
1087
+ *
1088
+ * `JSON.parse` + `JSON.stringify` would reformat the whole manifest — key order,
1089
+ * indentation, the escaping of every non-ASCII string — and those files carry
1090
+ * hand-written prose in their metadata. A release commit must contain exactly one
1091
+ * changed line. Only an unambiguous single occurrence is accepted: two `version`
1092
+ * keys mean the text is not a manifest this can safely edit.
1093
+ *
1094
+ * @param {string} source - the file's text.
1095
+ * @param {string} to - the new version.
1096
+ * @returns {{ok: true, text: string}|{ok: false, message: string}}
1097
+ */
1098
+ export function rewriteVersion(source, to) {
1099
+ if (typeof source !== 'string' || source === '') return { ok: false, message: 'package.json is empty' }
1100
+ if (!/^\d+\.\d+\.\d+$/.test(String(to))) return { ok: false, message: `refusing to write a non-version: ${String(to)}` }
1101
+ const matches = [...source.matchAll(/^([ \t]*"version"[ \t]*:[ \t]*")([^"]*)(")/gm)]
1102
+ if (matches.length !== 1) {
1103
+ return { ok: false, message: `expected exactly one top-level "version" key, found ${matches.length}` }
1104
+ }
1105
+ const match = matches[0]
1106
+ return { ok: true, text: `${source.slice(0, match.index)}${match[1]}${to}${match[3]}${source.slice(match.index + match[0].length)}` }
1107
+ }
1108
+
1109
+ /**
1110
+ * Read what the local checkout knows that the remote does not.
1111
+ *
1112
+ * This is the half of "should I release?" that GitHub cannot answer: a tree with
1113
+ * uncommitted work, or commits that exist only locally, is not what a release tag
1114
+ * would capture. Every field degrades to null rather than failing the overview —
1115
+ * a checkout that is missing, or not a git repository, is a normal state.
1116
+ *
1117
+ * @param {string} localPath - configured absolute path.
1118
+ * @param {number} timeoutMs - per-command deadline.
1119
+ * @returns {Promise<object>} local state.
1120
+ */
1121
+ export async function readLocalState(localPath, timeoutMs) {
1122
+ if (localPath === '') return { available: false, reason: 'no localPath configured' }
1123
+ if (!existsSync(localPath)) return { available: false, reason: 'localPath does not exist' }
1124
+ if (!existsSync(join(localPath, '.git'))) return { available: false, reason: 'not a git checkout' }
1125
+
1126
+ const git = async (args) => {
1127
+ const result = await runTool('git', ['-C', localPath, ...args], timeoutMs)
1128
+ return result.ok ? result.stdout.trim() : null
1129
+ }
1130
+
1131
+ const [branch, status, counts, head] = await Promise.all([
1132
+ git(['rev-parse', '--abbrev-ref', 'HEAD']),
1133
+ git(['status', '--porcelain']),
1134
+ // `@{u}` fails when the branch has no upstream; that is reported as "unknown",
1135
+ // not as zero, because "0 ahead" and "cannot tell" mean different things here.
1136
+ git(['rev-list', '--left-right', '--count', '@{u}...HEAD']),
1137
+ // The commit a dispatch of this branch would build, when the branch is in sync
1138
+ // with its upstream. Comparisons that decide whether a release may proceed are
1139
+ // only made when the two provably agree (see `releasePreflight`).
1140
+ git(['rev-parse', 'HEAD']),
1141
+ ])
1142
+
1143
+ let ahead = null
1144
+ let behind = null
1145
+ if (typeof counts === 'string') {
1146
+ const [behindRaw, aheadRaw] = counts.split(/\s+/)
1147
+ if (/^\d+$/.test(behindRaw ?? '') && /^\d+$/.test(aheadRaw ?? '')) {
1148
+ behind = Number(behindRaw)
1149
+ ahead = Number(aheadRaw)
1150
+ }
1151
+ }
1152
+
1153
+ /* `\r` only: a leading space is the X half of `XY`, so trimming here would throw
1154
+ away the status of every unstaged change (see `statusPath`). */
1155
+ const lines = typeof status === 'string'
1156
+ ? status.split('\n').map((line) => line.replace(/\r$/, '')).filter((line) => line.trim() !== '')
1157
+ : null
1158
+
1159
+ return {
1160
+ available: true,
1161
+ branch: branch ?? null,
1162
+ head: typeof head === 'string' && head !== '' ? head : null,
1163
+ dirty: lines === null ? null : lines.length,
1164
+ /*
1165
+ * The names, not just the count.
1166
+ *
1167
+ * 提交 commits the working tree with `git add -A`, and a count is not enough to
1168
+ * press that button honestly — "7 changes" could be the three files you meant and
1169
+ * four you have never seen. Capped, because this rides in the overview the panel
1170
+ * polls: the count is the number that matters, these are the ones that fit.
1171
+ */
1172
+ files: lines === null ? null : lines.slice(0, LOCAL_FILE_LIMIT).map(statusPath),
1173
+ ahead,
1174
+ behind,
1175
+ upstreamKnown: ahead !== null,
1176
+ }
1177
+ }
1178
+
1179
+ /* --------------------------------------------------- installed copy, releases -- */
1180
+
1181
+ /**
1182
+ * Where the running profile keeps its `package.json`.
1183
+ *
1184
+ * `DSH_PROFILE_DIR` is written for shell tools rather than for this process, so it
1185
+ * is only the first guess. The authoritative answer is the `profileContext`
1186
+ * service, which knows the directory it is actually running from; the environment
1187
+ * is the fallback for a Host that composes this plugin without that service.
1188
+ *
1189
+ * @param {object} [env] - environment to read, injectable for tests.
1190
+ * @returns {string} absolute path of the profile directory.
1191
+ */
1192
+ export function resolveProfileDir(env = process.env) {
1193
+ const explicit = text(env.DSH_PROFILE_DIR)
1194
+ if (explicit !== '') return explicit
1195
+ const home = text(env.DSH_HOME) !== ''
1196
+ ? text(env.DSH_HOME)
1197
+ : join(text(env.USERPROFILE, text(env.HOME, '.')), '.dsh')
1198
+ return join(home, 'profiles', text(env.DSH_PROFILE, 'desktop'))
1199
+ }
1200
+
1201
+ /** Read `name` and `version` from one directory's package.json, or null. */
1202
+ export function readManifest(directory) {
1203
+ if (typeof directory !== 'string' || directory === '') return null
1204
+ try {
1205
+ const parsed = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
1206
+ if (parsed === null || typeof parsed !== 'object') return null
1207
+ return {
1208
+ name: typeof parsed.name === 'string' ? parsed.name : '',
1209
+ version: typeof parsed.version === 'string' ? parsed.version : null,
1210
+ bundle: parsed.dsh?.bundle !== undefined,
1211
+ /**
1212
+ * `private: true` is the one manifest field that makes `publish` impossible, and
1213
+ * it is a deliberate choice by the author rather than a mistake — so the panel
1214
+ * reports it as a reason, instead of offering a button that must fail.
1215
+ */
1216
+ private: parsed.private === true,
1217
+ }
1218
+ } catch {
1219
+ return null
1220
+ }
1221
+ }
1222
+
1223
+ /** The comparison form of a path, so `F:\x` and `f:/x/` are the same checkout. */
1224
+ function comparablePath(value) {
1225
+ const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/, '')
1226
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized
1227
+ }
1228
+
1229
+ /**
1230
+ * Classify one profile dependency spec.
1231
+ *
1232
+ * The distinction that matters is where the code actually comes from. `link:` is a
1233
+ * symlink into a checkout, so the profile is running files that were never
1234
+ * published; a `file:` tarball IS the published artifact. Reporting both as
1235
+ * "installed v1.2.3" would hide the only difference the update button exists for.
1236
+ *
1237
+ * The shape test must NOT be `path.isAbsolute` alone. That function is
1238
+ * platform-specific: on a POSIX Host it reads `F:\CodeProj\x` as RELATIVE, so every
1239
+ * `link:`/`file:` spec a Windows profile writes classified as `other`. Measured on
1240
+ * the Linux CI runner: three checks failed and `an installed tarball reports the
1241
+ * version from node_modules` printed `"kind":"other"`. The consequence is the exact
1242
+ * bug this judgement exists to prevent — a `link:` checkout whose version matches
1243
+ * the release would be reported as `current`, with no way to tell that the profile
1244
+ * is not running the published code. A `link:`/`file:` prefix and a drive-letter
1245
+ * path therefore declare a local path by their TEXT, on every platform.
1246
+ *
1247
+ * @param {unknown} spec - the dependency value from the profile manifest.
1248
+ * @returns {{kind: 'link'|'path'|'tarball'|'registry'|'other', path: string|null, range: string|null}}
1249
+ */
1250
+ export function classifySpec(spec) {
1251
+ const value = typeof spec === 'string' ? spec.trim() : ''
1252
+ if (value === '') return { kind: 'other', path: null, range: null }
1253
+ const linked = /^link:/i.test(value)
1254
+ const filed = /^file:/i.test(value)
1255
+ const raw = value.replace(/^(?:file|link):/i, '')
1256
+ const declaresAPath = linked || filed || isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)
1257
+ if (declaresAPath) {
1258
+ if (raw === '') return { kind: 'other', path: null, range: null }
1259
+ if (linked) return { kind: 'link', path: raw, range: null }
1260
+ return { kind: /\.(?:tgz|tar\.gz)$/i.test(raw) ? 'tarball' : 'path', path: raw, range: null }
1261
+ }
1262
+ // A protocol or host alias is neither a path this can read nor a version this can
1263
+ // compare: guessing a version for `github:me/repo` would be an invented answer.
1264
+ if (/^(?:workspace:|npm:|git\+|git:|git@|github:|gitlab:|bitbucket:|ssh:|https?:)/i.test(value)) {
1265
+ return { kind: 'other', path: null, range: null }
1266
+ }
1267
+ return { kind: 'registry', path: null, range: value }
1268
+ }
1269
+
1270
+ /**
1271
+ * What this profile has installed for one package name.
1272
+ *
1273
+ * A checkout can publish under a name that is not its directory's name (the
1274
+ * checkout in `dsh-plugin-knowledge-console` publishes `dsh-knowledge-console`),
1275
+ * so the name is not enough on its own: when nothing matches it, the specs are
1276
+ * scanned for one that points at the configured checkout.
1277
+ *
1278
+ * @param {string} profileDir - the profile directory.
1279
+ * @param {string} packageName - the package the repository publishes.
1280
+ * @param {string} [localPath] - the configured checkout, if any.
1281
+ * @returns {object} the install record the panel renders.
1282
+ */
1283
+ export function readProfileInstall(profileDir, packageName, localPath = '') {
1284
+ const base = {
1285
+ packageName: typeof packageName === 'string' ? packageName : '',
1286
+ present: false,
1287
+ spec: '',
1288
+ kind: 'other',
1289
+ path: null,
1290
+ installedVersion: null,
1291
+ profileDir,
1292
+ profileReadable: false,
1293
+ }
1294
+ if (readManifest(profileDir) === null) return base
1295
+ const parsed = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
1296
+ const dependencies = parsed?.dependencies !== null && typeof parsed?.dependencies === 'object' ? parsed.dependencies : {}
1297
+ let key = base.packageName !== '' && Object.hasOwn(dependencies, base.packageName) ? base.packageName : null
1298
+ if (key === null && typeof localPath === 'string' && localPath !== '') {
1299
+ const wanted = comparablePath(localPath)
1300
+ for (const [candidate, spec] of Object.entries(dependencies)) {
1301
+ const classified = classifySpec(spec)
1302
+ if (classified.path !== null && comparablePath(classified.path) === wanted) {
1303
+ key = candidate
1304
+ break
1305
+ }
1306
+ }
1307
+ }
1308
+ if (key === null) return { ...base, profileReadable: true }
1309
+ const spec = String(dependencies[key] ?? '')
1310
+ const classified = classifySpec(spec)
1311
+ const installed = readManifest(join(profileDir, 'node_modules', key))
1312
+ return {
1313
+ packageName: key,
1314
+ present: true,
1315
+ spec,
1316
+ kind: classified.kind,
1317
+ path: classified.path,
1318
+ installedVersion: installed?.version ?? null,
1319
+ profileDir,
1320
+ profileReadable: true,
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Compare two `major.minor.patch` versions.
1326
+ *
1327
+ * Returns null rather than guessing for anything else — a prerelease, a range, a
1328
+ * version read from a directory that is not a package. "Cannot tell" and "older"
1329
+ * lead to different sentences on screen, and only one of them justifies a button.
1330
+ *
1331
+ * @param {unknown} left - installed version.
1332
+ * @param {unknown} right - released version.
1333
+ * @returns {-1|0|1|null} the order, or null when the two are not comparable.
1334
+ */
1335
+ export function compareVersions(left, right) {
1336
+ const parse = (value) => {
1337
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(typeof value === 'string' ? value.trim() : '')
1338
+ return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
1339
+ }
1340
+ const a = parse(left)
1341
+ const b = parse(right)
1342
+ if (a === null || b === null) return null
1343
+ for (const at of [0, 1, 2]) {
1344
+ if (a[at] !== b[at]) return a[at] < b[at] ? -1 : 1
1345
+ }
1346
+ return 0
1347
+ }
1348
+
1349
+ /** `v1.2.3` as `1.2.3`; anything else as null. */
1350
+ export function versionFromTag(tag) {
1351
+ const match = /^v?(\d+\.\d+\.\d+)$/.exec(typeof tag === 'string' ? tag.trim() : '')
1352
+ return match === null ? null : match[1]
1353
+ }
1354
+
1355
+ /**
1356
+ * The newest published release that carries an installable `.tgz`.
1357
+ *
1358
+ * Drafts are skipped — a draft is not downloadable by anyone but its author, and
1359
+ * "update to the draft" is not a thing the panel should offer. A release without a
1360
+ * tarball is skipped too: `.zip` is the tree, not what `pnpm add` takes.
1361
+ *
1362
+ * @param {object[]} releases - normalized releases, newest first as GitHub returns them.
1363
+ * @param {string} [tag] - an explicit tag, when the caller named one.
1364
+ * @returns {{tag: string, asset: string, version: string|null}|null} the choice, or null.
1365
+ */
1366
+ export function pickInstallableRelease(releases, tag = '') {
1367
+ const list = Array.isArray(releases) ? releases : []
1368
+ const wanted = text(tag)
1369
+ for (const release of list) {
1370
+ if (release === null || typeof release !== 'object') continue
1371
+ if (release.draft === true) continue
1372
+ const releaseTag = text(release.tag)
1373
+ if (releaseTag === '') continue
1374
+ if (wanted !== '' && releaseTag !== wanted) continue
1375
+ const assets = Array.isArray(release.assets) ? release.assets : []
1376
+ const asset = assets.find((candidate) => /\.(?:tgz|tar\.gz)$/i.test(text(candidate?.name)))
1377
+ if (asset === undefined) continue
1378
+ return { tag: releaseTag, asset: text(asset.name), version: versionFromTag(releaseTag) }
1379
+ }
1380
+ return null
1381
+ }
1382
+
1383
+ /**
1384
+ * What installing the newest release would mean for this profile.
1385
+ *
1386
+ * `checkout` is the state the version comparison cannot reach and the reason this
1387
+ * feature exists: a `link:` checkout and a published tarball can carry the very
1388
+ * same version string while being different code, and only one of them is what a
1389
+ * user who downloaded the release would run.
1390
+ *
1391
+ * @param {object} install - from `readProfileInstall`.
1392
+ * @param {object|null} latest - from `pickInstallableRelease`.
1393
+ * @returns {string} one of not-installed / no-release / current / update / ahead / differs / checkout.
1394
+ */
1395
+ export function updateState(install, latest) {
1396
+ if (install === null || typeof install !== 'object' || install.present !== true) return 'not-installed'
1397
+ if (latest === null || latest === undefined) return 'no-release'
1398
+ if (install.kind === 'link' || install.kind === 'path') return 'checkout'
1399
+ const order = compareVersions(install.installedVersion, latest.version ?? versionFromTag(latest.tag))
1400
+ if (order === 0) return 'current'
1401
+ if (order === null) return 'differs'
1402
+ return order < 0 ? 'update' : 'ahead'
1403
+ }
1404
+
1405
+ /** The repository part of `owner/name`. */
1406
+ export function bareRepoName(repo) {
1407
+ const value = text(repo)
1408
+ return value.includes('/') ? value.slice(value.indexOf('/') + 1) : value
1409
+ }
1410
+
1411
+ /**
1412
+ * Everything the panel shows about "is the installed copy the released one".
1413
+ *
1414
+ * @param {object} params - the two sides.
1415
+ * @param {string} params.profileDir - profile directory.
1416
+ * @param {string} params.profileName - profile name, for the sentence on screen.
1417
+ * @param {object} params.entry - the configured repository entry.
1418
+ * @param {object[]} params.releases - that repository's normalized releases.
1419
+ * @returns {object} the install block carried by every overview row.
1420
+ */
1421
+ export function describeInstall({ profileDir, profileName, entry, releases } = {}) {
1422
+ const checkout = readManifest(entry?.localPath ?? '')
1423
+ const declared = checkout !== null && checkout.name !== '' ? checkout.name : bareRepoName(entry?.repo ?? '')
1424
+ const install = readProfileInstall(profileDir, declared, entry?.localPath ?? '')
1425
+ const latest = pickInstallableRelease(releases)
1426
+ return {
1427
+ profile: text(profileName, 'desktop'),
1428
+ profileDir,
1429
+ profileReadable: install.profileReadable === true,
1430
+ packageName: install.present === true ? install.packageName : declared,
1431
+ present: install.present === true,
1432
+ spec: install.spec,
1433
+ kind: install.kind,
1434
+ installedVersion: install.installedVersion,
1435
+ latestTag: latest === null ? null : latest.tag,
1436
+ latestVersion: latest === null ? null : (latest.version ?? versionFromTag(latest.tag)),
1437
+ latestAsset: latest === null ? null : latest.asset,
1438
+ state: updateState(install, latest),
1439
+ }
1440
+ }
1441
+
1442
+ /* ------------------------------------------------------------- npm registry -- */
1443
+
1444
+ /**
1445
+ * The registry to talk to, in the form every URL below assumes.
1446
+ *
1447
+ * A registry is a base, not a package URL: normalizing the trailing slash once here
1448
+ * is what keeps `${registry}${name}` right for both `https://registry.npmjs.org` and
1449
+ * a private registry written with a path. Anything that is not an http(s) URL without
1450
+ * embedded credentials falls back to the default, because a credential smuggled into
1451
+ * a registry URL would end up in the panel and in logs.
1452
+ *
1453
+ * @param {unknown} value - configured or defaulted registry.
1454
+ * @param {string} [fallback] - what to answer when the value is unusable.
1455
+ * @returns {string} a registry base ending in `/`.
1456
+ */
1457
+ export function normalizeRegistry(value, fallback = DEFAULT_NPM_REGISTRY) {
1458
+ const raw = text(value)
1459
+ if (raw === '') return fallback
1460
+ try {
1461
+ const url = new URL(raw)
1462
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return fallback
1463
+ if (url.username !== '' || url.password !== '') return fallback
1464
+ return url.href.endsWith('/') ? url.href : `${url.href}/`
1465
+ } catch {
1466
+ return fallback
1467
+ }
1468
+ }
1469
+
1470
+ /** The `//host/path/` prefix an `.npmrc` auth line is keyed by. */
1471
+ export function npmrcAuthKey(registry) {
1472
+ const normalized = normalizeRegistry(registry)
1473
+ const url = new URL(normalized)
1474
+ const path = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
1475
+ return `//${url.host}${path}`
1476
+ }
1477
+
1478
+ /**
1479
+ * Where the user-level `.npmrc` is.
1480
+ *
1481
+ * This is the file `npm login` and `pnpm login` write, and it is the only credential
1482
+ * store this feature touches: the plugin holds no token of its own, exactly as it
1483
+ * holds none of `gh`'s.
1484
+ *
1485
+ * @param {object} [env] - environment, injectable for tests.
1486
+ * @returns {string} absolute path.
1487
+ */
1488
+ export function resolveNpmrcPath(env = process.env) {
1489
+ const explicit = text(env.NPM_CONFIG_USERCONFIG)
1490
+ if (explicit !== '') return explicit
1491
+ return join(text(env.USERPROFILE, text(env.HOME, '.')), '.npmrc')
1492
+ }
1493
+
1494
+ /**
1495
+ * Whether an `.npmrc` text carries a token for this registry.
1496
+ *
1497
+ * Only the presence of the key is answered. The value is deliberately not read out:
1498
+ * a secret this plugin has no use for is a secret it must not hold, and returning it
1499
+ * would put a live publish token into a JSON response and into whatever logs it.
1500
+ *
1501
+ * @param {string} source - the file's text.
1502
+ * @param {string} registry - registry base.
1503
+ * @returns {boolean} whether a usable line exists.
1504
+ */
1505
+ export function hasNpmToken(source, registry) {
1506
+ if (typeof source !== 'string' || source === '') return false
1507
+ const key = npmrcAuthKey(registry)
1508
+ for (const line of source.split(/\r?\n/)) {
1509
+ const trimmed = line.trim()
1510
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith(';')) continue
1511
+ const separator = trimmed.indexOf('=')
1512
+ if (separator === -1) continue
1513
+ const name = trimmed.slice(0, separator).trim()
1514
+ const value = trimmed.slice(separator + 1).trim()
1515
+ if ((name === `${key}:_authToken` || name === `${key}:_auth`) && value !== '') return true
1516
+ }
1517
+ return false
1518
+ }
1519
+
1520
+ /**
1521
+ * Put one token into an `.npmrc` text, replacing that registry's line and nothing else.
1522
+ *
1523
+ * `.npmrc` is line-oriented, so a value that could start a new line or a comment is
1524
+ * refused rather than escaped: the only thing worse than a rejected token is a token
1525
+ * that silently became two lines. Every other line — other registries, other
1526
+ * settings, comments — is preserved byte for byte, because this file usually belongs
1527
+ * to other tools too.
1528
+ *
1529
+ * @param {string} source - current file text (may be empty).
1530
+ * @param {string} registry - registry base.
1531
+ * @param {string} token - the token to write.
1532
+ * @returns {{ok: true, text: string, replaced: boolean}|{ok: false, message: string}}
1533
+ */
1534
+ export function upsertAuthToken(source, registry, token) {
1535
+ const value = typeof token === 'string' ? token.trim() : ''
1536
+ if (value === '') return { ok: false, message: 'the token is empty' }
1537
+ if (!/^[A-Za-z0-9_\-.:+/=]+$/.test(value)) {
1538
+ return { ok: false, message: 'the token has characters an .npmrc line cannot hold' }
1539
+ }
1540
+ const key = npmrcAuthKey(registry)
1541
+ const wanted = [`${key}:_authToken`, `${key}:_auth`]
1542
+ const line = `${key}:_authToken=${value}`
1543
+ const lines = (typeof source === 'string' ? source : '').split(/\r?\n/)
1544
+ let replaced = false
1545
+ const next = lines.map((candidate) => {
1546
+ const trimmed = candidate.trim()
1547
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith(';')) return candidate
1548
+ const separator = trimmed.indexOf('=')
1549
+ if (separator === -1) return candidate
1550
+ if (!wanted.includes(trimmed.slice(0, separator).trim())) return candidate
1551
+ replaced = true
1552
+ return line
1553
+ })
1554
+ if (!replaced) {
1555
+ // Keep the file's own shape: an .npmrc conventionally ends with a newline, which
1556
+ // splits into a final empty element that the new line goes before, not after.
1557
+ if (next.length > 0 && next[next.length - 1].trim() === '') next.splice(next.length - 1, 0, line)
1558
+ else next.push(line)
1559
+ }
1560
+ return { ok: true, text: next.join('\n'), replaced }
1561
+ }
1562
+
1563
+ /**
1564
+ * What the registry already knows about one package version.
1565
+ *
1566
+ * `versions` is the only real evidence: `dist-tags.latest` alone cannot answer "is
1567
+ * THIS version published", and a version that is already on npm can never be
1568
+ * republished — so the difference between the two decides whether the button exists
1569
+ * or is a trap.
1570
+ *
1571
+ * @param {unknown} packument - the registry document, or null for "404, nobody owns this name".
1572
+ * @param {string|null} version - the local version.
1573
+ * @returns {{state: 'unregistered'|'published'|'unpublished'|'unknown', latest: string|null, published: boolean}}
1574
+ */
1575
+ export function npmPackageState(packument, version) {
1576
+ if (packument === null || packument === undefined) return { state: 'unregistered', latest: null, published: false }
1577
+ if (typeof packument !== 'object') return { state: 'unknown', latest: null, published: false }
1578
+ const versions = packument.versions !== null && typeof packument.versions === 'object' ? packument.versions : {}
1579
+ const distTags = packument['dist-tags'] !== null && typeof packument['dist-tags'] === 'object' ? packument['dist-tags'] : {}
1580
+ const latest = text(distTags.latest)
1581
+ const published = typeof version === 'string' && version !== '' && Object.hasOwn(versions, version)
1582
+ return { state: published ? 'published' : 'unpublished', latest: latest === '' ? null : latest, published }
1583
+ }
1584
+
1585
+ /**
1586
+ * The URL a packument is read from.
1587
+ *
1588
+ * `?write=true` is how npm's own publish path asks for the document it is about to
1589
+ * write against, and here it is not decoration — it is the difference between the
1590
+ * panel agreeing with a publish and calling it a failure. Measured on this machine
1591
+ * against a package that was just published:
1592
+ *
1593
+ * GET /<pkg> cache-control: public, max-age=300, age: 116
1594
+ * GET /<pkg>?write=true cache-control: public, max-age=300, age: — (origin)
1595
+ *
1596
+ * The registry's packument is served through a CDN that may hand back a copy up to
1597
+ * five minutes old, so for five minutes after a successful publish the panel went on
1598
+ * saying "npm 待推 v1.1.1" about a version that was already there — and, worse, kept
1599
+ * offering a push for a version the registry would refuse. A local disk cache has a
1600
+ * TTL and can be cleared; this one belongs to someone else, so the read has to ask
1601
+ * for the origin.
1602
+ *
1603
+ * @param {string} registry - normalized registry base.
1604
+ * @param {string} packageName - the package to ask about.
1605
+ * @returns {string} the URL.
1606
+ */
1607
+ export function packumentUrl(registry, packageName) {
1608
+ const base = String(registry)
1609
+ const query = base.indexOf('?')
1610
+ if (query === -1) return `${base}${packageName}?write=true`
1611
+ // A base that already carries a query puts the package name BEFORE it, not inside it.
1612
+ return `${base.slice(0, query)}${packageName}${base.slice(query)}&write=true`
1613
+ }
1614
+
1615
+ /**
1616
+ * Whether this machine can publish, as three states rather than a boolean.
1617
+ *
1618
+ * `whoami` is the obvious probe and not a sufficient one. It is a user-level
1619
+ * endpoint, while a granular access token — the only kind npm has issued since
1620
+ * November 2025 — is scoped to packages, so it can be refused there and still work
1621
+ * for `publish`. Reading that refusal as "not signed in" would block the very
1622
+ * credential npm now tells everyone to create, which is the worst possible place for
1623
+ * a false negative: the panel would tell a first-time publisher that their brand-new
1624
+ * token is not a credential.
1625
+ *
1626
+ * So a token line in `.npmrc` counts as a credential on its own, and `whoami` is what
1627
+ * turns it into a name:
1628
+ * signed-in whoami answered; the account is known.
1629
+ * credential-present a token line exists but whoami would not confirm it. The
1630
+ * publish itself is the real test, and its failure is classified.
1631
+ * none nothing to authenticate with — where the guide belongs.
1632
+ *
1633
+ * @param {object} params - the two probes.
1634
+ * @param {boolean} params.whoami - whether `whoami` answered.
1635
+ * @param {boolean} params.hasToken - whether `.npmrc` carries a token for this registry.
1636
+ * @returns {'signed-in'|'credential-present'|'none'}
1637
+ */
1638
+ export function npmAuthState({ whoami = false, hasToken = false } = {}) {
1639
+ if (whoami === true) return 'signed-in'
1640
+ return hasToken === true ? 'credential-present' : 'none'
1641
+ }
1642
+
1643
+ /**
1644
+ * Whether the push should be offered, and every reason it should not be.
1645
+ *
1646
+ * The blockers are named strings rather than a boolean because "there is no button"
1647
+ * is the least useful thing a panel can say: `private-package` and `not-logged-in`
1648
+ * are the same absence on screen and completely different fixes.
1649
+ *
1650
+ * @param {object} params - what was read about this repository. `authed` means a
1651
+ * credential EXISTS (see `npmAuthState`), not that the account is confirmed.
1652
+ * @returns {{state: string, latest: string|null, packageName: string, version: string|null, blockers: string[], canPublish: boolean}}
1653
+ */
1654
+ export function npmPublishVerdict({ packageName = '', version = null, manifest = null, registryState = null, authed = false, dirty = null } = {}) {
1655
+ const blockers = []
1656
+ if (packageName === '') blockers.push('no-package-name')
1657
+ if (manifest === null) blockers.push('no-checkout')
1658
+ else if (manifest.private === true) blockers.push('private-package')
1659
+ if (version === null || version === '') blockers.push('no-version')
1660
+ if (authed !== true) blockers.push('not-logged-in')
1661
+ if (registryState === null || registryState.state === 'unknown') blockers.push('registry-unreachable')
1662
+ else if (registryState.published === true) blockers.push('already-published')
1663
+ if (Number.isFinite(dirty) && dirty > 0) blockers.push('dirty-tree')
1664
+ return {
1665
+ state: registryState === null ? 'unknown' : registryState.state,
1666
+ latest: registryState === null ? null : registryState.latest,
1667
+ packageName,
1668
+ version: version === null || version === '' ? null : version,
1669
+ blockers,
1670
+ canPublish: blockers.length === 0,
1671
+ }
1672
+ }
1673
+
1674
+ /**
1675
+ * What a failed publish actually means, as a name the panel can answer.
1676
+ *
1677
+ * A raw npm error is the least useful thing to show someone who has never made a
1678
+ * token: `EOTP` and `E403` are one line of jargon each and completely different
1679
+ * fixes. The patterns are matched most-specific-first, because `E403` appears inside
1680
+ * messages that are about an unverified email or a token scoped to another package.
1681
+ *
1682
+ * @param {unknown} output - combined stdout and stderr of the publish.
1683
+ * @returns {string} one of otp-required / email-unverified / not-logged-in /
1684
+ * already-published / payment-required / forbidden / not-found / rate-limited /
1685
+ * registry-error / network / unknown.
1686
+ */
1687
+ export function classifyPublishFailure(output) {
1688
+ const text = typeof output === 'string' ? output : ''
1689
+ if (text.trim() === '') return 'unknown'
1690
+ const rules = [
1691
+ [/one-time password|one-time passcode|\bEOTP\b|ERR_PNPM_OTP/i, 'otp-required'],
1692
+ [/verify your email|email address.{0,40}not verified|unverified email/i, 'email-unverified'],
1693
+ [/EPUBLISHCONFLICT|cannot publish over|previously published/i, 'already-published'],
1694
+ [/E402|Payment Required|private packages? (?:require|need)/i, 'payment-required'],
1695
+ [/ENEEDAUTH|\bE401\b|You must be logged in|unauthorized/i, 'not-logged-in'],
1696
+ [/\bE403\b|Forbidden|not authorized|does not have permission/i, 'forbidden'],
1697
+ [/\bE404\b|Not Found/i, 'not-found'],
1698
+ [/\bE429\b|Too Many Requests/i, 'rate-limited'],
1699
+ [/\bE5\d\d\b|Internal Server Error|Service Unavailable|Bad Gateway/i, 'registry-error'],
1700
+ [/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|socket hang up|network/i, 'network'],
1701
+ ]
1702
+ for (const [pattern, code] of rules) {
1703
+ if (pattern.test(text)) return code
1704
+ }
1705
+ return 'unknown'
1706
+ }
1707
+
1708
+ /**
1709
+ * The package manager this Host itself uses.
1710
+ *
1711
+ * Reused rather than re-found, for the same reason `gh` is reused instead of a token:
1712
+ * the Host already resolved one, it is the exact one the plugin manager installs
1713
+ * with, and a second answer would eventually disagree with the first. The fallbacks
1714
+ * are the two real ones — an explicit `DSH_PNPM`, and the packaged application's own
1715
+ * bundled pnpm, which is reachable only because this process knows where it started.
1716
+ *
1717
+ * @param {object} ctx - Host plugin context.
1718
+ * @returns {{command: string, args: string[], env: object, source: string}|null}
1719
+ */
1720
+ export function resolvePackageManagerInvocation(ctx) {
1721
+ const profile = typeof ctx?.get === 'function' ? ctx.get('profileContext') : undefined
1722
+ const invocation = profile?.packageManager
1723
+ if (invocation !== null && invocation !== undefined && typeof invocation.command === 'string' && invocation.command !== '') {
1724
+ return {
1725
+ command: invocation.command,
1726
+ args: Array.isArray(invocation.args) ? invocation.args.map((arg) => String(arg)) : [],
1727
+ env: invocation.env !== null && typeof invocation.env === 'object' ? { ...invocation.env } : {},
1728
+ source: 'profile',
1729
+ }
1730
+ }
1731
+ const explicit = text(process.env.DSH_PNPM)
1732
+ if (explicit !== '') return { command: explicit, args: [], env: {}, source: 'DSH_PNPM' }
1733
+ /*
1734
+ * The packaged application ships pnpm under `resources/runtime`, and its own
1735
+ * launcher runs it through the Electron binary with ELECTRON_RUN_AS_NODE — the same
1736
+ * mechanism, not an invented path. Below that, `pnpm` on PATH is the honest last
1737
+ * answer; a command that does not exist fails with ENOENT naming it, which is a
1738
+ * better report than this plugin guessing at an install layout it cannot see.
1739
+ */
1740
+ const runtime = join(dirname(process.execPath), 'resources', 'runtime', 'pnpm', 'bin', 'pnpm.cjs')
1741
+ if (existsSync(runtime)) {
1742
+ return { command: process.execPath, args: [runtime], env: { ELECTRON_RUN_AS_NODE: '1' }, source: 'bundled' }
1743
+ }
1744
+ return { command: 'pnpm', args: [], env: {}, source: 'path' }
1745
+ }
1746
+
1747
+ /**
1748
+ * How many entries are uncommitted in a checkout, or null when it cannot be read.
1749
+ *
1750
+ * One `git status` rather than `readLocalState`'s four commands: the npm status is
1751
+ * asked for per repository, and four spawns each would make opening the panel the
1752
+ * most expensive thing it does.
1753
+ *
1754
+ * @param {string} localPath - configured checkout.
1755
+ * @param {number} timeoutMs - deadline.
1756
+ * @returns {Promise<number|null>} the count, or null when there is no checkout to ask.
1757
+ */
1758
+ export async function readDirtyCount(localPath, timeoutMs) {
1759
+ if (typeof localPath !== 'string' || localPath === '' || !existsSync(join(localPath, '.git'))) return null
1760
+ const result = await runTool('git', ['-C', localPath, 'status', '--porcelain'], timeoutMs)
1761
+ if (result.ok !== true) return null
1762
+ return result.stdout.split('\n').filter((line) => line.trim() !== '').length
1763
+ }
1764
+
1765
+ /* --------------------------------------------------------------- overview -- */
1766
+
1767
+ /** Resolve a configured entry to the `owner/name` slug `gh` expects. */
1768
+ export function resolveSlug(owner, repo) {
1769
+ if (SLUG_WITH_OWNER.test(repo)) return repo
1770
+ if (owner === '') return null
1771
+ return `${owner}/${repo}`
1772
+ }
1773
+
1774
+ /** Normalize one gh run record into the panel's shape. */
1775
+ function normalizeRun(run) {
1776
+ if (run === null || typeof run !== 'object') return null
1777
+ return {
1778
+ id: Number.isInteger(run.databaseId) ? run.databaseId : null,
1779
+ workflow: typeof run.workflowName === 'string' ? run.workflowName : '',
1780
+ title: typeof run.displayTitle === 'string' ? run.displayTitle : '',
1781
+ status: typeof run.status === 'string' ? run.status : '',
1782
+ conclusion: typeof run.conclusion === 'string' ? run.conclusion : '',
1783
+ event: typeof run.event === 'string' ? run.event : '',
1784
+ branch: typeof run.headBranch === 'string' ? run.headBranch : '',
1785
+ createdAt: typeof run.createdAt === 'string' ? run.createdAt : '',
1786
+ updatedAt: typeof run.updatedAt === 'string' ? run.updatedAt : '',
1787
+ url: typeof run.url === 'string' ? run.url : '',
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * Normalize one REST release record into the panel's shape.
1793
+ *
1794
+ * `targetCommitish` is kept because it is the only evidence for the question the
1795
+ * release workflow asks itself: does this version already belong to a different
1796
+ * commit? The workflow passes `--target "$GITHUB_SHA"`, so it is a full commit id
1797
+ * for every release this set produces; a release made by hand can carry a branch
1798
+ * name there instead, which `fullSha` treats as "cannot prove" rather than as a
1799
+ * mismatch.
1800
+ */
1801
+ function normalizeRelease(release) {
1802
+ if (release === null || typeof release !== 'object') return null
1803
+ const assets = Array.isArray(release.assets) ? release.assets : []
1804
+ return {
1805
+ tag: typeof release.tag_name === 'string' ? release.tag_name : '',
1806
+ name: typeof release.name === 'string' ? release.name : '',
1807
+ draft: release.draft === true,
1808
+ prerelease: release.prerelease === true,
1809
+ targetCommitish: typeof release.target_commitish === 'string' ? release.target_commitish : '',
1810
+ createdAt: typeof release.created_at === 'string' ? release.created_at : '',
1811
+ url: typeof release.html_url === 'string' ? release.html_url : '',
1812
+ assets: assets.map((asset) => ({
1813
+ name: typeof asset?.name === 'string' ? asset.name : '',
1814
+ size: Number.isFinite(asset?.size) ? asset.size : null,
1815
+ })),
1816
+ }
1817
+ }
1818
+
1819
+ /**
1820
+ * Collect one repository's remote and local state.
1821
+ *
1822
+ * The three remote calls are independent and each degrades on its own: a missing
1823
+ * release list must not blank out the run list. Anything that failed is reported
1824
+ * in `problems` so the panel can say which part is unknown instead of implying
1825
+ * "nothing there".
1826
+ *
1827
+ * @param {object} params - dependencies.
1828
+ * @returns {Promise<object>} one repository's overview entry.
1829
+ */
1830
+ export async function collectRepo({ config, ghPath, entry }) {
1831
+ const slug = resolveSlug(config.owner, entry.repo)
1832
+ const base = {
1833
+ repo: entry.repo,
1834
+ slug,
1835
+ label: entry.label !== '' ? entry.label : entry.repo,
1836
+ localPath: entry.localPath,
1837
+ problems: [],
1838
+ }
1839
+ if (slug === null) {
1840
+ return { ...base, problems: ['set `owner` in the row config, or write the entry as "owner/repo"'] }
1841
+ }
1842
+
1843
+ const [runsResult, releasesResult, workflowsResult] = await Promise.all([
1844
+ ghJson(ghPath, ['run', 'list', '-R', slug, '--limit', '6', '--json',
1845
+ 'databaseId,workflowName,displayTitle,status,conclusion,event,headBranch,createdAt,updatedAt,url'], config.requestTimeoutMs),
1846
+ ghJson(ghPath, ['api', `repos/${slug}/releases?per_page=5`], config.requestTimeoutMs),
1847
+ ghJson(ghPath, ['api', `repos/${slug}/actions/workflows`], config.requestTimeoutMs),
1848
+ ])
1849
+
1850
+ const runs = []
1851
+ if (runsResult.ok && Array.isArray(runsResult.value)) {
1852
+ for (const run of runsResult.value) {
1853
+ const normalized = normalizeRun(run)
1854
+ if (normalized !== null) runs.push(normalized)
1855
+ }
1856
+ } else if (!runsResult.ok) {
1857
+ base.problems.push(`runs: ${runsResult.message}`)
1858
+ }
1859
+
1860
+ const releases = []
1861
+ if (releasesResult.ok && Array.isArray(releasesResult.value)) {
1862
+ for (const release of releasesResult.value) {
1863
+ const normalized = normalizeRelease(release)
1864
+ if (normalized !== null) releases.push(normalized)
1865
+ }
1866
+ } else if (!releasesResult.ok) {
1867
+ base.problems.push(`releases: ${releasesResult.message}`)
1868
+ }
1869
+
1870
+ const workflows = []
1871
+ if (workflowsResult.ok && Array.isArray(workflowsResult.value?.workflows)) {
1872
+ for (const workflow of workflowsResult.value.workflows) {
1873
+ if (typeof workflow?.path === 'string') {
1874
+ workflows.push({ name: typeof workflow.name === 'string' ? workflow.name : '', path: workflow.path.replace(/^\.github\/workflows\//, ''), state: typeof workflow.state === 'string' ? workflow.state : '' })
1875
+ }
1876
+ }
1877
+ } else if (!workflowsResult.ok) {
1878
+ base.problems.push(`workflows: ${workflowsResult.message}`)
1879
+ }
1880
+
1881
+ const version = readLocalVersion(entry.localPath)
1882
+ const expectedTag = version === null ? null : `v${version}`
1883
+ const matching = expectedTag === null ? null : releases.find((release) => release.tag === expectedTag) ?? null
1884
+ /*
1885
+ * Without a local checkout there is no version to compare against — and that is
1886
+ * the normal state on a fresh install, where not one repository has a
1887
+ * `localPath` yet. GitHub's own release list is then the only truth, so it
1888
+ * decides: a row that reports 未发布 while a published release sits in its own
1889
+ * expanded detail is simply wrong, and it was wrong for exactly this reason.
1890
+ */
1891
+ const publishedRelease = matching !== null
1892
+ ? (matching.draft ? null : matching)
1893
+ : releases.find((release) => !release.draft) ?? null
1894
+ const draftRelease = matching !== null
1895
+ ? (matching.draft ? matching : null)
1896
+ : releases.find((release) => release.draft) ?? null
1897
+ const local = await readLocalState(entry.localPath, config.requestTimeoutMs)
1898
+ /*
1899
+ * The commit a dispatch of the configured branch would build, known only when
1900
+ * this checkout provably IS that commit: same branch, upstream known, and no
1901
+ * divergence in either direction. When either side is in doubt the panel says
1902
+ * nothing rather than guessing, because a wrong "this version is taken" sends
1903
+ * someone to bump a version that did not need bumping.
1904
+ */
1905
+ const builtSha = local.available === true
1906
+ && local.upstreamKnown === true
1907
+ && local.ahead === 0
1908
+ && local.behind === 0
1909
+ && local.branch === config.defaultBranch
1910
+ ? local.head ?? null
1911
+ : null
1912
+
1913
+ return {
1914
+ ...base,
1915
+ version,
1916
+ expectedTag,
1917
+ /** Whether the local version is known; when it is not, the state comes from GitHub. */
1918
+ versionKnown: version !== null,
1919
+ /** A published release exists — for the local version when known, else the newest. */
1920
+ published: publishedRelease !== null,
1921
+ publishedTag: publishedRelease === null ? null : publishedRelease.tag,
1922
+ /** A draft release is waiting, for the local version when known, else the newest. */
1923
+ draftTag: draftRelease === null ? null : draftRelease.tag,
1924
+ /**
1925
+ * Whether releasing the local version is possible right now, and why not when
1926
+ * it is not. Computed on the Host so the panel and the dispatch route cannot
1927
+ * disagree about it.
1928
+ */
1929
+ releaseCheck: (() => {
1930
+ const verdict = releasePreflight({ version, expectedTag, releases, builtSha })
1931
+ const next = version === null ? { ok: false } : nextVersion(version, 'patch')
1932
+ return {
1933
+ ...verdict,
1934
+ /** What the panel offers when the version is taken: the next patch, if any. */
1935
+ next: next.ok === true ? next.to : null,
1936
+ nextTag: next.ok === true ? `v${next.to}` : null,
1937
+ }
1938
+ })(),
1939
+ latestRun: runs[0] ?? null,
1940
+ runs,
1941
+ releases,
1942
+ workflows,
1943
+ hasBuildWorkflow: workflows.some((workflow) => workflow.path === config.buildWorkflow),
1944
+ hasReleaseWorkflow: workflows.some((workflow) => workflow.path === config.releaseWorkflow),
1945
+ local,
1946
+ }
1947
+ }
1948
+
1949
+ /* ------------------------------------------------------------------- apply -- */
1950
+
1951
+ /**
1952
+ * Mount the release-console routes.
1953
+ * @param {object} ctx - Host plugin context carrying `webServer`.
1954
+ * @param {object} [rawConfig] - row config from the profile patch.
1955
+ */
1956
+ export function apply(ctx, rawConfig) {
1957
+ const config = resolveConfig(rawConfig)
1958
+ const ghPath = resolveGhPath(config)
1959
+
1960
+ /** Overview cache: the panel polls, and each poll fans out three calls per repo. */
1961
+ let cache = null
1962
+
1963
+ /**
1964
+ * One install at a time.
1965
+ *
1966
+ * The plugin manager serialises through its own profile lock, so a second
1967
+ * request would not corrupt anything — it would queue behind the first while the
1968
+ * panel showed two spinners and neither could be cancelled. Refusing is clearer.
1969
+ */
1970
+ let updateInFlight = false
1971
+
1972
+ /**
1973
+ * The configuration this request should use.
1974
+ *
1975
+ * Resolved per request so `scripts/configure.mjs add` is visible on the next
1976
+ * poll rather than at the next restart — the whole point of moving the list out
1977
+ * of the hand-edited patch.
1978
+ */
1979
+ const live = () => effectiveConfig(config)
1980
+
1981
+ /*
1982
+ * Hand gh the proxy the browser is already using.
1983
+ *
1984
+ * Resolved once, from the row config or the Windows internet settings, because
1985
+ * gh reads HTTP(S)_PROXY and ignores the setting the browser follows — which is
1986
+ * how a machine can open github.com in a browser while `gh auth login` prints
1987
+ * nothing at all.
1988
+ *
1989
+ * Injected only once the proxy has answered on its port. A local proxy that is
1990
+ * not running would otherwise be worse than no proxy: gh would fail to connect to
1991
+ * 127.0.0.1 instead of trying GitHub directly. Until the check completes the
1992
+ * children simply go direct.
1993
+ */
1994
+ const proxy = resolveProxy(live().proxy)
1995
+ if (proxy.url !== null) {
1996
+ try {
1997
+ const parsed = new URL(proxy.url)
1998
+ const host = parsed.hostname
1999
+ const port = Number(parsed.port === '' ? (parsed.protocol === 'https:' ? 443 : 80) : parsed.port)
2000
+ void probeHost(host, port, 1_500).then((probe) => {
2001
+ if (probe.ok) activeProxyEnvironment = proxyEnvironment(proxy.url)
2002
+ })
2003
+ } catch {
2004
+ /* A malformed proxy URL is gh's to report; going direct is the safe default. */
2005
+ }
2006
+ }
2007
+
2008
+ const guard = (req, res) => {
2009
+ if (!isTrustedRequest(req)) {
2010
+ writeJson(res, 403, { ok: false, code: 'forbidden', message: 'release-console routes are loopback-only' })
2011
+ return false
2012
+ }
2013
+ if (req.method !== 'POST') {
2014
+ writeJson(res, 405, { ok: false, code: 'method-not-allowed', message: `method not allowed: ${req.method ?? ''}` })
2015
+ return false
2016
+ }
2017
+ return true
2018
+ }
2019
+
2020
+ /**
2021
+ * The profile this Host is running from.
2022
+ *
2023
+ * `profileContext` is the authority — it is the service that knows where it was
2024
+ * started before any environment variable was written for a shell. The
2025
+ * environment is the fallback for a composition without that service.
2026
+ */
2027
+ const profileFacts = () => {
2028
+ const service = typeof ctx.get === 'function' ? ctx.get('profileContext') : undefined
2029
+ const dir = text(service?.dir)
2030
+ return {
2031
+ dir: dir !== '' ? dir : resolveProfileDir(),
2032
+ name: text(service?.name, text(process.env.DSH_PROFILE, 'desktop')),
2033
+ }
2034
+ }
2035
+
2036
+ /** Resolve a body's `repo` to a configured entry, refusing anything unconfigured. */
2037
+ const findEntry = (current, body) => {
2038
+ const requested = typeof body?.repo === 'string' ? body.repo.trim() : ''
2039
+ if (requested === '') return { ok: false, message: 'body.repo is required' }
2040
+ const entry = current.repos.find((candidate) => candidate.repo === requested || candidate.label === requested)
2041
+ if (entry === undefined) return { ok: false, message: `repository is not configured: ${requested}` }
2042
+ const slug = resolveSlug(current.owner, entry.repo)
2043
+ if (slug === null) return { ok: false, message: 'set `owner` in the config, or write the entry as "owner/repo"' }
2044
+ return { ok: true, entry, slug }
2045
+ }
2046
+
2047
+ /* ------------------------------------------------------------- sign-in -- */
2048
+
2049
+ /**
2050
+ * The single in-flight sign-in, if any.
2051
+ *
2052
+ * `gh auth login --web` prints a one-time code and a URL and then polls GitHub
2053
+ * until the user approves in a browser. Measured behaviour, which is what makes
2054
+ * this drivable from a panel at all: with stdin left untouched and not a
2055
+ * terminal, `gh` still prints both, so nothing has to fake a keystroke — and
2056
+ * nothing does, because a stray newline would answer whatever prompt came next.
2057
+ */
2058
+ let authAttempt = null
2059
+
2060
+ const stopAuth = () => {
2061
+ const child = authAttempt?.child
2062
+ if (child === null || child === undefined) return
2063
+ try {
2064
+ child.kill()
2065
+ } catch {
2066
+ /* already gone */
2067
+ }
2068
+ }
2069
+
2070
+ const settleAuth = (state, message = null) => {
2071
+ if (authAttempt === null) return
2072
+ if (authAttempt.timer !== null) clearTimeout(authAttempt.timer)
2073
+ authAttempt.state = state
2074
+ authAttempt.message = message
2075
+ authAttempt.child = null
2076
+ authAttempt.timer = null
2077
+ }
2078
+
2079
+ const authSnapshot = () => authAttempt === null
2080
+ ? { state: 'idle', mode: null, code: null, url: null, message: null, startedAt: null, waitedMs: 0, stalled: false, reachable: null, reachabilityStage: null, reachabilityAddress: null, outputBytes: 0, outputExcerpt: null }
2081
+ : {
2082
+ state: authAttempt.state,
2083
+ mode: authAttempt.mode,
2084
+ code: authAttempt.code,
2085
+ url: authAttempt.url,
2086
+ message: authAttempt.message,
2087
+ startedAt: new Date(authAttempt.startedAt).toISOString(),
2088
+ waitedMs: Date.now() - authAttempt.startedAt,
2089
+ /**
2090
+ * True once the code has been missing for AUTH_CODE_DEADLINE_MS.
2091
+ *
2092
+ * This does NOT end the attempt. `gh` was measured printing nothing while
2093
+ * github.com was unreachable and then producing the code once the network
2094
+ * came back, so ending it on a timer would turn a recoverable wait into a
2095
+ * failure the user has to notice and retry. The panel keeps its cancel
2096
+ * button, and the hard limit stays the code's own lifetime.
2097
+ */
2098
+ stalled: authAttempt.stalled === true,
2099
+ /**
2100
+ * The advisory probe's verdict: `true`, `false`, or `null` while unknown.
2101
+ * `false` is a hint, never a refusal — gh may still succeed through a path
2102
+ * this probe cannot see.
2103
+ */
2104
+ reachable: typeof authAttempt.reachable === 'boolean' ? authAttempt.reachable : null,
2105
+ /**
2106
+ * Which layer the probe failed at: `dns`, `tcp`, or `ok`. They need
2107
+ * different advice — a resolver problem is not a blocked port.
2108
+ */
2109
+ reachabilityStage: typeof authAttempt.reachabilityStage === 'string' ? authAttempt.reachabilityStage : null,
2110
+ reachabilityAddress: typeof authAttempt.reachabilityAddress === 'string' ? authAttempt.reachabilityAddress : null,
2111
+ outputBytes: authAttempt.output.length,
2112
+ /**
2113
+ * What `gh` actually printed, for the case where it printed nothing useful.
2114
+ *
2115
+ * Measured shape: output beginning with a bare newline, so the first line is
2116
+ * empty — which is how a message came to read "its output began:" followed by
2117
+ * nothing. Whitespace-only output is reported as such rather than quoted.
2118
+ */
2119
+ outputExcerpt: (() => {
2120
+ const cleaned = authAttempt.output.replace(/\s+/g, ' ').trim()
2121
+ return cleaned === '' ? null : cleaned.slice(0, 200)
2122
+ })(),
2123
+ }
2124
+
2125
+ const startAuth = (mode, scopes) => {
2126
+ stopAuth()
2127
+ const args = mode === 'refresh'
2128
+ ? ['auth', 'refresh', '--hostname', 'github.com', '-s', scopes.join(',')]
2129
+ // Ask for everything this plugin needs in the ONE prompt the user is already
2130
+ // looking at. Logging in with gh's defaults and then sending them back for a
2131
+ // second grant is a worse experience for no benefit.
2132
+ : ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web', '--scopes', REQUIRED_SCOPES.join(',')]
2133
+ const child = spawn(ghPath, args, {
2134
+ windowsHide: true,
2135
+ // `GH_PROMPT_DISABLED` must stay unset here: this IS the interactive flow,
2136
+ // just without a terminal. Everything else that would page or colour output
2137
+ // is, because the panel renders what comes back.
2138
+ env: { ...process.env, ...activeProxyEnvironment, GH_PROMPT_DISABLED: '', GH_PAGER: 'cat', NO_COLOR: '1' },
2139
+ stdio: ['pipe', 'pipe', 'pipe'],
2140
+ })
2141
+ const attempt = {
2142
+ id: String(Date.now()),
2143
+ mode,
2144
+ scopes,
2145
+ child,
2146
+ state: 'running',
2147
+ code: null,
2148
+ url: null,
2149
+ output: '',
2150
+ message: null,
2151
+ startedAt: Date.now(),
2152
+ timer: null,
2153
+ }
2154
+ authAttempt = attempt
2155
+
2156
+ const absorb = (chunk) => {
2157
+ attempt.output = `${attempt.output}${chunk.toString()}`.slice(-8_000)
2158
+ const code = /one-time code \(([A-Za-z0-9-]+)\)/i.exec(attempt.output)
2159
+ if (code !== null && attempt.code === null) attempt.code = code[1].toUpperCase()
2160
+ const url = /(https:\/\/\S*\/login\/device)/i.exec(attempt.output)
2161
+ if (url !== null && attempt.url === null) attempt.url = url[1]
2162
+ }
2163
+ child.stdout?.on('data', absorb)
2164
+ child.stderr?.on('data', absorb)
2165
+ child.on('error', (error) => {
2166
+ settleAuth('failed', error.message)
2167
+ })
2168
+ child.on('exit', (exitCode) => {
2169
+ if (attempt.state !== 'running') return
2170
+ if (exitCode === 0) settleAuth('succeeded')
2171
+ else settleAuth('failed', firstLine(attempt.output) || `gh exited with code ${String(exitCode)}`)
2172
+ })
2173
+ // The one-time code is valid for about fifteen minutes; the panel should not
2174
+ // keep a polling child alive for longer than a person would plausibly take.
2175
+ attempt.timer = setTimeout(() => {
2176
+ stopAuth()
2177
+ settleAuth('expired', 'the one-time code expired — start again')
2178
+ }, AUTH_TIMEOUT_MS)
2179
+ if (typeof attempt.timer.unref === 'function') attempt.timer.unref()
2180
+ /*
2181
+ * `login` is the flow that has a one-time code. Mark — do not fail — an attempt
2182
+ * that has gone AUTH_CODE_DEADLINE_MS without one: `gh` was measured producing
2183
+ * the code once the network recovered, so the panel reports the wait and lets
2184
+ * the user decide, while the fifteen-minute expiry still bounds it.
2185
+ */
2186
+ if (mode === 'login') {
2187
+ const stallTimer = setTimeout(() => {
2188
+ if (attempt.state === 'running' && attempt.code === null) attempt.stalled = true
2189
+ }, AUTH_CODE_DEADLINE_MS)
2190
+ if (typeof stallTimer.unref === 'function') stallTimer.unref()
2191
+ attempt.stallTimer = stallTimer
2192
+ /*
2193
+ * Advisory only, and deliberately not awaited: `null` means "not answered
2194
+ * yet", `false` is a hint that the wait will probably fail. It must never
2195
+ * prevent the attempt — see the note in `auth-start`.
2196
+ */
2197
+ attempt.reachable = null
2198
+ attempt.reachabilityStage = null
2199
+ /*
2200
+ * Probe the PROXY when one is in use, not github.com.
2201
+ *
2202
+ * gh reaches GitHub through the proxy, so a direct connection failing says
2203
+ * nothing about whether gh will succeed — and reporting it would send the
2204
+ * reader off to fix a network that is already working.
2205
+ */
2206
+ const proxyUrl = typeof activeProxyEnvironment.HTTPS_PROXY === 'string' ? activeProxyEnvironment.HTTPS_PROXY : null
2207
+ let probeTarget = { host: 'github.com', port: 443, via: null }
2208
+ if (proxyUrl !== null) {
2209
+ try {
2210
+ const parsed = new URL(proxyUrl)
2211
+ probeTarget = { host: parsed.hostname, port: Number(parsed.port === '' ? (parsed.protocol === 'https:' ? 443 : 80) : parsed.port), via: proxyUrl }
2212
+ } catch {
2213
+ /* A malformed proxy URL is reported by gh itself; keep the direct probe. */
2214
+ }
2215
+ }
2216
+ void probeHost(probeTarget.host, probeTarget.port, 3000)
2217
+ .then((probe) => {
2218
+ if (authAttempt !== attempt) return
2219
+ attempt.reachable = probe.ok
2220
+ /* 'dns' and 'tcp' need different advice, so the stage is kept. */
2221
+ attempt.reachabilityStage = probeTarget.via === null ? probe.stage : (probe.ok ? 'proxy' : 'proxy-failed')
2222
+ attempt.reachabilityAddress = probeTarget.via === null ? probe.address : probeTarget.via
2223
+ })
2224
+ .catch(() => {
2225
+ if (authAttempt === attempt) attempt.reachable = null
2226
+ })
2227
+ }
2228
+ return attempt
2229
+ }
2230
+
2231
+ /* --------------------------------------------------- repository list -- */
2232
+
2233
+ /**
2234
+ * Find the local checkout of a repository under a configured root.
2235
+ *
2236
+ * Matched by the `name` field of each candidate's package.json rather than by
2237
+ * directory name, because the two genuinely differ here: the checkout that
2238
+ * publishes `dsh-knowledge-console` lives in a directory called
2239
+ * `dsh-plugin-knowledge-console`.
2240
+ *
2241
+ * @param {string} root - configured projects root.
2242
+ * @param {string} repo - repository name (with or without an owner).
2243
+ * @returns {string} absolute path, or '' when nothing matched.
2244
+ */
2245
+ const findLocalCheckout = (root, repo) => {
2246
+ if (root === '' || !existsSync(root)) return ''
2247
+ const wanted = repo.includes('/') ? repo.slice(repo.indexOf('/') + 1) : repo
2248
+ let entries = []
2249
+ try {
2250
+ entries = readdirSync(root, { withFileTypes: true })
2251
+ } catch {
2252
+ return ''
2253
+ }
2254
+ for (const entry of entries) {
2255
+ if (!entry.isDirectory()) continue
2256
+ const directory = join(root, entry.name)
2257
+ const manifest = join(directory, 'package.json')
2258
+ if (!existsSync(manifest)) continue
2259
+ try {
2260
+ const parsed = JSON.parse(readFileSync(manifest, 'utf8'))
2261
+ if (parsed?.name === wanted || parsed?.name === repo) return directory
2262
+ } catch {
2263
+ /* an unreadable manifest is simply not a match */
2264
+ }
2265
+ }
2266
+ return ''
2267
+ }
2268
+
2269
+ /** Persist a change to the managed list, creating the file when it is absent. */
2270
+ const mutateConfig = (current, mutate) => {
2271
+ const stored = readConfig(current.configFile)
2272
+ if (stored.problem !== null && stored.exists) {
2273
+ return { ok: false, message: `the config file cannot be read, so it was left alone: ${stored.problem}` }
2274
+ }
2275
+ const next = mutate({ owner: stored.owner !== '' ? stored.owner : current.owner, repos: [...stored.repos] })
2276
+ if (next.ok !== true) return next
2277
+ writeConfig(current.configFile, { owner: next.owner, repos: next.repos })
2278
+ cache = null
2279
+ return { ok: true, owner: next.owner, count: next.repos.length }
2280
+ }
2281
+
2282
+ const statusHandler = async (req, res) => {
2283
+ if (!guard(req, res)) return
2284
+ const current = live()
2285
+ const [versionResult, auth] = await Promise.all([
2286
+ ghRun(ghPath, ['--version'], Math.min(current.requestTimeoutMs, 10_000)),
2287
+ readAuthStatus(ghPath, Math.min(current.requestTimeoutMs, 15_000)),
2288
+ ])
2289
+ // The scope list comes from `gh auth status`, not from `api user`: an
2290
+ // authenticated token that cannot dispatch a workflow is a state the panel
2291
+ // has to name before a button fails on it.
2292
+ const authenticated = auth.authenticated || (versionResult.ok && auth.exitCode === 0)
2293
+ writeJson(res, 200, {
2294
+ ok: true,
2295
+ value: {
2296
+ protocol: PROTOCOL,
2297
+ enabled: current.enabled,
2298
+ gh: {
2299
+ path: ghPath,
2300
+ available: versionResult.ok,
2301
+ version: versionResult.ok ? firstLine(versionResult.stdout) : null,
2302
+ authenticated,
2303
+ account: auth.account,
2304
+ scopes: auth.scopes,
2305
+ missingScopes: authenticated ? auth.missingScopes : [],
2306
+ message: versionResult.ok ? null : versionResult.message,
2307
+ },
2308
+ config: {
2309
+ owner: current.owner,
2310
+ defaultBranch: current.defaultBranch,
2311
+ buildWorkflow: current.buildWorkflow,
2312
+ releaseWorkflow: current.releaseWorkflow,
2313
+ pollSeconds: current.pollSeconds,
2314
+ overviewTtlMs: current.overviewTtlMs,
2315
+ projectsRoot: current.projectsRoot,
2316
+ bilibili: {
2317
+ enabled: current.bilibiliEnabled,
2318
+ auto: current.bilibiliAuto,
2319
+ watchSeconds: current.bilibiliWatchSeconds,
2320
+ template: current.bilibiliTemplate,
2321
+ cookieFile: current.bilibiliCookieFile,
2322
+ },
2323
+ },
2324
+ auth: authSnapshot(),
2325
+ /**
2326
+ * Which profile the update route would write to. Reported rather than
2327
+ * assumed: "not installed" is a different sentence from "the panel looked in
2328
+ * the wrong profile", and only the path makes the two distinguishable.
2329
+ */
2330
+ profile: {
2331
+ name: profileFacts().name,
2332
+ dir: profileFacts().dir,
2333
+ readable: readManifest(profileFacts().dir) !== null,
2334
+ },
2335
+ configFile: current.configFile,
2336
+ configSource: current.configSource,
2337
+ configProblem: current.configProblem,
2338
+ configDropped: current.configDropped ?? 0,
2339
+ /**
2340
+ * Commands quoted from where this copy is actually installed. A panel that
2341
+ * told the user to run `node scripts/configure.mjs` would be wrong for
2342
+ * everyone who installed the plugin somewhere else.
2343
+ */
2344
+ helper: {
2345
+ pluginRoot: moduleDir,
2346
+ configureScript: join(moduleDir, 'scripts', 'configure.mjs'),
2347
+ configFile: current.configFile,
2348
+ },
2349
+ repos: current.repos.map((entry) => ({ repo: entry.repo, label: entry.label !== '' ? entry.label : entry.repo, localPath: entry.localPath })),
2350
+ },
2351
+ })
2352
+ }
2353
+
2354
+ const overviewHandler = async (req, res) => {
2355
+ if (!guard(req, res)) return
2356
+ const body = await readJsonBody(req)
2357
+ const current = live()
2358
+ if (!current.enabled) {
2359
+ writeJson(res, 200, { ok: true, value: { fetchedAt: new Date().toISOString(), disabled: true, repos: [] } })
2360
+ return
2361
+ }
2362
+ if (current.repos.length === 0) {
2363
+ writeJson(res, 200, {
2364
+ ok: true,
2365
+ value: { fetchedAt: new Date().toISOString(), repos: [], unconfigured: true },
2366
+ })
2367
+ return
2368
+ }
2369
+ const fresh = cache !== null && Date.now() - cache.at < current.overviewTtlMs
2370
+ if (fresh && body?.force !== true) {
2371
+ writeJson(res, 200, { ok: true, value: { ...cache.value, cached: true } })
2372
+ return
2373
+ }
2374
+
2375
+ const settled = await Promise.allSettled(
2376
+ current.repos.map((entry) => collectRepo({ config: current, ghPath, entry })),
2377
+ )
2378
+ const profile = profileFacts()
2379
+ const repos = settled.map((outcome, index) => {
2380
+ const entry = current.repos[index]
2381
+ const value = outcome.status === 'fulfilled'
2382
+ ? outcome.value
2383
+ : {
2384
+ repo: entry.repo,
2385
+ slug: resolveSlug(current.owner, entry.repo),
2386
+ label: entry.label !== '' ? entry.label : entry.repo,
2387
+ localPath: entry.localPath,
2388
+ releases: [],
2389
+ problems: [String(outcome.reason?.message ?? outcome.reason)],
2390
+ }
2391
+ /*
2392
+ * The install block is computed from local files, not from GitHub, so it is
2393
+ * attached here rather than inside `collectRepo` — that function's contract is
2394
+ * "one repository's remote and local git state", and this is neither.
2395
+ */
2396
+ return {
2397
+ ...value,
2398
+ install: describeInstall({
2399
+ profileDir: profile.dir,
2400
+ profileName: profile.name,
2401
+ entry,
2402
+ releases: value.releases ?? [],
2403
+ }),
2404
+ }
2405
+ })
2406
+ const value = { fetchedAt: new Date().toISOString(), cached: false, repos, configSource: current.configSource, configProblem: current.configProblem }
2407
+ cache = { at: Date.now(), value }
2408
+ writeJson(res, 200, { ok: true, value })
2409
+ }
2410
+
2411
+ const runsHandler = async (req, res) => {
2412
+ if (!guard(req, res)) return
2413
+ const body = await readJsonBody(req)
2414
+ const target = findEntry(live(), body)
2415
+ if (!target.ok) {
2416
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2417
+ return
2418
+ }
2419
+ const limit = clampNumber(body?.limit, 1, 30, 10)
2420
+ const result = await ghJson(ghPath, ['run', 'list', '-R', target.slug, '--limit', String(limit), '--json',
2421
+ 'databaseId,workflowName,displayTitle,status,conclusion,event,headBranch,createdAt,updatedAt,url'], config.requestTimeoutMs)
2422
+ if (!result.ok) {
2423
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.message })
2424
+ return
2425
+ }
2426
+ const runs = Array.isArray(result.value) ? result.value.map(normalizeRun).filter((run) => run !== null) : []
2427
+ writeJson(res, 200, { ok: true, value: { repo: target.entry.repo, runs } })
2428
+ }
2429
+
2430
+ const dispatchHandler = async (req, res) => {
2431
+ if (!guard(req, res)) return
2432
+ const body = await readJsonBody(req)
2433
+ const target = findEntry(live(), body)
2434
+ if (!target.ok) {
2435
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2436
+ return
2437
+ }
2438
+ const workflow = text(body?.workflow, config.buildWorkflow)
2439
+ if (!SLUG.test(workflow.replace(/\.ya?ml$/, ''))) {
2440
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unusable workflow name: ${workflow}` })
2441
+ return
2442
+ }
2443
+ const ref = text(body?.ref, config.defaultBranch)
2444
+ /*
2445
+ * A release dispatch is preflighted, because the run it would start cannot
2446
+ * succeed when the local version is already taken by another commit. The check
2447
+ * asks GitHub for the two facts the workflow's own guard uses — the commit this
2448
+ * ref would build, and the release that owns `v<version>` — and refuses only on
2449
+ * a proven mismatch. An unreadable answer dispatches as before: a preflight that
2450
+ * blocks a release that would have worked is worse than no preflight.
2451
+ */
2452
+ if (workflowFileName(workflow) === workflowFileName(config.releaseWorkflow)) {
2453
+ const version = readLocalVersion(target.entry.localPath)
2454
+ const expectedTag = version === null ? null : `v${version}`
2455
+ if (expectedTag !== null) {
2456
+ const [headResult, releasesResult] = await Promise.all([
2457
+ runTool(ghPath, ['api', `repos/${target.slug}/commits/${encodeURIComponent(ref)}`, '--jq', '.sha'], config.requestTimeoutMs),
2458
+ ghJson(ghPath, ['api', `repos/${target.slug}/releases?per_page=5`], config.requestTimeoutMs),
2459
+ ])
2460
+ if (releasesResult.ok) {
2461
+ const releases = Array.isArray(releasesResult.value)
2462
+ ? releasesResult.value.map(normalizeRelease).filter((release) => release !== null)
2463
+ : []
2464
+ const verdict = releasePreflight({
2465
+ version,
2466
+ expectedTag,
2467
+ releases,
2468
+ builtSha: headResult.ok ? headResult.stdout.trim() : null,
2469
+ })
2470
+ if (verdict.state === 'blocked') {
2471
+ const next = nextVersion(version, 'patch')
2472
+ cache = null
2473
+ writeJson(res, 409, {
2474
+ ok: false,
2475
+ code: 'version-taken',
2476
+ message: verdict.message,
2477
+ value: {
2478
+ repo: target.entry.repo,
2479
+ workflow,
2480
+ ref,
2481
+ tag: verdict.tag,
2482
+ owner: verdict.owner,
2483
+ built: verdict.built,
2484
+ nextTag: next.ok === true ? `v${next.to}` : null,
2485
+ },
2486
+ })
2487
+ return
2488
+ }
2489
+ }
2490
+ }
2491
+ }
2492
+ const args = ['workflow', 'run', workflow, '-R', target.slug, '--ref', ref]
2493
+ const inputs = body?.inputs
2494
+ if (inputs !== null && typeof inputs === 'object') {
2495
+ for (const [key, value] of Object.entries(inputs)) {
2496
+ if (!SLUG.test(key)) continue
2497
+ args.push('-f', `${key}=${String(value)}`)
2498
+ }
2499
+ }
2500
+ const result = await ghRun(ghPath, args, config.requestTimeoutMs)
2501
+ cache = null
2502
+ if (!result.ok) {
2503
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.message, value: { repo: target.entry.repo, workflow } })
2504
+ return
2505
+ }
2506
+ writeJson(res, 200, {
2507
+ ok: true,
2508
+ value: { repo: target.entry.repo, workflow, ref, dispatched: true, note: firstLine(result.stdout) || null },
2509
+ })
2510
+ }
2511
+
2512
+ const runActionHandler = async (req, res) => {
2513
+ if (!guard(req, res)) return
2514
+ const body = await readJsonBody(req)
2515
+ const target = findEntry(live(), body)
2516
+ if (!target.ok) {
2517
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2518
+ return
2519
+ }
2520
+ const runId = clampNumber(body?.runId, 1, Number.MAX_SAFE_INTEGER, 0)
2521
+ if (runId === 0) {
2522
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'body.runId is required' })
2523
+ return
2524
+ }
2525
+ const action = text(body?.action)
2526
+ /** Only these three exist, and each maps to one documented `gh run` subcommand. */
2527
+ const args = action === 'rerun'
2528
+ ? ['run', 'rerun', String(runId), '-R', target.slug]
2529
+ : action === 'rerun-failed'
2530
+ ? ['run', 'rerun', String(runId), '-R', target.slug, '--failed']
2531
+ : action === 'cancel'
2532
+ ? ['run', 'cancel', String(runId), '-R', target.slug]
2533
+ : null
2534
+ if (args === null) {
2535
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unsupported action: ${action || '(empty)'}` })
2536
+ return
2537
+ }
2538
+ const result = await ghRun(ghPath, args, config.requestTimeoutMs)
2539
+ cache = null
2540
+ if (!result.ok) {
2541
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.message })
2542
+ return
2543
+ }
2544
+ writeJson(res, 200, { ok: true, value: { repo: target.entry.repo, runId, action, note: firstLine(result.stdout) || null } })
2545
+ }
2546
+
2547
+ const releaseActionHandler = async (req, res) => {
2548
+ if (!guard(req, res)) return
2549
+ const body = await readJsonBody(req)
2550
+ const target = findEntry(live(), body)
2551
+ if (!target.ok) {
2552
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2553
+ return
2554
+ }
2555
+ const tag = text(body?.tag)
2556
+ if (!SLUG.test(tag)) {
2557
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unusable tag: ${tag}` })
2558
+ return
2559
+ }
2560
+ const action = text(body?.action)
2561
+ // Publishing a draft is a one-way, user-visible action on a public page, which
2562
+ // is exactly why the release workflow creates drafts by default and why this
2563
+ // is the only route that can make one public.
2564
+ const args = action === 'publish'
2565
+ ? ['release', 'edit', tag, '-R', target.slug, '--draft=false']
2566
+ : action === 'delete'
2567
+ ? ['release', 'delete', tag, '-R', target.slug, '--yes']
2568
+ : null
2569
+ if (args === null) {
2570
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unsupported action: ${action || '(empty)'}` })
2571
+ return
2572
+ }
2573
+ const result = await ghRun(ghPath, args, config.requestTimeoutMs)
2574
+ cache = null
2575
+ if (!result.ok) {
2576
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.message })
2577
+ return
2578
+ }
2579
+ /* A published release is exactly the moment an update note becomes true, so
2580
+ the sweep is asked now rather than at the next tick. It is not awaited: the
2581
+ comment is Bilibili's business, and this answer is GitHub's. */
2582
+ if (action === 'publish') void sweepBilibili('release-published')
2583
+ writeJson(res, 200, { ok: true, value: { repo: target.entry.repo, tag, action, note: firstLine(result.stdout) || null } })
2584
+ }
2585
+
2586
+ /**
2587
+ * Bump `package.json`, commit it, and push the branch.
2588
+ *
2589
+ * This is the step 发布 was silently missing. Every repository in this set
2590
+ * releases from `package.json`'s version, and re-releasing a version that already
2591
+ * belongs to another commit is refused by the workflow on purpose (it would leave
2592
+ * the tag and the uploaded assets disagreeing) — so a console that cannot bump the
2593
+ * version cannot release a repository whose tree has moved on.
2594
+ *
2595
+ * It is the only route that writes to a checkout, so it is conservative:
2596
+ * - a dirty tree is refused rather than warned about, because the release is
2597
+ * built from the commit GitHub has and every uncommitted file would be
2598
+ * silently missing from the published package;
2599
+ * - a branch that is behind its upstream, or has none, is refused: "push" would
2600
+ * either fail or need a decision (merge? rebase?) that is not this button's;
2601
+ * - only the version line of `package.json` is written, and it is restored
2602
+ * verbatim if the commit does not go through, so a failed bump leaves no
2603
+ * half-applied state behind.
2604
+ *
2605
+ * A failed PUSH is not rolled back. The commit is real and the failure is usually
2606
+ * the intermittent block on `github.com:443` this machine already documents, so
2607
+ * the honest answer is "committed locally, not pushed" with the reason — retrying
2608
+ * the push is a decision the user can make, and a hidden reset is not.
2609
+ */
2610
+ const versionBumpHandler = async (req, res) => {
2611
+ if (!guard(req, res)) return
2612
+ const body = await readJsonBody(req)
2613
+ const target = findEntry(live(), body)
2614
+ if (!target.ok) {
2615
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2616
+ return
2617
+ }
2618
+ const kind = text(body?.release, 'patch')
2619
+ const localPath = target.entry.localPath
2620
+ if (localPath === '' || !existsSync(join(localPath, 'package.json'))) {
2621
+ writeJson(res, 400, {
2622
+ ok: false,
2623
+ code: 'no-checkout',
2624
+ message: 'this repository has no local checkout with a package.json to bump',
2625
+ value: { repo: target.entry.repo },
2626
+ })
2627
+ return
2628
+ }
2629
+ const state = await readLocalState(localPath, config.requestTimeoutMs)
2630
+ if (state.available !== true) {
2631
+ writeJson(res, 400, {
2632
+ ok: false,
2633
+ code: 'no-checkout',
2634
+ message: `cannot bump from here: ${state.reason ?? 'the local checkout is unusable'}`,
2635
+ value: { repo: target.entry.repo },
2636
+ })
2637
+ return
2638
+ }
2639
+ if (state.dirty !== 0) {
2640
+ writeJson(res, 409, {
2641
+ ok: false,
2642
+ code: 'dirty-tree',
2643
+ message: `the checkout has ${String(state.dirty)} uncommitted change(s); commit or stash them first — they would not be in the released package`,
2644
+ value: { repo: target.entry.repo, dirty: state.dirty },
2645
+ })
2646
+ return
2647
+ }
2648
+ if (state.upstreamKnown !== true || typeof state.branch !== 'string' || state.branch === '') {
2649
+ writeJson(res, 409, {
2650
+ ok: false,
2651
+ code: 'no-upstream',
2652
+ message: `branch ${state.branch ?? '(unknown)'} has no upstream, so a bump could not be pushed`,
2653
+ value: { repo: target.entry.repo, branch: state.branch ?? null },
2654
+ })
2655
+ return
2656
+ }
2657
+ if (Number.isInteger(state.behind) && state.behind > 0) {
2658
+ writeJson(res, 409, {
2659
+ ok: false,
2660
+ code: 'behind',
2661
+ message: `the branch is ${String(state.behind)} commit(s) behind its upstream; pull before releasing`,
2662
+ value: { repo: target.entry.repo, behind: state.behind },
2663
+ })
2664
+ return
2665
+ }
2666
+
2667
+ const manifest = join(localPath, 'package.json')
2668
+ let source = ''
2669
+ try {
2670
+ source = readFileSync(manifest, 'utf8')
2671
+ } catch (error) {
2672
+ writeJson(res, 502, { ok: false, code: 'read-failed', message: String(error?.message ?? error) })
2673
+ return
2674
+ }
2675
+ const next = nextVersion(readLocalVersion(localPath), kind)
2676
+ if (next.ok !== true) {
2677
+ writeJson(res, 400, { ok: false, code: 'unusable-version', message: next.message })
2678
+ return
2679
+ }
2680
+ const rewritten = rewriteVersion(source, next.to)
2681
+ if (rewritten.ok !== true) {
2682
+ writeJson(res, 400, { ok: false, code: 'unusable-manifest', message: rewritten.message })
2683
+ return
2684
+ }
2685
+
2686
+ const git = (args) => runTool('git', ['-C', localPath, ...args], config.requestTimeoutMs)
2687
+ const restore = () => {
2688
+ try {
2689
+ writeFileSync(manifest, source, 'utf8')
2690
+ } catch {
2691
+ /* Reported through the failure that follows; a second failure here is not the story. */
2692
+ }
2693
+ }
2694
+
2695
+ try {
2696
+ writeFileSync(manifest, rewritten.text, 'utf8')
2697
+ } catch (error) {
2698
+ writeJson(res, 502, { ok: false, code: 'write-failed', message: String(error?.message ?? error) })
2699
+ return
2700
+ }
2701
+
2702
+ const tag = `v${next.to}`
2703
+ const staged = await git(['add', '--', 'package.json'])
2704
+ if (staged.ok !== true) {
2705
+ restore()
2706
+ writeJson(res, 502, { ok: false, code: 'git-failed', message: commandFailureLine(staged, 'git add failed') })
2707
+ return
2708
+ }
2709
+ // `-- package.json` is `--only` semantics: the commit contains this one path,
2710
+ // whatever else the index happens to hold.
2711
+ const committed = await git(['commit', '-m', `chore(release): ${tag}`, '--', 'package.json'])
2712
+ if (committed.ok !== true) {
2713
+ restore()
2714
+ writeJson(res, 502, {
2715
+ ok: false,
2716
+ code: 'git-failed',
2717
+ message: commandFailureLine(committed, 'git commit failed'),
2718
+ })
2719
+ return
2720
+ }
2721
+ const head = await git(['rev-parse', 'HEAD'])
2722
+ const pushed = await git(['push', 'origin', state.branch])
2723
+ cache = null
2724
+ if (pushed.ok !== true) {
2725
+ writeJson(res, 502, {
2726
+ ok: false,
2727
+ code: 'push-failed',
2728
+ message: `committed ${tag} locally, but the push failed: ${commandFailureLine(pushed, 'git push failed')}`,
2729
+ value: { repo: target.entry.repo, from: next.from, to: next.to, tag, branch: state.branch, pushed: false },
2730
+ })
2731
+ return
2732
+ }
2733
+ writeJson(res, 200, {
2734
+ ok: true,
2735
+ value: {
2736
+ repo: target.entry.repo,
2737
+ from: next.from,
2738
+ to: next.to,
2739
+ tag,
2740
+ branch: state.branch,
2741
+ commit: head.ok === true ? head.stdout.trim() : null,
2742
+ pushed: true,
2743
+ /** Local commits that this push also delivered, so the panel can say so. */
2744
+ carried: Number.isInteger(state.ahead) ? state.ahead : 0,
2745
+ },
2746
+ })
2747
+ }
2748
+
2749
+ /**
2750
+ * Commit the working tree, and push it.
2751
+ *
2752
+ * This is the way out of the trap the panel spends a lot of words on: a release
2753
+ * builds the PUSHED commit, so work that is only in the working tree — or only on
2754
+ * this machine — is silently absent from the released package. The console could
2755
+ * already tell you that; until now the way to fix it was a terminal.
2756
+ *
2757
+ * `git add -A` on purpose: a release needs new files too, and the panel lists them
2758
+ * before the button is pressed (`local.files`), so this is not a blind sweep. An
2759
+ * empty message means "push what is already committed", which is the other half of
2760
+ * the same trap — a commit that never left the machine is equally absent.
2761
+ *
2762
+ * The order is commit first, push second, and a push that fails still reports what
2763
+ * was committed, because "your work is safe locally" and "the push failed" are two
2764
+ * different things to be told.
2765
+ */
2766
+ const commitHandler = async (req, res) => {
2767
+ if (!guard(req, res)) return
2768
+ const body = await readJsonBody(req)
2769
+ const target = findEntry(live(), body)
2770
+ if (!target.ok) {
2771
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2772
+ return
2773
+ }
2774
+ const localPath = target.entry.localPath
2775
+ if (localPath === '') {
2776
+ writeJson(res, 400, {
2777
+ ok: false,
2778
+ code: 'no-checkout',
2779
+ message: 'this repository has no local checkout, so there is nothing here to commit',
2780
+ value: { repo: target.entry.repo },
2781
+ })
2782
+ return
2783
+ }
2784
+ const state = await readLocalState(localPath, config.requestTimeoutMs)
2785
+ if (state.available !== true) {
2786
+ writeJson(res, 400, {
2787
+ ok: false,
2788
+ code: 'no-checkout',
2789
+ message: `cannot commit from here: ${state.reason ?? 'the local checkout is unusable'}`,
2790
+ value: { repo: target.entry.repo },
2791
+ })
2792
+ return
2793
+ }
2794
+ const dirty = Number.isInteger(state.dirty) ? state.dirty : 0
2795
+ const ahead = Number.isInteger(state.ahead) ? state.ahead : 0
2796
+ if (dirty === 0 && ahead === 0) {
2797
+ writeJson(res, 409, {
2798
+ ok: false,
2799
+ code: 'nothing-to-commit',
2800
+ message: 'the checkout is clean and in sync with its upstream; there is nothing to commit or push',
2801
+ value: { repo: target.entry.repo, dirty, ahead },
2802
+ })
2803
+ return
2804
+ }
2805
+ if (state.upstreamKnown !== true || typeof state.branch !== 'string' || state.branch === '') {
2806
+ writeJson(res, 409, {
2807
+ ok: false,
2808
+ code: 'no-upstream',
2809
+ message: `branch ${state.branch ?? '(unknown)'} has no upstream, so nothing could be pushed — the release builds what is on GitHub, not what is on this disk`,
2810
+ value: { repo: target.entry.repo, branch: state.branch ?? null },
2811
+ })
2812
+ return
2813
+ }
2814
+ const message = text(body?.message).trim()
2815
+ if (dirty > 0 && message === '') {
2816
+ writeJson(res, 400, {
2817
+ ok: false,
2818
+ code: 'message-required',
2819
+ message: 'a commit needs a message',
2820
+ value: { repo: target.entry.repo },
2821
+ })
2822
+ return
2823
+ }
2824
+
2825
+ const git = (args) => runTool('git', ['-C', localPath, ...args], config.requestTimeoutMs)
2826
+ const files = Array.isArray(state.files) ? state.files : []
2827
+ if (dirty > 0) {
2828
+ const staged = await git(['add', '-A'])
2829
+ if (staged.ok !== true) {
2830
+ writeJson(res, 502, { ok: false, code: 'git-failed', message: commandFailureLine(staged, 'git add failed') })
2831
+ return
2832
+ }
2833
+ const committed = await git(['commit', '-m', message])
2834
+ if (committed.ok !== true) {
2835
+ writeJson(res, 502, { ok: false, code: 'git-failed', message: commandFailureLine(committed, 'git commit failed') })
2836
+ return
2837
+ }
2838
+ }
2839
+ const head = await git(['rev-parse', 'HEAD'])
2840
+ const pushed = await git(['push', 'origin', state.branch])
2841
+ cache = null
2842
+ if (pushed.ok !== true) {
2843
+ writeJson(res, 502, {
2844
+ ok: false,
2845
+ code: 'push-failed',
2846
+ message: dirty > 0
2847
+ ? `committed locally, but the push failed: ${commandFailureLine(pushed, 'git push failed')}`
2848
+ : `the push failed: ${commandFailureLine(pushed, 'git push failed')}`,
2849
+ value: { repo: target.entry.repo, branch: state.branch, committed: dirty > 0, files },
2850
+ })
2851
+ return
2852
+ }
2853
+ writeJson(res, 200, {
2854
+ ok: true,
2855
+ value: {
2856
+ repo: target.entry.repo,
2857
+ branch: state.branch,
2858
+ committed: dirty > 0,
2859
+ commit: head.ok === true ? head.stdout.trim() : null,
2860
+ /** What was committed, so the panel can name it rather than say "done". */
2861
+ files,
2862
+ /** Local commits this push also delivered — the ones a release was missing. */
2863
+ carried: ahead,
2864
+ },
2865
+ })
2866
+ }
2867
+
2868
+ const logsHandler = async (req, res) => {
2869
+ if (!guard(req, res)) return
2870
+ const body = await readJsonBody(req)
2871
+ const target = findEntry(live(), body)
2872
+ if (!target.ok) {
2873
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2874
+ return
2875
+ }
2876
+ const runId = clampNumber(body?.runId, 1, Number.MAX_SAFE_INTEGER, 0)
2877
+ if (runId === 0) {
2878
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'body.runId is required' })
2879
+ return
2880
+ }
2881
+ const result = await runTool(ghPath, ['run', 'view', String(runId), '-R', target.slug, '--log-failed'], config.requestTimeoutMs)
2882
+ if (!result.ok && result.stdout.trim() === '') {
2883
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.killed ? `gh timed out after ${config.requestTimeoutMs} ms` : commandFailureLine(result, 'gh failed') })
2884
+ return
2885
+ }
2886
+ // The panel shows a tail, not a log viewer: `--log-failed` can be megabytes.
2887
+ const lines = result.stdout.split('\n')
2888
+ const tail = lines.slice(Math.max(0, lines.length - config.logTailLines))
2889
+ writeJson(res, 200, { ok: true, value: { repo: target.entry.repo, runId, truncated: lines.length > tail.length, lines: tail } })
2890
+ }
2891
+
2892
+ /* ------------------------------------------------- installed-copy update -- */
2893
+
2894
+ /**
2895
+ * Replace this profile's copy of a repository's package with the release's tgz.
2896
+ *
2897
+ * Why this exists: the console could trigger a release but never take it. A
2898
+ * checkout installed with `link:` is not the artifact anybody downloads, and the
2899
+ * only way to find that out was a terminal and `dsh plugin add file:<tgz>`. So
2900
+ * the artifact is fetched to a plugin-owned directory and handed to the Host's own
2901
+ * plugin manager — the same pnpm path `dsh plugin add` takes, which holds the
2902
+ * profile lock and restores `package.json` when a run fails. Shelling out to the
2903
+ * CLI from here would be a second writer against the same profile.
2904
+ *
2905
+ * Everything that can be decided locally is decided before GitHub is asked: a
2906
+ * repository that is not a dependency of this profile has nothing to update, and
2907
+ * saying so costs no network round trip.
2908
+ */
2909
+ const updateHandler = async (req, res) => {
2910
+ if (!guard(req, res)) return
2911
+ const body = await readJsonBody(req)
2912
+ const current = live()
2913
+ const target = findEntry(current, body)
2914
+ if (!target.ok) {
2915
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
2916
+ return
2917
+ }
2918
+ const manager = typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined
2919
+ if (manager === undefined || manager === null || typeof manager.installBundle !== 'function') {
2920
+ writeJson(res, 501, {
2921
+ ok: false,
2922
+ code: 'plugin-manager-missing',
2923
+ message: 'this Host has no pluginManager service, so nothing here can install a package into the profile',
2924
+ })
2925
+ return
2926
+ }
2927
+ if (updateInFlight) {
2928
+ writeJson(res, 409, { ok: false, code: 'busy', message: 'an update is already running; wait for it to settle' })
2929
+ return
2930
+ }
2931
+ const profile = profileFacts()
2932
+ const localPath = target.entry.localPath
2933
+ const checkout = readManifest(localPath)
2934
+ const declared = checkout !== null && checkout.name !== '' ? checkout.name : bareRepoName(target.entry.repo)
2935
+ const install = readProfileInstall(profile.dir, declared, localPath)
2936
+ if (install.present !== true) {
2937
+ writeJson(res, 409, {
2938
+ ok: false,
2939
+ code: 'not-installed',
2940
+ message: `${declared} is not a dependency of the ${profile.name} profile, so there is no installed copy to update`,
2941
+ value: { repo: target.entry.repo, packageName: declared, profile: profile.name, profileDir: profile.dir },
2942
+ })
2943
+ return
2944
+ }
2945
+ const wanted = text(body?.tag)
2946
+ if (wanted !== '' && !SLUG.test(wanted)) {
2947
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unusable tag: ${wanted}` })
2948
+ return
2949
+ }
2950
+
2951
+ updateInFlight = true
2952
+ try {
2953
+ const releasesResult = await ghJson(ghPath, ['api', `repos/${target.slug}/releases?per_page=10`], current.requestTimeoutMs)
2954
+ if (!releasesResult.ok) {
2955
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: releasesResult.message })
2956
+ return
2957
+ }
2958
+ const releases = Array.isArray(releasesResult.value)
2959
+ ? releasesResult.value.map(normalizeRelease).filter((release) => release !== null)
2960
+ : []
2961
+ const latest = pickInstallableRelease(releases, wanted)
2962
+ if (latest === null) {
2963
+ writeJson(res, 409, {
2964
+ ok: false,
2965
+ code: 'no-release',
2966
+ message: wanted === ''
2967
+ ? 'this repository has no published release carrying a .tgz asset yet'
2968
+ : `release ${wanted} is not published, or carries no .tgz asset`,
2969
+ value: { repo: target.entry.repo, tag: wanted === '' ? null : wanted },
2970
+ })
2971
+ return
2972
+ }
2973
+
2974
+ /*
2975
+ * Downloaded beside the managed config file rather than into a temp directory,
2976
+ * and kept: the profile's dependency ends up pointing at this path, so deleting
2977
+ * the tarball afterwards would leave a manifest that no longer installs.
2978
+ */
2979
+ const directory = join(dirname(resolveConfigFilePath(config)), DOWNLOAD_DIR_NAME)
2980
+ let tarball = ''
2981
+ try {
2982
+ mkdirSync(directory, { recursive: true })
2983
+ tarball = join(directory, latest.asset)
2984
+ } catch (error) {
2985
+ writeJson(res, 500, { ok: false, code: 'download-dir-failed', message: `cannot prepare ${directory}: ${String(error?.message ?? error)}` })
2986
+ return
2987
+ }
2988
+ const fetched = await ghRun(
2989
+ ghPath,
2990
+ ['release', 'download', latest.tag, '-R', target.slug, '-p', latest.asset, '-D', directory, '--clobber'],
2991
+ Math.max(current.requestTimeoutMs, 60_000),
2992
+ )
2993
+ if (!fetched.ok) {
2994
+ writeJson(res, 502, { ok: false, code: 'download-failed', message: commandFailureLine(fetched, `gh release download failed for ${latest.asset}`) })
2995
+ return
2996
+ }
2997
+ if (!existsSync(tarball)) {
2998
+ writeJson(res, 502, { ok: false, code: 'download-failed', message: `gh reported success but ${latest.asset} is not in ${directory}` })
2999
+ return
3000
+ }
3001
+ /*
3002
+ * Make a no-op a no-op.
3003
+ *
3004
+ * Handed a spec the manifest already carries, pnpm changes nothing, so it
3005
+ * reports no changed dependency — and the plugin manager reads "no dependency
3006
+ * changed" as `ambiguous-install` and restores the profile. That is a true
3007
+ * statement about pnpm and a false one about this request: the profile already
3008
+ * points at exactly this artifact, which is the outcome that was asked for.
3009
+ */
3010
+ if (install.kind === 'tarball' && install.path !== null && comparablePath(install.path) === comparablePath(tarball)) {
3011
+ cache = null
3012
+ writeJson(res, 200, {
3013
+ ok: true,
3014
+ value: {
3015
+ repo: target.entry.repo,
3016
+ packageName: install.packageName,
3017
+ tag: latest.tag,
3018
+ version: latest.version ?? versionFromTag(latest.tag),
3019
+ asset: latest.asset,
3020
+ from: install.installedVersion,
3021
+ previousSpec: install.spec,
3022
+ tarball,
3023
+ application: 'applied',
3024
+ /** Nothing was written, so there is nothing a restart would make live. */
3025
+ changed: false,
3026
+ restartRequired: false,
3027
+ pendingBuilds: [],
3028
+ warnings: [],
3029
+ },
3030
+ })
3031
+ return
3032
+ }
3033
+
3034
+ const change = await manager.installBundle(tarball, { enabled: true })
3035
+ cache = null
3036
+ const failed = change === null || typeof change !== 'object' || change.changed !== true
3037
+ || change.application === 'failed' || change.application === 'cancelled'
3038
+ if (failed) {
3039
+ const reason = change?.error?.diagnostic ?? change?.error?.code ?? 'the plugin manager installed nothing'
3040
+ writeJson(res, 502, {
3041
+ ok: false,
3042
+ code: 'install-failed',
3043
+ message: `installing ${latest.asset} failed: ${String(reason)}. The profile files were restored.`,
3044
+ value: { repo: target.entry.repo, tag: latest.tag, asset: latest.asset, application: change?.application ?? null },
3045
+ })
3046
+ return
3047
+ }
3048
+ writeJson(res, 200, {
3049
+ ok: true,
3050
+ value: {
3051
+ repo: target.entry.repo,
3052
+ packageName: install.packageName,
3053
+ tag: latest.tag,
3054
+ version: latest.version ?? versionFromTag(latest.tag),
3055
+ asset: latest.asset,
3056
+ from: install.installedVersion,
3057
+ previousSpec: install.spec,
3058
+ tarball,
3059
+ changed: true,
3060
+ application: change.application,
3061
+ /**
3062
+ * Always true for an update, and reported as such rather than inferred by
3063
+ * the panel: the module is already loaded, and even a live-reload profile
3064
+ * cannot swap ESM under a running Host.
3065
+ */
3066
+ restartRequired: true,
3067
+ pendingBuilds: Array.isArray(change.pendingBuilds) ? change.pendingBuilds : [],
3068
+ warnings: Array.isArray(change.warnings) ? change.warnings : [],
3069
+ },
3070
+ })
3071
+ } finally {
3072
+ updateInFlight = false
3073
+ }
3074
+ }
3075
+
3076
+ /**
3077
+ * Forward a restart to the one-click restart plugin, if this Host mounts it.
3078
+ *
3079
+ * This plugin deliberately restarts nothing itself. Which process to stop, which
3080
+ * executable to relaunch and how to survive the gap are `dsh-plugin-restart`'s
3081
+ * contract, and a second implementation of that would eventually kill an app it
3082
+ * cannot start again. The forward is same-authority and server-side, which the
3083
+ * sibling route's trust rule accepts for exactly the reason it accepts the
3084
+ * browser: the request came from this machine and from this Host.
3085
+ */
3086
+ const restartHandler = async (req, res) => {
3087
+ if (!guard(req, res)) return
3088
+ await readJsonBody(req)
3089
+ const authority = typeof req.headers?.host === 'string' ? req.headers.host : ''
3090
+ if (parseAuthority(authority) === undefined) {
3091
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'the request carries no Host authority to forward to' })
3092
+ return
3093
+ }
3094
+ let response
3095
+ try {
3096
+ response = await fetch(`http://${authority}/api/dsh-restart/restart`, {
3097
+ method: 'POST',
3098
+ headers: { 'content-type': 'application/json' },
3099
+ body: '{}',
3100
+ signal: AbortSignal.timeout(RESTART_FORWARD_TIMEOUT_MS),
3101
+ })
3102
+ } catch (error) {
3103
+ writeJson(res, 502, {
3104
+ ok: false,
3105
+ code: 'restart-unreachable',
3106
+ message: `could not reach the restart route: ${String(error?.message ?? error)}`,
3107
+ })
3108
+ return
3109
+ }
3110
+ /*
3111
+ * 401 is what this Host answers for a path no plugin mounted, so it means
3112
+ * "dsh-plugin-restart is not installed here" — not "you may not do this".
3113
+ * Saying so is the difference between a fixable install and a mystery.
3114
+ */
3115
+ if (response.status === 401 || response.status === 404) {
3116
+ writeJson(res, 501, {
3117
+ ok: false,
3118
+ code: 'restart-unavailable',
3119
+ message: 'dsh-plugin-restart is not mounted on this Host, so nothing here can restart DSH',
3120
+ })
3121
+ return
3122
+ }
3123
+ let payload = null
3124
+ try {
3125
+ payload = await response.json()
3126
+ } catch {
3127
+ payload = null
3128
+ }
3129
+ if (payload === null || typeof payload !== 'object') {
3130
+ writeJson(res, 502, {
3131
+ ok: false,
3132
+ code: 'restart-unreadable',
3133
+ message: `the restart route answered HTTP ${String(response.status)} without JSON`,
3134
+ })
3135
+ return
3136
+ }
3137
+ writeJson(res, response.status, payload)
3138
+ }
3139
+
3140
+ /* -------------------------------------------------------------- npm push -- */
3141
+
3142
+ /** One registry answer is reused briefly; a publish or a sign-in clears it. */
3143
+ let npmCache = null
3144
+
3145
+ /** The user-level `.npmrc` text, or '' when there is none to read. */
3146
+ const readNpmrcText = () => {
3147
+ try {
3148
+ return readFileSync(resolveNpmrcPath(), 'utf8')
3149
+ } catch {
3150
+ return ''
3151
+ }
3152
+ }
3153
+
3154
+ /** One publish at a time: npm refuses a second upload of the same version anyway. */
3155
+ let npmPublishInFlight = false
3156
+
3157
+ /**
3158
+ * Ask the registry about one package.
3159
+ *
3160
+ * A 404 is an ANSWER, not a failure: it is how the registry says "nobody owns this
3161
+ * name", which is exactly the state a first publish is in.
3162
+ *
3163
+ * @param {string} registry - normalized registry base.
3164
+ * @param {string} packageName - the package to ask about.
3165
+ * @returns {Promise<{ok: true, value: object|null}|{ok: false, message: string}>}
3166
+ */
3167
+ const fetchPackument = async (registry, packageName) => {
3168
+ const attempt = async (url) => {
3169
+ try {
3170
+ const response = await fetch(url, {
3171
+ // `no-cache` is the standard half of the same request: it asks any cache in
3172
+ // the path to revalidate rather than answer from its copy.
3173
+ headers: { accept: 'application/json', 'cache-control': 'no-cache' },
3174
+ signal: AbortSignal.timeout(NPM_STATUS_TIMEOUT_MS),
3175
+ })
3176
+ if (response.status === 404) return { ok: true, value: null }
3177
+ if (!response.ok) return { ok: false, message: `the registry answered HTTP ${String(response.status)}` }
3178
+ return { ok: true, value: await response.json() }
3179
+ } catch (error) {
3180
+ return { ok: false, message: String(error?.message ?? error) }
3181
+ }
3182
+ }
3183
+ const fresh = await attempt(packumentUrl(registry, packageName))
3184
+ if (fresh.ok === true) return fresh
3185
+ /*
3186
+ * An unknown query parameter is the one thing a different registry may refuse, and
3187
+ * `npmRegistry` is configurable. One retry without it keeps this from turning a
3188
+ * cache fix into a panel that says "unknown" everywhere.
3189
+ */
3190
+ return await attempt(`${registry}${packageName}`)
3191
+ }
3192
+
3193
+ /**
3194
+ * Ask the package manager who it is signed in as.
3195
+ *
3196
+ * This is the real check: an `.npmrc` line can be present and wrong, revoked, or
3197
+ * scoped to another registry, and `whoami` is the only thing that answers "will an
3198
+ * upload be accepted".
3199
+ *
3200
+ * @param {object} invocation - from `resolvePackageManagerInvocation`.
3201
+ * @param {string} registry - normalized registry base.
3202
+ * @returns {Promise<{loggedIn: boolean, account: string|null, message: string|null}>}
3203
+ */
3204
+ const readNpmAuth = async (invocation, registry) => {
3205
+ if (invocation === null) return { loggedIn: false, account: null, message: 'no package manager is available to ask' }
3206
+ const result = await runSpawn(invocation.command, [...invocation.args, 'whoami', '--registry', registry], {
3207
+ timeoutMs: 20_000,
3208
+ env: invocation.env,
3209
+ })
3210
+ if (result.ok !== true) {
3211
+ return { loggedIn: false, account: null, message: commandFailureLine(result, 'the package manager refused to answer') }
3212
+ }
3213
+ const account = firstLine(result.stdout)
3214
+ return account === ''
3215
+ ? { loggedIn: false, account: null, message: 'the package manager answered without an account name' }
3216
+ : { loggedIn: true, account, message: null }
3217
+ }
3218
+
3219
+ /**
3220
+ * What the npm registry holds for every configured repository.
3221
+ *
3222
+ * On demand rather than polled, and separate from `overview`: this is one HTTPS
3223
+ * request per repository plus one `whoami`, and the answer changes when someone
3224
+ * publishes, not every thirty seconds.
3225
+ */
3226
+ const npmStatusHandler = async (req, res) => {
3227
+ if (!guard(req, res)) return
3228
+ const body = await readJsonBody(req)
3229
+ const current = live()
3230
+ const registry = normalizeRegistry(current.npmRegistry)
3231
+ const fresh = npmCache !== null && Date.now() - npmCache.at < NPM_STATUS_TTL_MS
3232
+ if (fresh && body?.force !== true) {
3233
+ writeJson(res, 200, { ok: true, value: { ...npmCache.value, cached: true } })
3234
+ return
3235
+ }
3236
+ const invocation = resolvePackageManagerInvocation(ctx)
3237
+ const npmrcPath = resolveNpmrcPath()
3238
+ const npmrcReadable = existsSync(npmrcPath)
3239
+ const npmrcText = readNpmrcText()
3240
+ const hasToken = hasNpmToken(npmrcText, registry)
3241
+ const auth = await readNpmAuth(invocation, registry)
3242
+ const authState = npmAuthState({ whoami: auth.loggedIn, hasToken })
3243
+
3244
+ const repos = await Promise.all(current.repos.map(async (entry) => {
3245
+ const localPath = entry.localPath
3246
+ const manifest = readManifest(localPath)
3247
+ const packageName = manifest !== null && manifest.name !== '' ? manifest.name : bareRepoName(entry.repo)
3248
+ const version = manifest === null ? null : manifest.version
3249
+ const [packument, dirty] = await Promise.all([
3250
+ fetchPackument(registry, packageName),
3251
+ readDirtyCount(localPath, current.requestTimeoutMs),
3252
+ ])
3253
+ const registryState = packument.ok === true ? npmPackageState(packument.value, version) : null
3254
+ const verdict = npmPublishVerdict({
3255
+ packageName,
3256
+ version,
3257
+ manifest,
3258
+ registryState,
3259
+ // A credential that exists is enough to offer the push: whether it is accepted
3260
+ // is the registry's answer to give, and the publish route names it if not.
3261
+ authed: authState !== 'none',
3262
+ dirty,
3263
+ })
3264
+ return {
3265
+ repo: entry.repo,
3266
+ label: entry.label !== '' ? entry.label : entry.repo,
3267
+ localPath,
3268
+ packageName,
3269
+ version,
3270
+ dirty,
3271
+ privatePackage: manifest !== null && manifest.private === true,
3272
+ blockers: verdict.blockers,
3273
+ canPublish: verdict.canPublish,
3274
+ state: verdict.state,
3275
+ latest: verdict.latest,
3276
+ /** Why the registry could not be asked, when it could not. */
3277
+ registryProblem: packument.ok === true ? null : packument.message,
3278
+ /** Carried per row so a row can render its own confirmation unaided. */
3279
+ registry,
3280
+ pageUrl: `${registry}${packageName}`,
3281
+ }
3282
+ }))
3283
+
3284
+ const value = {
3285
+ fetchedAt: new Date().toISOString(),
3286
+ cached: false,
3287
+ registry,
3288
+ packageManager: invocation === null ? null : { source: invocation.source, command: invocation.command },
3289
+ auth: {
3290
+ /** signed-in / credential-present / none — see `npmAuthState`. */
3291
+ state: authState,
3292
+ loggedIn: authState === 'signed-in',
3293
+ account: auth.account,
3294
+ /** What `whoami` said when it did not confirm. Shown verbatim. */
3295
+ message: auth.message,
3296
+ npmrcPath,
3297
+ npmrcReadable,
3298
+ /** Whether a token line exists at all, without reading the token itself. */
3299
+ npmrcHasToken: hasToken,
3300
+ },
3301
+ repos,
3302
+ }
3303
+ npmCache = { at: Date.now(), value }
3304
+ writeJson(res, 200, { ok: true, value })
3305
+ }
3306
+
3307
+ /**
3308
+ * Put an npm token where npm itself would put it, and prove it works.
3309
+ *
3310
+ * The panel is meant to be usable without a terminal, and `pnpm login` needs one —
3311
+ * so the token a person already generated on npmjs.com is written into the same
3312
+ * user-level `.npmrc` that `npm login` writes, and then verified with `whoami`.
3313
+ * The plugin still stores nothing: the credential lives in the file the npm
3314
+ * ecosystem owns, and it is never returned, echoed or logged here.
3315
+ */
3316
+ const npmLoginHandler = async (req, res) => {
3317
+ if (!guard(req, res)) return
3318
+ const body = await readJsonBody(req)
3319
+ const current = live()
3320
+ const registry = normalizeRegistry(current.npmRegistry)
3321
+ const token = typeof body?.token === 'string' ? body.token.trim() : ''
3322
+ const npmrcPath = resolveNpmrcPath()
3323
+ const existing = readNpmrcText()
3324
+ const merged = upsertAuthToken(existing, registry, token)
3325
+ if (merged.ok !== true) {
3326
+ writeJson(res, 400, { ok: false, code: 'bad-token', message: merged.message })
3327
+ return
3328
+ }
3329
+ try {
3330
+ // Keep the previous revision, as every other write in this plugin does. The
3331
+ // backup holds whatever the file held, which may itself be an older token —
3332
+ // that is the user's own file and their own credential.
3333
+ if (existsSync(npmrcPath)) writeFileSync(`${npmrcPath}.bak`, existing, 'utf8')
3334
+ writeFileSync(npmrcPath, merged.text, 'utf8')
3335
+ } catch (error) {
3336
+ writeJson(res, 502, { ok: false, code: 'write-failed', message: `cannot write ${npmrcPath}: ${String(error?.message ?? error)}` })
3337
+ return
3338
+ }
3339
+ const invocation = resolvePackageManagerInvocation(ctx)
3340
+ const auth = await readNpmAuth(invocation, registry)
3341
+ npmCache = null
3342
+ if (auth.loggedIn !== true) {
3343
+ writeJson(res, 401, {
3344
+ ok: false,
3345
+ code: 'token-rejected',
3346
+ message: `the token was written to ${npmrcPath}, but the registry did not accept it: ${auth.message ?? 'unknown reason'}`,
3347
+ value: { registry, npmrcPath, replaced: merged.replaced },
3348
+ })
3349
+ return
3350
+ }
3351
+ writeJson(res, 200, { ok: true, value: { account: auth.account, registry, npmrcPath, replaced: merged.replaced } })
3352
+ }
3353
+
3354
+ /**
3355
+ * Publish this repository's current version to the npm registry.
3356
+ *
3357
+ * Every refusal happens before anything is uploaded, because a publish is one of
3358
+ * the two irreversible things this panel can do: npm lets a version be unpublished
3359
+ * only briefly and never lets the same version be published twice. So a dirty tree
3360
+ * is refused (the tarball is packed from the working directory, and the `files`
3361
+ * allow-list does not protect a file that sits inside a listed directory), a
3362
+ * version that is already on the registry is refused, and a missing credential is
3363
+ * refused with what to do about it.
3364
+ */
3365
+ const npmPublishHandler = async (req, res) => {
3366
+ if (!guard(req, res)) return
3367
+ const body = await readJsonBody(req)
3368
+ const current = live()
3369
+ const target = findEntry(current, body)
3370
+ if (!target.ok) {
3371
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
3372
+ return
3373
+ }
3374
+ const registry = normalizeRegistry(current.npmRegistry)
3375
+ const invocation = resolvePackageManagerInvocation(ctx)
3376
+ if (invocation === null) {
3377
+ writeJson(res, 501, { ok: false, code: 'no-package-manager', message: 'this Host has no package manager to publish with' })
3378
+ return
3379
+ }
3380
+ if (npmPublishInFlight) {
3381
+ writeJson(res, 409, { ok: false, code: 'busy', message: 'a publish is already running; wait for it to settle' })
3382
+ return
3383
+ }
3384
+ const localPath = target.entry.localPath
3385
+ const manifest = readManifest(localPath)
3386
+ if (manifest === null || manifest.name === '') {
3387
+ writeJson(res, 400, {
3388
+ ok: false,
3389
+ code: 'no-checkout',
3390
+ message: 'this repository has no local checkout with a package.json to publish',
3391
+ value: { repo: target.entry.repo },
3392
+ })
3393
+ return
3394
+ }
3395
+ if (manifest.private === true) {
3396
+ writeJson(res, 409, {
3397
+ ok: false,
3398
+ code: 'private-package',
3399
+ message: `${manifest.name} is marked "private": true, so npm would refuse it. Publishing it is the author's decision to make first.`,
3400
+ value: { repo: target.entry.repo, packageName: manifest.name },
3401
+ })
3402
+ return
3403
+ }
3404
+ if (manifest.version === null) {
3405
+ writeJson(res, 409, {
3406
+ ok: false,
3407
+ code: 'no-version',
3408
+ message: `${manifest.name} has no version in its package.json, so there is nothing to publish`,
3409
+ value: { repo: target.entry.repo, packageName: manifest.name },
3410
+ })
3411
+ return
3412
+ }
3413
+ const dirty = await readDirtyCount(localPath, current.requestTimeoutMs)
3414
+ if (Number.isFinite(dirty) && dirty > 0) {
3415
+ writeJson(res, 409, {
3416
+ ok: false,
3417
+ code: 'dirty-tree',
3418
+ message: `the checkout has ${String(dirty)} uncommitted change(s); a publish packs the working directory, so commit or stash them first`,
3419
+ value: { repo: target.entry.repo, dirty },
3420
+ })
3421
+ return
3422
+ }
3423
+ const packument = await fetchPackument(registry, manifest.name)
3424
+ if (packument.ok !== true) {
3425
+ writeJson(res, 502, {
3426
+ ok: false,
3427
+ code: 'registry-unreachable',
3428
+ message: `cannot ask ${registry} about ${manifest.name}: ${packument.message}`,
3429
+ value: { repo: target.entry.repo, packageName: manifest.name, registry },
3430
+ })
3431
+ return
3432
+ }
3433
+ const registryState = npmPackageState(packument.value, manifest.version)
3434
+ if (registryState.published === true) {
3435
+ writeJson(res, 409, {
3436
+ ok: false,
3437
+ code: 'already-published',
3438
+ message: `${manifest.name}@${manifest.version} is already on ${registry}; npm never accepts the same version twice, so bump the version first`,
3439
+ value: { repo: target.entry.repo, packageName: manifest.name, version: manifest.version, latest: registryState.latest, registry },
3440
+ })
3441
+ return
3442
+ }
3443
+ /*
3444
+ * The gate is "is there anything to authenticate WITH", not "did `whoami` answer".
3445
+ * A granular token is scoped to packages and can be refused by the user-level
3446
+ * endpoint while publishing perfectly well, so `whoami` is asked for the account
3447
+ * name and never used as the permission slip. A publish with a bad token fails
3448
+ * anyway, and it fails with a classification that says which step to repeat.
3449
+ */
3450
+ const hasToken = hasNpmToken(readNpmrcText(), registry)
3451
+ const auth = await readNpmAuth(invocation, registry)
3452
+ if (hasToken !== true && auth.loggedIn !== true) {
3453
+ writeJson(res, 401, {
3454
+ ok: false,
3455
+ code: 'not-logged-in',
3456
+ message: `${invocation.command} has no credential for ${registry}: ${auth.message ?? 'no account'}`,
3457
+ value: { repo: target.entry.repo, packageName: manifest.name, registry, npmrcPath: resolveNpmrcPath() },
3458
+ })
3459
+ return
3460
+ }
3461
+ const otp = text(body?.otp)
3462
+ if (otp !== '' && !/^\d{6,8}$/.test(otp)) {
3463
+ writeJson(res, 400, { ok: false, code: 'bad-otp', message: 'the one-time password must be 6 to 8 digits' })
3464
+ return
3465
+ }
3466
+
3467
+ const argv = [...invocation.args, 'publish', '--no-git-checks', '--registry', registry]
3468
+ if (otp !== '') argv.push('--otp', otp)
3469
+ // Only a scoped name carries an access level; passing it for an unscoped one is
3470
+ // noise at best.
3471
+ if (manifest.name.startsWith('@')) argv.push('--access', 'public')
3472
+
3473
+ npmPublishInFlight = true
3474
+ try {
3475
+ const result = await runSpawn(invocation.command, argv, {
3476
+ cwd: localPath,
3477
+ timeoutMs: NPM_PUBLISH_TIMEOUT_MS,
3478
+ env: invocation.env,
3479
+ })
3480
+ npmCache = null
3481
+ if (result.ok !== true) {
3482
+ /*
3483
+ * A named outcome, not a wall of npm text. `EOTP` and `E403` are one line of
3484
+ * jargon each and completely different fixes; the panel answers each with the
3485
+ * step that resolves it, which is the whole point of guiding a first publish.
3486
+ */
3487
+ const code = result.killed === true
3488
+ ? 'timeout'
3489
+ : classifyPublishFailure(`${result.stdout}\n${result.stderr}`)
3490
+ const message = result.killed === true
3491
+ ? `the publish timed out after ${String(NPM_PUBLISH_TIMEOUT_MS)} ms`
3492
+ : commandFailureLine(result, 'the publish failed')
3493
+ const status = code === 'otp-required' || code === 'not-logged-in' || code === 'forbidden' || code === 'email-unverified'
3494
+ ? 401
3495
+ : code === 'already-published'
3496
+ ? 409
3497
+ : 502
3498
+ writeJson(res, status, {
3499
+ ok: false,
3500
+ code,
3501
+ message,
3502
+ value: {
3503
+ repo: target.entry.repo,
3504
+ packageName: manifest.name,
3505
+ version: manifest.version,
3506
+ registry,
3507
+ needsOtp: code === 'otp-required',
3508
+ },
3509
+ })
3510
+ return
3511
+ }
3512
+ writeJson(res, 200, {
3513
+ ok: true,
3514
+ value: {
3515
+ repo: target.entry.repo,
3516
+ packageName: manifest.name,
3517
+ version: manifest.version,
3518
+ registry,
3519
+ pageUrl: `${registry}${manifest.name}`,
3520
+ wasUnregistered: registryState.state === 'unregistered',
3521
+ account: auth.account,
3522
+ note: firstLine(result.stdout) || null,
3523
+ },
3524
+ })
3525
+ } finally {
3526
+ npmPublishInFlight = false
3527
+ }
3528
+ }
3529
+
3530
+ /* --------------------------------------------------- setup, no terminal -- */
3531
+
3532
+ /**
3533
+ * Start the browser sign-in.
3534
+ *
3535
+ * This is the whole point of the route: `gh auth login` normally needs a
3536
+ * terminal, and asking someone with no programming background to open one is
3537
+ * asking them not to use the feature. The panel renders the code and the URL and
3538
+ * links the URL; `gh` does the polling and writes the credential itself, so the
3539
+ * plugin still never stores a token.
3540
+ */
3541
+ const authStartHandler = async (req, res) => {
3542
+ if (!guard(req, res)) return
3543
+ const body = await readJsonBody(req)
3544
+ const mode = text(body?.mode, 'login')
3545
+ if (mode !== 'login' && mode !== 'refresh') {
3546
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unsupported auth mode: ${mode}` })
3547
+ return
3548
+ }
3549
+ const current = live()
3550
+ const scopes = mode === 'refresh'
3551
+ // Only scopes this plugin can justify: `gh` refuses an unknown one, and the
3552
+ // panel asks for the minimum that makes its buttons work.
3553
+ ? (Array.isArray(body?.scopes) ? body.scopes : []).map((scope) => String(scope)).filter((scope) => REQUIRED_SCOPES.includes(scope))
3554
+ : []
3555
+ if (mode === 'refresh' && scopes.length === 0) {
3556
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'refresh needs at least one of: repo, workflow' })
3557
+ return
3558
+ }
3559
+ /*
3560
+ * Answer immediately, and never let the probe decide.
3561
+ *
3562
+ * A raw TCP connect is not the same question as "can gh complete its HTTP
3563
+ * device-code request": a proxy or an HTTP-layer filter makes the two disagree,
3564
+ * and where they disagreed here the refusal blocked a login that would have
3565
+ * worked. So gh is always started, the probe runs alongside it, and its verdict
3566
+ * reaches the panel through `auth-state` as a warning. Nothing is held open.
3567
+ */
3568
+ startAuth(mode, scopes)
3569
+ const value = authSnapshot()
3570
+ writeJson(res, 202, {
3571
+ ok: true,
3572
+ value: {
3573
+ ...value,
3574
+ scopes,
3575
+ gh: ghPath,
3576
+ /** False when the code has not arrived yet, so the panel can say so. */
3577
+ ready: typeof value.code === 'string' && value.code !== '',
3578
+ note: `waiting for the one-time code from ${current.configSource} configuration`,
3579
+ },
3580
+ })
3581
+ }
3582
+
3583
+ const authStateHandler = async (req, res) => {
3584
+ if (!guard(req, res)) return
3585
+ await readJsonBody(req)
3586
+ writeJson(res, 200, { ok: true, value: authSnapshot() })
3587
+ }
3588
+
3589
+ const authCancelHandler = async (req, res) => {
3590
+ if (!guard(req, res)) return
3591
+ await readJsonBody(req)
3592
+ stopAuth()
3593
+ settleAuth('cancelled', null)
3594
+ writeJson(res, 200, { ok: true, value: authSnapshot() })
3595
+ }
3596
+
3597
+ /**
3598
+ * Sign out of GitHub.
3599
+ *
3600
+ * The panel could log in, grant scopes, cancel and re-check, but not log out —
3601
+ * leaving the one credential-destroying action available only from a terminal,
3602
+ * which is the opposite of what this panel is for.
3603
+ *
3604
+ * `gh auth logout` asks for confirmation on stdin, so "y" has to be written to
3605
+ * the child. That is the one prompt in this whole plugin where answering stdin is
3606
+ * correct: there is nothing else the child could be waiting on.
3607
+ */
3608
+ const authLogoutHandler = async (req, res) => {
3609
+ if (!guard(req, res)) return
3610
+ await readJsonBody(req)
3611
+ // An attempt that is still running would re-authenticate what this removes.
3612
+ stopAuth()
3613
+ settleAuth('idle', null)
3614
+ const child = spawn(ghPath, ['auth', 'logout', '--hostname', 'github.com'], {
3615
+ windowsHide: true,
3616
+ env: { ...process.env, ...activeProxyEnvironment, GH_PAGER: 'cat', NO_COLOR: '1' },
3617
+ stdio: ['pipe', 'pipe', 'pipe'],
3618
+ })
3619
+ let output = ''
3620
+ child.stdout?.on('data', (chunk) => { output += String(chunk) })
3621
+ child.stderr?.on('data', (chunk) => { output += String(chunk) })
3622
+ child.on('error', (error) => {
3623
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: error.message })
3624
+ })
3625
+ child.stdin?.write('y\n')
3626
+ child.stdin?.end()
3627
+ const exitCode = await new Promise((resolve) => {
3628
+ child.on('exit', (code) => resolve(code ?? 1))
3629
+ child.on('error', () => resolve(1))
3630
+ const killTimer = setTimeout(() => {
3631
+ child.kill()
3632
+ resolve(1)
3633
+ }, 20_000)
3634
+ if (typeof killTimer.unref === 'function') killTimer.unref()
3635
+ })
3636
+ if (exitCode !== 0 && !/logged out/i.test(output)) {
3637
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: firstLine(output) || `gh exited with code ${String(exitCode)}` })
3638
+ return
3639
+ }
3640
+ // The account is gone, so every cached answer about its repositories is stale.
3641
+ cache = null
3642
+ writeJson(res, 200, { ok: true, value: { ...authSnapshot(), note: firstLine(output) || null, gh: await readAuthStatus(ghPath, live().requestTimeoutMs) } })
3643
+ }
3644
+
3645
+ /** The repositories this account can see, with any local checkout already found. */
3646
+ const reposAvailableHandler = async (req, res) => {
3647
+ if (!guard(req, res)) return
3648
+ const body = await readJsonBody(req)
3649
+ const current = live()
3650
+ const limit = clampNumber(body?.limit, 1, 200, 100)
3651
+ const result = await ghJson(ghPath, ['api', `user/repos?per_page=${String(limit)}&sort=pushed`, '--jq', '[.[] | {full_name, private, pushed_at, description, fork}]'], current.requestTimeoutMs)
3652
+ if (!result.ok) {
3653
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: result.message })
3654
+ return
3655
+ }
3656
+ const declared = new Map(current.repos.map((entry) => [entry.repo, entry]))
3657
+ const repos = (Array.isArray(result.value) ? result.value : []).map((entry) => {
3658
+ const fullName = typeof entry?.full_name === 'string' ? entry.full_name : ''
3659
+ const bare = fullName.includes('/') ? fullName.slice(fullName.indexOf('/') + 1) : fullName
3660
+ const existing = declared.get(bare) ?? declared.get(fullName) ?? null
3661
+ return {
3662
+ fullName,
3663
+ bare,
3664
+ private: entry?.private === true,
3665
+ fork: entry?.fork === true,
3666
+ pushedAt: typeof entry?.pushed_at === 'string' ? entry.pushed_at : '',
3667
+ description: typeof entry?.description === 'string' ? entry.description : '',
3668
+ registered: existing !== null,
3669
+ /**
3670
+ * The exact string this repository is registered under.
3671
+ *
3672
+ * The panel must remove by that string, not by the name it would have
3673
+ * chosen: a list registered when `owner` was set holds bare names, while
3674
+ * one registered from the picker holds `owner/name`, and both are
3675
+ * legitimate. Removing by the wrong form silently fails to match.
3676
+ */
3677
+ registeredAs: existing !== null ? existing.repo : null,
3678
+ localPath: existing !== null ? existing.localPath : findLocalCheckout(current.projectsRoot, bare),
3679
+ }
3680
+ })
3681
+ writeJson(res, 200, { ok: true, value: { repos, projectsRoot: current.projectsRoot, registered: current.repos.length } })
3682
+ }
3683
+
3684
+ /** Register a repository: the click-driven equivalent of `configure.mjs add`. */
3685
+ const configAddHandler = async (req, res) => {
3686
+ if (!guard(req, res)) return
3687
+ const body = await readJsonBody(req)
3688
+ const current = live()
3689
+ const repo = typeof body?.repo === 'string' ? body.repo.trim() : ''
3690
+ if (!isValidRepoName(repo)) {
3691
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: `unusable repository name: ${JSON.stringify(repo)}` })
3692
+ return
3693
+ }
3694
+ const requestedPath = typeof body?.localPath === 'string' ? body.localPath.trim() : ''
3695
+ // A path that was not asked for is looked up rather than demanded: typing an
3696
+ // absolute path is the step this route exists to remove.
3697
+ const localPath = requestedPath !== '' ? requestedPath : findLocalCheckout(current.projectsRoot, repo)
3698
+ if (localPath !== '' && !isAbsolute(localPath)) {
3699
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'localPath must be absolute' })
3700
+ return
3701
+ }
3702
+ const outcome = mutateConfig(current, (draft) => {
3703
+ const entry = { repo, ...(localPath !== '' ? { localPath } : {}), label: '' }
3704
+ const index = draft.repos.findIndex((candidate) => candidate.repo === repo)
3705
+ if (index === -1) draft.repos.push(entry)
3706
+ else draft.repos[index] = entry
3707
+ return { ok: true, owner: draft.owner, repos: draft.repos }
3708
+ })
3709
+ if (!outcome.ok) {
3710
+ writeJson(res, 502, { ok: false, code: 'config-failed', message: outcome.message })
3711
+ return
3712
+ }
3713
+ writeJson(res, 200, { ok: true, value: { repo, localPath, registered: outcome.count, file: current.configFile } })
3714
+ }
3715
+
3716
+ /** Unregister a repository. */
3717
+ const configRemoveHandler = async (req, res) => {
3718
+ if (!guard(req, res)) return
3719
+ const body = await readJsonBody(req)
3720
+ const current = live()
3721
+ const repo = typeof body?.repo === 'string' ? body.repo.trim() : ''
3722
+ if (repo === '') {
3723
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'body.repo is required' })
3724
+ return
3725
+ }
3726
+ const outcome = mutateConfig(current, (draft) => {
3727
+ const before = draft.repos.length
3728
+ draft.repos = draft.repos.filter((candidate) => candidate.repo !== repo)
3729
+ if (draft.repos.length === before) return { ok: false, message: `not registered: ${repo}` }
3730
+ return { ok: true, owner: draft.owner, repos: draft.repos }
3731
+ })
3732
+ if (!outcome.ok) {
3733
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: outcome.message })
3734
+ return
3735
+ }
3736
+ writeJson(res, 200, { ok: true, value: { repo, registered: outcome.count, file: current.configFile } })
3737
+ }
3738
+
3739
+ /* ------------------------------------------- Bilibili update notes -- */
3740
+
3741
+ /*
3742
+ * The third channel, and the only one that is not a package: the comment under
3743
+ * the video that introduces the plugin.
3744
+ *
3745
+ * The shape of the feature follows from two facts that were measured, not
3746
+ * assumed, and both of them are the reason this is not simply "POST a comment
3747
+ * when the release workflow succeeds":
3748
+ *
3749
+ * - A comment is a WEB API, so the credential has to be a web session. The
3750
+ * `cookies.json` biliup writes here is a `BiliTV` login: alive, and refused
3751
+ * by every web member endpoint with `-101`. So the credential is never
3752
+ * trusted because a file exists — it is asked, and the answer is shown.
3753
+ * - A release workflow creates a DRAFT. A draft is invisible to everybody but
3754
+ * the author, so announcing it would post "this is out" about something
3755
+ * nobody can download. Only a published release counts, and the console's own
3756
+ * 【公开草稿】 button is what makes one.
3757
+ *
3758
+ * Everything here is idempotent by `(repo, tag)`: the ledger is written before
3759
+ * the panel is told anything, a ledger that cannot be read blocks the post
3760
+ * instead of duplicating it, and binding a video seeds a baseline so the version
3761
+ * that was already public when it was bound is never announced.
3762
+ */
3763
+
3764
+ /** The directory the plugin owns: wherever the managed repository list lives. */
3765
+ const stateDirectory = () => dirname(resolveConfigFilePath(config))
3766
+
3767
+ const credentialPath = () => join(stateDirectory(), BILIBILI_CREDENTIAL_FILE_NAME)
3768
+ const ledgerPath = () => join(stateDirectory(), BILIBILI_LEDGER_FILE_NAME)
3769
+
3770
+ /** Write JSON through a temporary file, keeping the previous revision beside it. */
3771
+ const writeJsonAtomic = (file, value) => {
3772
+ mkdirSync(dirname(file), { recursive: true })
3773
+ if (existsSync(file)) {
3774
+ try {
3775
+ writeFileSync(`${file}.bak`, readFileSync(file))
3776
+ } catch {
3777
+ /* a missing backup must not block the write */
3778
+ }
3779
+ }
3780
+ const temporary = `${file}.tmp`
3781
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
3782
+ renameSync(temporary, file)
3783
+ }
3784
+
3785
+ /**
3786
+ * The credential this request would use, and where it came from.
3787
+ *
3788
+ * The file the panel's sign-in wrote always wins over a configured external
3789
+ * path: someone who just completed a sign-in in this panel means it, and a
3790
+ * path left in the patch from an earlier experiment must not shadow it.
3791
+ */
3792
+ const readCredential = () => {
3793
+ const current = live()
3794
+ const ownPath = credentialPath()
3795
+ const configuredPath = current.bilibiliCookieFile
3796
+ const ownExists = existsSync(ownPath)
3797
+ const path = ownExists ? ownPath : (configuredPath !== '' && existsSync(configuredPath) ? configuredPath : '')
3798
+ const source = ownExists ? 'plugin' : 'configured'
3799
+ if (path === '') {
3800
+ return {
3801
+ jar: {
3802
+ ok: false,
3803
+ /* Not "unreadable": there is simply nothing configured yet, which is where
3804
+ every install starts and is a setup step rather than a fault. */
3805
+ absent: true,
3806
+ cookies: {},
3807
+ sessdata: '',
3808
+ csrf: '',
3809
+ uid: '',
3810
+ platform: '',
3811
+ expiresAt: null,
3812
+ message: '还没有 B 站凭据:用面板里的【登录 B 站】,或粘贴浏览器里的 SESSDATA + bili_jct。',
3813
+ },
3814
+ source: 'none',
3815
+ path: '',
3816
+ ownPath,
3817
+ configuredPath,
3818
+ }
3819
+ }
3820
+ let raw = ''
3821
+ try {
3822
+ raw = readFileSync(path, 'utf8')
3823
+ } catch (error) {
3824
+ return {
3825
+ jar: {
3826
+ ok: false,
3827
+ cookies: {},
3828
+ sessdata: '',
3829
+ csrf: '',
3830
+ uid: '',
3831
+ platform: '',
3832
+ expiresAt: null,
3833
+ message: `读不到 ${path}:${String(error?.message ?? error)}`,
3834
+ },
3835
+ source,
3836
+ path,
3837
+ ownPath,
3838
+ configuredPath,
3839
+ }
3840
+ }
3841
+ return { jar: parseCookieJar(raw), source, path, ownPath, configuredPath }
3842
+ }
3843
+
3844
+ /** The Bilibili transport, with the test seam the route tests drive. */
3845
+ const bilibiliClient = () => createBilibiliClient({
3846
+ fetchImpl: typeof config.bilibiliFetch === 'function' ? config.bilibiliFetch : globalThis.fetch,
3847
+ timeoutMs: Math.min(live().requestTimeoutMs, 20_000),
3848
+ })
3849
+
3850
+ /** The last account answer, so a panel that polls does not poll Bilibili. */
3851
+ let credentialCache = null
3852
+
3853
+ /** BV id to resolved video, reused for the same reason. */
3854
+ const videoCache = new Map()
3855
+
3856
+ /**
3857
+ * Ask Bilibili who this credential belongs to, reusing a recent answer.
3858
+ *
3859
+ * The account endpoint is the whole point: it is the one call that separates
3860
+ * "there is a SESSDATA line in a file" from "this is a web session that may
3861
+ * comment". A `-101` is an answer, not an error, and it is reported with the
3862
+ * platform it came from.
3863
+ */
3864
+ const verifyCredential = async ({ force = false } = {}) => {
3865
+ const current = live()
3866
+ const found = readCredential()
3867
+ if (found.jar.ok !== true) return { account: null, credential: found }
3868
+ const fresh = credentialCache !== null
3869
+ && credentialCache.path === found.path
3870
+ && Date.now() - credentialCache.at < current.bilibiliVerifyTtlMs
3871
+ if (fresh && force !== true) return { account: credentialCache.account, credential: found }
3872
+ const account = await bilibiliClient().readAccount(cookieHeader(found.jar.cookies))
3873
+ credentialCache = { at: Date.now(), path: found.path, account }
3874
+ return { account, credential: found }
3875
+ }
3876
+
3877
+ /** Resolve one bound video to its `aid`, reusing a recent answer. */
3878
+ const resolveVideoCached = async (bvid, { force = false } = {}) => {
3879
+ const current = live()
3880
+ const found = readCredential()
3881
+ const key = `${bvid}|${found.path}`
3882
+ const cached = videoCache.get(key)
3883
+ if (force !== true && cached !== undefined && Date.now() - cached.at < current.bilibiliVerifyTtlMs) return cached.value
3884
+ const value = await bilibiliClient().resolveVideo(cookieHeader(found.jar.cookies), bvid)
3885
+ videoCache.set(key, { at: Date.now(), value })
3886
+ return value
3887
+ }
3888
+
3889
+ /**
3890
+ * The announcement ledger.
3891
+ *
3892
+ * An unreadable file is reported, never replaced. Treating it as empty is the
3893
+ * one failure that would be worse than not posting at all: every comment this
3894
+ * plugin has ever written would be written again on the next sweep.
3895
+ */
3896
+ const readLedgerFile = () => {
3897
+ const file = ledgerPath()
3898
+ if (!existsSync(file)) return { ledger: emptyLedger(), problem: null, file }
3899
+ let raw = ''
3900
+ try {
3901
+ raw = readFileSync(file, 'utf8')
3902
+ } catch (error) {
3903
+ return { ledger: emptyLedger(), problem: `读不到 ${file}:${String(error?.message ?? error)}`, file }
3904
+ }
3905
+ const parsed = parseLedger(raw)
3906
+ return parsed.ok === true
3907
+ ? { ledger: parsed.ledger, problem: null, file }
3908
+ : { ledger: emptyLedger(), problem: `${file}:${parsed.message}`, file }
3909
+ }
3910
+
3911
+ /** Append one ledger entry, bounded in size. */
3912
+ const appendLedger = (entry) => {
3913
+ const read = readLedgerFile()
3914
+ const next = recordLedgerEntry(read.ledger, entry, { maxEntries: BILIBILI_MAX_LEDGER_ENTRIES })
3915
+ try {
3916
+ writeJsonAtomic(read.file, next)
3917
+ return { ok: true, file: read.file }
3918
+ } catch (error) {
3919
+ return { ok: false, message: `写不进 ${read.file}:${String(error?.message ?? error)}` }
3920
+ }
3921
+ }
3922
+
3923
+ /** The comment one release would produce, in the configured template. */
3924
+ const composeForEntry = (current, entry, release) => composeComment({
3925
+ label: entry.label !== '' ? entry.label : entry.repo,
3926
+ repo: entry.repo,
3927
+ tag: typeof release?.tag === 'string' ? release.tag : '',
3928
+ release,
3929
+ template: current.bilibiliTemplate,
3930
+ date: new Date().toISOString().slice(0, 10),
3931
+ })
3932
+
3933
+ /**
3934
+ * Post one release's update note, and record what happened either way.
3935
+ *
3936
+ * The order is deliberate: credential, then the video, then the ledger write,
3937
+ * then — and only then — the answer the panel shows. A comment that exists on
3938
+ * Bilibili but not in the ledger is the one state this feature cannot recover
3939
+ * from, so the ledger is the record, not a cache of one.
3940
+ */
3941
+ const announceEntry = async ({ current, entry, release, text = '', trigger = 'auto' }) => {
3942
+ const binding = entry.bilibili
3943
+ if (binding === null || binding === undefined) {
3944
+ return { ok: false, code: 'unbound', message: '这个仓库还没有绑定 B 站视频。', value: { repo: entry.repo } }
3945
+ }
3946
+ const read = readLedgerFile()
3947
+ if (read.problem !== null) {
3948
+ return {
3949
+ ok: false,
3950
+ code: 'ledger-unreadable',
3951
+ message: `播报记录读不出来,为避免重复刷评论,这次没有发送:${read.problem}`,
3952
+ value: { repo: entry.repo, ledger: read.file },
3953
+ }
3954
+ }
3955
+ const { account, credential } = await verifyCredential()
3956
+ const verdict = credentialVerdict({ jar: credential.jar, account })
3957
+ if (verdict.state !== 'ready') {
3958
+ return { ok: false, code: `credential-${verdict.state}`, message: verdict.message, value: { repo: entry.repo, path: credential.path } }
3959
+ }
3960
+ const video = await resolveVideoCached(binding.bvid, { force: true })
3961
+ if (video.ok !== true || video.aid === null) {
3962
+ return {
3963
+ ok: false,
3964
+ code: 'video-unreadable',
3965
+ message: `读不到视频 ${binding.bvid}:${video.message === '' ? 'B 站没有回答' : video.message}`,
3966
+ value: { repo: entry.repo, bvid: binding.bvid },
3967
+ }
3968
+ }
3969
+ const composed = composeForEntry(current, entry, release)
3970
+ const message = (text !== '' ? text : composed.text).trim()
3971
+ if (message === '') {
3972
+ return { ok: false, code: 'empty-comment', message: '评论内容是空的,没有发送。', value: { repo: entry.repo, tag: release.tag } }
3973
+ }
3974
+
3975
+ const client = bilibiliClient()
3976
+ /*
3977
+ * A device id is what a browser sends, and its absence is one of the things
3978
+ * that earns a `-412`. Best effort on purpose: a failed fingerprint call is
3979
+ * not a reason to hold back a comment that is otherwise ready.
3980
+ */
3981
+ const fingerprint = await client.fingerPrint().catch(() => ({ buvid3: '', buvid4: '' }))
3982
+ /* Both device ids, and the credential's own value wins — see `withDeviceIds`. */
3983
+ const cookies = withDeviceIds(credential.jar.cookies, fingerprint)
3984
+
3985
+ const posted = await client.postComment({
3986
+ cookie: cookieHeader(cookies),
3987
+ csrf: credential.jar.csrf,
3988
+ aid: video.aid,
3989
+ bvid: binding.bvid,
3990
+ message,
3991
+ })
3992
+ const previous = findLedgerEntry(read.ledger, entry.repo, release.tag)
3993
+ const attempts = (Number.isFinite(previous?.attempts) ? Number(previous.attempts) : 0) + (posted.ok === true ? 0 : 1)
3994
+ const record = {
3995
+ repo: entry.repo,
3996
+ tag: release.tag,
3997
+ bvid: binding.bvid,
3998
+ at: new Date().toISOString(),
3999
+ state: posted.ok === true ? 'announced' : 'failed',
4000
+ trigger,
4001
+ attempts,
4002
+ text: message,
4003
+ rpid: posted.ok === true ? posted.rpid : null,
4004
+ code: posted.code,
4005
+ message: posted.message,
4006
+ failure: posted.ok === true ? null : posted.failure.kind,
4007
+ url: posted.ok === true && posted.rpid !== null ? `https://www.bilibili.com/video/${binding.bvid}/#reply${posted.rpid}` : null,
4008
+ }
4009
+ const stored = appendLedger(record)
4010
+ if (posted.ok !== true) {
4011
+ return {
4012
+ ok: false,
4013
+ code: `reply-${posted.failure.kind}`,
4014
+ message: `${posted.failure.advice}(B 站原话:${posted.message === '' ? String(posted.code) : posted.message})`,
4015
+ value: { repo: entry.repo, tag: release.tag, bvid: binding.bvid, attempts, ledgerWritten: stored.ok === true },
4016
+ }
4017
+ }
4018
+ return {
4019
+ ok: true,
4020
+ value: {
4021
+ repo: entry.repo,
4022
+ tag: release.tag,
4023
+ bvid: binding.bvid,
4024
+ rpid: posted.rpid,
4025
+ url: record.url,
4026
+ text: message,
4027
+ account: account?.uname ?? '',
4028
+ video: video.title,
4029
+ trigger,
4030
+ },
4031
+ }
4032
+ }
4033
+
4034
+ /**
4035
+ * Look for a release that has not been announced yet, and announce it.
4036
+ *
4037
+ * This is the half that makes the feature "automatic": a release published from
4038
+ * the console, from another machine, or by hand on GitHub's website all arrive
4039
+ * here, because what is watched is the release list and not the button that was
4040
+ * pressed. The credential is checked once for the whole sweep — a broken
4041
+ * credential is not a per-repository failure, and burning the retry budget of
4042
+ * every repository on it would hide the real reason.
4043
+ */
4044
+ let sweepRunning = false
4045
+ let lastSweep = null
4046
+
4047
+ const sweepBilibili = async (reason) => {
4048
+ const current = live()
4049
+ if (!current.enabled || !current.bilibiliEnabled || !current.bilibiliAuto) return
4050
+ if (sweepRunning) return
4051
+ const bound = current.repos.filter((entry) => entry.bilibili !== null && entry.bilibili.bvid !== '' && entry.bilibili.auto !== false)
4052
+ if (bound.length === 0) return
4053
+ sweepRunning = true
4054
+ const results = []
4055
+ try {
4056
+ const { account, credential } = await verifyCredential()
4057
+ const state = credentialVerdict({ jar: credential.jar, account })
4058
+ if (state.state !== 'ready') {
4059
+ results.push({ repo: '', tag: null, state: `credential-${state.state}`, message: state.message })
4060
+ return
4061
+ }
4062
+ for (const entry of bound) {
4063
+ const slug = resolveSlug(current.owner, entry.repo)
4064
+ if (slug === null) {
4065
+ results.push({ repo: entry.repo, tag: null, state: 'no-slug', message: 'set `owner` or write the entry as "owner/repo"' })
4066
+ continue
4067
+ }
4068
+ const releasesResult = await ghJson(ghPath, ['api', `repos/${slug}/releases?per_page=10`], current.requestTimeoutMs)
4069
+ if (!releasesResult.ok) {
4070
+ results.push({ repo: entry.repo, tag: null, state: 'gh-failed', message: releasesResult.message })
4071
+ continue
4072
+ }
4073
+ const releases = (Array.isArray(releasesResult.value) ? releasesResult.value : [])
4074
+ .map(normalizeRelease)
4075
+ .filter((release) => release !== null)
4076
+ const release = newestPublishedRelease(releases)
4077
+ const read = readLedgerFile()
4078
+ const verdict = announcementVerdict({
4079
+ binding: { repo: entry.repo, bvid: entry.bilibili.bvid },
4080
+ release,
4081
+ ledger: read.ledger,
4082
+ })
4083
+ if (verdict.state !== 'ready') {
4084
+ results.push({ repo: entry.repo, tag: release?.tag ?? null, state: verdict.state, message: verdict.message })
4085
+ continue
4086
+ }
4087
+ const outcome = await announceEntry({ current, entry, release, trigger: 'auto' })
4088
+ results.push({
4089
+ repo: entry.repo,
4090
+ tag: release.tag,
4091
+ state: outcome.ok === true ? 'announced' : 'failed',
4092
+ message: outcome.ok === true ? '' : outcome.message,
4093
+ url: outcome.ok === true ? outcome.value.url : null,
4094
+ })
4095
+ }
4096
+ } catch (error) {
4097
+ results.push({ repo: '', tag: null, state: 'sweep-failed', message: String(error?.message ?? error) })
4098
+ } finally {
4099
+ sweepRunning = false
4100
+ lastSweep = { at: new Date().toISOString(), reason, results }
4101
+ }
4102
+ }
4103
+
4104
+ /* ------------------------------------------------------ sign-in, panel -- */
4105
+
4106
+ /** The one in-flight Bilibili sign-in, if any. */
4107
+ let biliLogin = null
4108
+
4109
+ const loginSnapshot = () => biliLogin === null
4110
+ ? { state: 'idle', url: '', startedAt: null, expiresAt: null, scanned: false, message: '' }
4111
+ : {
4112
+ state: biliLogin.state,
4113
+ url: biliLogin.url,
4114
+ startedAt: biliLogin.startedAt,
4115
+ expiresAt: biliLogin.expiresAt,
4116
+ scanned: biliLogin.scanned === true,
4117
+ message: biliLogin.message,
4118
+ }
4119
+
4120
+ /** Store a credential this plugin owns, in its own file. */
4121
+ const storeCredential = (cookies, source, account) => {
4122
+ writeJsonAtomic(credentialPath(), {
4123
+ version: 1,
4124
+ source,
4125
+ savedAt: new Date().toISOString(),
4126
+ account: account ?? null,
4127
+ cookies,
4128
+ })
4129
+ credentialCache = null
4130
+ }
4131
+
4132
+ /* --------------------------------------------------------------- routes -- */
4133
+
4134
+ /**
4135
+ * The credential, the bindings, and what has already been said.
4136
+ *
4137
+ * Deliberately free of GitHub calls: the panel asks for this on open and on
4138
+ * refresh, and "which version is next" is answered by the overview it already
4139
+ * has. The two Bilibili calls it may make are cached (`bilibiliVerifyTtlMs`),
4140
+ * so a panel left open is not a poll of Bilibili.
4141
+ */
4142
+ const bilibiliStatusHandler = async (req, res) => {
4143
+ if (!guard(req, res)) return
4144
+ const body = await readJsonBody(req)
4145
+ const current = live()
4146
+ const force = body?.force === true
4147
+ const found = readCredential()
4148
+ const account = found.jar.ok === true ? (await verifyCredential({ force })).account : null
4149
+ const verdict = credentialVerdict({ jar: found.jar, account })
4150
+ const read = readLedgerFile()
4151
+
4152
+ const repos = []
4153
+ for (const entry of current.repos) {
4154
+ const binding = entry.bilibili
4155
+ const mine = read.ledger.entries.filter((candidate) => candidate.repo === entry.repo)
4156
+ const baseline = latestBaseline(read.ledger, entry.repo)
4157
+ const base = {
4158
+ repo: entry.repo,
4159
+ label: entry.label !== '' ? entry.label : entry.repo,
4160
+ binding: binding === null ? null : { bvid: binding.bvid, auto: binding.auto !== false },
4161
+ announced: mine
4162
+ .filter((candidate) => candidate.state === 'announced')
4163
+ .map((candidate) => ({ tag: candidate.tag, at: candidate.at, url: candidate.url ?? null, rpid: candidate.rpid ?? null, text: candidate.text ?? '', trigger: candidate.trigger ?? '' })),
4164
+ failures: mine
4165
+ .filter((candidate) => candidate.state === 'failed')
4166
+ .map((candidate) => ({ tag: candidate.tag, at: candidate.at, attempts: candidate.attempts ?? 0, failure: candidate.failure ?? null, message: candidate.message ?? '' })),
4167
+ baseline: baseline === null ? null : { tag: baseline.tag ?? null, at: baseline.at ?? null },
4168
+ video: null,
4169
+ commentUrl: binding === null ? null : `https://www.bilibili.com/video/${binding.bvid}/`,
4170
+ }
4171
+ if (binding !== null) {
4172
+ const video = await resolveVideoCached(binding.bvid, { force })
4173
+ base.video = video.ok === true
4174
+ ? { ok: true, aid: video.aid, title: video.title, owner: video.owner }
4175
+ : { ok: false, code: video.code, message: video.message }
4176
+ }
4177
+ repos.push(base)
4178
+ }
4179
+
4180
+ writeJson(res, 200, {
4181
+ ok: true,
4182
+ value: {
4183
+ enabled: current.bilibiliEnabled,
4184
+ auto: current.bilibiliAuto,
4185
+ watchSeconds: current.bilibiliWatchSeconds,
4186
+ template: current.bilibiliTemplate,
4187
+ credential: {
4188
+ ...verdict,
4189
+ source: found.source,
4190
+ path: found.path,
4191
+ ownPath: found.ownPath,
4192
+ configuredPath: found.configuredPath,
4193
+ platform: found.jar.platform,
4194
+ expiresAt: found.jar.expiresAt,
4195
+ hasSession: found.jar.sessdata !== '',
4196
+ hasCsrf: found.jar.csrf !== '',
4197
+ },
4198
+ login: loginSnapshot(),
4199
+ ledger: { file: read.file, problem: read.problem, entries: read.ledger.entries.length },
4200
+ lastSweep,
4201
+ repos,
4202
+ },
4203
+ })
4204
+ }
4205
+
4206
+ const bilibiliLoginStartHandler = async (req, res) => {
4207
+ if (!guard(req, res)) return
4208
+ await readJsonBody(req)
4209
+ const started = await bilibiliClient().startQrLogin()
4210
+ if (started.ok !== true) {
4211
+ writeJson(res, 502, { ok: false, code: 'login-start-failed', message: `B 站没有给出登录二维码:${started.message}` })
4212
+ return
4213
+ }
4214
+ biliLogin = {
4215
+ key: started.key,
4216
+ url: started.url,
4217
+ state: 'waiting',
4218
+ message: '',
4219
+ scanned: false,
4220
+ startedAt: new Date().toISOString(),
4221
+ expiresAt: new Date(Date.now() + BILIBILI_LOGIN_TTL_MS).toISOString(),
4222
+ }
4223
+ writeJson(res, 202, { ok: true, value: loginSnapshot() })
4224
+ }
4225
+
4226
+ const bilibiliLoginPollHandler = async (req, res) => {
4227
+ if (!guard(req, res)) return
4228
+ await readJsonBody(req)
4229
+ if (biliLogin === null) {
4230
+ writeJson(res, 409, { ok: false, code: 'no-login', message: '没有正在进行的 B 站登录。' })
4231
+ return
4232
+ }
4233
+ if (Date.now() > Date.parse(biliLogin.expiresAt)) {
4234
+ biliLogin = null
4235
+ writeJson(res, 200, { ok: true, value: { ...loginSnapshot(), state: 'expired', message: '二维码已过期,请重新开始。' } })
4236
+ return
4237
+ }
4238
+ const polled = await bilibiliClient().pollQrLogin(biliLogin.key)
4239
+ if (polled.ok === true && polled.state === 'waiting') {
4240
+ writeJson(res, 200, { ok: true, value: loginSnapshot() })
4241
+ return
4242
+ }
4243
+ if (polled.ok === true && polled.state === 'scanned') {
4244
+ biliLogin.state = 'scanned'
4245
+ biliLogin.scanned = true
4246
+ writeJson(res, 200, { ok: true, value: loginSnapshot() })
4247
+ return
4248
+ }
4249
+ if (polled.ok !== true || polled.state !== 'succeeded') {
4250
+ biliLogin.state = 'failed'
4251
+ biliLogin.message = polled.message
4252
+ const value = loginSnapshot()
4253
+ biliLogin = null
4254
+ writeJson(res, 502, { ok: false, code: 'login-failed', message: polled.message === '' ? 'B 站登录没有完成。' : polled.message, value })
4255
+ return
4256
+ }
4257
+ const jar = parseCookieJar({ cookies: polled.cookies })
4258
+ if (jar.ok !== true || jar.sessdata === '' || jar.csrf === '') {
4259
+ biliLogin = null
4260
+ writeJson(res, 502, {
4261
+ ok: false,
4262
+ code: 'login-incomplete',
4263
+ message: `B 站回了成功,但下发的 Cookie 里缺 ${jar.sessdata === '' ? 'SESSDATA' : 'bili_jct'},请重新登录。`,
4264
+ value: loginSnapshot(),
4265
+ })
4266
+ return
4267
+ }
4268
+ /*
4269
+ * The QR flow is authoritative — Bilibili itself handed these cookies over —
4270
+ * so the credential is stored even when the account read fails, and the
4271
+ * failure is reported rather than turned into a refused sign-in.
4272
+ */
4273
+ const account = await bilibiliClient().readAccount(cookieHeader(jar.cookies))
4274
+ let stored = { ok: true }
4275
+ try {
4276
+ storeCredential(jar.cookies, 'qr-login', account.ok === true ? { mid: account.mid, uname: account.uname } : null)
4277
+ } catch (error) {
4278
+ stored = { ok: false, message: String(error?.message ?? error) }
4279
+ }
4280
+ biliLogin = null
4281
+ if (stored.ok !== true) {
4282
+ writeJson(res, 502, { ok: false, code: 'write-failed', message: `登录成功但凭据没有落盘:${stored.message}` })
4283
+ return
4284
+ }
4285
+ writeJson(res, 200, {
4286
+ ok: true,
4287
+ value: {
4288
+ state: 'succeeded',
4289
+ scanned: true,
4290
+ url: '',
4291
+ startedAt: null,
4292
+ expiresAt: null,
4293
+ message: '',
4294
+ account: account.ok === true ? { mid: account.mid, uname: account.uname } : null,
4295
+ accountProblem: account.ok === true ? null : account.message,
4296
+ path: credentialPath(),
4297
+ },
4298
+ })
4299
+ }
4300
+
4301
+ const bilibiliLoginCancelHandler = async (req, res) => {
4302
+ if (!guard(req, res)) return
4303
+ await readJsonBody(req)
4304
+ biliLogin = null
4305
+ writeJson(res, 200, { ok: true, value: loginSnapshot() })
4306
+ }
4307
+
4308
+ /**
4309
+ * Store a pasted credential.
4310
+ *
4311
+ * Refused unless Bilibili accepts it right now. Writing a jar that has already
4312
+ * been answered with `-101` would leave the panel saying "已登录" about a
4313
+ * credential that cannot post, which is worse than saying nothing.
4314
+ */
4315
+ const bilibiliCredentialHandler = async (req, res) => {
4316
+ if (!guard(req, res)) return
4317
+ const body = await readJsonBody(req)
4318
+ const sessdata = text(body?.sessdata)
4319
+ const csrf = text(body?.bili_jct)
4320
+ const raw = sessdata !== '' || csrf !== ''
4321
+ ? { SESSDATA: sessdata, bili_jct: csrf, DedeUserID: text(body?.dedeUserId) }
4322
+ : text(body?.cookie)
4323
+ const jar = parseCookieJar(raw)
4324
+ if (jar.ok !== true || jar.sessdata === '' || jar.csrf === '') {
4325
+ writeJson(res, 400, { ok: false, code: 'incomplete-credential', message: credentialVerdict({ jar }).message })
4326
+ return
4327
+ }
4328
+ const account = await bilibiliClient().readAccount(cookieHeader(jar.cookies))
4329
+ const verdict = credentialVerdict({ jar, account })
4330
+ if (verdict.state !== 'ready') {
4331
+ writeJson(res, 401, {
4332
+ ok: false,
4333
+ code: `credential-${verdict.state}`,
4334
+ message: verdict.message,
4335
+ value: { bilibiliCode: account.code, bilibiliMessage: account.message, platform: jar.platform },
4336
+ })
4337
+ return
4338
+ }
4339
+ try {
4340
+ storeCredential(jar.cookies, 'paste', verdict.account)
4341
+ } catch (error) {
4342
+ writeJson(res, 502, { ok: false, code: 'write-failed', message: `凭据没有落盘:${String(error?.message ?? error)}` })
4343
+ return
4344
+ }
4345
+ writeJson(res, 200, { ok: true, value: { account: verdict.account, path: credentialPath(), platform: jar.platform } })
4346
+ }
4347
+
4348
+ /**
4349
+ * Forget the credential this plugin stored.
4350
+ *
4351
+ * Only its own file is deleted. A configured external file belongs to whatever
4352
+ * wrote it — biliup, in the case this machine has — and deleting it would break
4353
+ * an unrelated tool. When a fallback takes over, the answer says so instead of
4354
+ * reporting a sign-out that did not happen.
4355
+ */
4356
+ const bilibiliLogoutHandler = async (req, res) => {
4357
+ if (!guard(req, res)) return
4358
+ await readJsonBody(req)
4359
+ const own = credentialPath()
4360
+ let removed = false
4361
+ if (existsSync(own)) {
4362
+ try {
4363
+ unlinkSync(own)
4364
+ removed = true
4365
+ } catch (error) {
4366
+ writeJson(res, 502, { ok: false, code: 'delete-failed', message: `删不掉 ${own}:${String(error?.message ?? error)}` })
4367
+ return
4368
+ }
4369
+ }
4370
+ credentialCache = null
4371
+ videoCache.clear()
4372
+ const after = readCredential()
4373
+ writeJson(res, 200, {
4374
+ ok: true,
4375
+ value: {
4376
+ removed,
4377
+ path: own,
4378
+ stillAvailable: after.path === '' ? null : { source: after.source, path: after.path },
4379
+ },
4380
+ })
4381
+ }
4382
+
4383
+ /**
4384
+ * Bind a repository to the comment section of one video.
4385
+ *
4386
+ * Binding seeds a baseline: the version already public at that moment is
4387
+ * marked as "not this update", so wiring a video up can never fire a comment
4388
+ * about a release that went out months ago. Unbinding keeps the history —
4389
+ * what was said in public is not erased by a configuration change.
4390
+ */
4391
+ const bilibiliBindHandler = async (req, res) => {
4392
+ if (!guard(req, res)) return
4393
+ const body = await readJsonBody(req)
4394
+ const current = live()
4395
+ const target = findEntry(current, body)
4396
+ if (!target.ok) {
4397
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
4398
+ return
4399
+ }
4400
+ const requested = typeof body?.bvid === 'string' ? body.bvid.trim() : ''
4401
+ const normalized = requested === '' ? null : normalizeBinding({ bvid: requested, auto: body?.auto !== false })
4402
+ if (requested !== '' && normalized === null) {
4403
+ writeJson(res, 400, { ok: false, code: 'bad-bvid', message: `不像是 BV 号:${JSON.stringify(requested)}(形如 BV1RopP6FEJp)` })
4404
+ return
4405
+ }
4406
+ const bvid = normalized?.bvid ?? ''
4407
+ const auto = normalized?.auto !== false
4408
+ const outcome = mutateConfig(current, (draft) => {
4409
+ const index = draft.repos.findIndex((candidate) => candidate.repo === target.entry.repo)
4410
+ if (index === -1) return { ok: false, message: `not registered: ${target.entry.repo}` }
4411
+ const previous = draft.repos[index]
4412
+ draft.repos[index] = {
4413
+ repo: previous.repo,
4414
+ ...(previous.localPath !== '' ? { localPath: previous.localPath } : {}),
4415
+ ...(previous.label !== '' ? { label: previous.label } : {}),
4416
+ ...(bvid === '' ? {} : { bilibili: { bvid, auto } }),
4417
+ }
4418
+ return { ok: true, owner: draft.owner, repos: draft.repos }
4419
+ })
4420
+ if (outcome.ok !== true) {
4421
+ writeJson(res, 502, { ok: false, code: 'config-failed', message: outcome.message })
4422
+ return
4423
+ }
4424
+ if (bvid === '') {
4425
+ writeJson(res, 200, { ok: true, value: { repo: target.entry.repo, bvid: '', video: null, baseline: null, file: current.configFile } })
4426
+ return
4427
+ }
4428
+
4429
+ const video = await resolveVideoCached(bvid, { force: true })
4430
+ const slug = resolveSlug(current.owner, target.entry.repo)
4431
+ let baselineTag = null
4432
+ let note = ''
4433
+ if (slug === null) {
4434
+ note = '这个仓库没有 owner,读不到 Release 列表,所以把绑定时刻之前发布的版本都视为已公开。'
4435
+ } else {
4436
+ const releasesResult = await ghJson(ghPath, ['api', `repos/${slug}/releases?per_page=10`], current.requestTimeoutMs)
4437
+ if (releasesResult.ok) {
4438
+ const releases = (Array.isArray(releasesResult.value) ? releasesResult.value : [])
4439
+ .map(normalizeRelease)
4440
+ .filter((release) => release !== null)
4441
+ baselineTag = newestPublishedRelease(releases)?.tag ?? null
4442
+ } else {
4443
+ note = `读不到 Release 列表(${releasesResult.message}),所以把绑定时刻之前发布的版本都视为已公开。`
4444
+ }
4445
+ }
4446
+ appendLedger({
4447
+ repo: target.entry.repo,
4448
+ tag: baselineTag,
4449
+ bvid,
4450
+ at: new Date().toISOString(),
4451
+ state: 'baseline',
4452
+ trigger: 'bind',
4453
+ attempts: 0,
4454
+ text: '',
4455
+ rpid: null,
4456
+ code: null,
4457
+ message: '',
4458
+ failure: null,
4459
+ url: null,
4460
+ })
4461
+ cache = null
4462
+ writeJson(res, 200, {
4463
+ ok: true,
4464
+ value: {
4465
+ repo: target.entry.repo,
4466
+ bvid,
4467
+ auto,
4468
+ video: video.ok === true ? { ok: true, title: video.title, owner: video.owner, aid: video.aid } : { ok: false, code: video.code, message: video.message },
4469
+ baseline: baselineTag,
4470
+ note,
4471
+ file: current.configFile,
4472
+ },
4473
+ })
4474
+ }
4475
+
4476
+ /**
4477
+ * Compose (and, unless asked not to, post) one repository's update note.
4478
+ *
4479
+ * `dryRun` exists because posting is public and irreversible in the way that
4480
+ * matters: the panel shows the exact sentence, and the sentence it shows is the
4481
+ * sentence the Host would send — composed here, not by the browser, so a
4482
+ * template change cannot be previewed one way and posted another.
4483
+ */
4484
+ const bilibiliAnnounceHandler = async (req, res) => {
4485
+ if (!guard(req, res)) return
4486
+ const body = await readJsonBody(req)
4487
+ const current = live()
4488
+ const target = findEntry(current, body)
4489
+ if (!target.ok) {
4490
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
4491
+ return
4492
+ }
4493
+ const binding = target.entry.bilibili
4494
+ if (binding === null) {
4495
+ writeJson(res, 409, {
4496
+ ok: false,
4497
+ code: 'unbound',
4498
+ message: `${target.entry.repo} 还没有绑定 B 站视频。`,
4499
+ value: { repo: target.entry.repo },
4500
+ })
4501
+ return
4502
+ }
4503
+ const slug = resolveSlug(current.owner, target.entry.repo)
4504
+ if (slug === null) {
4505
+ writeJson(res, 400, { ok: false, code: 'bad-request', message: 'set `owner` in the config, or write the entry as "owner/repo"' })
4506
+ return
4507
+ }
4508
+ const releasesResult = await ghJson(ghPath, ['api', `repos/${slug}/releases?per_page=10`], current.requestTimeoutMs)
4509
+ if (!releasesResult.ok) {
4510
+ writeJson(res, 502, { ok: false, code: 'gh-failed', message: releasesResult.message })
4511
+ return
4512
+ }
4513
+ const releases = (Array.isArray(releasesResult.value) ? releasesResult.value : [])
4514
+ .map(normalizeRelease)
4515
+ .filter((release) => release !== null)
4516
+ const wantedTag = text(body?.tag)
4517
+ const release = wantedTag === ''
4518
+ ? newestPublishedRelease(releases)
4519
+ : releases.find((candidate) => candidate.tag === wantedTag) ?? null
4520
+ const force = body?.force === true
4521
+ const read = readLedgerFile()
4522
+ const verdict = announcementVerdict({
4523
+ binding: { repo: target.entry.repo, bvid: binding.bvid },
4524
+ release,
4525
+ ledger: read.ledger,
4526
+ force,
4527
+ })
4528
+ const composed = release === null ? null : composeForEntry(current, target.entry, release)
4529
+
4530
+ if (body?.dryRun === true) {
4531
+ writeJson(res, 200, {
4532
+ ok: true,
4533
+ value: {
4534
+ repo: target.entry.repo,
4535
+ bvid: binding.bvid,
4536
+ tag: release?.tag ?? null,
4537
+ state: verdict.state,
4538
+ message: verdict.message,
4539
+ text: composed?.text ?? '',
4540
+ summary: composed?.summary ?? '',
4541
+ unknown: composed?.unknown ?? [],
4542
+ release: release === null ? null : { tag: release.tag, name: release.name, url: release.url, createdAt: release.createdAt },
4543
+ ledgerProblem: read.problem,
4544
+ attempts: verdict.attempts,
4545
+ },
4546
+ })
4547
+ return
4548
+ }
4549
+ if (verdict.state !== 'ready') {
4550
+ writeJson(res, 409, {
4551
+ ok: false,
4552
+ code: `announce-${verdict.state}`,
4553
+ message: verdict.message,
4554
+ value: { repo: target.entry.repo, tag: release?.tag ?? null, text: composed?.text ?? '', state: verdict.state },
4555
+ })
4556
+ return
4557
+ }
4558
+ const outcome = await announceEntry({ current, entry: target.entry, release, text: text(body?.text), trigger: 'manual' })
4559
+ if (outcome.ok !== true) {
4560
+ const status = outcome.code.startsWith('credential-') ? 401 : outcome.code === 'unbound' ? 409 : outcome.code === 'ledger-unreadable' ? 500 : 502
4561
+ writeJson(res, status, { ok: false, code: outcome.code, message: outcome.message, value: outcome.value ?? null })
4562
+ return
4563
+ }
4564
+ writeJson(res, 200, { ok: true, value: outcome.value })
4565
+ }
4566
+
4567
+ const routes = [
4568
+ [`${ROUTE_PREFIX}/status`, statusHandler],
4569
+ [`${ROUTE_PREFIX}/overview`, overviewHandler],
4570
+ [`${ROUTE_PREFIX}/runs`, runsHandler],
4571
+ [`${ROUTE_PREFIX}/dispatch`, dispatchHandler],
4572
+ [`${ROUTE_PREFIX}/run-action`, runActionHandler],
4573
+ [`${ROUTE_PREFIX}/release-action`, releaseActionHandler],
4574
+ [`${ROUTE_PREFIX}/version-bump`, versionBumpHandler],
4575
+ // The step before 构建 and 发布: a release builds the pushed commit, so the console
4576
+ // that can cut a release should be able to get the work to GitHub in the first place.
4577
+ [`${ROUTE_PREFIX}/commit`, commitHandler],
4578
+ [`${ROUTE_PREFIX}/logs`, logsHandler],
4579
+ // Taking the release, not just cutting it: the artifact is fetched and handed
4580
+ // to the Host's own plugin manager, and the restart is left to the plugin that
4581
+ // owns restarting.
4582
+ [`${ROUTE_PREFIX}/update`, updateHandler],
4583
+ [`${ROUTE_PREFIX}/restart`, restartHandler],
4584
+ // One package, two channels: the GitHub Release above, and npm below it. The npm
4585
+ // state is asked for on demand and the push is its own button, because a publish
4586
+ // cannot be undone and must not ride along with a release.
4587
+ [`${ROUTE_PREFIX}/npm-status`, npmStatusHandler],
4588
+ [`${ROUTE_PREFIX}/npm-login`, npmLoginHandler],
4589
+ [`${ROUTE_PREFIX}/npm-publish`, npmPublishHandler],
4590
+ [`${ROUTE_PREFIX}/auth-start`, authStartHandler],
4591
+ [`${ROUTE_PREFIX}/auth-state`, authStateHandler],
4592
+ [`${ROUTE_PREFIX}/auth-cancel`, authCancelHandler],
4593
+ [`${ROUTE_PREFIX}/auth-logout`, authLogoutHandler],
4594
+ [`${ROUTE_PREFIX}/repos-available`, reposAvailableHandler],
4595
+ [`${ROUTE_PREFIX}/config-add`, configAddHandler],
4596
+ [`${ROUTE_PREFIX}/config-remove`, configRemoveHandler],
4597
+ // The third channel: an update note under the video that introduces the
4598
+ // plugin. Credential first (the panel can sign in without a terminal), then
4599
+ // the binding, then the comment — and the ledger behind all of it, so the
4600
+ // same release is never announced twice.
4601
+ [`${ROUTE_PREFIX}/bilibili-status`, bilibiliStatusHandler],
4602
+ [`${ROUTE_PREFIX}/bilibili-login-start`, bilibiliLoginStartHandler],
4603
+ [`${ROUTE_PREFIX}/bilibili-login-poll`, bilibiliLoginPollHandler],
4604
+ [`${ROUTE_PREFIX}/bilibili-login-cancel`, bilibiliLoginCancelHandler],
4605
+ [`${ROUTE_PREFIX}/bilibili-credential`, bilibiliCredentialHandler],
4606
+ [`${ROUTE_PREFIX}/bilibili-logout`, bilibiliLogoutHandler],
4607
+ [`${ROUTE_PREFIX}/bilibili-bind`, bilibiliBindHandler],
4608
+ [`${ROUTE_PREFIX}/bilibili-announce`, bilibiliAnnounceHandler],
4609
+ ]
4610
+ // A sign-in child polls GitHub for up to ten minutes; it must not outlive the
4611
+ // plugin that owns it.
4612
+ if (typeof ctx.effect === 'function') ctx.effect(() => stopAuth, 'dsh-plugin-cicd: sign-in child')
4613
+ for (const [path, handler] of routes) {
4614
+ const dispose = ctx.webServer.register({ kind: 'exact', path, handler })
4615
+ if (typeof ctx.effect === 'function') ctx.effect(() => dispose, `dsh-plugin-cicd: ${path}`)
4616
+ }
4617
+ const startup = live()
4618
+ /*
4619
+ * The sweep's triggers.
4620
+ *
4621
+ * The timer is what makes the feature work while nobody is looking, and it is
4622
+ * configurable because it is not free: one `gh api` call per bound repository
4623
+ * per tick, which is the same shape the panel already produces when it is open.
4624
+ * Setting `bilibiliWatchSeconds: 0` leaves the manual button and the sweep that
4625
+ * a publish triggers, and stops the clock.
4626
+ */
4627
+ if (startup.bilibiliWatchSeconds > 0) {
4628
+ const timer = setInterval(() => {
4629
+ void sweepBilibili('timer')
4630
+ }, startup.bilibiliWatchSeconds * 1000)
4631
+ if (typeof timer.unref === 'function') timer.unref()
4632
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => clearInterval(timer), 'dsh-plugin-cicd: bilibili sweep')
4633
+ /*
4634
+ * One sweep shortly after startup. The releases that matter most are the ones
4635
+ * published while DSH was closed — a timer that only looks forward from the
4636
+ * moment it starts would never see them.
4637
+ */
4638
+ const boot = setTimeout(() => {
4639
+ void sweepBilibili('startup')
4640
+ }, 15_000)
4641
+ if (typeof boot.unref === 'function') boot.unref()
4642
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => clearTimeout(boot), 'dsh-plugin-cicd: bilibili startup sweep')
4643
+ }
4644
+ if (startup.bilibiliEnabled) {
4645
+ const bound = startup.repos.filter((entry) => entry.bilibili !== null).length
4646
+ ctx.logger?.info?.(
4647
+ 'dsh-plugin-cicd: bilibili notes %s (bound=%d, auto=%s, watch=%ds)',
4648
+ bound === 0 ? 'configured but no video is bound' : 'armed',
4649
+ bound,
4650
+ startup.bilibiliAuto ? 'on' : 'off',
4651
+ startup.bilibiliWatchSeconds,
4652
+ )
4653
+ }
4654
+ ctx.logger?.info?.(
4655
+ 'dsh-plugin-cicd: routes mounted at %s (gh=%s, repos=%d from %s, owner=%s)',
4656
+ ROUTE_PREFIX,
4657
+ ghPath,
4658
+ startup.repos.length,
4659
+ startup.configSource,
4660
+ startup.owner === '' ? '(unset)' : startup.owner,
4661
+ )
4662
+ }