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/LICENSE +21 -0
- package/README.md +389 -2
- package/RELEASING.md +63 -0
- package/client.js +3786 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4662 -0
- package/lib/bilibili.mjs +822 -0
- package/lib/config-store.mjs +159 -0
- package/package.json +62 -3
- package/scripts/configure.mjs +213 -0
- package/scripts/push-via-api.ps1 +111 -0
- package/scripts/verify-bundle.mjs +320 -0
- package/tests/bilibili-check.mjs +398 -0
- package/tests/bump-e2e.mjs +352 -0
- package/tests/client-render.mjs +1267 -0
- package/tests/host-checks.mjs +521 -0
- package/tests/mount-check.mjs +297 -0
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
|
+
}
|